Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/5944-preview-page-source-query-params.md
Original file line numberDiff line numberDiff line change
@@ -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.
205 changes: 205 additions & 0 deletions apps/console/src/__tests__/helpers/preview-page-sources.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
/**
* 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 `../../<file>` by Vite. */
const harnessModules = import.meta.glob('../../*-preview.tsx', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;

/** 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;
}

/**
* `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<string, unknown> & { 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 — 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 } = harnessParser.parseForESLint(text, {
sourceType: 'module',
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
});

// Pass 1: `const <name> = \`…\`` — the shape every harness uses today.
const templates = new Map<string, string>();
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<string, Node>();
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;
}
Original file line numberDiff line numberDiff line change
@@ -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]));
},
);
});
Loading
Loading