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
Open
feat(spec): gate the schema-free principle — a declared browser-reachable entry must link no zod#10398qq9340100 wants to merge 1 commit into
qq9340100 wants to merge 1 commit into
Conversation
…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
Contributor
📓 Docs Drift CheckNothing 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
Coarse fallback — 116 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
qq9340100
marked this pull request as ready for review
August 20, 2026 18:16
qq9340100
enabled auto-merge
August 20, 2026 18:16
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10199
Mechanizes the standing principle minted by the 2026-08-20 maintainer ruling on #10096 — recorded verbatim, untranslated:
Until now it bound only as prose (
packages/spec/README.md"Export surfaces",src/index.ts's module doc). This addscheck:browser-reachable-entries: it walks the built module graph behind every subpath inpackages/spec'sexportsmap 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.jsonclassifies every subpath theexportsmap publishes into exactly one of three sections, reconciled in both directions (thecheck:generatedledger 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 fromunjudgedon 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
exportsmap without classifying it fails the gate, and so does a ledger row for a subpath nobody publishes any more. The file is hand-maintained, likedual-source-exports.baseline.jsonnext door and for the same reason: promoting an entry is a contract decision a maintainer makes, not something agen: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:
exportsmap (import.default andrequire.default — both format halves are scanned), not by thedist/{entry}/index.mjsconventionscripts/js-comment-mask.mjs, this tree's one answer to comment-vs-literal-vs-codeThat 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.mjsreally does carry the textfrom '@objectstack/spec/ui'inside areason: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 newinspectBundleFreshnessinpackages/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 tsuprefreshes declarations and re-emits no bundle — so a.d.ts-fresh tree can hold bundles older than the edit under test. ReusinginspectDistFreshnesswould have been wrong in the dangerous direction.OS_SKIP_DTS=1emits fresh bundles and skips declarations — sodistIsStalereports 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 todistIsStaleandschemaTreeIsStaleinscripts/check-regen-pending.mjs— the one home for "ispackages/spec/distcurrent", which already documents adding a third axis with its deliberate differences.tsup.config.tscounts 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.
./sharedbrowser-reachablebrowserReachable-shared0 → 1,unjudged-shared1 → 0dist/shared/index.mjsandindex.js.browser-reachableroot-in-browserReachable0 → 1,root-in-unjudged1 → 0pg-connection-string×2./uifrom the ledgerui-in-unjudged1 → 0'./ui' is in package.json's exports map but is classified in NO sectiontouch tsup.config.ts(mtime only, content unchanged)1787246983→1787248143, newest bundle1787247153NOT MEASURED, namingpnpm --filter @objectstack/spec buildRestore 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 statusclean, so every gate below ran againstaad47f546exactly.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 daysplittingis 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, afrom: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 thetsup.config.tslimb; the three reconciliation failures plus the accepting case; and that both format halves of a subpath are scanned.Gates
node scripts/pm/dispatch-gates.mjsderived 23 families from the actual 9-path diff; all ran green, ataad47f546: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· speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docsPlus the ones implicated by what the diff actually touches:
pnpm --filter @objectstack/spec typecheckgreen (tsc --noEmit+check:scripts-typecheck, which is what judges the new.tsgate, +check:test-typecheck);check:generated --reconcile-onlygreen — "24check:+ 17gen: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-measureandcheck-dev-prereqsrefuse because only@objectstack/specis 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.tspins the real CLI refusing on a stale tree forcheck:dual-source-exportsandcheck:exported-anyby name. This gate is not added to it — its fixture builds.d.tsfiles 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'sfilesfield isdist,json-schema,liveness,prompts,llms.txt,README.md,src/**/*.zod.ts,CHANGELOG.md,api-surface,spec-changes.json— and neitherscripts/norbrowser-reachable-entries.jsonis among them. Thepackage.jsonedit adds ascriptsentry 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 forchangesets/action.Out of scope, filed not fixed
@objectstack/spec/contractsalready links no zod — a free candidate for the declared browser-reachable list #10397 —@objectstack/spec/contractsis the other entry that links no zod today (12.7 KB, zero externals), but only incidentally. Promoting it is a contract decision needing a real-consumer measurement, so it is classifiedunjudgedhere and filed as afindingrather than declared in this PR.Generated by Claude Code