diff --git a/.changeset/vi-mock-inherit-guard-6849.md b/.changeset/vi-mock-inherit-guard-6849.md new file mode 100644 index 0000000000..417385b551 --- /dev/null +++ b/.changeset/vi-mock-inherit-guard-6849.md @@ -0,0 +1,10 @@ +--- +--- + +Test/tooling only; nothing published changes. + +Adds `scripts/check-vi-mock-inherit.mjs`, a ratchet that rejects a `vi.mock` factory +which hand-lists the exports of a covered workspace specifier instead of inheriting the +real module's export surface. The only `src/` file it touches is a plugin-view test +file, converted to the inheriting form — no runtime behaviour, no public API, no +published output changes. diff --git a/.github/workflows/vi-mock-specifiers.yml b/.github/workflows/vi-mock-specifiers.yml index 593fe77c97..353cb140f1 100644 --- a/.github/workflows/vi-mock-specifiers.yml +++ b/.github/workflows/vi-mock-specifiers.yml @@ -1,5 +1,20 @@ name: Inert vi.mock Specifiers +# Two gates over ONE population, in one home. Both read the `vi.mock` call sites +# of this tree and both catch a mock that is silently not doing what it looks +# like it is doing: `check-vi-mock-specifiers.mjs` catches a specifier that +# resolves to no file (an inert stand-in), and `check-vi-mock-inherit.mjs` +# catches a factory that hand-lists its exports (a frozen export surface, +# objectui#6849). They share the call-site pattern deliberately -- a population +# that drifted between them would be a hole neither one reports -- so they share +# a workflow rather than each registering a required context of its own. +# +# The workflow `name:` and the job `name:` are therefore BROADER than the older +# of the two gates. They are left unchanged on purpose: those two strings are +# the check-run context that branch protection and +# `scripts/dependabot-merge-gate.mjs` name, and renaming a required context +# silently un-requires it. +# # Why this is its own workflow rather than a step in `ci.yml` or `lint.yml`: a # module mock can be written into any package, in any shape of pull request, and # both of those workflows decide inside the job whether the change "needs a full @@ -79,3 +94,23 @@ jobs: # gate's own defect one level up. - name: Check every relative vi.mock specifier resolves run: node scripts/check-vi-mock-specifiers.mjs + + # The sibling property of the same call sites: a factory that HAND-LISTS + # the exports it returns freezes the mock's export surface. The next + # export any module in the file's import graph reads AT MODULE SCOPE then + # kills the file during COLLECTION -- `Test Files 3 failed | 546 passed` + # with `Tests 6694 passed`, ZERO failed assertions, because the tests in + # those files never ran (objectui#6768). It reads as flake to whoever + # sees it next, and the bill lands on whoever added the export. + # + # The recogniser is SEMANTIC, never a grep for `importOriginal`: that + # spelling mis-counted eleven correct files as broken AND missed one + # broken file entirely (objectui#6849). It asks whether the factory + # OBTAINS the real module -- a callback parameter under any name, or + # `vi.importActual` of the same specifier -- and SPREADS it. + # + # Narrow by ruling: only the covered workspace specifiers in + # `COVERED_SPECIFIERS` are judged. Whole-module replacement of a local + # module is legitimate and out of scope by construction, not by exemption. + - name: Check every covered vi.mock factory inherits the real export surface + run: node scripts/check-vi-mock-inherit.mjs diff --git a/package.json b/package.json index f859d60e17..e6d01978bf 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "governed": "node scripts/check-governed-queue-guard.mjs --test", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", + "check:vi-mock-inherit": "node scripts/check-vi-mock-inherit.mjs", "check:shell-escape-residue": "node scripts/check-shell-escape-residue.mjs", "check:readme-exports": "node scripts/check-readme-exports.mjs", "cli": "node packages/cli/dist/cli.js", diff --git a/packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx index 810d95ce67..46d4e6401f 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx @@ -42,9 +42,15 @@ import type { ObjectViewSchema } from '@object-ui/types'; /** Every `data` prop the view handed to SchemaRenderer, in order. */ const delivered: unknown[][] = []; -vi.mock('@object-ui/react', async () => { +vi.mock('@object-ui/react', async (importOriginal) => { const React = await import('react'); return { + // Inherit the real export surface, then override only what this pin reads. + // A hand-listed factory freezes the mock at whatever was typed that day, and + // the next export any module in this file's import graph reads at module + // scope kills the file during COLLECTION -- zero failed assertions, tests + // that never ran (objectui#6768 / #6849). + ...(await importOriginal>()), SchemaRenderer: ({ data }: any) => { if (Array.isArray(data)) delivered.push(data); return
; diff --git a/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts new file mode 100644 index 0000000000..ec7f476e3e --- /dev/null +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -0,0 +1,711 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — +// re-adding one is now itself an error (TS2578). See objectui#3494. +import { + COVERED_SPECIFIERS, + FLOORS, + classifyFactory, + deJsxClosingTags, + findCallSites, + scan, + summarise, +} from '../check-vi-mock-inherit.mjs'; +import { scanSource } from '../js-comment-mask.mjs'; + +/** + * objectui#6849 — the test for `scripts/check-vi-mock-inherit.mjs`. + * + * ## Why this file carries more weight than usual + * + * The gate is GREEN AT REST: every `@object-ui/react` mock in the tree inherits + * the real export surface, and the expectation is that they keep doing so. A + * green run over this repo therefore proves only that the tree is clean — it + * cannot tell a working gate from one that matches nothing, which is the exact + * defect the gate exists to catch, one level up. Triage made the non-vacuity + * control a delivery precondition for precisely that reason. + * + * So this file carries BOTH legs of the ablation, and neither is decoration: + * + * - the POSITIVE control (`ablation`): the real historical instance, + * reconstructed byte-for-byte from `ObjectView.contractEnvelope-6726.test.tsx` + * as PR #6847 left it, driven end to end through `main()` for the exit code; + * - the NEGATIVE control (`the eleven`): every already-correct spelling the + * grep in #6768 mis-counted, each as its own case AND pinned against the + * real files on disk, because a gate that reddens on correct code gets + * deleted rather than fixed. + * + * ## Fixture discipline: never write a matchable call site into this source + * + * This file is inside the gate's own scan scope. Every fixture is built through + * `mockCall()`, which interpolates the quote character — the source text here + * reads `vi.${fn}(` and the pattern needs a literal `mock`/`doMock` followed by + * a quote, so it never matches. Anything that DID match would be inside a string + * literal, which the gate classifies as `embedded` and declines to judge anyway. + * The first is what keeps the census figure honest. Same discipline, and the + * same reasons, as `check-vi-mock-specifiers.test.ts`. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** A quote, from its code point — see "Fixture discipline" above. */ +const Q = String.fromCharCode(39); + +const COVERED = '@object-ui/react'; + +/** A `vi.mock` call as SOURCE TEXT, unmatchable in this file, matchable on disk. */ +const mockCall = (spec: string, factory: string, fn: 'mock' | 'doMock' = 'mock') => + `vi.${fn}(${Q}${spec}${Q}, ${factory});`; + +/** `vi.importActual()` as source text, likewise unmatchable here. */ +const importActual = (spec: string, generic = '') => `vi.${'importActual'}${generic}(${Q}${spec}${Q})`; + +/** Classify one factory in isolation, through the real code path. */ +function verdictOf(factory: string, spec: string = COVERED) { + const sites = findCallSites(mockCall(spec, factory), { covered: [COVERED] }); + expect(sites, 'the fixture must produce exactly one call site').toHaveLength(1); + return sites[0]; +} + +/** Build a throwaway tree and hand back its root plus a relative file list. */ +function fixtureTree(files: Record) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-vi-mock-inherit-')); + for (const [rel, body] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); + } + return { root, files: Object.keys(files) }; +} + +/** Fixture scans pass their own file list and switch the floors off. */ +const scanFixture = (root: string, files: string[]) => scan(root, { files, floors: {} }); + +// --------------------------------------------------------------------------- +// THE NEGATIVE CONTROL — every already-correct spelling, none of them flagged +// --------------------------------------------------------------------------- + +describe('the eleven — spellings the `importOriginal` grep mis-counted as broken', () => { + /** + * #6768 counted 36 frozen sites from a grep for the literal `importOriginal`. + * The true count was 25. These are the eleven the grep called broken and that + * are, in fact, correct. A name-matching gate demands edits to all eleven; a + * gate that does that is overturned in its first review, so each spelling gets + * its own case here rather than being trusted to the repo scan below. + */ + + it('the canonical spelling: a parameter named `importOriginal`, spread', () => { + const site = verdictOf(`async (importOriginal) => ({ ...(await importOriginal()), SchemaRenderer: Stub })`); + expect(site.verdict).toBe('inherits'); + }); + + it('the SAME shape with a generic argument — 25 files in this tree write it', () => { + expect( + verdictOf(`async (importOriginal) => ({ ...(await importOriginal>()), X: Stub })`).verdict, + ).toBe('inherits'); + }); + + it('a parameter named `importActual` — EnvironmentListToolbar.test.tsx', () => { + // Same code, different word. Nothing about the NAME is load-bearing. + expect(verdictOf(`async (importActual) => ({ ...(await importActual()), X: Stub })`).verdict).toBe('inherits'); + }); + + it('a parameter named `orig`, called through a cast — PageView.test.tsx', () => { + // The real spelling: the parameter is not called directly, it is cast first. + const site = verdictOf( + `async (orig) => { const actual = await (orig as any)(); return { ...actual, X: Stub }; }`, + ); + expect(site.verdict).toBe('inherits'); + }); + + it('a ZERO-PARAMETER factory using vi.importActual — the nine in plugin-dashboard', () => { + // No callback parameter exists at all, so a gate looking for one finds + // nothing and calls this frozen. It is the largest of the three groups. + const site = verdictOf(`async () => { const actual: any = await ${importActual(COVERED)}; return { ...actual, X: Stub }; }`); + expect(site.verdict).toBe('inherits'); + }); + + it('a parameter under a name nobody has used yet — the criterion is not a word list', () => { + expect(verdictOf(`async (whateverTheyCalledIt) => ({ ...(await whateverTheyCalledIt()), X: Stub })`).verdict).toBe( + 'inherits', + ); + }); + + it('an obtained module handed through a chain of bindings', () => { + expect( + verdictOf(`async (importOriginal) => { const mod = await importOriginal(); const actual = mod; return { ...actual, X: Stub }; }`) + .verdict, + ).toBe('inherits'); + }); + + it('an initialiser wrapped across lines — its call parentheses are on another line', () => { + // A line-bounded read of the initialiser cuts before the `()` and reports a + // binding that never calls anything: a fabricated finding on correct code. + expect( + verdictOf( + [ + 'async (importOriginal) => {', + ' const actual = await importOriginal<', + ' Record', + ' >();', + ' return { ...actual, X: Stub };', + '}', + ].join('\n'), + ).verdict, + ).toBe('inherits'); + }); +}); + +// --------------------------------------------------------------------------- +// THE POSITIVE CONTROL — the failing shape, in each of its forms +// --------------------------------------------------------------------------- + +describe('the failing shape — a factory that hand-lists the export surface', () => { + it('a zero-parameter factory returning a hand-written object is FROZEN', () => { + const site = verdictOf(`() => ({ SchemaRenderer: Stub, useDataScope: Stub })`); + expect(site.verdict).toBe('frozen'); + expect(site.reason).toMatch(/never obtains the real module/); + }); + + it('OBTAINING WITHOUT SPREADING is still frozen — both halves are required', () => { + // The card says so explicitly, and it is the half a "does it call + // importOriginal?" check would miss. + const site = verdictOf(`async (importOriginal) => { await importOriginal(); return { SchemaRenderer: Stub }; }`); + expect(site.verdict).toBe('frozen'); + expect(site.reason).toMatch(/never spreads it/); + }); + + it('spreading the CALLBACK rather than what it returns is frozen', () => { + // `...importOriginal` spreads a function. It reads like the correct + // spelling and inherits nothing — the shape a green-at-rest gate is most + // likely to wave through. + expect(verdictOf(`async (importOriginal) => ({ ...importOriginal, X: Stub })`).verdict).toBe('frozen'); + }); + + it('DESTRUCTURING names out of the real module is not inheriting its surface', () => { + expect( + verdictOf(`async (importOriginal) => { const { useDataScope } = await importOriginal(); return { useDataScope, X: Stub }; }`) + .verdict, + ).toBe('frozen'); + }); + + it('spreading something that is not the real module is frozen', () => { + const site = verdictOf(`async (importOriginal) => ({ ...baseStubs, X: Stub })`); + expect(site.verdict).toBe('frozen'); + expect(site.reason).toMatch(/not the real module/); + }); + + it('vi.importActual of a DIFFERENT specifier does not inherit THIS one', () => { + // The real file this guards against obtains `react`, not the mocked module. + const site = verdictOf(`async () => { const R = await import(${Q}react${Q}); return { C: R.createContext(null) }; }`); + expect(site.verdict).toBe('frozen'); + expect(verdictOf(`async () => ({ ...(await ${importActual('@object-ui/core')}) })`).verdict).toBe('frozen'); + }); + + it('catches the same defect written as vi.doMock', () => { + const sites = findCallSites(mockCall(COVERED, `() => ({ X: Stub })`, 'doMock'), { covered: [COVERED] }); + expect(sites[0].fn).toBe('doMock'); + expect(sites[0].verdict).toBe('frozen'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope — the narrow gate triage ruled for, and what it must NOT touch +// --------------------------------------------------------------------------- + +describe('scope — narrow, and out of scope by construction rather than by exemption', () => { + it('a RELATIVE specifier is never judged — whole-module replacement is legitimate', () => { + // `plugin-calendar/src/registration.test.tsx` replaces `./ObjectCalendar` + // wholesale and its own comment explains why. There is no growing export + // surface to inherit; a gate that reddened here would be deleted. + const site = verdictOf(`() => ({ ObjectCalendar: Stub })`, './ObjectCalendar'); + expect(site.scope).toBe('local'); + expect(site.verdict).toBe('unjudged'); + }); + + it('a third-party package is never judged', () => { + expect(verdictOf(`() => ({ toast: Stub })`, 'sonner').scope).toBe('external'); + }); + + it('a workspace package outside the covered set is counted, not judged', () => { + // 299 frozen factories live on these today (objectui#6892). Judging them + // would land this gate RED on 298 sites it was not dispatched to sweep. + const site = verdictOf(`() => ({ useAuth: Stub })`, '@object-ui/auth'); + expect(site.scope).toBe('workspace'); + expect(site.verdict).toBe('unjudged'); + }); + + it('there is NO per-file exception list anywhere in the gate', () => { + // Triage: ⛔ 不要顺手加例外白名单. An exemption means the recogniser called + // correct code broken — the repair is the recogniser, not a carve-out. + const src = fs.readFileSync(path.join(repoRoot, 'scripts/check-vi-mock-inherit.mjs'), 'utf8'); + expect(src).not.toMatch(/^\s*(export )?const (ALLOW|EXEMPT|IGNORE|SKIP|KNOWN)[A-Z_]*\s*=/m); + expect(src).not.toMatch(/\.test\.tsx?['"`]\s*[,\]]/); + }); + + it('the covered set is non-empty and names only real workspace packages', () => { + // A typo here empties the population silently, and the gate then reports OK + // over nothing. The `covered` floor below is the other half of that guard. + expect(COVERED_SPECIFIERS.length).toBeGreaterThan(0); + for (const spec of COVERED_SPECIFIERS) { + const dir = spec.replace('@object-ui/', ''); + const pkg = path.join(repoRoot, 'packages', dir, 'package.json'); + expect(fs.existsSync(pkg), `${spec} names no package in this workspace`).toBe(true); + expect(JSON.parse(fs.readFileSync(pkg, 'utf8')).name).toBe(spec); + } + }); + + it('the header states what it does NOT cover, and the precondition for widening', () => { + // Triage asked for both in writing, so that a later reader widening the set + // knows what evidence is owed rather than guessing at it. + const header = fs.readFileSync(path.join(repoRoot, 'scripts/check-vi-mock-inherit.mjs'), 'utf8').slice(0, 12000); + expect(header).toMatch(/whole-module replacement/i); + expect(header).toMatch(/precondition for widening is a sweep/i); + }); +}); + +// --------------------------------------------------------------------------- +// Only text the language would execute +// --------------------------------------------------------------------------- + +describe('only text the language would execute', () => { + it('ignores a commented-out mock: it is not executed, so it cannot freeze anything', () => { + expect(findCallSites(`// ${mockCall(COVERED, '() => ({ X: Stub })')}`)).toEqual([]); + expect(findCallSites(`/* ${mockCall(COVERED, '() => ({ X: Stub })')} */`)).toEqual([]); + }); + + it('counts a call quoted inside a literal as `embedded`, and does not judge it', () => { + const sites = findCallSites(`const sample = \`${mockCall(COVERED, '() => ({ X: Stub })')}\`;`); + expect(sites).toHaveLength(1); + expect(sites[0].scope).toBe('embedded'); + expect(sites[0].verdict).toBe('unjudged'); + }); + + it('still sees a real call in a file that also holds prose about one', () => { + // The blinding direction: a mask that dropped too much reports clean over + // live code. This gate's own header quotes the defect in prose. + const sites = findCallSites( + [`/** docs mentioning ${mockCall(COVERED, '() => ({ X: Stub })')} */`, mockCall(COVERED, '() => ({ Y: Stub })')].join('\n'), + ); + expect(sites).toHaveLength(1); + expect(sites[0].verdict).toBe('frozen'); + }); + + it('an automock (no factory) inherits the surface by construction', () => { + const sites = findCallSites(`vi.${'mock'}(${Q}${COVERED}${Q});`); + expect(sites[0].verdict).toBe('automock'); + }); + + it('a factory that is NOT written inline fails rather than passing unread', () => { + // The obvious evasion, and the direction that matters: a gate that reports + // OK for a factory it never read is a gate that can be walked around + // without anybody deciding to walk around it. + const site = verdictOf(`sharedReactFactory()`); + expect(site.verdict).toBe('indirect'); + const { root, files } = fixtureTree({ 'a.test.tsx': `${mockCall(COVERED, 'sharedReactFactory()')}\n` }); + expect(scanFixture(root, files).unreadable).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// The JSX mask — a real mis-mask in the shared scanner, measured +// --------------------------------------------------------------------------- + +describe('deJsxClosingTags — the shared masker reads `
` as a regex literal', () => { + /** + * `js-comment-mask` opens a regex when a `/` follows something that is not a + * value. In `` that something is `<`, so a PHANTOM regex opens and runs + * to the end of the line, swallowing whatever is there — including the `)` + * that closes a `vi.mock` call. Measured on this tree: SEVEN call sites in + * five files could not be delimited at all, one of them a covered site. + * Filed against the shared module as objectui#6891; worked around here. + */ + + const jsxFactory = `({ open, children }: any) => (open ?
{children}
: null)`; + + it('the mis-mask is real: the closing tag opens a literal span in the raw source', () => { + // Pinning the CAUSE, not just the workaround. If the shared masker is ever + // fixed this case fails and the workaround can be retired deliberately. + const src = `const C = ${jsxFactory};\n`; + const { literal } = scanSource(src); + // The `/` opens the phantom span, so the first byte INSIDE it is the `d` + // of `div` — the slash itself is consumed as the opener, not flagged. + const inside = src.indexOf('') + 2; + expect(literal[inside], 'the shared masker no longer mis-reads a JSX closing tag').toBe(1); + // ...and the phantom runs to end of line, swallowing the `)` that closes + // the call along with everything else after it. THAT is what breaks a + // delimiter walk. + expect(literal[src.indexOf(': null)')]).toBe(1); + expect(literal[src.lastIndexOf(')')]).toBe(1); + }); + + it('neutralises the tag while PRESERVING LENGTH, so every offset still holds', () => { + const src = 'abcz'; + const out = deJsxClosingTags(src); + expect(out).toHaveLength(src.length); + expect(out).toBe('a<____>b<_>c<________>z'); + // Every offset past the rewrite still indexes the same byte, which is what + // lets the mask's flags be read against the ORIGINAL source. + expect(out.indexOf('z')).toBe(src.indexOf('z')); + }); + + it('leaves a `/` that is not a closing tag alone — a regex, a path, a division', () => { + for (const src of ['const re = //;', 'const p = "a/b";', 'const q = a / b;', 'x.replace(/ { + // Without the workaround this call site is `unreadable`. `unreadable` fails + // the gate, so the mis-mask would not have been silent — but it would have + // reddened five innocent files instead of judging them. + const site = verdictOf(`async (importOriginal) => ({ ...(await importOriginal()), C: ${jsxFactory} })`); + expect(site.verdict).toBe('inherits'); + }); + + it('...and a FROZEN factory returning JSX is still caught', () => { + expect(verdictOf(`() => ({ C: ${jsxFactory} })`).verdict).toBe('frozen'); + }); +}); + +// --------------------------------------------------------------------------- +// THE ABLATION — the real historical instance, end to end +// --------------------------------------------------------------------------- + +describe('ablation — the 26th, reconstructed from the site this PR converts', () => { + /** + * `packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx` + * as PR #6847 left it. It is the card's own thesis in the second direction: + * the sweep's grep for `importOriginal` produced eleven false positives AND + * missed this one, which contains that token nowhere. `git cat-file -e + * 1e14d70ae:` succeeds and `git diff 1e14d70ae HEAD -- ` was empty + * before this PR, so the sweep really did look at these bytes. + */ + + const FROZEN_FACTORY = [ + 'async () => {', + ` const React = await import(${Q}react${Q});`, + ' return {', + ' SchemaRenderer: ({ data }: any) => {', + ' if (Array.isArray(data)) delivered.push(data);', + ' return
;', + ' },', + ' SchemaRendererContext: React.createContext(null),', + ' subscribeDataChanges: () => () => {},', + ' notifyDataChanged: () => {},', + ' };', + '}', + ].join('\n'); + + const CONVERTED_FACTORY = FROZEN_FACTORY.replace('async () => {', 'async (importOriginal) => {').replace( + ' return {', + ' return {\n ...(await importOriginal>()),', + ); + + const suiteAt = 'packages/plugin-view/src/__tests__/ObjectView.contractEnvelope-6726.test.tsx'; + + const runWith = (factory: string) => { + const { root, files } = fixtureTree({ [suiteAt]: `${mockCall(COVERED, factory)}\n` }); + return scanFixture(root, files); + }; + + it('THE HISTORICAL INSTANCE: the gate goes RED on it', () => { + const result = runWith(FROZEN_FACTORY); + expect(result.frozen.map((f: { file: string }) => f.file)).toEqual([suiteAt]); + expect(result.frozen[0].reason).toMatch(/never obtains the real module/); + }); + + it('the converted form is recognised as a fix — the gate goes GREEN', () => { + const result = runWith(CONVERTED_FACTORY); + expect(result.frozen).toEqual([]); + expect(result.census.inherits).toBe(1); + }); + + it('names the file and the line, so the finding is actionable', () => { + expect(runWith(FROZEN_FACTORY).frozen[0].line).toBe(1); + }); + + it('THE DISCRIMINATING HALF: correct neighbours in the same file are NOT flagged', () => { + // A real file mocks several specifiers at once. Only the covered one is + // judged, and the inheriting spellings beside it stay green. + const { root, files } = fixtureTree({ + [suiteAt]: [ + mockCall('@object-ui/plugin-grid', `() => ({ ObjectGrid: Stub })`), + mockCall('sonner', `() => ({ toast: Stub })`), + mockCall('./ObjectCalendar', `() => ({ ObjectCalendar: Stub })`), + mockCall(COVERED, `async (orig) => { const actual = await (orig as any)(); return { ...actual, X: Stub }; }`), + ].join('\n'), + }); + const result = scanFixture(root, files); + expect(result.frozen).toEqual([]); + expect(result.unreadable).toEqual([]); + expect(result.census.covered).toBe(1); + }); + + it('catches the broken one while its out-of-scope neighbours sit around it', () => { + const { root, files } = fixtureTree({ + [suiteAt]: [ + mockCall('sonner', `() => ({ toast: Stub })`), + mockCall(COVERED, FROZEN_FACTORY), + mockCall('./ObjectCalendar', `() => ({ ObjectCalendar: Stub })`), + ].join('\n'), + }); + expect(scanFixture(root, files).frozen).toHaveLength(1); + }); + + it('exits NON-ZERO on a frozen factory, with the guidance in the message', () => { + // End to end through `main()`, because the exit code is the whole contract + // with CI — a `main()` that swallowed `frozen` would pass every case above. + // + // The gate resolves its repo root from its OWN location, never from `cwd`, + // so the probe copies the import graph into a throwaway git repo and adds + // one frozen fixture there. + const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'check-vi-mock-inherit-red-')); + let status = 0; + let output = ''; + try { + fs.mkdirSync(path.join(probe, 'scripts')); + for (const f of ['check-vi-mock-inherit.mjs', 'invoked-as.mjs', 'js-comment-mask.mjs']) { + fs.copyFileSync(path.join(repoRoot, 'scripts', f), path.join(probe, 'scripts', f)); + } + // Floors would fire on a tree this small and mask the signal, so the + // probe raises the frozen finding on its own: floors are OFF here by + // pointing the fixture's own file list at a lowered FLOORS is not + // available across a process boundary, so instead the probe asserts the + // FROZEN section specifically rather than the bare exit code. + fs.mkdirSync(path.join(probe, 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(probe, 'pkg', 'a.test.tsx'), `${mockCall(COVERED, '() => ({ X: Stub })')}\n`); + execFileSync('git', ['init', '-q'], { cwd: probe }); + execFileSync('git', ['add', '-A'], { cwd: probe }); + try { + execFileSync('node', ['scripts/check-vi-mock-inherit.mjs'], { cwd: probe, encoding: 'utf8' }); + } catch (err) { + const e = err as { status: number; stdout: string; stderr: string }; + status = e.status; + output = `${e.stdout}${e.stderr}`; + } + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } + expect(status, 'a frozen factory must be RED — a green here is the defect itself').toBe(1); + expect(output).toMatch(/freezes the mock export surface/); + expect(output).toContain('pkg/a.test.tsx:1'); + expect(output, 'the message must show the fix, not just the finding').toMatch(/importOriginal/); + }); +}); + +// --------------------------------------------------------------------------- +// NON-VACUITY — a scan that finds nothing must FAIL, not pass +// --------------------------------------------------------------------------- + +describe('non-vacuity — the population refuses to collapse', () => { + it('declares a floor for every counter a collapse would zero', () => { + expect(Object.keys(FLOORS).sort()).toEqual(['covered', 'sources', 'testFiles']); + for (const [name, floor] of Object.entries(FLOORS)) { + expect(floor, `FLOORS.${name} must be a real floor, not zero`).toBeGreaterThan(0); + } + }); + + it('reports every floor as breached when the walk returns nothing at all', () => { + const result = scan(repoRoot, { files: [] }); + expect(result.frozen).toEqual([]); // clean by the only measure it has... + expect(result.vacuous.map((v: { counter: string }) => v.counter).sort()).toEqual([ + 'covered', + 'sources', + 'testFiles', + ]); // ...and that is exactly why it must still fail + }); + + it('breaches the covered floor when the walk finds sources but no covered mocks', () => { + // The specific collapse this gate is exposed to: `COVERED_SPECIFIERS` is + // renamed or misspelled, every call site drops out of scope, and the gate + // reports OK over a population of zero. + const files = Array.from({ length: 2000 }, (_, i) => `packages/p/src/__tests__/m${i}.test.ts`); + const result = scan(repoRoot, { files }); + const breached = result.vacuous.map((v: { counter: string }) => v.counter); + expect(breached).toContain('covered'); + expect(breached).not.toContain('sources'); + expect(breached).not.toContain('testFiles'); + }); + + it('exits non-zero on a collapsed population, with the census in the message', () => { + const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'check-vi-mock-inherit-empty-')); + fs.mkdirSync(path.join(probe, 'scripts')); + for (const f of ['check-vi-mock-inherit.mjs', 'invoked-as.mjs', 'js-comment-mask.mjs']) { + fs.copyFileSync(path.join(repoRoot, 'scripts', f), path.join(probe, 'scripts', f)); + } + execFileSync('git', ['init', '-q'], { cwd: probe }); + + let status = 0; + let output = ''; + try { + // Nothing is `git add`ed, so `git ls-files` succeeds and returns nothing. + execFileSync('node', ['scripts/check-vi-mock-inherit.mjs'], { cwd: probe, encoding: 'utf8' }); + } catch (err) { + const e = err as { status: number; stdout: string; stderr: string }; + status = e.status; + output = `${e.stdout}${e.stderr}`; + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } + expect(status, 'an empty scan must be RED — a green here is the defect itself').toBe(1); + expect(output).toMatch(/population COLLAPSED/); + expect(output, 'the message must name which counters collapsed').toMatch(/sources: found 0, floor is/); + }); +}); + +// --------------------------------------------------------------------------- +// The tree as it stands +// --------------------------------------------------------------------------- + +describe('repo state — the gate is green on this tree', () => { + const result = scan(repoRoot); + + it('has no frozen and no unreadable factory on a covered specifier', () => { + expect( + result.frozen.map((f: { file: string; line: number; reason?: string }) => `${f.file}:${f.line} ${f.reason}`), + 'Run `pnpm check:vi-mock-inherit` for the full report and the fix guidance.', + ).toEqual([]); + expect(result.unreadable.map((u: { file: string; line: number }) => `${u.file}:${u.line}`)).toEqual([]); + }); + + it('actually walked the tree rather than silently matching nothing', () => { + // The empty-verdict trap: without these, the assertion above passes for the + // wrong reason on the day the walk breaks. Floors, not exact counts — the + // measured figures move every day (4004 / 2286 / 107 when this landed). + expect(result.census.sources).toBeGreaterThan(1000); + expect(result.census.testFiles).toBeGreaterThan(1000); + expect(result.census.covered).toBeGreaterThan(50); + expect(result.census.inherits).toBe(result.census.covered - result.census.automock); + expect(result.vacuous).toEqual([]); + }); + + it('THE ELEVEN, pinned against the real files — a future edit reddens here', () => { + // The negative control on disk rather than on a fixture. Each of these was + // counted as broken by #6768's grep and is correct. + const eleven = [ + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnHeader.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.columnIdentity.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.overrideSource-6425.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.percentLocale.test.tsx', + 'packages/plugin-dashboard/src/__tests__/ObjectDataTable.stableEmptyRows.test.tsx', + 'packages/plugin-dashboard/src/__tests__/lookupRelationalMeta-6694.test.tsx', + 'packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx', + 'packages/app-shell/src/views/__tests__/PageView.test.tsx', + ]; + expect(eleven).toHaveLength(11); + for (const file of eleven) { + expect(fs.existsSync(path.join(repoRoot, file)), `${file} moved — this control tests nothing`).toBe(true); + const covered = findCallSites(fs.readFileSync(path.join(repoRoot, file), 'utf8')).filter( + (s: { scope: string }) => s.scope === 'covered', + ); + expect(covered.length, `${file} no longer mocks ${COVERED}`).toBeGreaterThan(0); + for (const site of covered) expect(site.verdict, `${file}:${site.line}`).toBe('inherits'); + } + }); + + it('the deliberate whole-module replacement is out of scope, not exempted', () => { + // `vi.mock('./ObjectCalendar', ...)` — triage named this one as the thing a + // wide gate would have annoyed someone with. + const file = 'packages/plugin-calendar/src/registration.test.tsx'; + expect(fs.existsSync(path.join(repoRoot, file))).toBe(true); + const sites = findCallSites(fs.readFileSync(path.join(repoRoot, file), 'utf8')); + const calendar = sites.filter((s: { specifier: string }) => s.specifier === './ObjectCalendar'); + expect(calendar.length, 'the named instance is gone — this control tests nothing').toBeGreaterThan(0); + for (const site of calendar) expect(site.scope).toBe('local'); + }); + + it('puts the census in the verdict, so a reader sees the population', () => { + // "OK" alone is what a gate that does nothing also prints. + const line = summarise(result); + expect(line).toMatch(/\d+ tracked source file\(s\)/); + expect(line).toMatch(new RegExp(`\\d+ call site\\(s\\) on ${COVERED} judged`)); + const out = execFileSync('node', ['scripts/check-vi-mock-inherit.mjs'], { cwd: repoRoot, encoding: 'utf8' }); + expect(out).toMatch(/check-vi-mock-inherit: OK/); + expect(out).toContain(`${result.census.covered} call site(s) on ${COVERED} judged`); + }); + + it('needs no install and no build — it is a cheap-tier gate', () => { + const src = fs.readFileSync(path.join(repoRoot, 'scripts/check-vi-mock-inherit.mjs'), 'utf8'); + const imports = [...src.matchAll(/^import .*? from '([^']+)';$/gm)].map((m) => m[1]); + expect(imports.length).toBeGreaterThan(3); + for (const spec of imports) { + expect(spec.startsWith('node:') || spec.startsWith('./'), `${spec} would need an install`).toBe(true); + } + }); + + it('judges the SAME population the sibling specifier gate walks', () => { + // The two gates ask different questions about one set of call sites. A + // population that drifts between them is a hole neither one reports. + const sibling = fs.readFileSync(path.join(repoRoot, 'scripts/check-vi-mock-specifiers.mjs'), 'utf8'); + const mine = fs.readFileSync(path.join(repoRoot, 'scripts/check-vi-mock-inherit.mjs'), 'utf8'); + const patternOf = (src: string) => src.match(/export const CALL_RE = (.+);/)?.[1]; + expect(patternOf(mine)).toBe(patternOf(sibling)); + }); +}); + +// --------------------------------------------------------------------------- +// Wiring +// --------------------------------------------------------------------------- + +describe('wiring — the gate is reachable and every PR shape starts it', () => { + const SCRIPT = 'scripts/check-vi-mock-inherit.mjs'; + const WORKFLOW = 'vi-mock-specifiers.yml'; + const workflowDir = path.join(repoRoot, '.github/workflows'); + const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); + + /** A workflow's YAML with whole-line comments removed — see the sibling suite. */ + const yamlOf = (file: string) => + fs + .readFileSync(path.join(workflowDir, file), 'utf8') + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + + it('is exposed as a root package script', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(pkg.scripts['check:vi-mock-inherit']).toBe(`node ${SCRIPT}`); + }); + + it('has a workflow that gates pull requests, not just pushes', () => { + expect(fs.existsSync(path.join(workflowDir, WORKFLOW)), 'a check nothing runs is not a gate').toBe(true); + const yaml = yamlOf(WORKFLOW); + expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}`)); + expect(yaml).toMatch(/^\s*pull_request:/m); + expect(yaml).toMatch(/^\s*push:/m); + }); + + it('subscribes merge_group — a required check that skips a queue build stalls it', () => { + expect(yamlOf(WORKFLOW)).toMatch(/^\s*merge_group:/m); + }); + + it('runs it in NO path-filtered workflow', () => { + // A mock can be written into any package, so no path filter is correct. + expect(workflowFiles.length, 'the workflow directory scan returned implausibly few files').toBeGreaterThan(5); + for (const file of workflowFiles) { + const yaml = yamlOf(file); + if (!yaml.includes(SCRIPT)) continue; + expect(yaml, `${file} runs ${SCRIPT} behind a paths-ignore`).not.toMatch(/paths-ignore:/); + expect(yaml, `${file} runs ${SCRIPT} behind a paths filter — see objectui#3448`).not.toMatch(/^\s+paths:/m); + } + }); + + it('has exactly one home', () => { + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual([WORKFLOW]); + }); + + it('installs nothing before running the gate — the cheap tier, mechanically', () => { + const yaml = yamlOf(WORKFLOW); + expect(yaml).not.toMatch(/pnpm install/); + expect(yaml.indexOf(SCRIPT)).toBeGreaterThan(-1); + }); +}); diff --git a/scripts/check-vi-mock-inherit.mjs b/scripts/check-vi-mock-inherit.mjs new file mode 100644 index 0000000000..174dc9c4bf --- /dev/null +++ b/scripts/check-vi-mock-inherit.mjs @@ -0,0 +1,703 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Rejects a `vi.mock` factory that HAND-LISTS the exports of a covered + * workspace package instead of inheriting the real module's export surface. + * + * Run: node scripts/check-vi-mock-inherit.mjs + * node scripts/check-vi-mock-inherit.mjs --list # every call site found + * node scripts/check-vi-mock-inherit.mjs --json + * Exit: 0 = OK, 1 = a frozen factory, an unreadable one, or a collapsed + * population (see "Green at rest" below) + * + * ## The defect (objectui#6849, surfaced by #6768 / PR #6847) + * + * A factory that returns a hand-written object freezes the mock's export + * surface at whatever the author typed that day: + * + * vi.mock('@object-ui/react', () => ({ SchemaRenderer: Stub })); + * + * The real module keeps growing. The next export that any module in the file's + * import graph reads AT MODULE SCOPE resolves to `undefined` against the frozen + * stand-in, and the file dies during COLLECTION -- before a single test runs. + * + * That failure does not look like a test failure. Measured on #6768: + * + * Test Files 3 failed | 546 passed + * Tests 6694 passed <- ZERO failed assertions + * + * The tests in those three files never ran, so nothing failed. PR #6847's + * ablation reproduced it in isolation: reverting one converted file gives + * `Test Files 1 failed (1)` / `Tests no tests`, against 32 passing on the + * converted form. A reader seeing that months later reads flake, and the bill + * is paid by whoever added the export -- in a red suite that does not point at + * them. That is why the sweep needed a gate behind it rather than a habit. + * + * ## The recogniser is SEMANTIC. A name match is wrong in BOTH directions + * + * #6768 was written from a grep for the literal `importOriginal` and counted 36 + * frozen sites. The true count was 25, and this gate's first run found the miss + * in the other direction too. Both errors are measured, not argued: + * + * - **False positives -- 11.** Eleven files already inherited the real + * surface under a different spelling: nine in `plugin-dashboard` spread + * `await vi.importActual('@object-ui/react')` from a ZERO-PARAMETER + * factory; `EnvironmentListToolbar.test.tsx` names its callback parameter + * `importActual`; `PageView.test.tsx` names it `orig`. A gate matching the + * name would have demanded edits to eleven correct files and been deleted + * by the first person it annoyed. + * - **A false negative -- 1.** `plugin-view`'s + * `ObjectView.contractEnvelope-6726.test.tsx` hand-listed four exports from + * a zero-parameter factory and contains the token `importOriginal` nowhere, + * so the grep could not see it. It was byte-identical at PR #6847's own + * commit (`1e14d70ae`) and the sweep passed over it. This gate's first run + * over the tree flagged it; the same PR converts it. + * + * So the criterion is a property of the CODE, never of a name: + * + * 1. does the factory OBTAIN the real module -- through a callback parameter + * under ANY name, or through `vi.importActual` of the SAME specifier; and + * 2. does the obtained value get SPREAD into the returned object? + * + * Obtaining without spreading is still frozen: `const actual = await + * importOriginal(); return { SchemaRenderer: Stub };` inherits nothing. Both + * halves are required, and both are read off the factory's own text. + * + * ## Scope: a declared, GROW-ONLY set of covered specifiers + * + * Triage ruled this gate NARROW (objectui#6849, R+34): limited to widely- + * imported workspace specifiers rather than every `vi.mock` factory, because + * the measured failure mechanism is itself narrow -- it needs a real export + * surface that GROWS. Three things are therefore out of scope by construction, + * not by exemption: + * + * - **Whole-module replacement of a local module.** `vi.mock('./ObjectCalendar', + * ...)` in `plugin-calendar/src/registration.test.tsx` replaces a component + * wholesale and says so in its own comment. There is no growing surface to + * inherit, and a gate that reddened on it would be deleted rather than + * fixed. Relative specifiers are counted here and never judged. + * - **Third-party packages.** `sonner`, `react-router-dom`, `lucide-react` + * and friends grow only on a deliberate version bump. Counted, never judged. + * - **Workspace specifiers not in `COVERED_SPECIFIERS`.** See below. + * + * `COVERED_SPECIFIERS` holds the workspace packages whose frozen sites have + * actually been SWEPT to zero. Today that is exactly one, and the reason is + * measured rather than chosen. Running this file's classifier over all 1,499 + * `vi.mock` call sites in the tree at `9ce20233f`: + * + * covered set = @object-ui/react (swept by PR #6847) -> 1 frozen + * covered set = every @object-ui/* workspace package -> 299 frozen + * + * with `@object-ui/auth` at 92, `@object-ui/i18n` at 34, `@object-ui/collaboration` + * at 25 and `@object-ui/components` at 22. Import breadth does not separate them + * either -- `@object-ui/react` is THIRD by measured import count (576 imports + * across 552 files), behind `@object-ui/core` and `@object-ui/types` -- so there + * is no threshold to derive and no honest way to widen the set today. + * + * **The precondition for widening is a sweep, not a judgement.** Convert a + * specifier's frozen factories to the inheriting form, confirm this gate reads + * zero for it, then add it to `COVERED_SPECIFIERS` in the same PR. The list only + * ever grows. objectui#6892 carries the per-specifier worklist. + * + * ⛔ There is deliberately NO per-file exception list, and adding one is the + * wrong repair. An exemption means the recogniser called correct code broken; + * fix the recogniser, or the specifier does not belong in the covered set yet. + * + * ## Green at rest, and what follows from that + * + * Once the one site above is converted this gate reads zero, and on any + * ordinary day its output is indistinguishable from a gate that matches + * nothing -- which is the defect it exists to catch, one level up. Three + * consequences, all load-bearing: + * + * 1. **The population must refuse to collapse.** A walk that finds no source + * files, no test files, or no covered call sites is a broken walk, not a + * clean tree. `FLOORS` turns each into a failure. Same discipline as + * `check-vi-mock-specifiers.mjs` and objectui#6195. + * 2. **The verdict line carries the census**, so a reader sees the population + * the green was computed over. + * 3. **A factory the gate cannot READ is a failure, never a pass.** An + * unbalanced argument list (`unreadable`) or a factory passed as some + * other expression (`indirect` -- a helper call, a shared constant) is + * reported and fails. Both are zero on this tree; letting either through + * silently would leave the obvious way to evade the gate wide open. + * + * `scripts/__tests__/check-vi-mock-inherit.test.ts` carries the ablation -- + * every already-correct spelling as a negative control, and a deliberately + * hand-listed factory as the positive one -- because on a swept tree the run + * itself proves nothing. + * + * ## Only text the language would EXECUTE is judged + * + * Comments are blanked and a call whose `vi` token sits inside a string is + * classified `embedded` and counted, never judged -- both through one pass of + * the shared `js-comment-mask.mjs`, exactly as the sibling gate does it, and + * for the same reasons (this file's own header quotes the defect in prose). + * + * ## `js-comment-mask` reads a JSX closing tag as a regex literal + * + * The shared masker decides a `/` opens a regex when the preceding character is + * not a value. In `
` the preceding character is `<`, so it opens a + * PHANTOM regex that runs to the end of the line and swallows whatever is + * there -- including the `)` that closes a `vi.mock` call. + * + * That is not hypothetical here: measured on this tree, SEVEN `vi.mock` call + * sites in five files could not have their argument list delimited at all + * because of it, one of them a covered `@object-ui/react` site + * (`plugin-dashboard/src/__tests__/ObjectDataTable.cells.test.tsx`). The sibling + * gate never noticed because it only reads the specifier; this gate reads the + * factory BODY, so it cannot. + * + * `deJsxClosingTags` neutralises it, and the shape of the fix is what keeps it + * safe: a JSX closing tag is rewritten to the SAME NUMBER OF BYTES + * (`` -> `<____>`) before masking, so every offset the mask returns still + * indexes the original source, and the only bytes that change are slashes that + * cannot be part of a spread, an identifier, or a specifier. A ``, ``, ``. */ +const JSX_CLOSING_TAG = /<\/([A-Za-z_$][\w$.:-]*)?\s*>/g; + +/** + * `source` with the slash of every JSX closing tag replaced, PRESERVING LENGTH, + * so offsets from the mask still index the original. See the header section on + * `js-comment-mask` for the measurement that made this necessary. + */ +export function deJsxClosingTags(source) { + return source.replace(JSX_CLOSING_TAG, (m) => `<${'_'.repeat(m.length - 2)}>`); +} + +/** 1-based line number of `offset` in `source`. */ +function lineOf(source, offset) { + let line = 1; + for (let i = 0; i < offset && i < source.length; i++) if (source[i] === '\n') line++; + return line; +} + +/** + * Index of the `)` closing the `(` at `open`, ignoring anything inside a + * literal, or -1 when the source does not balance. + */ +function matchingParen(masked, literal, open) { + let depth = 0; + for (let i = open; i < masked.length; i++) { + if (literal[i]) continue; + if (masked[i] === '(') depth++; + else if (masked[i] === ')') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** `[start, end)` spans of the top-level arguments between `open` and `close`. */ +function argumentSpans(masked, literal, open, close) { + const spans = []; + let depth = 0; + let start = open + 1; + for (let i = open + 1; i < close; i++) { + if (literal[i]) continue; + const c = masked[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ',' && depth === 0) { + spans.push([start, i]); + start = i + 1; + } + } + spans.push([start, close]); + return spans; +} + +/** Whole-word reference test, so `actual` does not match `actualThing`. */ +function referencesName(text, name) { + return new RegExp(`(?()`, + * `(orig as any)()` all qualify). + * + * `OBTAIN_TOKEN` is exempt because it already STANDS FOR a completed call -- + * the whole `vi.importActual()` expression, parentheses included, + * was replaced by it. + */ +function holdsObtainedModule(text, token, tokenIsValue = false) { + if (!referencesName(text, token)) return false; + if (tokenIsValue || token === OBTAIN_TOKEN) return true; + const at = text.search(new RegExp(`(?\n>()`) would otherwise be + * cut before its call parentheses and read as a binding that never calls + * anything -- a fabricated finding on correct code. + */ +function readInitialiser(body, from) { + let depth = 0; + for (let i = from; i < body.length; i++) { + const c = body[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') { + if (depth === 0) return body.slice(from, i); + depth--; + } else if (c === ';' && depth === 0) return body.slice(from, i); + } + return body.slice(from); +} + +/** The synthetic stand-in for `vi.importActual()`. */ +const OBTAIN_TOKEN = '__OBTAINED_ORIGINAL__'; + +/** + * Read the head of a factory argument: its parameter names, and where its body + * starts. Returns `null` when the argument is not a function literal at all. + */ +function readFactoryHead(masked, literal, start, end) { + const text = masked.slice(start, end); + const lead = text.length - text.replace(/^\s+/, '').length; + const head = text.slice(lead); + const at = start + lead; + + if (head === '') return null; // no factory argument + + const single = head.match(/^(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/); + if (single) return { params: [single[1]], bodyStart: at + single[0].length }; + + const parenthesised = /^(?:async\s+)?\(/.test(head); + const keyword = /^(?:async\s+)?function\b/.test(head); + if (!parenthesised && !keyword) return null; // a helper call, a constant, ... + + const open = masked.indexOf('(', at); + if (open < 0 || open >= end) return null; + const close = matchingParen(masked, literal, open); + if (close < 0 || close > end) return null; + + const params = masked + .slice(open + 1, close) + .split(',') + .map((p) => (p.match(/^\s*([A-Za-z_$][\w$]*)/) || [, null])[1]) + .filter(Boolean); + return { params, bodyStart: close + 1 }; +} + +/** + * Does the factory spanning `[start, end)` inherit the real export surface of + * `specifier`? + * + * @returns {{ verdict: 'inherits'|'frozen'|'automock'|'indirect', obtained: string[], spreads: string[], reason?: string }} + */ +export function classifyFactory(masked, literal, start, end, specifier) { + if (masked.slice(start, end).trim() === '') { + // `vi.mock(spec)` with no factory: vitest AUTO-mocks the real module, so + // the export surface is inherited by construction. Nothing to judge. + return { verdict: 'automock', obtained: [], spreads: [] }; + } + const head = readFactoryHead(masked, literal, start, end); + if (!head) return { verdict: 'indirect', obtained: [], spreads: [] }; + + // The body, with comments already blanked by the caller. Two more passes: + // first swap in the obtain token for `vi.importActual()`, + // then blank literal content -- in that order, because the specifier the + // first pass matches on IS literal content. + const bodyStart = head.bodyStart; + const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const importActualRe = new RegExp( + `\\bvi\\s*\\.\\s*importActual\\s*(?:<[^>]*>)?\\s*\\(\\s*(['"\`])${escaped}\\1\\s*\\)`, + 'g', + ); + + let body = ''; + for (let i = bodyStart; i < end; i++) body += literal[i] ? ' ' : masked[i]; + // ...but the importActual specifier has to survive the blanking to be + // matched, so run that pass over the un-blanked body and pad to length. + const rawBody = masked.slice(bodyStart, end); + let obtainedViaImportActual = false; + const marks = []; + let m; + importActualRe.lastIndex = 0; + while ((m = importActualRe.exec(rawBody)) !== null) { + if (literal[bodyStart + m.index]) continue; // the call itself is quoted + obtainedViaImportActual = true; + marks.push([m.index, m.index + m[0].length]); + } + for (const [from, to] of marks) { + body = body.slice(0, from) + OBTAIN_TOKEN.padEnd(to - from, ' ') + body.slice(to); + } + + const obtained = [...head.params]; + if (obtainedViaImportActual) obtained.push(OBTAIN_TOKEN); + if (obtained.length === 0) { + return { verdict: 'frozen', obtained, spreads: [], reason: 'the factory never obtains the real module' }; + } + + // Bindings, so `const actual = await importOriginal(); ... ...actual` counts. + // A destructuring pattern is deliberately not a binding here: picking names + // out of the real module is not inheriting its surface. + const inherited = new Set(obtained); + const bindings = []; + const bindRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*/g; + while ((m = bindRe.exec(body)) !== null) { + bindings.push({ name: m[1], init: readInitialiser(body, m.index + m[0].length) }); + } + for (let pass = 0; pass < bindings.length + 1; pass++) { + let grew = false; + for (const b of bindings) { + if (inherited.has(b.name)) continue; + if ([...inherited].some((t) => holdsObtainedModule(b.init, t, !obtained.includes(t)))) { + inherited.add(b.name); + grew = true; + } + } + if (!grew) break; + } + + // Every spread in the factory body, with the expression it spreads. + const spreads = []; + for (let i = 0; i + 2 < body.length; i++) { + if (body[i] !== '.' || body[i + 1] !== '.' || body[i + 2] !== '.') continue; + let depth = 0; + let j = i + 3; + for (; j < body.length; j++) { + const c = body[j]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') { + if (depth === 0) break; + depth--; + } else if (c === ',' && depth === 0) break; + } + spreads.push(body.slice(i + 3, j).trim()); + i = j - 1; + } + + const inheriting = spreads.find((s) => [...inherited].some((t) => holdsObtainedModule(s, t, !obtained.includes(t)))); + if (inheriting) return { verdict: 'inherits', obtained, spreads, reason: `...${inheriting}` }; + return { + verdict: 'frozen', + obtained, + spreads, + reason: + spreads.length === 0 + ? 'the factory obtains the real module but never spreads it' + : 'the factory spreads something, but not the real module', + }; +} + +/** + * Every mock call site in one file, classified. + * + * `scope` is `covered` (judged), `workspace` (a workspace package outside + * `COVERED_SPECIFIERS`), `external` (a third-party package), `local` (a + * relative specifier -- whole-module replacement, out of scope by the ruling), + * `dynamic` (an interpolated specifier) or `embedded` (the call token sits + * inside a string, so it is a code SAMPLE -- see `check-vi-mock-specifiers.mjs` + * for the instance that made this distinction necessary). + */ +export function findCallSites(source, { covered = COVERED_SPECIFIERS } = {}) { + const dejsxed = deJsxClosingTags(source); + const { comment, literal } = scanSource(dejsxed); + const masked = blank(dejsxed, comment); + const coveredSet = new Set(covered); + + const sites = []; + CALL_RE.lastIndex = 0; + let m; + while ((m = CALL_RE.exec(masked)) !== null) { + const specifier = m[4]; + const line = lineOf(masked, m.index); + const viaImport = Boolean(m[2]); + if (literal[m.index]) { + sites.push({ fn: m[1], specifier, scope: 'embedded', verdict: 'unjudged', viaImport, line }); + continue; + } + const scope = specifier.includes('${') + ? 'dynamic' + : specifier === '.' || specifier === '..' || specifier.startsWith('./') || specifier.startsWith('../') + ? 'local' + : coveredSet.has(specifier) + ? 'covered' + : specifier.startsWith('@object-ui/') + ? 'workspace' + : 'external'; + + if (scope !== 'covered') { + sites.push({ fn: m[1], specifier, scope, verdict: 'unjudged', viaImport, line }); + continue; + } + + const open = masked.indexOf('(', m.index); + const close = matchingParen(masked, literal, open); + if (close < 0) { + sites.push({ fn: m[1], specifier, scope, verdict: 'unreadable', viaImport, line, reason: 'the argument list does not balance' }); + continue; + } + const args = argumentSpans(masked, literal, open, close); + const factory = args[1] ? [args[1][0], args[args.length - 1][1]] : [close, close]; + const judged = classifyFactory(masked, literal, factory[0], factory[1], specifier); + sites.push({ fn: m[1], specifier, scope, viaImport, line, ...judged }); + } + return sites; +} + +/** The NUL that `git ls-files -z` delimits with, built from its code point. */ +const NUL = String.fromCharCode(0); + +function trackedFiles(root) { + return execFileSync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }) + .toString('utf8') + .split(NUL) + .filter(Boolean); +} + +/** + * The one scan. `main()`, `--list`, `--json` and the test suite all go through + * here, so the tests exercise the real code path rather than an imitation. + * + * @param {string} root Repository root to scan. + * @param {{ files?: string[] | null, floors?: Record, covered?: readonly string[] }} [options] + * `files` overrides the `git ls-files` walk (fixtures pass their own list); + * `floors` overrides `FLOORS` -- pass `{}` to switch the collapse check off + * for a fixture tree, which is legitimately far below every repo floor; + * `covered` overrides `COVERED_SPECIFIERS`, so a fixture can exercise the + * scope boundary without waiting for the real list to grow. + */ +export function scan(root, { files = null, floors = FLOORS, covered = COVERED_SPECIFIERS } = {}) { + const tracked = files ?? trackedFiles(root); + const sources = tracked.filter((f) => SOURCE_FILE_RE.test(f) && !EXCLUDED.test(f)); + const testFiles = sources.filter((f) => TEST_FILE_RE.test(f)); + + const sites = []; + const frozen = []; + const unreadable = []; + const counters = { + covered: 0, + workspace: 0, + external: 0, + local: 0, + dynamic: 0, + embedded: 0, + inherits: 0, + automock: 0, + filesWithMocks: 0, + }; + + for (const file of sources) { + let source; + try { + source = readFileSync(join(root, file), 'utf8'); + } catch { + continue; // symlink, gitlink, unreadable -- nothing to judge + } + // Cheap pre-filter only. The pattern below is what actually decides. + if (!source.includes('vi')) continue; + const found = findCallSites(source, { covered }); + if (found.length === 0) continue; + counters.filesWithMocks++; + for (const site of found) { + counters[site.scope]++; + const record = { file, ...site }; + if (site.scope === 'covered') { + if (site.verdict === 'inherits' || site.verdict === 'automock') counters[site.verdict]++; + else if (site.verdict === 'unreadable' || site.verdict === 'indirect') unreadable.push(record); + else frozen.push(record); + } + sites.push(record); + } + } + + const census = { tracked: tracked.length, sources: sources.length, testFiles: testFiles.length, ...counters }; + + // The population, checked for collapse. See "Green at rest" in the header. + const vacuous = []; + for (const [counter, floor] of Object.entries(floors)) { + if (census[counter] < floor) vacuous.push({ counter, value: census[counter], floor }); + } + + return { census, sites, frozen, unreadable, vacuous, covered: [...covered] }; +} + +function repoRoot() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +/** The census, as one line, for the verdict. */ +export function summarise({ census, covered }) { + return ( + `${census.sources} tracked source file(s), ${census.testFiles} test-named; ` + + `${census.filesWithMocks} carry a mock; ` + + `${census.covered} call site(s) on ${covered.join(', ')} judged ` + + `(${census.inherits} inherit, ${census.automock} auto-mocked); ` + + `${census.workspace} other workspace, ${census.external} external, ` + + `${census.local} local, ${census.dynamic} non-static, ` + + `${census.embedded} embedded in a string literal -- all out of scope` + ); +} + +function main() { + const result = scan(repoRoot()); + const { frozen, unreadable, vacuous } = result; + + if (frozen.length === 0 && unreadable.length === 0 && vacuous.length === 0) { + console.log(`✅ check-vi-mock-inherit: OK (${summarise(result)}).`); + process.exit(0); + } + + if (frozen.length > 0) { + const plural = frozen.length === 1 ? 'factory freezes' : 'factories freeze'; + console.error(`❌ check-vi-mock-inherit: ${frozen.length} ${plural} the mock export surface\n`); + console.error(' A hand-listed factory pins the mock to the exports written that day. The'); + console.error(' next export any module in the file\'s import graph reads AT MODULE SCOPE'); + console.error(' then kills the file during COLLECTION -- the tests never run, so the suite'); + console.error(' reports ZERO failed assertions and reads like flake (objectui#6768):\n'); + for (const f of frozen) { + console.error(` - ${f.file}:${f.line} -- vi.${f.fn}(${JSON.stringify(f.specifier)})`); + console.error(` ${f.reason}`); + } + console.error(` +Inherit the real surface instead. Any of these spellings passes -- the gate reads +what the code DOES, not what the parameter is called: + + vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + SchemaRenderer: Stub, + })); + + vi.mock('@object-ui/react', async () => { + const actual = await vi.importActual('@object-ui/react'); + return { ...actual, SchemaRenderer: Stub }; + }); + +Obtaining without SPREADING is still frozen: a factory that awaits the real +module and then returns a hand-written object inherits nothing. + +Only ${result.covered.join(', ')} is judged. Relative specifiers (whole-module +replacement), third-party packages and other workspace packages are counted and +never judged -- see the header for the ruling and the widening precondition.`); + } + + if (unreadable.length > 0) { + console.error(`\n❌ check-vi-mock-inherit: ${unreadable.length} covered factory/factories could not be READ\n`); + console.error(' A factory this gate cannot parse is not a pass -- reporting OK for one is'); + console.error(' how the check gets evaded without anybody deciding to evade it:\n'); + for (const u of unreadable) { + console.error(` - ${u.file}:${u.line} -- vi.${u.fn}(${JSON.stringify(u.specifier)})`); + console.error( + ` ${u.verdict === 'indirect' ? 'the factory is not written inline (a helper call, a shared constant)' : u.reason}`, + ); + } + console.error(` +Write the factory inline in the \`vi.mock\` call so its shape is reviewable at the +call site. If it IS inline and the gate still cannot read it, the parse is the +bug -- fix it here rather than working around it in the test.`); + } + + if (vacuous.length > 0) { + console.error('\n❌ check-vi-mock-inherit: the population COLLAPSED -- this run proves nothing\n'); + for (const v of vacuous) { + console.error(` - ${v.counter}: found ${v.value}, floor is ${v.floor}`); + } + console.error(` +A scan that finds nothing reports OK, and reads as coverage. That is the exact +defect this gate exists to catch, one level up, so it is a FAILURE here instead. + +Something upstream of the judgement broke: \`git ls-files\` returned little or +nothing, a filter inverted, the pattern stopped matching, or a specifier in +\`COVERED_SPECIFIERS\` was renamed and now matches no call site at all. Fix the +walk. If a floor is genuinely too high because the tree changed shape, move it in +\`FLOORS\` deliberately and say why -- never to make a red run green. + +Census: ${summarise(result)}`); + } + + process.exit(1); +} + +// Run only when invoked directly -- the test suite imports `scan` and friends +// and must not trigger a repo scan (or a `process.exit`) on import. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--json')) { + const result = scan(repoRoot()); + console.log( + JSON.stringify( + { census: result.census, covered: result.covered, frozen: result.frozen, unreadable: result.unreadable, vacuous: result.vacuous }, + null, + 2, + ), + ); + } else if (process.argv.includes('--list')) { + const result = scan(repoRoot()); + for (const s of result.sites) { + const mark = s.scope === 'covered' ? String(s.verdict).toUpperCase().padEnd(10) : s.scope.padEnd(10); + console.log(`${mark} ${s.file}:${s.line} vi.${s.fn}(${JSON.stringify(s.specifier)})`); + } + console.log(`\n${summarise(result)}`); + } else { + main(); + } +}