Skip to content

Commit 2274894

Browse files
fix(spec): break the eager import cycle that crashed /api and /data under OS_EAGER_SCHEMAS=1 (#19943)
Fixes #19930 Clause-②: no ## What was wrong `OS_EAGER_SCHEMAS=1` is the documented emergency rollback of the lazy-schema memory optimization (`content/docs/deployment/environment-variables.mdx:30`). With it set, a process whose first spec import was `@objectstack/spec/api` or `@objectstack/spec/data` threw at import. The default lazy path was not affected. ## Measured before changing anything (`origin/main` `fdeeea0cc9`) - **Load chain**, taken with a `Module._load` hook on the source entry: `src/data/index.ts` → `data/query.zod.ts` → `data/filter.zod.ts` → `shared/strict-object.ts` → `shared/suggestions.zod.ts` → `data/field.zod.ts`. The throw is `ReferenceError: Cannot access 'FilterConditionSchema' before initialization` at `field.zod.ts:1529` (`relatedListFilter`), reached through the eager `lazySchema` at `:1000`. - **Whole-package sweep**: each of the 1039 non-test source modules taken as the first import of a fresh module graph under the flag: **22 threw**, 20 with the `FilterConditionSchema` signature and 2 (`data/authoring-key-lint.ts`, `kernel/metadata-authoring-lint.ts`) with `Cannot access 'FIELD_KEY_GUIDANCE' before initialization`, through the same `suggestions.zod.ts` → `field.zod.ts` edge. - **`FieldType` in `suggestions.zod.ts`** was read only inside `suggestFieldType` (`FieldType.options`), never while the module loads. - `scripts/lib/refinement-projection.ts` and `src/shared/refinement-projection.ts`, reported crashing elsewhere, do **not** crash as a first import under the flag (exit 0 in both probe shapes). No independent second cycle exists. ## The fix The fix is the edge the loop closes on. `suggestFieldType` and its alias table move out of `shared/suggestions.zod.ts` into `shared/field-type-suggestion.ts`, which imports `data/field.zod` in one direction only. After the move `suggestions.zod.ts` imports no schema module, and its header says why that must stay true. `shared/index.ts`, the root barrel and `error-map.zod.ts` import the function from its new home. **Public names are unchanged**: `suggestFieldType` is still exported from `@objectstack/spec` and `@objectstack/spec/shared`, same signature, same answers; `check:api-surface` reports "public API surface + factory signatures unchanged". `export-origins/` records the new declaration site. `lazy-schema.ts` and `field.zod.ts` are untouched, so both named fences hold. ## Ruling A, implemented: an allowlisted skill pointer publishes on existence Ruling: issue comment `5814528240` (batch #220 item 2, letter A, maintainer 「批 #220 同意」). Cutting the edge changed the textual import closure `gen:skill-refs` walks; the two `objectstack-i18n` rows of `TRANSITIVE_ALLOWLIST` were reached only through that edge, so the generator refused and `check:skill-refs` was red. - `packages/spec/scripts/lib/skill-map-guards.ts`: a `TRANSITIVE_ALLOWLIST` row is now a **declared** pointer. `checkTransitiveAllowlist(map, allowlist, existsInSpecSrc)` keeps its typo half (a package the map does not have), its dead-file half (a path not on disk), the already-core and listed-twice checks, and gains a does-not-ship half (a path outside `*.zod.ts`, which would 404 in a consumer's `node_modules`). Its reachability half is gone. The selection moved into `publishedPointers(coreFiles, closure, allowed, existsInSpecSrc)`: with no list the closure publishes unchanged; with a list the index publishes the core files plus every declared row that ships, and nothing the closure reached on its own. The docblock explains why a row publishes on existence and records the removed edge in the past tense. - `packages/spec/scripts/build-skill-references.ts`: the guard is handed the disk (`existsInSpecSrc`), not a closure; the emit loop calls `publishedPointers`. The up-front closure pass the guard needed is gone. - `packages/spec/scripts/skill-map-guards.test.ts`: an allowlisted, existing, unreachable pointer publishes; an allowlisted dead file is still refused (guard) and never emitted (selection); a non-allowlisted unreachable pointer is not published, with or without a list; a file that exists but does not ship is refused; with a list, a file the closure reached on its own stays out; with no list the closure publishes unchanged; rows come back sorted; the wiring pin asserts the generator hands the guard `existsInSpecSrc` and no longer reads `closures[skillName]`. 36 tests pass. - **Ablation** (commit-first, `scripts/ablation-replace.mjs`, expected direction stated before the run: red): re-introducing the reachability filter in `publishedPointers` (`&& closure.includes(rel)`, blob `99c90e3e` → `1cca6cf1`, anchor 1 → 0) turned exactly the two publishing tests red (`2 failed | 34 passed`) and `check:skill-refs` red naming `skills/objectstack-i18n/references/_index.md (out of date)`. Restore proven: blob == HEAD `99c90e3e`, `git diff HEAD` empty. ### Regenerated indexes (`pnpm --filter @objectstack/spec gen:skill-refs`, never by hand) - `objectstack-i18n/references/_index.md`: **byte-identical** to `origin/main` (`git diff --exit-code origin/main -- skills/objectstack-i18n/references/_index.md` exits 0). `data`, `ai`, `ui`, `platform` and `formula` are unchanged too. - `objectstack-query` (7 → 1 transitive pointers) loses `data/field-value.zod.ts`, `data/field.zod.ts`, `kernel/metadata-protection.zod.ts`, `shared/expression.zod.ts`, `shared/identifiers.zod.ts`, `shared/value-domain.zod.ts`; keeps `shared/suggestions.zod.ts`. - `objectstack-api` (18 → 13) loses `data/field-value.zod.ts`, `data/field.zod.ts`, `shared/expression.zod.ts`, `shared/identifiers.zod.ts`, `shared/value-domain.zod.ts`. - `objectstack-automation` (11 → 7) loses `data/field-value.zod.ts`, `data/field.zod.ts`, `data/filter.zod.ts`, `shared/value-domain.zod.ts`. - **None of them is taught by its skill.** Over each skill's authored markdown (every `.md` except the generated `references/_index.md`): by file stem (`field.zod`, `field-value`, `expression.zod`, `identifiers.zod`, `value-domain`, `metadata-protection`, `filter.zod`), 0 hits for every removed pointer in its own skill (query's one `filter.zod` hit names its own core file, which it keeps). By exported identifier, every `export` name of each removed file, word-bounded: no schema-level name (`FieldSchema`, `FilterConditionSchema`, `ExpressionSchema`, `IdentifierSchema`, `ValueDomain…`, `MetadataProtection…`) appears; the only collisions are ordinary English words some file also exports (`Field`, `Filter`, `FilterCondition`, `Expression`, `expression`, `cel`, `cron`, `P`, `Predicate`), for example the api routing row "Filter, sort, paginate, or aggregate a request → objectstack-query" and the automation heading "Filter tokens (`config.filter`)", a node-config slot whose shape lives in the core `builtin-node-config` / `io-node-config` pointers. ## Pin: `packages/spec/src/eager-entry-import.test.ts` Every module subpath in spec's own `exports` map is mapped to its source entry, 17 today. Each entry is imported first in a fresh child process with `OS_EAGER_SCHEMAS=1` and must not throw. Two controls keep a green meaningful: under the flag a known `lazySchema` export on `./data` (`FieldSchema`) and on `./api` (`ApiErrorSchema`) arrives as a built zod object (`typeof` is `object`), proving the flag reached the child; without the flag the same entries still import and the export stays the lazy Proxy (`typeof` is `function`). Source entries only: `@objectstack/spec#test` depends on `^build`, not its own build, so `dist/` is not guaranteed at test time, and `check:test-source-alias` wants unit verdicts on source. The bundles were measured by hand (below). ## Evidence on the merged tree (head `247d0611fc`, `origin/main` `61609edf81` merged via `scripts/pm/os-regen-merge.sh`) - **Merge**: 326 files from main; on this surface it brought `packages/spec/src/shared/lazy-schema.ts` (+49 / −2, the landed lazy-schema metadata aliasing) and `skills/objectstack-ui/SKILL.md`; no `_index.md`, no `skill-map-guards.ts`. Step 2 kept the branch's `export-origins/root.json` and `shared.json` (branch-only edits). After `pnpm install --frozen-lockfile` and a spec rebuild, `check:generated` read all 15 artifacts current, so the regeneration commit the script prescribes was empty. - **Cycle fix re-measured**: the pin passes (`Test Files 1 passed, Tests 20 passed`: 17 entries under the flag, 2 unflagged controls, 1 mapping). Dist, on the rebuilt `dist/` (`.build-input-hash` `61e345f25156fe1e…`): all **44** published JS files (main and browser conditions, `.js` and `.mjs`) exit 0 as a first import both with and without the flag (88 runs, 0 failures); on the 8 `/data` and `/api` files the probe export reads `object` under the flag and `function` without it. - **Spec suites**: `vitest run --project local --maxWorkers=2`: 533 files passed, 15675 tests passed, 2 todo. `--project repo`: 31 files, 579 tests passed. `typecheck` (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`): exit 0, 53 files / 255 errors / 142 pinned signatures held. - **Gates**: `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 97 families on this diff at `247d0611fc`; every one was run with its exit code landed on disk and reconciled with `--ran`: "97 derived famil(ies) accounted for — 95 run, 2 NOT-MEASURED, 0 UNRUN". 95 exit 0, among them `check:skill-refs` ("9 generated files in sync with packages/spec"), `check:generated` (15/15 current), `check:api-surface` (unchanged), `check:export-origins` (5260 exports across 17 entry points), `check:doc-authoring`, `check:skill-identifier-liveness`, `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:pm-governed-merges`, `check:pm-dispatch-gates` (1925 cases). NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 on their own prerequisite (every workspace package built; 66 have no `dist/` here); CI builds all. ## 维护者速读(草稿) **改了什么**:两件事。① 文档写的应急回退开关 `OS_EAGER_SCHEMAS=1` 一打开,`@objectstack/spec` 的 `/api`、`/data` 入口在导入时就崩;修法是把 `suggestFieldType` 搬到一个只单向引用字段 schema 的小文件里,断开那条导入环。公开导出一字不变。② 按您批的 A:技能说明书的「相关 schema 指针」允许清单,从「导入链够得着才发布」改成「文件存在且随包发布就发布」,只对清单内条目生效;生成器重跑后,`objectstack-i18n` 的索引逐字节不变,query / api / automation 三份索引各少掉几条从没教过的内部指针。 **为什么改**:一个会崩的回退开关比没有更糟;运维最需要它的时候正是怀疑延迟加载本身出问题的时候。而 i18n 那两个指针本来就是说明书教的内容,不是导入关系——靠一条偶然的环把它们带出来,恰恰是这张卡要拆的隐患。 **风险与代价(含回滚)**:运行时零风险——只搬了一个函数的声明位置,`check:api-surface` 读作公开面不变,17 个入口、44 个发布产物在开关下全部实测能导入。生成器一侧的代价是三份技能索引少了几条指针;实测这三份说明书的正文一个都没教过它们。回滚:整体 revert 本 PR 即可,不需要数据迁移。本 PR 改到 `skills/**`,按规矩 Tier H,由您合并。 **席位意见**:建议合并。达档复核 PASS(`5820632479`,head `247d0611fc`):裁决 A 逐条兑现,只对允许清单生效、不外扩;`objectstack-i18n` 索引逐字节不变;三份少掉的指针复核员逐一对过各自技能正文,全部没教过;公开导出面逐字节不变;两道围栏都守住。CI 32 项通过、3 项按设计跳过。 **你要做的**:确认 query / api / automation 三份索引少掉的指针清单(上文列出)可以接受;可以就合并。不合并也不会更糟——只是回退开关继续崩、门禁继续红。 ## Acceptance notes (noted, not filed) - Several comments still describe the removed edge in the present tense; the defensive code under them (hoisted functions, deferred error maps, lazily built sets) stays valid and is not removed here. Sites: the `shared/strict-object.ts` docblocks around `declarationStore`, `strictObjectError`, `PrimableErrorMap` and `closedObjectConstructor`; `shared/strict-object.test.ts` (the eager-construction block); `data/field-value.zod.ts:35`; `data/field.zod.ts:184`, fenced by the PR holding that file. The `scripts/lib/skill-map-guards.ts` docblock is rewritten in this PR because ruling A changes what it explains. Carrier: none. - The first-import crash reported for the two `refinement-projection` modules did not reproduce at `fdeeea0cc9` (see above). ## Contract review No accept or reject result changes, and the public export surface is byte-identical per `check:api-surface`; only the declaration site of one function moves, plus generator tooling and its generated `skills/**` output. The at-tier contract review the ruling asks for is the seat's record on the card. This PR touches `skills/**`, so it lands **Tier H** on the maintainer's merge. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e6ca17 commit 2274894

17 files changed

Lines changed: 525 additions & 176 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `OS_EAGER_SCHEMAS=1` rollback no longer crashes the `@objectstack/spec/api` and `/data` entries at import
6+
7+
`OS_EAGER_SCHEMAS=1` is the documented emergency rollback of the lazy-schema
8+
memory optimization. With it set, a process whose first spec import was
9+
`@objectstack/spec/api` or `@objectstack/spec/data` threw at import: the bundles
10+
failed with `Cannot read properties of undefined (reading 'optional')`, and the
11+
source with `Cannot access 'FilterConditionSchema' before initialization`. The
12+
default lazy path was not affected.
13+
14+
The cause was an import cycle. `shared/suggestions.zod` held a value import of
15+
`FieldType` from `data/field.zod`, only to feed `suggestFieldType`, and
16+
`shared/strict-object` (which nearly every closed schema imports) imports
17+
`suggestions.zod`. So `data/filter.zod` pulled `field.zod` in before it had
18+
finished loading, and `field.zod`'s eagerly built `FieldSchema` read
19+
`FilterConditionSchema` too early.
20+
21+
`suggestFieldType` now lives in its own module, and `suggestions.zod` imports no
22+
schema module. **Nothing an author or a consumer writes changes:**
23+
`suggestFieldType` is still exported from `@objectstack/spec` and
24+
`@objectstack/spec/shared` with the same signature and the same answers, and no
25+
schema accepts or rejects anything differently. Every published entry now
26+
imports cleanly as the first spec import under the flag, and a test pins that
27+
for each subpath in the `exports` map.

‎packages/spec/export-origins/root.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@
225225
"objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)",
226226
"partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)",
227227
"safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)",
228-
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
228+
"suggestFieldType": "src/shared/field-type-suggestion.ts#suggestFieldType (function)",
229229
"tmpl": "src/shared/expression.zod.ts#tmpl (function)"
230230
}
231231
}

