Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 127 additions & 62 deletions packages/spec/scripts/strictness-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -136,20 +142,37 @@ 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 {
fs.rmSync(dir, { recursive: true, force: true });
}
};

/** 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');
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 });',
Expand All @@ -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',
});
});
});
Loading