Skip to content

Commit 54fd182

Browse files
committed
fix(spec): registry array survives the field.zod ↔ suggestions.zod ↔ strict-object import cycle under eager schemas
DECLARATIONS moved from a module-level const (TDZ at import time when the cycle enters through the automation barrel — an ordering #5593's migration itself introduced) to a hoisted function-owned store, the same property flowNodeObject() relies on (#4415). Pinned from both sides; the eager reproduction path is gen:schema (OS_EAGER_SCHEMAS=1). Recovery commit: the dev agent was killed by a container restart during its final verification run; verification re-run to completion by the PM — gen:schema eager pass, targeted cycle tests 36/36, full spec suite 342 files / 8768 tests green.
1 parent 4b62fd6 commit 54fd182

2 files changed

Lines changed: 83 additions & 3 deletions

File tree

packages/spec/src/shared/strict-object.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { execFileSync } from 'node:child_process';
4+
35
import { z } from 'zod';
46
import { describe, expect, it } from 'vitest';
57

@@ -350,3 +352,47 @@ describe('strictObject — the error map is lazy, so cycles cannot break it', ()
350352
expect(aliasesRead).toBe(1);
351353
});
352354
});
355+
356+
// ============================================================================
357+
// #5593 — this module survives being entered FIRST in its own import cycle.
358+
//
359+
// `strictObject` is called at MODULE SCOPE by schemas that sit inside the
360+
// `field.zod` ↔ `suggestions.zod` ↔ `strict-object` cycle, so under
361+
// `OS_EAGER_SCHEMAS=1` it can run while this module is still initializing.
362+
// Everything it touches on the way in must therefore be reachable from the
363+
// first instruction of module evaluation — which rules out a module-level
364+
// `const` for the declaration registry, and is why `declarationStore()` is a
365+
// hoisted `function` declaration.
366+
//
367+
// ⚠️ Why this needs its own subprocess rather than an ordinary assertion:
368+
// `lazySchema` defers construction behind a Proxy, so a normal `vitest run`
369+
// never evaluates a schema at import time and the hazard is invisible. #5593
370+
// shipped the regression and the ordinary suite stayed green — what caught it
371+
// was CI's `check-test-completeness` gate noticing that
372+
// `automation/flow-region-cycle.test.ts` was counted and never reported,
373+
// because ITS subprocess died at import with `ReferenceError: Cannot access
374+
// 'DECLARATIONS' before initialization` and vitest could not even format the
375+
// stack. Two guards, one hazard: that file states the cycle it protects
376+
// (#4415), this one states the rule this module has to keep.
377+
//
378+
// The entry point is deliberately `data/field.zod.ts` — the module whose
379+
// own module-scope `strictObject(…)` call is the one that lands here
380+
// mid-initialization — reached through a module that pulls THIS file first.
381+
// ============================================================================
382+
describe('#5593 — eager construction with this module entered first', () => {
383+
it('does not throw at import time under OS_EAGER_SCHEMAS=1', () => {
384+
const barrel = new URL('../automation/index.ts', import.meta.url).href;
385+
const run = (): string =>
386+
execFileSync(
387+
process.execPath,
388+
['--import', 'tsx', '--input-type=module', '-e',
389+
`import ${JSON.stringify(barrel)};
390+
console.log('ok');`],
391+
{ env: { ...process.env, OS_EAGER_SCHEMAS: '1' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
392+
).trim();
393+
expect(
394+
run(),
395+
'a module-level `const` in strict-object.ts is in its TDZ here — use a hoisted function',
396+
).toBe('ok');
397+
}, 60_000);
398+
});

packages/spec/src/shared/strict-object.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,41 @@ export interface StrictObjectDeclaration {
163163
readonly shape: z.ZodRawShape;
164164
}
165165

166-
const DECLARATIONS: StrictObjectDeclaration[] = [];
166+
/**
167+
* The registry array, owned by a HOISTED function declaration.
168+
*
169+
* ⚠️ **Deliberately not a module-level `const`, and this is load-bearing.**
170+
* `strictObject` is called at MODULE SCOPE by schemas that sit inside the
171+
* `field.zod` ↔ `suggestions.zod` ↔ this module import cycle, so under
172+
* `OS_EAGER_SCHEMAS=1` it can run while this module is still initializing. A
173+
* `const` is in its temporal dead zone until its own line executes, so the call
174+
* throws `ReferenceError: Cannot access 'DECLARATIONS' before initialization`
175+
* at IMPORT time — before a single test body runs. A hoisted `function`
176+
* declaration is fully initialized from the first instruction of module
177+
* evaluation, which is the same property `automation/flow.zod.ts`'s
178+
* `flowNodeObject()` relies on for its own cycle (#4415, and its docblock says
179+
* so out loud).
180+
*
181+
* Measured, not assumed. On `main` the cycle happened to be entered through
182+
* `field.zod` first, which resolves this module fully before anything calls
183+
* into it. #5593 moved `automation/`'s schemas from `strictUnknownKeyError` to
184+
* this helper, and that one edge reordered the entry: the `automation` barrel
185+
* now reaches THIS module first, then `suggestions.zod`, then `field.zod`,
186+
* whose own module-scope `strictObject(…)` call lands here mid-initialization.
187+
*
188+
* ⚠️ The failure mode is why this is written down rather than left to the
189+
* types. `lazySchema` defers construction behind a Proxy, so an ordinary
190+
* `vitest run` never evaluates a schema at import time and stays GREEN; the
191+
* eager subprocess dies before any test body runs, and vitest cannot even
192+
* format the stack, so the owning file reports *no result at all*. What caught
193+
* it was CI's `check-test-completeness` gate noticing that
194+
* `automation/flow-region-cycle.test.ts` was counted and never reported. Pinned
195+
* from this side too, in `strict-object.test.ts`.
196+
*/
197+
function declarationStore(): StrictObjectDeclaration[] {
198+
const self = declarationStore as unknown as { list?: StrictObjectDeclaration[] };
199+
return (self.list ??= []);
200+
}
167201

168202
/**
169203
* Every authoring shape {@link strictObject} has built **so far in this
@@ -187,7 +221,7 @@ const DECLARATIONS: StrictObjectDeclaration[] = [];
187221
* nothing judges, and that must fail loudly rather than pass quietly.
188222
*/
189223
export function strictObjectDeclarations(): readonly StrictObjectDeclaration[] {
190-
return DECLARATIONS;
224+
return declarationStore();
191225
}
192226

193227
/**
@@ -251,7 +285,7 @@ export function strictObject<T extends z.ZodRawShape>(options: StrictObjectOptio
251285
}))(issue);
252286
};
253287

254-
DECLARATIONS.push({ options, shape });
288+
declarationStore().push({ options, shape });
255289

256290
return z.object(shape, { error }).strict();
257291
}

0 commit comments

Comments
 (0)