Skip to content

fix(gate): correct why check-published-files lets packages/spec ship its zod sources — a read route, not an import route - #19468

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-19009-published-files-zod-glob
Sep 21, 2026
Merged

os-warren merged 3 commits into
mainfrom
claude/issue-19009-published-files-zod-glob

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #19009

Clause-②: yes

The experiment the card said nobody had run

The gate scripts/check-published-files.mjs lets @objectstack/spec ship src/**/*.zod.ts in files[], and states why, verbatim:

The Zod schemas are themselves the contract (Prime Directive #1); downstream code imports them directly, so these sources are product rather than build input.

The card measured both halves of that sentence statically and said plainly that the decisive experiment — pack the tarball, install it, import a shipped .zod.ts from a real consumer — had not been run. It is run here, first, before any edit.

npm pack of packages/spec at this branch's base, installed into a scratch consumer, then every spelling of the deep import:

probe result
ESM @objectstack/spec/src/data/object.zod.ts ERR_PACKAGE_PATH_NOT_EXPORTED
ESM, no extension, and .js extension ERR_PACKAGE_PATH_NOT_EXPORTED
CJS require.resolve(...) ERR_PACKAGE_PATH_NOT_EXPORTED
esbuild bundle of the deep specifier The path "./src/data/object.zod.ts" is not exported by package
tsc, moduleResolution: bundler TS2307
tsc, moduleResolution: nodenext TS2307
literal relative path into node_modules ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING

Lit controls, same install, same run: @objectstack/spec/data resolves with 490 exports, the root entry with 137, require.resolve of ./package.json returns a path, and esbuild bundles the declared subpath to 1.4 MB. The zeros above are readings, not a dead harness.

One resolver mode does reach the files, and it measures both halves of the card at once: tsc --moduleResolution node10, which ignores exports maps. It opens the shipped source and then fails — 120 errors, the first Cannot find module '../shared/lazy-schema' — and in that same mode the package's own declared subpath @objectstack/spec/data does not resolve either. It is not a mode in which this package is consumable at all.

So the card's first half holds: no consumer can import these files. The stated reason's mechanism is false.

What the files are actually for

Re-measured on this branch: 190 of the 203 shipped *.zod.ts modules carry a relative import onto one of 42 src/ modules the glob does not ship, and src/shared/lazy-schema.ts alone is named by 182. The card's second half holds too.

But both halves only bite if the point is to IMPORT them, and it is not. The published skills catalog points agents at these files BY PATH, to read inside a consumer's node_modules. skills/README.md states the mechanism itself:

a references/_index.md that points into the authoritative Zod sources in node_modules/@objectstack/spec/src/... (the published @objectstack/spec package ships these .zod.ts sources, so the pointers resolve in consumer apps too)

Those index files are generated from this very glob by packages/spec/scripts/build-skill-references.ts, whose own guard already states the dependency from the other end:

resolveAll() keeps only *.zod.ts from the closure, because the published package's files allowlist ships those sources and nothing else — a pointer to any other src file 404s in a consumer's node_modules.

Measured with the tarball in hand: 170 pointers across 9 published index files, 0 of them outside the glob. check:skill-refs is green, reporting 9 generated files in sync with packages/spec.

That is also why the 190-of-203 count is a property of the glob rather than a defect in it: a reader never resolves those imports.

The route taken, and the two that were not

Correct the reason. The entry stays; the sentence justifying it now names the read route instead of an import route that has never been open. The long-form rationale sits in a comment above the entry so the next reader does not re-derive the two wrong repairs:

  • Do not open the exports map to make the old sentence true. It would advertise a route broken for 190 of 203 files — a machine-readable surface that lies (Route and surface ownership rule 4; Prime Directive chore: version packages #10).
  • Do not drop the entry as unreachable payload. It is reachable product, by read rather than by resolve, and dropping it makes every generated pointer in the shipped catalog 404 at once. Triage's boundary applies and is respected here: that would be removing a published capability, and the measurement above is what shows the capability is real rather than absent.

No downstream resolution result changes. This diff edits a reason string and a comment in one repo-root gate script. Zero published bytes move — the tarball is byte-identical before and after — so triage's pm:retriage condition is not met.

Cost of this glob, measured here

The card pointed at #16045's 12,661,943-byte reading as the same question asked of a different files[] entry, explicitly not as this entry's cost. Measured for THIS glob, from the packed tarball: 5,557,289 bytes uncompressed across 203 files, 3.8% of the unpacked tree (tarball 28,299,508 bytes compressed, 147,205,318 uncompressed). Recorded as a reading; it vetoes nothing.

Hold #8133 — restart trigger

#8133 is open and pm:on-hold, carrying Restart-when: any PR touches the packages/spec build/publish pipeline (tsup config, or the exports map in packages/spec/package.json).

This PR does not trip that trigger. It touches neither the tsup config nor the exports map — packages/spec/package.json is not in this diff at all. What a reader of that hold needs from here:

  • the exports map was read in this work and is unchanged: 19 subpaths, none of them a ./src/* and no wildcard;
  • one branch considered and rejected here WOULD have tripped it — opening the map to admit the src subpath. It was rejected on the measurement above, so the hold keeps its standing and its blast radius is untouched;
  • #8133 remains open and is not addressed here.

Stated by hand rather than left to the patrol: the half-state patrol's H17 trigger-file index does not list #8133, because that index carries only tracked file paths and this hold's trigger is written as prose. The index says of itself that it under-reports and never invents, so its silence on this file is a reach limit and not a clear.

Gates

Derived from the actual changed path with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every exit code captured before any pipe, reconciled with --ran.

Readings are labelled by which side of the edit they came from, because this PR edits the gate it also runs:

  • pre-edit: node scripts/check-published-files.mjs exit 0, and --self-test exit 0.
  • post-edit: all 31 derived families exit 0 at the merged head, check:published-files and its self-test among them.
  • check:pm-dispatch-gates and check:watch-hint-literal are both in that set and green. That is the guard that matters most here: the new prose lives in comments and in one long string, so it declares no watch hint and fabricates no lead for this gate — the failure mode its own build-time script entry warns about.
  • --ran reconciliation: 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.

Lint, narrowed with the narrowing proven rather than assumed: eslint --no-inline-config --format json over the one changed file gives 1 file, 0 errors, 0 warnings, exit 0. The population is read from eslint's own config via isPathIgnored over the 9,096 tracked files: 6,946 files in the lint population. The config never enables type-aware linting — no parserOptions.project, no projectService, the only textual hit being the config's own comment saying so — so a one-file diff cannot move any untouched file's verdict. The repo-wide sweep stays CI's run.

Consumer-package tests:

  • @objectstack/downstream-contract — 3 files, 31 tests, all passed. This is the harness that resolves named out-of-repo consumer specifiers out of a packed tarball, so it is the on-point consumer reading for a publish-surface card. Its first run refused with a stated prerequisite (@objectstack/cli not built), which is the harness working as designed; re-run after pnpm --filter '@objectstack/cli...' build.
  • @objectstack/spec — 506 files, 14,797 tests, all passed.
  • pnpm --filter @objectstack/spec check:skill-refs exit 0; check:skill-docs exit 0.

Changeset

skip-changeset. Nothing in this diff publishes: the only changed file is scripts/check-published-files.mjs, the root manifest is private, and this gate's own FORBIDDEN rule bars scripts/ from every package tarball. Verified mechanically against the packed spec tarball — 0 hits for the changed file, with a positive control (package/src/data/object.zod.ts, 1 hit) proving the query was alive.

For review: the claim declared Clause-②: yes because one live branch, opening the exports map, expands the public surface. That branch was measured and rejected, so the landed diff expands nothing and publishes nothing. The yes is kept as declared.

Acceptance notes

Observations from this work, deliberately not filed and not fixed here:

  • The glob-to-catalog dependency is one-directional in prose. The generator's guard says it filters to *.zod.ts because the files allowlist ships those and nothing else; after this PR the gate says the catalog is why the allowlist has them. Neither names the other by path, and nothing fails mechanically if one side moves. No PR and no person is carrying this today — successor: none. packages/spec/scripts/** is also held by an open sibling PR from this seat, so the back-pointer half was out of reach here regardless.
  • files[] carries a bare README.md entry, which npm matches at any depth, so package/src/migrations/entries/README.md rides into the tarball — the single non-.zod.ts file under src/ in it. Observation only: one small file, and the entry is canonical.
  • The card's static readings drifted between filing and this branch — 188 of 202 over 35 modules there, 190 of 203 over 42 here. Direction unchanged; recorded so the counts in the gate comment are not read as contradicting the card.
  • My own first pass at the import measurement resolved relative specifiers without mapping the NodeNext .js spelling back to .ts, which counted five shipped files as unshipped targets. Corrected in a second commit on this branch before the numbers were relied on; the corrected run leaves 0 unresolved specifiers.

Generated by Claude Code

`check-published-files` allowed `src/**/*.zod.ts` in the spec package's
`files[]` on the ground that "downstream code imports them directly, so
these sources are product rather than build input".

Measured against a real packed tarball, the mechanism in that sentence is
false: the exports map declares no `./src/*` subpath and no wildcard, so
Node (ESM and CJS), esbuild and tsc all refuse the deep specifier, and a
literal path into node_modules cannot load a `.ts` file there either.
191 of the 203 shipped modules also import one of 44 src/ modules the
glob does not ship, so a route that reached them would not load them.

The conclusion of that sentence is nevertheless true, for a different
mechanism: the published skills catalog points agents at these files by
path, to READ in a consumer's node_modules, and each skill's reference
index is generated from this very set -- 170 pointers across 9 published
index files, all inside the glob. Correct the reason to name the read
route, and record why neither opening the exports map nor dropping the
entry is the repair the false reason invites.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
The first measurement resolved relative specifiers without mapping the
NodeNext `.js` spelling back to `.ts`, so five `.js` specifiers were
counted as unshipped targets when the files are shipped (`tenant.zod.ts`
and `date-macros.zod.ts` among them). Re-measured with that mapping and
zero unresolved specifiers left: 190 of 203, over 42 modules, not
191 over 44. `src/shared/lazy-schema.ts` at 182 importers is unchanged.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
@os-warren os-warren added skip-changeset PR has no user-facing published change; bypasses the changeset gate needs:contract-review labels Sep 21, 2026 — with Claude

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b9c80eb161c25fe3f676db9dc50e5b9b05014ca4

Isolated review at the contract-review tier for the domain:spec lane, from a worktree pinned to the head sha and from the GitHub record (card #19009, triage 5729704088, report 5754192305, the PR body and the full diff). Every number below was re-taken here, never copied; what could not be re-taken is listed as NOT MEASURED at the end and counts as nothing.

① Derived judgments

Shape of the diff, re-measured. git diff --stat 8f6d831 b9c80eb (the merge base with main) is exactly one file, scripts/check-published-files.mjs, +43/-1: a comment block above the src/**/*.zod.ts row of EXTRA_ENTRIES['@objectstack/spec'] and a new value for that row's reason string. No path under packages/, no .changeset/, no manifest. The base-side sentence at 8f6d831 is byte-for-byte the one the card quoted.

Accept/reject behaviour of the gate: unchanged, by construction. The reason string is consumed at exactly one site, REGISTERED (if (!(pattern in registered)), around line 1498): a key-existence lookup. The value is never read, printed, matched or compared. So the set of manifests the gate accepts and refuses at the head is identical to the base, and the entry it governs still admits the same 203 files. Run here at the head with no node_modules: node scripts/check-published-files.mjs exit 0 (70 publishable packages, 1 with registered extras); --self-test exit 0. Judgment: correct — a reason string is documentation the gate carries, not a predicate the gate evaluates.

Claim 1, the experiment — re-run where an install is not required, and it reproduces. From the head's own packages/spec/package.json plus its 203 src/**/*.zod.ts files laid out as node_modules/@objectstack/spec in a scratch consumer (Node v22.22.2, tsc 6.0.2):

  • ESM @objectstack/spec/src/data/object.zod.ts, .zod and .zod.js: ERR_PACKAGE_PATH_NOT_EXPORTED, all three; import.meta.resolve of the deep specifier: the same code.
  • CJS require.resolve of the .ts and extensionless spellings: ERR_PACKAGE_PATH_NOT_EXPORTED, both.
  • Literal ./node_modules/@objectstack/spec/src/data/object.zod.ts: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
  • tsc --moduleResolution bundler and nodenext, .ts and .js spellings: TS2307 all four. --traceResolution discriminates the refusal from an unbuilt package: the deep specifier reads Export specifier './src/data/object.zod.ts' does not exist in package.json scope, while the ./data control reads Using 'exports' subpath './data' with target './dist/data/index.d.mts' and fails only because dist/ is not built here.
  • Lit controls in Node, same consumer: import.meta.resolve('@objectstack/spec/data') resolves to dist/data/index.mjs and the root to dist/index.mjs (each then ERR_MODULE_NOT_FOUND, a different code, because dist/ is unbuilt); @objectstack/spec/package.json resolves to a path under both ESM and CJS. The zeros are readings, not a dead harness.
  • Static, re-measured on the head with the NodeNext .js spelling mapped back to .ts: 203 shipped modules, 190 import at least one of 42 src/ modules the glob does not ship, src/shared/lazy-schema.ts named by 182, 0 unresolved specifiers; controls discriminate (lazy-schema.ts on disk and unshipped, object.zod.ts shipped). Identical to the numbers in the diff comment.
  • Manifest: 19 exports subpaths, none ./src/*, no wildcard, no typesVersions, no lifecycle scripts.

Judgment: the card's first half (the route is closed) and second half (most files could not load if it were open) both hold on the head. The dev's table is internally coherent with the manifest and with Node's documented behaviour; the parts that need a built dist/ and an install (esbuild, the 490/137-export controls, the 1.4 MB bundle) were not re-run and are recorded below as NOT MEASURED.

Claim 2, the pivot — verified, it holds. Both quoted prose sites exist: skills/README.md:23-27 says the pointers go into node_modules/@objectstack/spec/src/... because the published package ships these .zod.ts sources (verbatim), and packages/spec/scripts/build-skill-references.ts:269-273 filters its closure to .zod.ts because only src/**/*.zod.ts ships and a pointer to any other src file 404s in a consumer's node_modules (the PR body's blockquote is a paraphrase of that comment, not verbatim — substance identical). Census here: 9 skills/*/references/_index.md files, 170 pointers, all spelled node_modules/@objectstack/spec/src/**/*.zod.ts, 0 outside the glob, 0 not on disk (per-file: ai 17, api 25, automation 20, data 44, formula 1, i18n 4, platform 15, query 10, ui 34; cross-check by .zod.ts mentions gives the same 170). The catalog is published: skills/README.md:5-10 names npx skills add objectstack-ai/objectstack/skills and npm create objectstack, and packages/create-objectstack points every scaffold at that command. The index files themselves instruct a reader to Read the schema by path and to import runtime values from the matching subpath export — the read route in the catalog's own words. Judgment: the entry is reachable published capability, by read; dropping it 404s 170 pointers in a Tier H governed catalog. The route choice stands.

Claim 3, Clause-②: yes and byte identity. Nothing under packages/ moves; npm pack --dry-run --json of packages/spec at the head (no build, no deps) lists 276 entries: no scripts/ path at all, 203 src/**/*.zod.ts, and src/migrations/entries/README.md as the only non-zod file under src/ (the dev's observation, confirmed). The tarball's inputs are unchanged between base and head, so no downstream resolution result moves and triage's pm:retriage condition is genuinely unmet. On the declaration: SKILL.md defines Clause-② as widening the accept set or expanding a published surface, and check-changeset-no-major.mjs spells it as "this PR puts a new key on a published payload". This diff does neither, so as a description of the landed diff the correct value is no, and the PR body says as much ("the landed diff expands nothing and publishes nothing"). That is not a fault and not a FAIL ground: the contract-review reference states the declaration is provisional and conservative by design (claim 拿不准 ⇒ yes), the review is the real gate, and a declaration the review overturns is no seat fault. The changeset gate reads this shape clean (a yes with no moved packages/**/src/** has nothing to refuse). This record is the authority on the level and concurs with ② below.

Claim 4, node10. Reproduced: tsc --moduleResolution node10 --ignoreDeprecations 6.0 opens node_modules/@objectstack/spec/src/api/errors.zod.ts and fails on ../shared/lazy-schema and ../shared/retired-key (92 errors here with no zod installed; the dev's 120 was with deps — count differs, shape identical), and in that same mode @objectstack/spec/data is TS2307 too, structurally: node10 ignores exports, and the package has no typesVersions and no root-level data/. Judgment: this is not 「import 并成功解析」. The mode reaches a file it cannot load and cannot resolve the package's own declared subpaths; no consumer runs against this package in it. No re-grade to p3 is owed and the PR's framing is right.

Claim 5, the two refused routes. Opening the exports map would advertise a route broken for 190 of 203 files; Route & surface ownership §4 (AGENTS.md:855, "Machine-readable surfaces must not lie") and the Prime Directive #10 corollary (AGENTS.md:211, never advertise a capability the runtime does not deliver) both exist and say what the diff comment attributes to them. Dropping the entry would 404 the 170 pointers measured above. Both refusals survive contact with the tree.

Claim 6, hold #8133. The PR names it and quotes its Restart-when: (tsup config, or the exports map in packages/spec/package.json); neither path is in the diff and packages/spec/package.json is unchanged. The hold is open and pm:on-hold, and is not tripped. Correct.

Governed register. The one changed path matches none of the six GOVERNED_SURFACES rows (docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md): 0 of 1, confirmed. No ADR governs this entry's reason (ADR-0122 mentions the glob only incidentally). No other file at the head mirrors the old or new reason string.

② Semver level

skip-changeset, correctly. Ground: the diff publishes nothing from any released package — the single changed file is a repo-root gate script, the root manifest is private: true, the gate's own FORBIDDEN rule bars scripts/ from every tarball, and the dry-run pack of the only package the entry belongs to shows no scripts/ path. The PR edits no .changeset/ file (git diff --name-only 8f6d831 b9c80eb -- .changeset is empty), so decision batch #158 item 1 (skip-changeset is not applied to a PR that edits an existing changeset) does not attach and the label is the ordinary case. The Clause-②: yes line and skip-changeset do not contradict here for the reason given under Claim 3: the declaration over-states the diff in the protocol's designed direction, and the level this record concurs with is the one that describes the diff.

③ Boundary flags

  • Gate weakening — no. The manual floor defines it as 降阈 / 删必查 / 抬上限 / 跳测. None occurs: no threshold, required check, cap or test moves, and the accept/reject set is identical because the edited string is never evaluated. Correcting a false justification to a true one strengthens what the gate is standing on; a reader who removes this entry on the strength of the old sentence would have been acting on a reason that never held. Not floor contact.
  • Published-contract change — none. Zero bytes of any tarball move; the exports map is untouched; the accept set of every published schema is untouched.
  • Removing a published capability — none, and explicitly avoided. The measured read route (170 pointers, 9 files) is what the PR preserves; the route that would have removed it was refused on measurement.
  • Security / permission boundaries — none touched.
  • New required gate, hook or ratchet — none. No new check, no new watch hint (the new prose is comments and one string in an existing registry).
  • New runtime dependency — none.
  • Dev flags and open_questions: open_questions is empty. The three acceptance notes are accepted as notes: the one-directional prose dependency between the generator's guard and this reason (nothing fails mechanically today, 0 dead pointers measured here too, successor none); the bare README.md entry riding src/migrations/entries/README.md into the tarball (confirmed, one file, canonical entry); the count drift between the card and the head (direction unchanged).

NOT MEASURED here, named so nothing below reads as a pass: esbuild 0.28.1 probe (no esbuild on this host); the load-side lit controls (490 exports on ./data, 137 on root, 1.4 MB bundle) and the real npm pack sizes (28,299,508 B / 147,205,318 B) — each needs a built dist/ and an install; a literal byte-diff of two packed tarballs (same reason — the identity claim is supported here by the unchanged inputs and the dry-run file list, not by two tarball hashes); pnpm --filter @objectstack/spec check:skill-refs (needs tsx; my independent pointer census is the substitute reading); the @objectstack/downstream-contract and @objectstack/spec test suites (need deps); check:pm-dispatch-gates (started here without deps twice, once with and once without its self-test; neither run concluded inside the time available, so it is unmeasured — its sibling node scripts/check-watch-hint-literal.mjs did run to completion at the head and exited 0: 71 declarations, 158 literals admitted); CI Lint & Repo Gates was in_progress on this head when read (23 check-runs success, 15 skipped, 0 failed); the text of decision batch #158 (search endpoints are not available to this session — its precondition was measured directly instead).

Implemented-by: claude/issue-19009-published-files-zod-glob
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants