From a6f502f4087b013ac76c55f36208034142e7220f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:47:47 +0000 Subject: [PATCH 1/3] test(tooling): ratchet the vi.mock factories that must inherit the real surface A `vi.mock` factory that hand-lists the exports it returns freezes the mock's export surface at whatever was typed that day. 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. It reads as flake, and the bill lands on whoever added the export. PR #6847 swept 25 such sites. Nothing stopped the 26th, and this installs the ratchet that does. The recogniser is SEMANTIC, never a grep for `importOriginal`. That spelling is wrong in both directions and both were measured on this tree: it called eleven already-correct files broken (nine zero-parameter `vi.importActual` factories in plugin-dashboard, one parameter named `importActual`, one named `orig`), and it missed a genuinely frozen one that contains the token nowhere. The criterion is what the code DOES -- obtain the real module under any binding name, and spread it into the returned object. Obtaining without spreading is still frozen. Narrow by triage's ruling (objectui#6849, R+34): only the workspace specifiers in COVERED_SPECIFIERS are judged, and widening one in needs a sweep first. Local whole-module replacement, third-party packages and unswept workspace packages are counted in the census and never judged -- out of scope by construction, with no per-file exception list anywhere. Also converts the 26th, which this gate's first run found: plugin-view's ObjectView.contractEnvelope-6726.test.tsx was byte-identical at PR #6847's own commit and the sweep passed over it. Fixes #6849 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .github/workflows/vi-mock-specifiers.yml | 35 + package.json | 1 + .../ObjectView.contractEnvelope-6726.test.tsx | 8 +- .../__tests__/check-vi-mock-inherit.test.ts | 711 ++++++++++++++++++ scripts/check-vi-mock-inherit.mjs | 703 +++++++++++++++++ 5 files changed, 1457 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/check-vi-mock-inherit.test.ts create mode 100644 scripts/check-vi-mock-inherit.mjs 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..420e9ddab6 --- /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#6851). 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#6850; 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 (4002 / 2285 / 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..78b9e320a0 --- /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#6851 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(); + } +} From 7c104d10161fd5a215ba52752d20e335c64fc7b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 12:04:58 +0000 Subject: [PATCH 2/3] chore(tooling): declare the changeset and fix the suite's inferred reason type Empty-frontmatter changeset: the only released-package source file in this diff is a plugin-view test converted to the inheriting mock form, so nothing published changes. `check-changeset-presence` guards `/src/**` of released packages, which is what pulls that one file into its scope. Also narrows the census figure in the suite's comment to this tree, and marks `reason` optional where the scan's inferred union makes it so (TS2345 under `tsconfig.scripts.json`). Part of #6849 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/vi-mock-inherit-guard-6849.md | 10 ++++++++++ scripts/__tests__/check-vi-mock-inherit.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/vi-mock-inherit-guard-6849.md 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/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts index 420e9ddab6..2a90744331 100644 --- a/scripts/__tests__/check-vi-mock-inherit.test.ts +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -569,7 +569,7 @@ describe('repo state — the gate is green on this tree', () => { 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}`), + 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([]); @@ -578,7 +578,7 @@ describe('repo state — the gate is green on this tree', () => { 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 (4002 / 2285 / 107 when this landed). + // 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); From c9352890bca1b6c7f836239bd526c77707df1cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 12:09:46 +0000 Subject: [PATCH 3/3] docs(tooling): point the guard's cross-references at the filed findings The header and suite carried placeholder numbers for two findings that had not been filed yet. They are #6891 (the shared comment-masker reads a JSX closing tag as a regex literal, which is why this guard neutralises them locally) and #6892 (the 298 frozen factories on other workspace specifiers, i.e. the per-specifier sweep worklist that widening COVERED_SPECIFIERS depends on). A header naming a wrong issue number is worse than one naming none: it reads as a citation. Part of #6849 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- scripts/__tests__/check-vi-mock-inherit.test.ts | 4 ++-- scripts/check-vi-mock-inherit.mjs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts index 2a90744331..ec7f476e3e 100644 --- a/scripts/__tests__/check-vi-mock-inherit.test.ts +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -234,7 +234,7 @@ describe('scope — narrow, and out of scope by construction rather than by exem }); it('a workspace package outside the covered set is counted, not judged', () => { - // 299 frozen factories live on these today (objectui#6851). Judging them + // 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'); @@ -324,7 +324,7 @@ describe('deJsxClosingTags — the shared masker reads `` as a regex liter * 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#6850; worked around here. + * Filed against the shared module as objectui#6891; worked around here. */ const jsxFactory = `({ open, children }: any) => (open ?
{children}
: null)`; diff --git a/scripts/check-vi-mock-inherit.mjs b/scripts/check-vi-mock-inherit.mjs index 78b9e320a0..174dc9c4bf 100644 --- a/scripts/check-vi-mock-inherit.mjs +++ b/scripts/check-vi-mock-inherit.mjs @@ -98,7 +98,7 @@ * **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#6851 carries the per-specifier worklist. + * 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; @@ -160,7 +160,7 @@ * * This is a LOCAL workaround in this gate, not a change to the shared masker -- * that module is used by many gates and its JSX behaviour is filed separately - * as objectui#6850. + * as objectui#6891. */ import { execFileSync } from 'node:child_process';