Skip to content

feat(spec): gate the schema-free principle — a declared browser-reachable entry must link no zod - #10398

Open
qq9340100 wants to merge 1 commit into
mainfrom
claude/issue-10199-schema-free-entry-gate
Open

feat(spec): gate the schema-free principle — a declared browser-reachable entry must link no zod#10398
qq9340100 wants to merge 1 commit into
mainfrom
claude/issue-10199-schema-free-entry-gate

Conversation

@qq9340100

@qq9340100 qq9340100 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes #10199

Mechanizes the standing principle minted by the 2026-08-20 maintainer ruling on #10096 — recorded verbatim, untranslated:

浏览器可达的 spec 导出面必须 schema-free。

Until now it bound only as prose (packages/spec/README.md "Export surfaces", src/index.ts's module doc). This adds check:browser-reachable-entries: it walks the built module graph behind every subpath in packages/spec's exports map and fails when one declared browser-reachable links zod — directly or through a relative hop — or links an external it has not declared.

The docs for the principle already landed in #10201; this card references them and does not rewrite them. No export surface and no bundling config is touched — the gate only observes.

The contract half

packages/spec/browser-reachable-entries.json classifies every subpath the exports map publishes into exactly one of three sections, reconciled in both directions (the check:generated ledger pattern):

  • browserReachable — must link no zod, and must link only the externals it declares. Today: ./meta-spelling, "externals": [].
  • unjudged — the server/build-time surfaces. The gate asserts nothing about them, so it is additive and shrink-only and raises no weakening question.
  • notAModule./openapi.json, ./package.json. A separate section from unjudged on purpose: "a module we chose not to judge" and "not a module at all" say different things to a reader.

Adding a subpath to the exports map without classifying it fails the gate, and so does a ledger row for a subpath nobody publishes any more. The file is hand-maintained, like dual-source-exports.baseline.json next door and for the same reason: promoting an entry is a contract decision a maintainer makes, not something a gen: command should be able to grant.

Instrument 1, with its spoof paths closed

The card asked for the cheap instrument (static scan of the built bundle) unless it proved spoofable, and for the choice to be documented either way. It is instrument 1 — because the spoof paths are closed, not assumed away. Each closure, with what it answers:

spoof closure
"it scans the wrong file" the file is resolved through the exports map (import.default and require.default — both format halves are scanned), not by the dist/{entry}/index.mjs convention
"it only reads one file" the walk is transitive over relative hops, and an unresolvable relative specifier is a hard error — an incomplete walk reporting "no zod" is the false green the gate exists to prevent
"it reads prose" specifiers count only where the statement keyword sits in code, decided by scripts/js-comment-mask.mjs, this tree's one answer to comment-vs-literal-vs-code
"zod stops being an external and gets inlined" a calibration assertion: across all scanned bundles the scan must find at least one real zod link. Today it finds 30

That last row is the reason there is no fragile inlined-zod content regex, and the reason instrument 2 is not needed. A specifier scan genuinely cannot see a zod that got bundled in — so the gate refuses rather than reporting every entry clean, naming the instrument instead of the surface. A zod-version-specific marker would need rewriting on every zod major; a calibration against whatever the tree actually builds cannot rot.

Instrument 2 (esbuild probe) buys exactly one thing over this: it measures the exports map's resolution rather than the build layout. Resolving through the exports map buys the same thing for no dependency and no bundle step, so it is not adopted. The script's header records the point at which to revisit it (a wildcard subpath or a conditional export tree a static scan cannot follow) — and the reconciliation is what will force that conversation rather than letting it pass quietly.

The prose row is not hypothetical. dist/index.mjs really does carry the text from '@objectstack/spec/ui' inside a reason: string. A naive grep reports it as an import; the masked scan does not. Both directions are pinned in the self-test.

Reads BUILT output — an unbuilt tree is NOT MEASURED, never "not applicable"

On a missing dist, "found no zod link" and "read no bundle" would be the same green; on a stale one the verdict describes a build that predates the import under test. Both are hard refusals naming the build command (#4690's class), via a new inspectBundleFreshness in packages/spec/scripts/lib/dist-freshness.ts.

It is a new axis, not a second copy, and the reason is measured: packages/spec's build is two tsup passes, and they disagree in both directions.

  • BUILD_DTS=true tsup refreshes declarations and re-emits no bundle — so a .d.ts-fresh tree can hold bundles older than the edit under test. Reusing inspectDistFreshness would have been wrong in the dangerous direction.
  • OS_SKIP_DTS=1 emits fresh bundles and skips declarations — so distIsStale reports stale for a tree whose bundles are exactly current, and the gate would refuse a build that could have answered.

The rule itself (bundlesAreStale) lands next to distIsStale and schemaTreeIsStale in scripts/check-regen-pending.mjs — the one home for "is packages/spec/dist current", which already documents adding a third axis with its deliberate differences. tsup.config.ts counts as a source input there, because it is the one file whose edit invalidates this gate's measurement most directly: it decides the entries, the externals, and whether entries stay self-contained.

Reverse verification — every limb observed red, then restored

All four ablations were run from the committed state and reverted; the ledger ones are content-only, so no rebuild is involved and the mutation was confirmed on disk before each run.

ablation mutation confirmed on disk observed
declare ./shared browser-reachable browserReachable-shared 0 → 1, unjudged-shared 1 → 0 red, 2 problems — zod named in both dist/shared/index.mjs and index.js
declare . browser-reachable root-in-browserReachable 0 → 1, root-in-unjudged 1 → 0 red, 4 problems — zod ×2 and undeclared external pg-connection-string ×2
drop ./ui from the ledger ui-in-unjudged 1 → 0 red, 1 problem — './ui' is in package.json's exports map but is classified in NO section
touch tsup.config.ts (mtime only, content unchanged) mtime 17872469831787248143, newest bundle 1787247153 redNOT MEASURED, naming pnpm --filter @objectstack/spec build

Restore leg: ledger restored with git checkout HEAD --, bundle mtimes restored, gate green again — 1 declared browser-reachable entry links no zod; 34 bundle(s) scanned, 30 zod link(s) seen elsewhere (instrument calibrated); exports map fully classified. git status clean, so every gate below ran against aad47f546 exactly.

Self-test — 15 cases, and it is load-bearing here

Wired the way its neighbours are (--self-test && the audit). It carries more weight than usual for one specific reason: this tree has zero relative hops between bundles (splitting: false, every entry self-contained), so the transitive walk — the part that keeps the gate honest the day splitting is turned on — can only be exercised against fixtures. It ships having run.

Covered: the five specifier spellings; the five lookalikes that must not register (real prose from dist/index.mjs, commented-out imports, Array.from/Buffer.from, a from: key); the chunk-chain walk and a zod link found two hops deep that a one-file scan provably misses; an unresolvable hop reported rather than walked past; the four freshness verdicts including the tsup.config.ts limb; the three reconciliation failures plus the accepting case; and that both format halves of a subpath are scanned.

Gates

node scripts/pm/dispatch-gates.mjs derived 23 families from the actual 9-path diff; all ran green, at aad47f546:

check:merge-driver · check:node-version · check:override-consistency · check:release-page-status · check:required-contexts · check:shard-attestation · check:slot-lookup · check:type-check-coverage · check:type-source-resolution · check:workflow-status-functions · check:cross-package-test-inputs · check:nul-bytes · check:entry-guard · check-changeset-fixed · check-osv-exemptions · check-affected-docs · spec check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs

Plus the ones implicated by what the diff actually touches: pnpm --filter @objectstack/spec typecheck green (tsc --noEmit + check:scripts-typecheck, which is what judges the new .ts gate, + check:test-typecheck); check:generated --reconcile-only green — "24 check: + 17 gen: scripts, all classified (14 gated, 8 source audits, …)", the new script among the source audits; and the four pin tests over the files this edits (dist-freshness, dist-freshness-adoption, check-generated-ledger, schema-tree-freshness) — 37 passed.

Two declared narrowings, both the same environment precondition and neither able to see this diff: check:type-check-debt --re-measure and check-dev-prereqs refuse because only @objectstack/spec is built in this worktree, not the full workspace closure — each is its own refuse-outright guard firing correctly. CI builds the closure before both. The structural half, check:type-check-coverage, ran green here.

One documented boundary: scripts/dist-freshness-adoption.test.ts pins the real CLI refusing on a stale tree for check:dual-source-exports and check:exported-any by name. This gate is not added to it — its fixture builds .d.ts files for the declaration axis, and adapting the shared helper for the bundle axis would reach into fixtures three describe blocks depend on. The equivalent coverage is in this gate's own --self-test (both freshness verdicts) plus ablation 4 above, which drove the real CLI on the real tree.

Changeset

skip-changeset — this releases nothing. The diff is a gate script, a repo-only ledger, a CI step, two root type-declaration files and a shared staleness predicate. Nothing published moves: packages/spec's files field is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json — and neither scripts/ nor browser-reachable-entries.json is among them. The package.json edit adds a scripts entry only. An empty-frontmatter changeset is not the route here: #5471 ruled it shut for new files, and the label is the gate-level exemption that produces no input for changesets/action.

Out of scope, filed not fixed


Generated by Claude Code

…able entry must link no zod

Mechanizes the standing principle minted by the 2026-08-20 ruling on #10096
(「浏览器可达的 spec 导出面必须 schema-free」), which until now bound only as prose
in packages/spec/README.md and src/index.ts.

check:browser-reachable-entries walks the BUILT module graph behind every subpath
in the exports map and fails when one declared browser-reachable in
browser-reachable-entries.json links zod — directly or through a relative hop — or
links an external it has not declared.

Instrument 1 (static scan of the built bundle), with its spoof paths closed:
the file is resolved through the exports map, the walk is transitive over
relative hops, specifiers are accepted only where js-comment-mask says the
keyword sits in code, and a calibration assertion refuses the run if the scan
finds zero zod links anywhere — the one shape a specifier scan cannot otherwise
tell apart from a clean surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file tooling labels Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/browser-reachable-entries.json) — pages documenting those are invisible to this run

Coarse fallback — 116 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b9a5c46dc964a587a99ee508560080222ec9b4eepackageMentionDocs.

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

Labels

domain:spec skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mechanize the schema-free-export principle as a gate: a declared browser-reachable spec entry must link no zod in its module graph

2 participants