diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index f3fe222a18..f99ecd2068 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -28,7 +28,13 @@ import path from 'node:path'; import url from 'node:url'; import { describe, expect, it } from 'vitest'; -import { analyzeSites, countSites, countStripSites, listSchemaFiles } from './lib/strictness-ledger'; +import { + analyzeSites, + analyzeTree, + countSites, + countStripSites, + listSchemaFiles, +} from './lib/strictness-ledger'; const HERE = path.dirname(url.fileURLToPath(import.meta.url)); const SPEC = path.resolve(HERE, '..'); @@ -136,13 +142,17 @@ describe('site counting reads the AST, not the source text', () => { * is the exact instrument this campaign keeps getting burned by. */ describe('posture reading, with a red control for each', () => { - /** Analyze a mutated copy of a real source file. */ - const mutate = (rel: string, from: string, to: string) => { - const src = fs.readFileSync(path.join(SRC, rel), 'utf-8'); - expect(src, `mutation anchor missing in ${rel}`).toContain(from); + /** + * Analyze source the tree does not have to contain, from a temp file the test + * owns for the duration of the case. + * + * `rel` is what the reading reports as the site's `file`; the temp file itself + * is written flat under one mkdtemp dir, so a nested `rel` needs no mkdir. + */ + const analyzeSource = (source: string, rel: string) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'strictness-')); const tmp = path.join(dir, path.basename(rel)); - fs.writeFileSync(tmp, src.replace(from, to)); + fs.writeFileSync(tmp, source); try { return analyzeSites(tmp, rel); } finally { @@ -150,6 +160,19 @@ describe('posture reading, with a red control for each', () => { } }; + /** Analyze a mutated copy of a real source file. */ + const mutate = (rel: string, from: string, to: string) => { + const src = fs.readFileSync(path.join(SRC, rel), 'utf-8'); + expect(src, `mutation anchor missing in ${rel}`).toContain(from); + return analyzeSource(src.replace(from, to), rel); + }; + + /** Every site under the triaged directories, `file` relative to `src/`. */ + const triagedSites = () => + TRIAGED.flatMap((dir) => + analyzeTree(path.join(SRC, dir)).map((s) => ({ ...s, file: `${dir}/${s.file}` })), + ); + it('reads the strictObject( helper as strict — and as strip once un-converted', () => { const rel = 'ui/action.zod.ts'; const live = analyzeSites(at(rel)).find((s) => s.name === 'ActionAiSchema'); @@ -162,49 +185,98 @@ describe('posture reading, with a red control for each', () => { }); it('reads the OLDER z.object(…).strict() spelling as strict too', () => { - // The reading the `strictObject(`-only count could not make. + // The reading the `strictObject(`-only count could not make, carried by an + // OWNED fixture (#6940). + // + // ## Why this is synthetic and not a real site // - // The fixture used to be `security/permission.zod.ts`, whose four sites - // were the campaign's canonical `z.object(shape, { error }).strict()` - // wiring; #5593 migrated all four to `strictObject`, so the file no longer - // exercised the branch under test, and the fixture moved to - // `TenancyConfigSchema` — until #6619 folded ITS hand-written map into the - // shared template. It then moved to `ObjectCapabilities`, same file, on the - // reading that its map (`strictCapabilitiesError`) emitted NO trailing - // history sentence and so could not fold. **#6805 disproved that reading**: - // the missing sentence was a gap in the TEXT, not a limit of the template - // (`history` encodes position, and `enable` had a real history nobody had - // written down), so that site is `strictObject` too now. + // It used to be a real one, and it moved three times in three PRs: + // `security/permission.zod.ts`'s four sites (#5593 migrated all four), then + // `TenancyConfigSchema` (#6619 folded its hand-written map into the shared + // template), then `ObjectCapabilities` (#6805 folded that one too), then + // `PerOperationRequiredPermissionsSchema`. Every author obeyed the "move + // this fixture rather than deleting the assertion" instruction — the + // instruction was the problem, not its readers. The #4001 campaign's whole + // direction is to convert every remaining `z.object(shape, { error }).strict()` + // carrier to `strictObject`, so each wave evicts whatever site is standing + // here, and the TERMINAL state — zero carriers in the tree — leaves that + // instruction with nowhere to point. // - // `PerOperationRequiredPermissionsSchema`, still the same file, is the - // spelling's carrier today — and a more durable one, because it carries no - // guidance table at all and therefore nothing pulls it toward the helper. - // If it is ever converted, move this fixture AGAIN rather than deleting the - // assertion: the AST reader still has to make the reading, and - // `packages/spec` is not the only tree it reads. - const objectSites = analyzeSites(at('data/object.zod.ts')); - const perOperation = objectSites.find((s) => s.name === 'PerOperationRequiredPermissionsSchema'); - expect(perOperation?.posture, 'a plain `.strict()` chain is still strict').toBe('strict'); - expect(perOperation?.idiom).toBe('z.object'); - - // …and the two sites the fixture vacated read as the helper now, which is - // the control that keeps the line above a statement about the READER - // rather than about one lucky survivor. Without it, a reader that simply - // stopped distinguishing idioms would still satisfy the assertion. - for (const name of ['ObjectCapabilities', 'TenancyConfigSchema']) { - const folded = objectSites.find((s) => s.name === name); - expect(folded, `${name} is not a site any more — re-point this test, do not delete it`).toBeDefined(); - expect(folded?.idiom, `${name} folded into the helper at #6619/#6805`).toBe('strictObject'); - expect(folded?.posture).toBe('strict'); + // The reading under test is a property of the READER + // (`lib/strictness-ledger.ts`), not of any file in this tree, and + // `packages/spec` is not the only tree that reader reads. So the load-bearing + // material is written by the test, is outside the campaign's conversion + // surface, and survives the terminal state unchanged. The live tree is still + // read below — as a control, not as the carrier. + const owned = analyzeSource( + [ + "import { z } from 'zod';", + '', + '/** The campaign-era spelling: shape, error map, then the closing call. */', + 'export const OwnedClosedSchema = z.object(', + ' { a: z.string() },', + " { error: () => 'unknown key' },", + ').strict();', + '', + '/** The same spelling as prettier wraps it once the chain gets long. */', + 'export const OwnedWrappedClosedSchema = z', + ' .object({ b: z.string() })', + ' .strict();', + '', + '/** Control: same idiom, left open — a reader that calls every site strict fails here. */', + 'export const OwnedOpenSchema = z.object({ c: z.string() });', + '', + '/** Control: the helper the campaign converts TO — the idioms must stay distinct. */', + 'export const OwnedHelperSchema = strictObject({ d: z.string() });', + ].join('\n'), + 'owned-older-spelling.zod.ts', + ); + + // Pinned as whole maps rather than per-site lookups, so an extra or missing + // site is a failure too — the counting defects in this campaign have all + // been omissions that a `find()` would have walked past. + expect(Object.fromEntries(owned.map((s) => [s.name, s.posture]))).toEqual({ + OwnedClosedSchema: 'strict', + OwnedWrappedClosedSchema: 'strict', + OwnedOpenSchema: 'strip', + OwnedHelperSchema: 'strict', + }); + expect(Object.fromEntries(owned.map((s) => [s.name, s.idiom]))).toEqual({ + OwnedClosedSchema: 'z.object', + OwnedWrappedClosedSchema: 'z.object', + OwnedOpenSchema: 'z.object', + OwnedHelperSchema: 'strictObject', + }); + + // ## The live control, stated so the campaign cannot evict it + // + // What the borrowed site really bought was evidence that this spelling is + // not a museum piece the reader keeps supporting for nobody. That is a + // statement about the POPULATION, so it is read as one — no site is named, + // and an empty population is a legitimate reading (it is the campaign's + // declared goal), which is why there is no `toBeGreaterThan(0)` here. + const live = triagedSites(); + const carriers = live.filter((s) => s.idiom === 'z.object' && s.posture === 'strict'); + + // Corroboration that does NOT share the AST reader — the same tactic as the + // coverage-walk case above, for the same reason: a bug in the reader must + // not be able to confirm itself. Coarse by construction (file-level text, + // not the site's own chain), so it cannot prove the reading is right; it can + // only catch the reader claiming a file closes keys that never says so. + for (const site of carriers) { + const text = fs.readFileSync(path.join(SRC, site.file), 'utf-8'); + expect(text, `${site.file} is read as closing ${site.name}, but never spells it`).toContain( + '.strict()', + ); } - // The permission file's four are now the helper, and still strict — the - // control that keeps this test a statement about the READER rather than - // about one file. - const perm = analyzeSites(at('security/permission.zod.ts')); - expect(perm.filter((s) => s.posture === 'strict')).toHaveLength(4); - expect(perm.find((s) => s.name === 'PermissionSetSchema')?.idiom).toBe('strictObject'); - expect(countStripSites(at('security/permission.zod.ts'))).toBe(0); + // And the anti-collapse control, on real material and with nothing named: a + // reader that stopped distinguishing idioms — or postures — would satisfy + // every assertion above about the owned fixture only by accident, and fails + // here outright. True in the terminal state too, where the surviving idioms + // are `strictObject` and friends rather than `z.object`. + expect(new Set(live.map((s) => s.idiom)).size).toBeGreaterThan(1); + expect(new Set(live.map((s) => s.posture)).size).toBeGreaterThan(1); }); it('reads a default site as strip — and as strict once closed', () => { @@ -289,10 +361,7 @@ describe('posture reading, with a red control for each', () => { // `z.looseObject(` under a triaged directory — the early return for // `z.looseObject` was the same defect waiting for its first instance, so it // is covered before that instance exists rather than after. - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'strictness-idiom-')); - const file = path.join(dir, 'synthetic.zod.ts'); - fs.writeFileSync( - file, + const sites = analyzeSource( [ 'export const A = strictObject({ a: 1 }).passthrough();', 'export const B = strictObject({ b: 1 });', @@ -302,19 +371,15 @@ describe('posture reading, with a red control for each', () => { // Last explicit call wins, not the first. 'export const F = strictObject({ f: 1 }).passthrough().strict();', ].join('\n'), + 'synthetic.zod.ts', ); - try { - const posture = Object.fromEntries(analyzeSites(file).map((s) => [s.name, s.posture])); - expect(posture).toEqual({ - A: 'passthrough', - B: 'strict', - C: 'strict', - D: 'passthrough', - E: 'catchall', - F: 'strict', - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(Object.fromEntries(sites.map((s) => [s.name, s.posture]))).toEqual({ + A: 'passthrough', + B: 'strict', + C: 'strict', + D: 'passthrough', + E: 'catchall', + F: 'strict', + }); }); });