Skip to content

Commit 0681a76

Browse files
Jack Qclaude
andauthored
feat(spec): gate the schema-free principle — a declared browser-reachable entry must link no zod (#10398)
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. Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7679f8b commit 0681a76

9 files changed

Lines changed: 1038 additions & 5 deletions

File tree

.github/workflows/lint.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,6 +2179,40 @@ jobs:
21792179
- name: Check no new same-name dual-source spec exports
21802180
run: pnpm --filter @objectstack/spec run check:dual-source-exports
21812181

2182+
# [#10199] The mechanized form of the 2026-08-20 ruling on #10096:
2183+
# 「浏览器可达的 spec 导出面必须 schema-free」. Walks the built module graph
2184+
# behind every subpath the exports map publishes and fails if one DECLARED
2185+
# browser-reachable (browser-reachable-entries.json — today `./meta-spelling`)
2186+
# links zod, directly or through a relative hop, or links an external it has
2187+
# not declared.
2188+
#
2189+
# WHY IT NEEDS A GATE AT ALL. The principle bound as prose until now, and
2190+
# the failure it guards was invisible to every check this repo had: one
2191+
# string fold reached through `/shared` cost +246.9 KB minified / +69.7 KB
2192+
# gzipped, one pure predicate through `/security` cost +261.5 KB, and
2193+
# objectui#5324 records the CONSUMER's own bundle-budget check reporting
2194+
# PASS on the PR that added the bytes. A budget cannot attribute a delta
2195+
# that arrives inside a dependency it already imports; a module graph can.
2196+
#
2197+
# WHY THIS JOB AND THIS POSITION. It reads the BUILT `.mjs`/`.js` bundles a
2198+
# consumer's import resolves to — not the source, and not the `.d.ts` — so
2199+
# it belongs after the build step with the other dist-reading consumer
2200+
# gates (`check:exported-any`, `check:dual-source-exports` above), not with
2201+
# the tsx-only artifact gates near the top. On an unbuilt or stale tree it
2202+
# is a HARD REFUSAL naming the build command, never a skip: a
2203+
# build-dependent gate that reads nothing reports NOT MEASURED as if it
2204+
# were measured and clean (#4690).
2205+
#
2206+
# It adds no required context — a step in an existing job, so no open PR
2207+
# waits on a check whose name no head has ever reported (#9325). Reads 34
2208+
# bundles in ~1.5s. Self-tests first, like its two neighbours, and that
2209+
# self-test is load-bearing here for a specific reason: this tree has ZERO
2210+
# relative hops between bundles (every entry is self-contained), so the
2211+
# transitive walk that keeps the gate honest the day `splitting` is turned
2212+
# on can only be exercised against fixtures.
2213+
- name: Check declared browser-reachable spec entries link no zod
2214+
run: pnpm --filter @objectstack/spec run check:browser-reachable-entries
2215+
21822216
# Anti-drift for the skill EXAMPLES, not just the skill reference indexes
21832217
# (#3094). The TypeScript in skills/ is the first thing an AI copies when
21842218
# authoring metadata, yet nothing type-checked it — so it rotted silently
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"_comment": "The DECLARED browser-reachable export surface of @objectstack/spec (#10199), and the contract half of check:browser-reachable-entries. The 2026-08-20 maintainer ruling on #10096 minted the standing principle 「浏览器可达的 spec 导出面必须 schema-free」: an export surface browser/client consumers reach carries vocabulary — maps, folds, enums, pure predicates — without linking the zod schema/validation machinery. This file says WHICH entries make that promise; the gate reads the BUILT bundle each one resolves to and proves the promise. Every subpath in package.json's exports map must appear in exactly one of the three sections below — an entry nobody classified fails the gate's reconciliation rather than quietly dropping out of coverage (the check:generated ledger pattern). Hand-maintained on purpose, like dual-source-exports.baseline.json next door: promoting an entry to browserReachable is a CONTRACT decision a maintainer makes, not something a gen: command should be able to grant. The gate is additive/shrink-only — an unlisted entry is unjudged, so no weakening question arises.",
3+
"browserReachable": {
4+
"./meta-spelling": {
5+
"why": "The reference pattern the ruling shipped (#10096): the /meta URL-spelling contract as vocabulary — one frozen map, one fold, two refusal predicates — with the map derived at BUILD time by gen:meta-url-spelling so no schema graph is linked. Reaching the same four symbols through ./shared cost a browser consumer +246.9 KB minified / +69.7 KB gzipped.",
6+
"externals": []
7+
}
8+
},
9+
"_unjudgedComment": "Entries this gate asserts NOTHING about. They are not declared browser-reachable, so linking zod is not a defect here — these are the server/build-time surfaces. Listing them is what makes the reconciliation two-directional: a new exports-map subpath cannot land without someone deciding which list it belongs in. Moving one UP to browserReachable is the only direction this file is meant to travel, and it is a maintainer decision plus a passing gate, never a ledger edit alone.",
10+
"unjudged": [
11+
".",
12+
"./ai",
13+
"./api",
14+
"./automation",
15+
"./cloud",
16+
"./contracts",
17+
"./data",
18+
"./identity",
19+
"./integration",
20+
"./kernel",
21+
"./qa",
22+
"./security",
23+
"./shared",
24+
"./studio",
25+
"./system",
26+
"./ui"
27+
],
28+
"_notAModuleComment": "Exports-map subpaths that resolve to something other than a JS module, so there is no module graph to walk and no zod link to look for. A separate section from `unjudged` because the two say different things to a reader: `unjudged` means 'a module we chose not to judge', this means 'not a module at all'. The gate refuses to scan these rather than silently reporting them clean.",
29+
"notAModule": [
30+
"./openapi.json",
31+
"./package.json"
32+
]
33+
}

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
"check:export-origins": "tsx scripts/build-export-origins.ts --self-test && tsx scripts/build-export-origins.ts --check",
217217
"check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts",
218218
"check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts",
219+
"check:browser-reachable-entries": "tsx scripts/check-browser-reachable-entries.ts --self-test && tsx scripts/check-browser-reachable-entries.ts",
219220
"check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check",
220221
"gen:migration-registry": "tsx scripts/build-migration-registry.ts",
221222
"check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check",

0 commit comments

Comments
 (0)