‎packages/spec/export-origins/shared.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
"safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)",
121121
"singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)",
122122
"strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)",
123-
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
123+
"suggestFieldType": "src/shared/field-type-suggestion.ts#suggestFieldType (function)",
124124
"tmpl": "src/shared/expression.zod.ts#tmpl (function)",
125125
"unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
126126
}

‎packages/spec/scripts/build-skill-references.ts‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
checkCoreEntryShape,
3535
checkSingleOwner,
3636
checkTransitiveAllowlist,
37+
publishedPointers,
3738
stripInternalIssueIds,
3839
} from './lib/skill-map-guards';
3940

@@ -411,13 +412,11 @@ function main() {
411412
];
412413
let totalSkills = 0;
413414

414-
// The allowlist guard needs each package's closure, so the closures are
415-
// resolved once, up front, and reused by the emit loop below.
416-
const closures: Record<string, string[]> = {};
417-
for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) {
418-
closures[skillName] = resolveAll(coreFiles).files;
419-
}
420-
problems.push(...checkTransitiveAllowlist(SKILL_MAP, TRANSITIVE_ALLOWLIST, closures));
415+
// The allowlist guard asks whether each declared row exists on disk, never
416+
// whether the closure reaches it — see TRANSITIVE_ALLOWLIST for why a row is
417+
// a declaration and not a filter.
418+
const existsInSpecSrc = (rel: string): boolean => fs.existsSync(path.resolve(SPEC_SRC, rel));
419+
problems.push(...checkTransitiveAllowlist(SKILL_MAP, TRANSITIVE_ALLOWLIST, existsInSpecSrc));
421420

