@@ -28,7 +28,13 @@ import path from 'node:path';
2828import url from 'node:url' ;
2929import { describe , expect , it } from 'vitest' ;
3030
31- import { analyzeSites , countSites , countStripSites , listSchemaFiles } from './lib/strictness-ledger' ;
31+ import {
32+ analyzeSites ,
33+ analyzeTree ,
34+ countSites ,
35+ countStripSites ,
36+ listSchemaFiles ,
37+ } from './lib/strictness-ledger' ;
3238
3339const HERE = path . dirname ( url . fileURLToPath ( import . meta. url ) ) ;
3440const SPEC = path . resolve ( HERE , '..' ) ;
@@ -136,20 +142,37 @@ describe('site counting reads the AST, not the source text', () => {
136142 * is the exact instrument this campaign keeps getting burned by.
137143 */
138144describe ( 'posture reading, with a red control for each' , ( ) => {
139- /** Analyze a mutated copy of a real source file. */
140- const mutate = ( rel : string , from : string , to : string ) => {
141- const src = fs . readFileSync ( path . join ( SRC , rel ) , 'utf-8' ) ;
142- expect ( src , `mutation anchor missing in ${ rel } ` ) . toContain ( from ) ;
145+ /**
146+ * Analyze source the tree does not have to contain, from a temp file the test
147+ * owns for the duration of the case.
148+ *
149+ * `rel` is what the reading reports as the site's `file`; the temp file itself
150+ * is written flat under one mkdtemp dir, so a nested `rel` needs no mkdir.
151+ */
152+ const analyzeSource = ( source : string , rel : string ) => {
143153 const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'strictness-' ) ) ;
144154 const tmp = path . join ( dir , path . basename ( rel ) ) ;
145- fs . writeFileSync ( tmp , src . replace ( from , to ) ) ;
155+ fs . writeFileSync ( tmp , source ) ;
146156 try {
147157 return analyzeSites ( tmp , rel ) ;
148158 } finally {
149159 fs . rmSync ( dir , { recursive : true , force : true } ) ;
150160 }
151161 } ;
152162
163+ /** Analyze a mutated copy of a real source file. */
164+ const mutate = ( rel : string , from : string , to : string ) => {
165+ const src = fs . readFileSync ( path . join ( SRC , rel ) , 'utf-8' ) ;
166+ expect ( src , `mutation anchor missing in ${ rel } ` ) . toContain ( from ) ;
167+ return analyzeSource ( src . replace ( from , to ) , rel ) ;
168+ } ;
169+
170+ /** Every site under the triaged directories, `file` relative to `src/`. */
171+ const triagedSites = ( ) =>
172+ TRIAGED . flatMap ( ( dir ) =>
173+ analyzeTree ( path . join ( SRC , dir ) ) . map ( ( s ) => ( { ...s , file : `${ dir } /${ s . file } ` } ) ) ,
174+ ) ;
175+
153176 it ( 'reads the strictObject( helper as strict — and as strip once un-converted' , ( ) => {
154177 const rel = 'ui/action.zod.ts' ;
155178 const live = analyzeSites ( at ( rel ) ) . find ( ( s ) => s . name === 'ActionAiSchema' ) ;
@@ -162,49 +185,98 @@ describe('posture reading, with a red control for each', () => {
162185 } ) ;
163186
164187 it ( 'reads the OLDER z.object(…).strict() spelling as strict too' , ( ) => {
165- // The reading the `strictObject(`-only count could not make.
188+ // The reading the `strictObject(`-only count could not make, carried by an
189+ // OWNED fixture (#6940).
190+ //
191+ // ## Why this is synthetic and not a real site
166192 //
167- // The fixture used to be `security/permission.zod.ts`, whose four sites
168- // were the campaign 's canonical `z.object(shape, { error }).strict()`
169- // wiring; #5593 migrated all four to `strictObject`, so the file no longer
170- // exercised the branch under test, and the fixture moved to
171- // `TenancyConfigSchema` — until #6619 folded ITS hand-written map into the
172- // shared template. It then moved to `ObjectCapabilities`, same file, on the
173- // reading that its map (`strictCapabilitiesError`) emitted NO trailing
174- // history sentence and so could not fold. **#6805 disproved that reading**:
175- // the missing sentence was a gap in the TEXT, not a limit of the template
176- // (`history` encodes position , and `enable` had a real history nobody had
177- // written down), so that site is `strictObject` too now .
193+ // It used to be a real one, and it moved three times in three PRs:
194+ // `security/permission.zod.ts` 's four sites (#5593 migrated all four), then
195+ // `TenancyConfigSchema` (#6619 folded its hand-written map into the shared
196+ // template), then `ObjectCapabilities` (#6805 folded that one too), then
197+ // `PerOperationRequiredPermissionsSchema`. Every author obeyed the "move
198+ // this fixture rather than deleting the assertion" instruction — the
199+ // instruction was the problem, not its readers. The #4001 campaign's whole
200+ // direction is to convert every remaining `z.object(shape, { error }).strict()`
201+ // carrier to `strictObject`, so each wave evicts whatever site is standing
202+ // here , and the TERMINAL state — zero carriers in the tree — leaves that
203+ // instruction with nowhere to point .
178204 //
179- // `PerOperationRequiredPermissionsSchema`, still the same file, is the
180- // spelling's carrier today — and a more durable one, because it carries no
181- // guidance table at all and therefore nothing pulls it toward the helper.
182- // If it is ever converted, move this fixture AGAIN rather than deleting the
183- // assertion: the AST reader still has to make the reading, and
184- // `packages/spec` is not the only tree it reads.
185- const objectSites = analyzeSites ( at ( 'data/object.zod.ts' ) ) ;
186- const perOperation = objectSites . find ( ( s ) => s . name === 'PerOperationRequiredPermissionsSchema' ) ;
187- expect ( perOperation ?. posture , 'a plain `.strict()` chain is still strict' ) . toBe ( 'strict' ) ;
188- expect ( perOperation ?. idiom ) . toBe ( 'z.object' ) ;
189-
190- // …and the two sites the fixture vacated read as the helper now, which is
191- // the control that keeps the line above a statement about the READER
192- // rather than about one lucky survivor. Without it, a reader that simply
193- // stopped distinguishing idioms would still satisfy the assertion.
194- for ( const name of [ 'ObjectCapabilities' , 'TenancyConfigSchema' ] ) {
195- const folded = objectSites . find ( ( s ) => s . name === name ) ;
196- expect ( folded , `${ name } is not a site any more — re-point this test, do not delete it` ) . toBeDefined ( ) ;
197- expect ( folded ?. idiom , `${ name } folded into the helper at #6619/#6805` ) . toBe ( 'strictObject' ) ;
198- expect ( folded ?. posture ) . toBe ( 'strict' ) ;
205+ // The reading under test is a property of the READER
206+ // (`lib/strictness-ledger.ts`), not of any file in this tree, and
207+ // `packages/spec` is not the only tree that reader reads. So the load-bearing
208+ // material is written by the test, is outside the campaign's conversion
209+ // surface, and survives the terminal state unchanged. The live tree is still
210+ // read below — as a control, not as the carrier.
211+ const owned = analyzeSource (
212+ [
213+ "import { z } from 'zod';" ,
214+ '' ,
215+ '/** The campaign-era spelling: shape, error map, then the closing call. */' ,
216+ 'export const OwnedClosedSchema = z.object(' ,
217+ ' { a: z.string() },' ,
218+ " { error: () => 'unknown key' }," ,
219+ ').strict();' ,
220+ '' ,
221+ '/** The same spelling as prettier wraps it once the chain gets long. */' ,
222+ 'export const OwnedWrappedClosedSchema = z' ,
223+ ' .object({ b: z.string() })' ,
224+ ' .strict();' ,
225+ '' ,
226+ '/** Control: same idiom, left open — a reader that calls every site strict fails here. */' ,
227+ 'export const OwnedOpenSchema = z.object({ c: z.string() });' ,
228+ '' ,
229+ '/** Control: the helper the campaign converts TO — the idioms must stay distinct. */' ,
230+ 'export const OwnedHelperSchema = strictObject({ d: z.string() });' ,
231+ ] . join ( '\n' ) ,
232+ 'owned-older-spelling.zod.ts' ,
233+ ) ;
234+
235+ // Pinned as whole maps rather than per-site lookups, so an extra or missing
236+ // site is a failure too — the counting defects in this campaign have all
237+ // been omissions that a `find()` would have walked past.
238+ expect ( Object . fromEntries ( owned . map ( ( s ) => [ s . name , s . posture ] ) ) ) . toEqual ( {
239+ OwnedClosedSchema : 'strict' ,
240+ OwnedWrappedClosedSchema : 'strict' ,
241+ OwnedOpenSchema : 'strip' ,
242+ OwnedHelperSchema : 'strict' ,
243+ } ) ;
244+ expect ( Object . fromEntries ( owned . map ( ( s ) => [ s . name , s . idiom ] ) ) ) . toEqual ( {
245+ OwnedClosedSchema : 'z.object' ,
246+ OwnedWrappedClosedSchema : 'z.object' ,
247+ OwnedOpenSchema : 'z.object' ,
248+ OwnedHelperSchema : 'strictObject' ,
249+ } ) ;
250+
251+ // ## The live control, stated so the campaign cannot evict it
252+ //
253+ // What the borrowed site really bought was evidence that this spelling is
254+ // not a museum piece the reader keeps supporting for nobody. That is a
255+ // statement about the POPULATION, so it is read as one — no site is named,
256+ // and an empty population is a legitimate reading (it is the campaign's
257+ // declared goal), which is why there is no `toBeGreaterThan(0)` here.
258+ const live = triagedSites ( ) ;
259+ const carriers = live . filter ( ( s ) => s . idiom === 'z.object' && s . posture === 'strict' ) ;
260+
261+ // Corroboration that does NOT share the AST reader — the same tactic as the
262+ // coverage-walk case above, for the same reason: a bug in the reader must
263+ // not be able to confirm itself. Coarse by construction (file-level text,
264+ // not the site's own chain), so it cannot prove the reading is right; it can
265+ // only catch the reader claiming a file closes keys that never says so.
266+ for ( const site of carriers ) {
267+ const text = fs . readFileSync ( path . join ( SRC , site . file ) , 'utf-8' ) ;
268+ expect ( text , `${ site . file } is read as closing ${ site . name } , but never spells it` ) . toContain (
269+ '.strict()' ,
270+ ) ;
199271 }
200272
201- // The permission file's four are now the helper, and still strict — the
202- // control that keeps this test a statement about the READER rather than
203- // about one file.
204- const perm = analyzeSites ( at ( 'security/permission.zod.ts' ) ) ;
205- expect ( perm . filter ( ( s ) => s . posture === 'strict' ) ) . toHaveLength ( 4 ) ;
206- expect ( perm . find ( ( s ) => s . name === 'PermissionSetSchema' ) ?. idiom ) . toBe ( 'strictObject' ) ;
207- expect ( countStripSites ( at ( 'security/permission.zod.ts' ) ) ) . toBe ( 0 ) ;
273+ // And the anti-collapse control, on real material and with nothing named: a
274+ // reader that stopped distinguishing idioms — or postures — would satisfy
275+ // every assertion above about the owned fixture only by accident, and fails
276+ // here outright. True in the terminal state too, where the surviving idioms
277+ // are `strictObject` and friends rather than `z.object`.
278+ expect ( new Set ( live . map ( ( s ) => s . idiom ) ) . size ) . toBeGreaterThan ( 1 ) ;
279+ expect ( new Set ( live . map ( ( s ) => s . posture ) ) . size ) . toBeGreaterThan ( 1 ) ;
208280 } ) ;
209281
210282 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', () => {
289361 // `z.looseObject(` under a triaged directory — the early return for
290362 // `z.looseObject` was the same defect waiting for its first instance, so it
291363 // is covered before that instance exists rather than after.
292- const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'strictness-idiom-' ) ) ;
293- const file = path . join ( dir , 'synthetic.zod.ts' ) ;
294- fs . writeFileSync (
295- file ,
364+ const sites = analyzeSource (
296365 [
297366 'export const A = strictObject({ a: 1 }).passthrough();' ,
298367 'export const B = strictObject({ b: 1 });' ,
@@ -302,19 +371,15 @@ describe('posture reading, with a red control for each', () => {
302371 // Last explicit call wins, not the first.
303372 'export const F = strictObject({ f: 1 }).passthrough().strict();' ,
304373 ] . join ( '\n' ) ,
374+ 'synthetic.zod.ts' ,
305375 ) ;
306- try {
307- const posture = Object . fromEntries ( analyzeSites ( file ) . map ( ( s ) => [ s . name , s . posture ] ) ) ;
308- expect ( posture ) . toEqual ( {
309- A : 'passthrough' ,
310- B : 'strict' ,
311- C : 'strict' ,
312- D : 'passthrough' ,
313- E : 'catchall' ,
314- F : 'strict' ,
315- } ) ;
316- } finally {
317- fs . rmSync ( dir , { recursive : true , force : true } ) ;
318- }
376+ expect ( Object . fromEntries ( sites . map ( ( s ) => [ s . name , s . posture ] ) ) ) . toEqual ( {
377+ A : 'passthrough' ,
378+ B : 'strict' ,
379+ C : 'strict' ,
380+ D : 'passthrough' ,
381+ E : 'catchall' ,
382+ F : 'strict' ,
383+ } ) ;
319384 } ) ;
320385} ) ;
0 commit comments