From e0e284e399897682e397027f2fe794ad2a1340ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:34:28 +0000 Subject: [PATCH 1/2] test(console): gate query params inside preview page-source template literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object-ui/no-unprefixed-query-params` anchors on a `CallExpression`, so the fourth objectui#5458 site — `apps/console/src/sdui-workbench-preview.tsx`, whose page source is a template literal — is structurally invisible to it. No AST rule can reach inside one, and a text scan over the rule's key list would match its own docblock (`top`, `limit`, `filter`, `sort`, `count` are ordinary English words), which is why the rule is call-anchored in the first place. The fix is not a different rule but a different subject: extract each preview harness's page `source` and run the REAL rule over it, where its own anchor works. `helpers/preview-page-sources.ts` now owns the single enumeration and the single extractor — both tests in the family read from it, so they cannot disagree about what a preview page is — and it returns the template literal's COOKED value, because the raw text of `sdui-workbench-preview`'s source is not parseable JS (`\u{1F5C2}` reads as JSX text plus an expression container). Non-vacuity is pinned rather than argued: a control pair, and a mutation over the real extracted source that strips the `$` off the canonical spellings taken from the rule's own `QUERY_OPTION_SPELLINGS` (now exported for that reason) and requires the gate to go red. Part of #5944 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .../5944-preview-page-source-query-params.md | 9 + .../__tests__/helpers/preview-page-sources.ts | 180 ++++++++++++++++++ ...i-preview-page-source-query-params.test.ts | 174 +++++++++++++++++ .../sdui-preview-page-source-styling.test.ts | 92 ++------- eslint-rules/no-unprefixed-query-params.js | 11 +- 5 files changed, 388 insertions(+), 78 deletions(-) create mode 100644 .changeset/5944-preview-page-source-query-params.md create mode 100644 apps/console/src/__tests__/helpers/preview-page-sources.ts create mode 100644 apps/console/src/__tests__/sdui-preview-page-source-query-params.test.ts diff --git a/.changeset/5944-preview-page-source-query-params.md b/.changeset/5944-preview-page-source-query-params.md new file mode 100644 index 0000000000..287a5f7a82 --- /dev/null +++ b/.changeset/5944-preview-page-source-query-params.md @@ -0,0 +1,9 @@ +--- +--- + +Test-only change: the ADR-0080 preview harnesses' page sources are now held to +`object-ui/no-unprefixed-query-params` by that rule itself, run over the source +strings ESLint structurally cannot reach inside a template literal +(objectui#5944). No published behaviour changes — the new file is a test under +`apps/console/src/__tests__/`, and `eslint-rules/` is a repo-local plugin +directory rather than a workspace package. diff --git a/apps/console/src/__tests__/helpers/preview-page-sources.ts b/apps/console/src/__tests__/helpers/preview-page-sources.ts new file mode 100644 index 0000000000..c1ee1b4263 --- /dev/null +++ b/apps/console/src/__tests__/helpers/preview-page-sources.ts @@ -0,0 +1,180 @@ +/** + * ONE enumeration of the ADR-0080 browser preview harnesses, and ONE extractor + * for the page `source` strings inside them — shared by every test that holds + * those sources to a rule. + * + * WHY IT IS SHARED (objectui#5944). The value of the enumeration is that "a new + * harness appears without anyone remembering to add it". Two copies of it + * cannot deliver that: the day they disagree about what counts as a preview + * page, one of them silently stops covering a harness and still reports green. + * `sdui-preview-page-source-styling.test.ts` (objectui#5470) and + * `sdui-preview-page-source-query-params.test.ts` (objectui#5944) both read + * from here. + * + * WHY VITE AND NOT `node:fs`. This app's tsconfig is browser-only (`lib: ES2020, + * DOM`, `types` without `node`), so a `node:fs` import passes under Vitest and + * fails the console's `tsc` — the trap `insecure-origin-crypto.placement.test.ts` + * records. `import.meta.glob` is expanded by Vite against the real directory at + * transform time, which is also what makes the enumeration self-maintaining. + * + * WHY AN AST AND NOT A REGEX. A page `source` is a template literal, and its + * RAW text is not the string the page actually gets: `sdui-workbench-preview` + * writes its folder glyph as `\u{1F5C2}`, which cooks to one character but, read + * raw, is JSX text followed by an expression container — `{1F5C2}` — that no JS + * parser accepts. A consumer that parses the extracted source (objectui#5944) + * would therefore hit a fatal parse error on exactly the harness the gate exists + * for, and a gate that cannot parse its subject reports nothing. So the template + * literal is read off the parsed harness and its COOKED value is returned. + */ +import tseslint from 'typescript-eslint'; + +export interface HarnessPage { + kind: string; + name: string; + /** The page source as the page really receives it — escapes cooked. */ + source: string; +} + +/** The harness files as TEXT, keyed `../../` by Vite. */ +const harnessModules = import.meta.glob('../../*-preview.tsx', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; + +/** Every `*-preview.tsx` in `apps/console/src`, by bare filename, sorted. */ +export const previewHarnessFiles: string[] = Object.keys(harnessModules) + .map((key) => key.slice(key.lastIndexOf('/') + 1)) + .sort(); + +/** + * The text of one harness. A missing key means the file was renamed or the glob + * stopped matching — fail loudly rather than hand back an empty string, which + * reads exactly like a clean file. + */ +export function readHarness(file: string): string { + const text = harnessModules[`../../${file}`]; + if (typeof text !== 'string') { + throw new Error( + `${file} is not in the preview-harness glob (found: ${previewHarnessFiles.join(', ')})`, + ); + } + return text; +} + +type Node = Record & { type: string }; + +function isNode(value: unknown): value is Node { + return ( + typeof value === 'object' + && value !== null + && typeof (value as { type?: unknown }).type === 'string' + ); +} + +function walk(value: unknown, visit: (node: Node) => void): void { + if (Array.isArray(value)) { + for (const item of value) walk(item, visit); + return; + } + if (!isNode(value)) return; + visit(value); + for (const key of Object.keys(value)) { + if (key === 'parent') continue; + const child = value[key]; + if (child && typeof child === 'object') walk(child, visit); + } +} + +/** + * The cooked value of a template literal that carries no interpolation. + * Anything else is a shape this extractor has never seen and must not guess at. + */ +function cookedTemplate(node: Node, what: string): string { + const expressions = node.expressions as unknown[]; + if (expressions.length > 0) { + throw new Error(`${what}: page source template literal interpolates — extractor cannot resolve it`); + } + const quasis = node.quasis as Array<{ value: { cooked?: string | null } }>; + const cooked = quasis[0]?.value?.cooked; + if (typeof cooked !== 'string') { + throw new Error(`${what}: page source template literal has no cooked value`); + } + return cooked; +} + +function staticKey(property: Node): string | null { + if (property.type !== 'Property' || property.computed === true) return null; + const key = property.key as Node; + if (key.type === 'Identifier') return key.name as string; + if (key.type === 'Literal' && typeof key.value === 'string') return key.value; + return null; +} + +/** + * Every page object in a harness — an object literal carrying both `kind` and + * `source`, with the source resolved to the cooked text of the template literal + * it names (or of an inline template literal). + * + * Deliberately throws rather than skipping when a page's source cannot be + * resolved: an extractor that silently finds zero pages is indistinguishable + * from a clean file, which is the exact way a count-based guard rots + * (objectui#5470). + */ +export function pagesOf(text: string, label = 'harness'): HarnessPage[] { + const { ast } = tseslint.parser.parseForESLint(text, { + sourceType: 'module', + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + }); + + // Pass 1: `const = \`…\`` — the shape every harness uses today. + const templates = new Map(); + walk(ast, (node) => { + if (node.type !== 'VariableDeclarator') return; + const id = node.id as Node; + const init = node.init as Node | null; + if (id.type !== 'Identifier' || !init || init.type !== 'TemplateLiteral') return; + templates.set(id.name as string, cookedTemplate(init, `${label} const ${String(id.name)}`)); + }); + + // Pass 2: the page objects. + const pages: HarnessPage[] = []; + walk(ast, (node) => { + if (node.type !== 'ObjectExpression') return; + const props = new Map(); + for (const property of node.properties as Node[]) { + const key = staticKey(property); + if (key !== null) props.set(key, property); + } + const kindProp = props.get('kind'); + const sourceProp = props.get('source'); + if (!kindProp || !sourceProp) return; + + const kindValue = kindProp.value as Node; + if (kindValue.type !== 'Literal' || typeof kindValue.value !== 'string') return; + + const sourceValue = sourceProp.value as Node; + let identifier: string | null = null; + let source: string | null = null; + if (sourceValue.type === 'Identifier') { + identifier = sourceValue.name as string; + source = templates.get(identifier) ?? null; + } else if (sourceValue.type === 'TemplateLiteral') { + source = cookedTemplate(sourceValue, `${label} inline source`); + } + if (source === null) { + throw new Error( + `${label}: page kind:'${kindValue.value}' has a \`source\` this extractor cannot resolve` + + ` (${identifier ? `identifier \`${identifier}\` is not a template-literal const` : `it is a ${sourceValue.type}`}).`, + ); + } + + const nameValue = props.get('name')?.value as Node | undefined; + const name = nameValue && nameValue.type === 'Literal' && typeof nameValue.value === 'string' + ? nameValue.value + : identifier ?? '(unnamed)'; + pages.push({ kind: kindValue.value, name, source }); + }); + return pages; +} diff --git a/apps/console/src/__tests__/sdui-preview-page-source-query-params.test.ts b/apps/console/src/__tests__/sdui-preview-page-source-query-params.test.ts new file mode 100644 index 0000000000..13260e0c47 --- /dev/null +++ b/apps/console/src/__tests__/sdui-preview-page-source-query-params.test.ts @@ -0,0 +1,174 @@ +/** + * The ADR-0080 browser preview harnesses, held to `no-unprefixed-query-params` + * BY THAT RULE — the same `eslint-rules/no-unprefixed-query-params.js` that + * `eslint.config.js` loads, run here over text ESLint itself cannot reach. + * + * WHY THIS FILE EXISTS (objectui#5944). The rule (objectui#5458) anchors on a + * `CallExpression` whose callee is `.find`/`.findOne`. That anchor is + * load-bearing, not incidental: every spelling on its list (`top`, `limit`, + * `filter`, `sort`, `count`) is an ordinary English word, so outside a finder + * call the name carries no signal — which is why a text scan over the same list + * is NOT the answer here (it would match the rule's own docblock, the issue, + * and the prose in `content/docs/guide/react-pages.md`). + * + * Three of that card's four live sites are real calls and the rule reported all + * three. The fourth — `sdui-workbench-preview.tsx` — holds its page source in a + * TEMPLATE LITERAL, which the parser sees as one `TemplateLiteral` token and + * never as a `CallExpression`. No AST rule can reach inside it. It was fixed by + * hand and nothing rejected the next one written there. + * + * The fix is not a different rule, it is a different SUBJECT: pull the page + * source out of the harness, and the rule's own anchor works on it perfectly. + * So this file extracts each page `source` (`helpers/preview-page-sources.ts` — + * the same enumeration the styling test uses, so a NEW harness is covered + * without anyone remembering to add it) and runs the REAL rule over it. Not a + * re-implementation and not a second key list: a copy of a rule cannot disagree + * with itself. + * + * NON-VACUITY IS PINNED HERE, NOT ARGUED. The tree is clean at that site, so a + * green run proves nothing on its own. Two guards make the green mean + * something: a control that the rule fires and falls silent on a synthetic + * pair, and — the load-bearing one — a MUTATION over the real extracted source, + * which strips the `$` off the canonical spellings taken from the rule's own + * `QUERY_OPTION_SPELLINGS` and requires the gate to go red. That mutation runs + * against whatever the harness says today, so it cannot rot into a tautology. + */ +import { describe, it, expect } from 'vitest'; +import { Linter, type Rule } from 'eslint'; +import rule, { QUERY_OPTION_SPELLINGS } from '../../../../eslint-rules/no-unprefixed-query-params.js'; +import { pagesOf, previewHarnessFiles, readHarness } from './helpers/preview-page-sources'; + +const RULE_ID = 'object-ui/no-unprefixed-query-params'; + +const linter = new Linter(); + +/** + * A flat config carrying just this rule. + * + * `files` and the `.jsx` filename below are both required, and their absence is + * SILENT: `Linter#verify` called without a filename that matches the config + * returns `[]` — indistinguishable from a clean source. The control test is + * what keeps that from passing unnoticed. + */ +const config = { + files: ['**/*.jsx'], + plugins: { + 'object-ui': { rules: { 'no-unprefixed-query-params': rule as unknown as Rule.RuleModule } }, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { 'object-ui/no-unprefixed-query-params': 'error' }, +} as unknown as Linter.Config; + +/** + * Lint one extracted page source. Every message is returned, INCLUDING a fatal + * parse error: a source the parser choked on is a source the rule never + * visited, and "the rule found nothing" must never be spelled the same way as + * "the rule never ran". + */ +function lint(source: string, where: string): Linter.LintMessage[] { + return linter.verify(source, config, `${where}.jsx`); +} + +/** Every page in every harness, resolved once so `it.each` can name them. */ +const pages = previewHarnessFiles.flatMap((file) => + pagesOf(readHarness(file), file).map((page) => ({ file, name: page.name, kind: page.kind, source: page.source })), +); + +/** The canonical `$`-prefixed spellings, from the rule's own map. */ +const canonicalSpellings = [...new Set(Object.values(QUERY_OPTION_SPELLINGS))]; + +/** The defect this gate exists for: the same source with the `$` taken off. */ +function stripQueryOptionPrefixes(source: string): string { + let mutated = source; + for (const canonical of canonicalSpellings) { + mutated = mutated.split(canonical).join(canonical.slice(1)); + } + return mutated; +} + +const mutablePages = pages.filter((page) => stripQueryOptionPrefixes(page.source) !== page.source); + +describe('ADR-0080 preview harnesses — page-source query params', () => { + // ---- the instrument, before anything is asserted with it ---------------- + it('the rule fires on an unprefixed query option in an extracted source (control)', () => { + const dirty = lint("const rows = await adapter.find('showcase_project', { top: 200 });", 'control'); + expect(dirty.map((m) => m.ruleId)).toEqual([RULE_ID]); + expect(dirty[0].messageId).toBe('unprefixedQueryOption'); + expect(dirty[0].message).toContain('`top` is not a `QueryParams` key — write `$top`'); + + // …and is silent on the same call spelled correctly, so a green result + // below means "clean", not "rule inert". + const clean = lint("const rows = await adapter.find('showcase_project', { $top: 200 });", 'control'); + expect(clean).toEqual([]); + }); + + // ---- the enumeration ---------------------------------------------------- + it('every preview harness in apps/console/src is enumerated', () => { + expect(previewHarnessFiles).toEqual( + expect.arrayContaining([ + 'record-header-preview.tsx', + 'row-actions-preview.tsx', + 'sdui-jsx-preview.tsx', + 'sdui-tiers-preview.tsx', + 'sdui-workbench-preview.tsx', + ]), + ); + // A glob that silently matches nothing is the failure mode here. + expect(previewHarnessFiles.length).toBeGreaterThanOrEqual(5); + }); + + it('the harnesses yield page sources to check', () => { + expect(pages.length).toBeGreaterThanOrEqual(4); + expect(pages.map((p) => `${p.file}:${p.name}:${p.kind}`)).toEqual( + expect.arrayContaining([ + 'sdui-jsx-preview.tsx:command_center:jsx', + 'sdui-tiers-preview.tsx:release_notes:html', + 'sdui-tiers-preview.tsx:pipeline_react:react', + 'sdui-workbench-preview.tsx:crm_workbench:react', + ]), + ); + }); + + it('the objectui#5458 fourth site is inside the extracted text, as a real call', () => { + // The one site the ESLint rule structurally cannot see. If this call ever + // stops being in the extracted source, the mutation test below stops + // meaning anything and this assertion is the notice. + const workbench = pages.filter((p) => p.file === 'sdui-workbench-preview.tsx'); + expect(workbench.map((p) => p.name)).toEqual(['crm_workbench']); + expect(workbench[0].source).toContain("adapter.find('showcase_project', { $top: 200 })"); + }); + + // ---- the assertion ------------------------------------------------------ + it.each(pages)( + '$file — $name ($kind): no unprefixed query option in a find/findOne params object', + ({ file, name, source }) => { + const messages = lint(source, `${file}.${name}`); + expect( + messages.map((m) => `${m.fatal ? 'PARSE ERROR' : m.ruleId} L${m.line}:${m.column} ${m.message}`), + ).toEqual([]); + }, + ); + + // ---- non-vacuity, over the real sources --------------------------------- + it('at least one enumerated page really uses a query option', () => { + // Without this, every mutation below is a no-op and the suite is a + // tautology that would survive the harness losing its finder call. + expect(mutablePages.map((p) => `${p.file}:${p.name}`)).toContain( + 'sdui-workbench-preview.tsx:crm_workbench', + ); + }); + + it.each(mutablePages)( + '$file — $name: stripping the `$` off its query options turns this gate RED', + ({ file, name, source }) => { + const messages = lint(stripQueryOptionPrefixes(source), `${file}.${name}`); + expect(messages.every((m) => !m.fatal)).toBe(true); + expect(messages.map((m) => m.ruleId)).not.toEqual([]); + expect(new Set(messages.map((m) => m.ruleId))).toEqual(new Set([RULE_ID])); + }, + ); +}); diff --git a/apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts b/apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts index 94a6cf2dc9..3a01054caa 100644 --- a/apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts +++ b/apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts @@ -27,88 +27,26 @@ import { describe, it, expect } from 'vitest'; import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from '@objectstack/lint'; import { parseJsx } from '@object-ui/sdui-parser'; - /** - * The harness files as TEXT, enumerated by Vite rather than `node:fs`: this - * app's tsconfig is browser-only (`lib: ES2020, DOM`, `types` without `node`), - * so a `node:fs` import passes under Vitest and fails the console's `tsc` — - * the trap `insecure-origin-crypto.placement.test.ts` records. The glob is also - * the enumeration the last test needs: Vite expands it against the real - * directory at transform time, so a NEW harness appears here without anyone - * remembering to add it. + * The enumeration and the page-source extractor both live in the helper, so + * this file and `sdui-preview-page-source-query-params.test.ts` cannot disagree + * about what a preview page is (objectui#5944). The helper documents why Vite's + * glob rather than `node:fs`, and why the source is read off a parsed AST. */ -const harnesses = import.meta.glob('../*-preview.tsx', { - query: '?raw', - import: 'default', - eager: true, -}) as Record; +import { + pagesOf, + previewHarnessFiles, + readHarness as read, + type HarnessPage, +} from './helpers/preview-page-sources'; /** The header line every harness that keeps its Tailwind must carry. */ const EXCEPTION_ANCHOR = ' * ADR-0080 EXCEPTION — Tailwind in page source'; -interface HarnessPage { - kind: string; - name: string; - source: string; -} - -/** - * Pull the page objects out of a harness file: every `kind: ''` inside an - * object literal that also carries a `source`, with the source resolved from - * the template literal it names (or the ES-shorthand `source` const). - * - * Deliberately fails loudly rather than returning nothing: an extractor that - * silently finds zero pages is indistinguishable from a clean file, which is - * the exact way a count-based guard rots (objectui#5470 — the card's own 79 was - * a whole-file `grep -c`, i.e. LINES, harness JSX included; the rule's own count - * over the source strings is 95). - */ -function pagesOf(text: string): HarnessPage[] { - const pages: HarnessPage[] = []; - for (const m of text.matchAll(/\bkind:\s*'([a-z]+)'/g)) { - // widen from the `kind:` match to the enclosing object literal - const open = text.lastIndexOf('{', m.index); - if (open < 0) continue; - let depth = 0; - let close = open; - for (; close < text.length; close++) { - if (text[close] === '{') depth++; - else if (text[close] === '}' && --depth === 0) break; - } - const objText = text.slice(open, close + 1); - const bound = objText.match(/\bsource:\s*([A-Za-z_$][\w$]*)/); - const ident = bound ? bound[1] : /\bsource\s*[,}]/.test(objText) ? 'source' : null; - if (!ident) continue; - const decl = new RegExp(String.raw`^const\s+${ident}\s*=\s*\``, 'm').exec(text); - if (!decl) continue; - const start = decl.index + decl[0].length; - let i = start; - while (i < text.length) { - if (text[i] === '\\') { i += 2; continue; } - if (text[i] === '`') break; - i++; - } - const name = objText.match(/\bname:\s*'([^']+)'/)?.[1] ?? ident; - pages.push({ kind: m[1], name, source: text.slice(start, i) }); - } - return pages; -} - function findingsFor(pages: HarnessPage[]) { return validatePageSourceStyling({ pages: pages as unknown as Record[] }); } -function read(file: string): string { - const text = harnesses[`../${file}`]; - // A missing key means the file was renamed or the glob stopped matching — - // fail loudly rather than assert over an empty string, which reads exactly - // like a clean file. - if (typeof text !== 'string') { - throw new Error(`${file} is not in the preview-harness glob (found: ${Object.keys(harnesses).join(', ')})`); - } - return text; -} - describe('ADR-0080 preview harnesses — page-source styling', () => { // ---- the instrument, before anything is asserted with it ---------------- it('the rule fires on an authored className (control)', () => { @@ -128,7 +66,7 @@ describe('ADR-0080 preview harnesses — page-source styling', () => { // ---- (a) the authoring example: zero findings --------------------------- it('sdui-tiers-preview.tsx authors no Tailwind in either page source', () => { - const pages = pagesOf(read('sdui-tiers-preview.tsx')); + const pages = pagesOf(read('sdui-tiers-preview.tsx'), 'sdui-tiers-preview.tsx'); expect(pages.map((p) => `${p.name}:${p.kind}`)).toEqual([ 'release_notes:html', 'pipeline_react:react', @@ -137,7 +75,7 @@ describe('ADR-0080 preview harnesses — page-source styling', () => { }); it("the html-tier source's JSON style objects materialize (not deferred expressions)", () => { - const [html] = pagesOf(read('sdui-tiers-preview.tsx')); + const [html] = pagesOf(read('sdui-tiers-preview.tsx'), 'sdui-tiers-preview.tsx'); const parsed = parseJsx(html.source); expect(parsed.diagnostics.filter((d) => d.severity === 'error')).toEqual([]); @@ -167,7 +105,7 @@ describe('ADR-0080 preview harnesses — page-source styling', () => { ['sdui-workbench-preview.tsx', 21], ])('%s keeps its Tailwind AND declares the exception', (file, expected) => { const text = read(file); - const pages = pagesOf(text); + const pages = pagesOf(text, file); expect(pages).toHaveLength(1); const findings = findingsFor(pages); @@ -183,7 +121,7 @@ describe('ADR-0080 preview harnesses — page-source styling', () => { // ---- no silent third path ---------------------------------------------- it('every preview harness in src/ is one of the two declared shapes', () => { - const files = Object.keys(harnesses).map((k) => k.replace('../', '')); + const files = previewHarnessFiles; expect(files.length).toBeGreaterThanOrEqual(3); expect(files).toEqual( expect.arrayContaining([ @@ -195,7 +133,7 @@ describe('ADR-0080 preview harnesses — page-source styling', () => { for (const file of files) { const text = read(file); - const pages = pagesOf(text); + const pages = pagesOf(text, file); if (pages.length === 0) continue; // not a source-tier harness const dirty = findingsFor(pages).length > 0; const declared = text.split('\n').includes(EXCEPTION_ANCHOR); diff --git a/eslint-rules/no-unprefixed-query-params.js b/eslint-rules/no-unprefixed-query-params.js index 4e76f41762..d1a6b1f11f 100644 --- a/eslint-rules/no-unprefixed-query-params.js +++ b/eslint-rules/no-unprefixed-query-params.js @@ -104,8 +104,17 @@ /** * Unprefixed spellings that are really query options, mapped to the * `QueryParams` key each one means. Closed on purpose — see the scope note. + * + * EXPORTED for objectui#5944, which needs the canonical (`$`-prefixed) half of + * this map to mutate a real page source and prove the gate over page-source + * template literals can go red. `eslint-rules/` is a repo-local plugin + * directory, not a workspace package — it has no `package.json` and is not in + * `pnpm-workspace.yaml` — so a named export here widens no published surface. + * The point of exporting rather than copying: a second list of these spellings + * would drift from this one silently, and the drift would show up as a gate + * that quietly stops covering a spelling. */ -const QUERY_OPTION_SPELLINGS = { +export const QUERY_OPTION_SPELLINGS = { top: '$top', limit: '$top', skip: '$skip', From 8821b7d9d700945a6f2638321bdeb1303afe0c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:57:52 +0000 Subject: [PATCH 2/2] test(console): restate the parser signature at the one boundary that needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `typescript-eslint` exports its parser typed as a minimal compatibility shim — `parseForESLint(text: string): { ast: unknown }` — which drops the options parameter, so the console's `tsc` rejected the `ecmaFeatures: { jsx: true }` the harness parse needs (TS2554). Importing `@typescript-eslint/parser` directly for the real declarations would be a phantom dependency, so the signature is restated at that call and nothing downstream trusts more than `unknown`. Also restores objectui#5470's 79-vs-95 measurement, which moved out of the styling test with the extractor. Part of #5944 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .../__tests__/helpers/preview-page-sources.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/console/src/__tests__/helpers/preview-page-sources.ts b/apps/console/src/__tests__/helpers/preview-page-sources.ts index c1ee1b4263..bcb6d154b8 100644 --- a/apps/console/src/__tests__/helpers/preview-page-sources.ts +++ b/apps/console/src/__tests__/helpers/preview-page-sources.ts @@ -62,6 +62,30 @@ export function readHarness(file: string): string { return text; } +/** + * `typescript-eslint` exports its parser typed as a minimal COMPATIBILITY SHIM — + * `parseForESLint(text: string): { ast: unknown; scopeManager: unknown }` — which + * hides both the real `@typescript-eslint/parser` signature (`(code, + * parserOptions)`) and the AST type. Importing `@typescript-eslint/parser` + * directly to get the real declarations would be a phantom dependency: nothing + * in this workspace declares it, it is only a transitive one + * (`scripts/check-phantom-dependencies.mjs`). So the signature is restated here, + * at the one boundary that needs it, and everything downstream still trusts no + * more than `unknown`. + */ +interface PreviewHarnessParser { + parseForESLint( + text: string, + options: { + sourceType: 'module'; + ecmaVersion: 'latest'; + ecmaFeatures: { jsx: boolean }; + }, + ): { ast: unknown }; +} + +const harnessParser = tseslint.parser as unknown as PreviewHarnessParser; + type Node = Record & { type: string }; function isNode(value: unknown): value is Node { @@ -119,10 +143,11 @@ function staticKey(property: Node): string | null { * Deliberately throws rather than skipping when a page's source cannot be * resolved: an extractor that silently finds zero pages is indistinguishable * from a clean file, which is the exact way a count-based guard rots - * (objectui#5470). + * (objectui#5470 — that card's own 79 was a whole-file `grep -c`, i.e. LINES, + * harness JSX included; the rule's own count over the source strings is 95). */ export function pagesOf(text: string, label = 'harness'): HarnessPage[] { - const { ast } = tseslint.parser.parseForESLint(text, { + const { ast } = harnessParser.parseForESLint(text, { sourceType: 'module', ecmaVersion: 'latest', ecmaFeatures: { jsx: true },