422421
for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) {
423422
const skillDir = path.resolve(SKILLS_DIR, skillName);
@@ -431,15 +430,11 @@ function main() {
431430
for (const m of missing) problems.push(`${skillName} → ${m} (no such file under packages/spec/src)`);
432431

433432
// A package that declares a transitive allowlist publishes its core files
434-
// plus exactly those pointers; one that declares none publishes the whole
435-
// closure, as before. See TRANSITIVE_ALLOWLIST for why the constraint is a
436-
// hand-authored list and not a rule over the import graph.
437-
const allowed = TRANSITIVE_ALLOWLIST[skillName];
438-
const coreSet = new Set(coreFiles);
439-
const allFiles =
440-
allowed === undefined
441-
? resolved
442-
: resolved.filter((f) => coreSet.has(f) || allowed.includes(f));
433+
// plus exactly those declared pointers, each on existence rather than on
434+
// reachability; one that declares none publishes the whole closure, as
435+
// before. See TRANSITIVE_ALLOWLIST for why the constraint is a hand-authored
436+
// list and not a rule over the import graph.
437+
const allFiles = publishedPointers(coreFiles, resolved, TRANSITIVE_ALLOWLIST[skillName], existsInSpecSrc);
443438
console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps`);
444439

445440
const refsDir = path.resolve(skillDir, 'references');

‎packages/spec/scripts/lib/skill-map-guards.ts‎

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,16 @@ export function checkSingleOwner(map: SkillCoreMap, shared: Record<string, strin
187187
/**
188188
* Which transitive pointers a package publishes, when the closure is wrong.
189189
*
190-
* ## The feasibility question this answers, decided before anything was built
190+
* A row here is a DECLARED pointer. It publishes when the file exists under
191+
* `packages/spec/src` and ships (`*.zod.ts` is what the package's `files`
192+
* allowlist sends to a consumer's `node_modules`), whether or not the textual
193+
* import closure of the package's core files reaches it. For a package with a
194+
* list, the closure decides nothing; the list does. A package with NO entry
195+
* here publishes its full closure, unchanged. Declaring a list is a claim about
196+
* that package's authorable face, and only a package whose face someone has
197+
* actually read should carry one.
198+
*
199+
* ## Why a list beside the map, and not a rule over the import graph
191200
*
192201
* The closure walks every local `import ... from` edge out of a package's core
193202
* files. Two routes were on the table for constraining it: (1) a REACHABILITY
@@ -197,14 +206,14 @@ export function checkSingleOwner(map: SkillCoreMap, shared: Record<string, strin
197206
* made precise. It cannot, and the measurement is specific rather than
198207
* hand-wavy:
199208
*
200-
* - `objectstack-i18n` publishes eight transitive pointers, and SEVEN of them
201-
* arrive through one edge -- `shared/strict-object.ts` imports
202-
* `shared/suggestions.zod.ts` for its "did you mean?" text, which imports
203-
* `data/field.zod.ts`, which drags in filter, expression, field-value,
204-
* identifiers and value-domain. That is a schema-building HELPER's
205-
* implementation, not the authorable shape of a translation bundle. Cutting
206-
* traversal through non-shipping helpers is the obvious precise rule, and it
207-
* removes five of the five pointers the finding names.
209+
* - `objectstack-i18n` published eight transitive pointers, and SEVEN of them
210+
* arrived through one edge -- `shared/strict-object.ts` imports
211+
* `shared/suggestions.zod.ts` for its "did you mean?" text, which at the
212+
* time imported `data/field.zod.ts`, which dragged in filter, expression,
213+
* field-value, identifiers and value-domain. That is a schema-building
214+
* HELPER's implementation, not the authorable shape of a translation
215+
* bundle. Cutting traversal through non-shipping helpers is the obvious
216+
* precise rule, and it removes five of the five pointers the finding named.
208217
* - It also removes `shared/identifiers.zod.ts`, which MUST STAY: a bundle's
209218
* object and field keys are the `snake_case` identifiers that file defines,
210219
* and the SKILL.md spends a table and a "Critical:" note on exactly that.
@@ -220,15 +229,30 @@ export function checkSingleOwner(map: SkillCoreMap, shared: Record<string, strin
220229
* not in the graph. Route 1 is therefore not merely unbuilt here; it is
221230
* unbuildable from this input, and route 2 is what ships.
222231
*
232+
* ## Why a row publishes on existence, not on reachability
233+
*
234+
* The list began as a FILTER over the closure: a row published only when some
235+
* import edge also reached the file, and the guard refused a row the closure
236+
* missed. That tied every declared pointer to whichever helper edge happened
237+
* to reach it. Both `objectstack-i18n` rows were reached through exactly the
238+
* `suggestions.zod.ts` -> `field.zod.ts` edge above, and that edge was one
239+
* side of an import cycle that made every `/api` and `/data` first import
240+
* throw under `OS_EAGER_SCHEMAS=1`, the documented rollback of lazy schemas.
241+
* Breaking the cycle removed the edge; as a filter, the list would then have
242+
* dropped both pointers the i18n SKILL.md teaches, and the generator refused
243+
* instead. Keeping a type-only import alive to steer this generator was
244+
* refused too: an edge kept for a generator is the accidental edge the fix
245+
* removes. So a row means what its comment says -- the SKILL.md teaches this
246+
* schema -- and the guard below asks only what a hand-authored row can get
247+
* wrong on its own: a package the map does not have, a file that is not there,
248+
* a file that would not ship, a row that is already a core entry, a row
249+
* written twice. Reachability is not one of those questions.
250+
*
223251
* The list is an ALLOWLIST, not a denylist, and that is the half that keeps it
224252
* from rotting the way the closure did: `shared/value-domain.zod.ts` joined the
225-
* i18n index recently, unnoticed, when a new import edge appeared several files
226-
* away. An allowlist cannot silently gain a row; a denylist silently misses
227-
* every new arrival.
228-
*
229-
* A package with NO entry here publishes its full closure, unchanged. Declaring
230-
* a list is a claim about that package's authorable face, and only a package
231-
* whose face someone has actually read should carry one.
253+
* i18n index unnoticed when a new import edge appeared several files away. An
254+
* allowlist cannot silently gain a row; a denylist silently misses every new
255+
* arrival.
232256
*/
233257
export const TRANSITIVE_ALLOWLIST: Record<string, readonly string[]> = {
234258
// Kept iff `skills/objectstack-platform/SKILL.md` names one of the module's
@@ -256,19 +280,29 @@ export const TRANSITIVE_ALLOWLIST: Record<string, readonly string[]> = {
256280
],
257281
};
258282

283+
/** Whether a `packages/spec/src`-relative path exists on disk; injected so the guard stays pure. */
284+
export type ExistsInSpecSrc = (rel: string) => boolean;
285+
286+
/** A path the published package carries: a `*.zod.ts` source that exists. */
287+
function shipsFromSpec(rel: string, exists: ExistsInSpecSrc): boolean {
288+
return rel.endsWith('.zod.ts') && exists(rel);
289+
}
290+
259291
/**
260-
* A declared transitive allowlist must name a real package and reachable files.
292+
* A declared transitive allowlist must name a real package and real, shipped files.
261293
*
262294
* The list is hand-authored, and a hand-authored list that can quietly say
263295
* nothing is the same defect one layer up: a typo'd package name would leave
264-
* the over-eager closure fully published while the map LOOKS constrained, and a
265-
* file the closure never reaches would read as a pointer that is being kept
266-
* when it was never there to keep.
296+
* the over-eager closure fully published while the map LOOKS constrained; a
297+
* file that is not on disk would read as a pointer that is being kept when
298+
* there is nothing to keep; a file outside `*.zod.ts` would publish a pointer
299+
* that 404s in every consumer's `node_modules`. Reachability through the import
300+
* closure is deliberately NOT asked -- see `TRANSITIVE_ALLOWLIST`.
267301
*/
268302
export function checkTransitiveAllowlist(
269303
map: SkillCoreMap,
270304
allowlist: Record<string, readonly string[]>,
271-
closures: Record<string, readonly string[]>,
305+
exists: ExistsInSpecSrc,
272306
): string[] {
273307
const problems: string[] = [];
274308
for (const [skillName, allowed] of Object.entries(allowlist)) {
@@ -281,7 +315,6 @@ export function checkTransitiveAllowlist(
281315
continue;
282316
}
283317
const core = new Set(coreFiles);
284-
const closure = new Set(closures[skillName] ?? []);
285318
const seen = new Set<string>();
286319
for (const rel of allowed) {
287320
if (seen.has(rel)) {
@@ -294,17 +327,52 @@ export function checkTransitiveAllowlist(
294327
`${skillName} → ${rel} is already a core entry; listing it as a transitive ` +
295328
`pointer says it is both, and the index would name it once regardless.`,
296329
);
297-
} else if (!closure.has(rel)) {
330+
} else if (!rel.endsWith('.zod.ts')) {
331+
problems.push(
332+
`${skillName} → ${rel} is in TRANSITIVE_ALLOWLIST but is not a *.zod.ts path — only ` +
333+
`those sources ship in @objectstack/spec, so this row would publish a pointer that ` +
334+
`404s in a consumer's node_modules. Point it at the schema file, or drop the row.`,
335+
);
336+
} else if (!exists(rel)) {
298337
problems.push(
299-
`${skillName} → ${rel} is in TRANSITIVE_ALLOWLIST but nothing in the package's ` +
300-
`core closure imports it — this row keeps a pointer that does not exist.`,
338+
`${skillName} → ${rel} is in TRANSITIVE_ALLOWLIST but no such file exists under ` +
339+
`packages/spec/src — this row keeps a pointer that does not exist. Point it at the ` +
340+
`schema's current path, or drop the row.`,
301341
);
302342
}
303343
}
304344
}
305345
return problems;
306346
}
307347

348+
/**
349+
* The pointer rows one package's index publishes, sorted.
350+
*
351+
* `closure` is the package's resolved import closure as the generator computed
352+
* it -- already restricted to shipped `*.zod.ts` files and already containing
353+
* the core files that exist. With no allowlist the closure is the answer,
354+
* unchanged. With one, the index publishes the core files plus every declared
355+
* row that ships from `packages/spec/src`, and nothing the closure reached on
356+
* its own: a declared row publishes whether or not any import edge reaches it,
357+
* and an undeclared file stays out whether or not one does. A declared row
358+
* that does not ship is left out here so the run cannot crash on it; the guard
359+
* above refuses the run for it by name.
360+
*/
361+
export function publishedPointers(
362+
coreFiles: readonly string[],
363+
closure: readonly string[],
364+
allowed: readonly string[] | undefined,
365+
exists: ExistsInSpecSrc,
366+
): string[] {
367+
if (allowed === undefined) return [...closure];
368+
const coreSet = new Set(coreFiles);
369+
const kept = new Set(closure.filter((rel) => coreSet.has(rel)));
370+
for (const rel of allowed) {
371+
if (shipsFromSpec(rel, exists)) kept.add(rel);
372+
}
373+
return [...kept].sort();
374+
}
375+
308376
/**
309377
* Every core entry must be a path this generator can actually publish.
310378
*

0 commit comments

Comments
 (0)