Skip to content

Commit 5e5ec9f

Browse files
os-elon-muskclaude
andauthored
feat(spec)!: publish the two named refinement patterns the runtime already enforces (#18952)
Part of #18670 (item 2 only — see "What is left" below; item 1 landed as #18729). Clause-②: yes (narrowing) Director ruling batch #154 item 3, letter **C** (comment 5725370614, maintainer 「同意」): 「the projection emits a refinement only where the rule is a complete, mechanically derivable JSON Schema pattern — banned keys, required-one-of, non-blank — one ledger row at a time; everything else stays annotated as `x-dropped-refinements`」. ## What this does `z.toJSONSchema()` has no arm for a `custom` check. On zod 4.4.3 — the version `packages/spec` resolves — a plain record, the same record with a `.refine()`, and the same record with an **aborting** `.refine()` project byte-identically. So every rule written as a refinement reached the runtime and not `packages/spec/json-schema/**`: the published file was **wider** than the contract it is generated from, which is the direction where an author's (or an AI's) validator answers PASS right up to the moment the platform answers NO. Two of the ruling's four named patterns now project, and only those two: | pattern | emitted as | sites | why it is EXACT, not approximate | |:---|:---|--:|:---| | `required-one-of` | `anyOf` of one `required` per key, conjoined through `allOf` | **137** | A key absent from a JSON object is the only way for its value to read `undefined`, so `required` and `!== undefined` name the same set of documents. A key present with any JSON value, `null` included, satisfies both. | | `non-blank-string` | `minLength: 1` plus the pattern `\S` | **60** | `String.prototype.trim` removes exactly ECMA-262 WhiteSpace ∪ LineTerminator, and `\S` is the complement of that same set. | `system/TraceSamplingConfig.json` — the card's own named specimen — now carries no `x-dropped-refinements` at all, and `shared/Expression.json` states the source-or-ast rule, so `{ "dialect": "cel" }` is refused by the published file exactly as the runtime already refused it. ## Proof of work: the shrink-only ledger `packages/spec/dropped-refinements.baseline.json`, measured by the generator itself: | reading | before | after | |:---|--:|--:| | `publishedSchemasWithDroppedRefinements` | **246** | **201** | | `droppedRefinementSites` | **750** | **553** | | `refinementSitesThatDidProject` | **0** | **197** | | `refinementSitesWithNoJsonFormToCompare` | 3 | 3 | 45 rows deleted outright, 75 rows shrunk, **197 sites closed, 0 sites added anywhere**. The edit was not typed by hand: a throwaway auditor parsed the gate's own "corrected entries, in full" output and refused to write unless the file round-tripped byte-identically through `JSON.stringify(obj, null, 2)`, every removed site was one the post-change census classifies `projected`, no entry gained a site, and the per-entry arithmetic closed. Independently re-proved at JSON level against `git show HEAD:...`: 0 keys added, 45 keys deleted, 0 sites added, 197 sites removed, header equals body. The generator also prints the closed population **per pattern** on every run, with a line of its own for a site that projects with no declared pattern — that bucket reads **0**: ``` 🔇 553 refinement site(s) across 201 published schema(s) reach the RUNTIME and not the published JSON Schema Also measured this run: 197 refinement site(s) DID reach the file, 3 had no JSON form on either side to compare. 📣 197 refinement site(s) DO reach the published JSON Schema, by declared pattern: 137 required-one-of 60 non-blank-string ``` ## The contract: nothing the runtime accepts becomes refused, MEASURED The ruling is explicit that this is a correction of the machine-readable declaration and ⛔ not a behaviour change. That is a reading here, not an assertion. A probe parses one shared corpus of **6027 documents** across **12 schemas** — `ExpressionSchema`, `EvaluatedExpressionSchema`, the four input unions, `PredicateSchema` / `PredicateInputSchema`, `UpdateAiConversationRequestSchema`, and three deep composers (`FlowSchema.edges[].condition`, `ObjectSchema.titleFormat`, `CronScheduleSchema.expression`) — recording per document the success bit and every issue as a sorted `code@path`. Run in two worktrees, at the merge base and at this head, over the same corpus file: ``` merge-base 46559f6 cases=12 documents=6027 accepted=1873 refused=4154 this head eae168e cases=12 documents=6027 accepted=1873 refused=4154 cmp exit 0 · sha256 15a714e471f1ba43ec0c0c8773ac345c5c0d03974855ed15116ae2014119008a (both files) ``` Byte-identical, so the accept set, the refusal set and every refusal message and path are unchanged. ⭐ **And the probe can fire.** Under a lit control that weakens `NON_BLANK_STRING` from `source.trim().length > 0` to `source.length > 0` — one token — **732 of the 6027 documents move**. So the zero above is a measurement, not a vacuous pass. ## The two `packages/spec/src/**` files, and why each had to move The projection cannot READ a predicate's meaning: `.superRefine()` and `.check()` carry no readable function at all (their check def holds only `{ check: 'custom' }`, where `.refine()`'s holds `{ type, check, fn }`), and a projection turning on `fn.toString()` would be a source-text parser. So the pattern is **declared at the refinement's own call site**, and `requiredOneOf` builds its predicate **from** that declaration, so the published `anyOf` and the enforced rule cannot name different keys. Each change replaces the predicate expression handed to an **existing** `.refine()` and nothing else — no schema shape, no key, no message, no `.strict()`, no optionality, nothing added or removed: - `src/shared/expression.zod.ts` — `e => e.source !== undefined || e.ast !== undefined` becomes `requiredOneOf(['source', 'ast'])`; three copies of `(source) => source.trim().length > 0` become the shared `NON_BLANK_STRING` (one is inside `typedExpressionStringArm`, so it covers both the cron and the template slot). - `src/api/protocol.zod.ts` — `p => p.title !== undefined || p.metadata !== undefined` becomes `requiredOneOf(['title', 'metadata'])`, plus its import. The parse-equivalence reading above is the evidence that both substitutions are behaviour-preserving. ## ⚠️ One place the spelling differs from the ruling's prose, and why The ruling names 「`anyOf` + `required` for required-one-of」. Emitted as a **top-level** `anyOf` beside the node's own `type: object` and `properties`, that is valid JSON Schema and correct to a validator — and it degraded the reference pages. `scripts/lib/format-type.ts` tests `anyOf` before `properties`, so the node stopped rendering as its object shape: `content/docs/references/system/tracing.mdx`'s `condition` cell went from ``` Record[string, any] | string | { dialect: Enum[...]; source?: string; ast?: any; meta?: object } ``` (angle brackets written as square ones throughout this paragraph — the platform's body sanitizer eats a short angle-bracket fragment even inside a fence, so the real cell reads with the usual generic spelling) to `Record[string, any] | string | any | any`, and **26 reference pages moved the same way — 182 insertions / 182 deletions**. Those pages are the ADR-0033 authoritative input for AI authors, so that would be a second machine-readable lie traded for the first one. The same `anyOf` + `required` is therefore conjoined through `allOf`: identical to a validator, and `gen:docs` then produces a **zero-line diff**. Both the measurement and the absence are pinned (`⛔ never writes a TOP-LEVEL anyOf`). The PM accepted this reading as more faithful to the ruling than its literal nesting; ⛔ teaching `format-type.ts` to skip pure-`required` branches was declined by name — a shared renderer is the wrong blast radius for an equivalent emission. ## Ablations — three, each restored with proof Every leg ran through `scripts/ablation-replace.mjs`, so the mutation is proved on disk (anchor count and blob hash) and the restore by blob equality with HEAD plus an empty `git diff HEAD`. | mutation | expected | observed | |:---|:---|:---| | `required-one-of` emits nothing | the ledger's row deletions must fail | `check:authorable-surface` exit 1 — **45 undeclared + 75 miscounted**, exactly the rows this PR deleted and shrank | | the detector's differential drops the generator's `override` | the ledger can no longer see a closed site | same 45 + 75 red: the coupling is load-bearing, not decoration | | `NON_BLANK_PATTERN` corrupted to `.` | the equivalence pin must fail | 3 cases red, including every ECMA-262 blank code point | The second leg is the one that matters for the ruling's mechanism: without it, a site whose rule the file already states would read `dropped` for ever, and 「every site it closes deletes its ledger row」 would be unreachable. ## What is left, and why this is `Part of` rather than a closing keyword Two of the ruling's four named patterns are **not** taken here, and the census says why rather than leaving it to judgement: - **banned keys** (`propertyNames` / `not`) — **zero** clean candidates. The nearest sites judge a banned *value* on a string, or an allowed key set that is data-dependent (`ai.paramHints` against the action's own `params`), which is not mechanically derivable. - **`dependentRequired`** — exactly **one** candidate, 2 sites: `data/SSLConfig`'s `hasCert === hasKey`, which is precisely `dependentRequired: { cert: ['key'], key: ['cert'] }`. Sound and small; deliberately not taken in this round so the verification surface stays two arms wide, per the dispatch's 「landing one or two patterns with the ledger shrinking measurably beats four half-done ones」. So this request does not carry a closing keyword for the card: the remaining named-arm worklist above is a real remainder, and whoever takes it starts from these two measurements rather than a fresh census. The 553 sites still in the ledger are the ruling's intended terminal state for refinements outside the closed list — they stay dropped and annotated. ## Verification - `pnpm --filter @objectstack/spec build` · `check:generated` — "All 15 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test` — **489 files / 14209 tests**, green. `typecheck` green (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`: 54 files / 259 errors / 144 pinned signatures, the standing ledger unchanged). - `pnpm --filter @objectstack/spec exec vitest run --project repo` over the five generator-facing files (`build-schemas-check-mode`, `check-generated-ledger`, `schema-tree-freshness`, `dist-freshness`, `def-key-collisions`) — **5 files / 141 tests**, green. - `npx eslint . --no-inline-config --format json` — the **whole** repo, no narrowing: **6858 files, 0 errors, 0 warnings**, taken at `eae168e20`. - Gate families derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the merge base and re-run **in full on this head**: **84 derived, 79 exit 0, 5 NOT MEASURED, 0 UNRUN** under `--ran`. The five: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` each answer PREREQUISITE NOT MET (exit 3 — they sweep the built output of the whole workspace, which is CI's Build Core job), and `check:pm-dispatch-gates` was **killed by the container's foreground cap** at both 200s and 560s without reaching a verdict of its own. ⛔ None of the five reads as a pass. - `origin/main` merged three times through `scripts/pm/os-regen-merge.sh`, regeneration committed after each merge. The branch delta against `origin/main` is exactly the 11 paths of this change; `control-flow.zod.ts`, `check-duration-unit-keys.ts`, `check-widening-tells.mjs` and `check-adr-0087-registration.mjs` all diff empty against main, so the merges took main's content intact. ## Acceptance notes ⛔ Observations only — nothing below is addressed here, and none of them is filed. - The `x-dropped-refinements` annotation still does not reach `content/docs/references/**`: the docs renderer drops unknown `x-` keys, so a reference page states a narrowed rule only where it became real JSON Schema keywords. Carrying the annotation onto the page is a docs-surface change. Successor: whoever takes the remaining named arms. - `packages/spec/json-schema/**` is gitignored and untracked while being shipped through `files[]`, so the narrowing is visible in a review only through the ledger, the reference pages and the generator log — never as a diff of the artefact itself. - Four other `z.toJSONSchema()` callers inside `packages/spec/src/**` (approval node config, schemaless node config, driver common, metadata-type schemas) serve Studio's SchemaForm at runtime and were deliberately left alone: narrowing those would change what a form refuses, which is the behaviour change the ruling forbids. The override is exported so they can adopt it under a decision of their own. - The five sandbox builders in `build-schemas-check-mode.test.ts` still mount the committed package-root ledgers from a hand-kept list; nothing holds that list equal to the set the generator actually reads. Carried over from item 1, unchanged here. ## 维护者速读(草稿) **改了什么** —— 已发布的 `packages/spec/json-schema/**` 过去对「写成 `.refine()` 的规则」一字不提:作者或 AI 拿这些文件校验 metadata,校验通过,平台随后拒收。本 PR 让两条规则真正出现在文件里:「source 与 ast 至少有一个」和「字符串去空白后非空」。共 **197 个站点**从「运行时有、文件没有」变成「两边都有」,只降不升的台账从 **246 个 schema / 750 个站点**缩到 **201 / 553**。卡片点名的样本 `TraceSamplingConfig` 现在一条缺口都不剩。 **为什么改** —— 裁决(批次 #154 第 3 项 C,维护者「同意」)把方向定死:只在规则是**完整且可机械推导**的命名模式时收窄,其余保持注解。这不是行为变化,而是把一份机器可读的声明改成它一直描述的那个运行时。 **风险与代价(含回滚)** —— 风险集中在一处:收窄已发布产物,理论上可能让昨天通过的文档今天不通过。这一点是**实测**排除的,不是论证排除的:6027 份文档、12 个 schema,在合并基与本分支上跑出**逐字节相同**的接受/拒收结果与错误码;并用一条对照变异证明这个探针能发现差异(732 份文档会移动)。代价是新增两个模块与一套等价性 pin。回滚成本低:两个新模块与 5 处调用点的替换是可逆的,台账回退到 246/750 即恢复原状;但回滚会让文件重新对机器撒谎。 **席位意见** —— **你要做的** —— ① 确认「已发布 JSON Schema 可以向运行时方向收窄」这件事按裁决执行无误(裁决已定,此处仅复核落地与裁决一致)。② 决定剩下两条命名模式的去向:banned keys 实测零个干净候选,`dependentRequired` 只有 `data/SSLConfig` 一个 2 站点候选 —— 是另起一单,还是就此收尾。③ 本 PR 未携带关卡关键词,卡片的开闭由你或 PM 决定。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 106717c commit 5e5ec9f

11 files changed

Lines changed: 899 additions & 481 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states two of the rules it used to leave entirely to the runtime, so a validator reading the published files stops answering PASS on metadata the platform then refuses (#18670 item 2).
6+
7+
Clause-②: yes (narrowing)
8+
9+
`z.toJSONSchema()` has no arm for a `custom` check: on zod 4.4.3 a plain record, the same record with a `.refine()`, and the same record with an **aborting** `.refine()` all project byte-identically. Every rule written as a refinement was therefore enforced by the runtime and absent from the published file — the direction in which an author's, or an AI's, validator says yes right up to the moment the platform says no.
10+
11+
Two named patterns now project, and only those two:
12+
13+
- **at least one of these keys is present** — emitted as `anyOf` of one `required` per key. `shared/Expression.json` states the source-or-ast rule, so `{ "dialect": "cel" }` is refused by the published file exactly as the runtime already refused it.
14+
- **a string with at least one non-whitespace character** — emitted as `minLength: 1` plus the pattern `\S`. Every evaluated and typed expression slot states it, so a whitespace-only `source` is refused at the door.
15+
16+
**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** Both patterns are EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `String.prototype.trim` removes exactly the ECMA-262 whitespace set that `\S` is the complement of. Both equalities are pinned over their whole input space in `packages/spec/scripts/refinement-projection.test.ts`, including every ECMA-262 WhiteSpace and LineTerminator code point. No refinement was weakened, removed or added; the runtime accepts and refuses exactly what it did before.
17+
18+
**The list is CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration, so the rule the runtime enforces and the keywords the file publishes cannot name different things. A refinement outside that list stays unprojected and keeps its `x-dropped-refinements` annotation. Adding an arm is a public-contract decision with its own measurement, never a refactor — and ⛔ never an open-ended zod-to-JSON-Schema translator over the whole population.
19+
20+
**Proof of work, in the shrink-only ledger.** `packages/spec/dropped-refinements.baseline.json` reads 201 published schemas / 553 dropped sites, from 246 / 750: 45 rows deleted, 75 rows shrunk, 197 sites closed, zero sites added anywhere. The generator now prints the closed population per pattern on every run (137 `required-one-of`, 60 `non-blank-string`), and reports a site that projects with no declared pattern on its own line.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author can write is removed, renamed or re-spelled: no spec key, no export and no config field changes, and the accepted set of metadata documents is byte-for-byte what it was. What changed is a machine-readable DECLARATION catching up with the runtime it always described, so there is nothing for `objectstack migrate meta` to rewrite and no stored representation to convert. -->

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 50 additions & 427 deletions
Large diffs are not rendered by default.

‎packages/spec/scripts/build-schemas.ts‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ import {
4545
projectByPruningUnionBranches,
4646
type PrunedBranch,
4747
} from './lib/union-branch-projection';
48+
// The closed list of refinements this generator DOES publish (#18670 item 2).
49+
// The ratchet below measures against this same override, so a rule the list
50+
// emits leaves the ledger and a rule it does not emit stays in it — see the
51+
// module header for why the two halves must not be read against each other.
52+
import { refinementProjectionOverride } from './lib/refinement-projection';
4853
// The dropped-refinement ratchet (#18670). The mirror image of the branch
4954
// pruning above, and deliberately its own module for the same reason: the
5055
// pruner guards a projection NARROWER than the Zod type, this one the direction
@@ -495,6 +500,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
495500
try {
496501
jsonSchema = z.toJSONSchema(value, {
497502
target: 'draft-2020-12',
503+
override: refinementProjectionOverride,
498504
}) as Record<string, unknown>;
499505
} catch (outputError) {
500506
if (!isKnownUnsupported(outputError)) throw outputError;
@@ -503,6 +509,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
503509
jsonSchema = z.toJSONSchema(value, {
504510
target: 'draft-2020-12',
505511
io: 'input',
512+
override: refinementProjectionOverride,
506513
}) as Record<string, unknown>;
507514
} catch (inputError) {
508515
if (!isKnownUnsupported(inputError)) throw inputError;
@@ -519,7 +526,10 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
519526
// then re-thrown with the message Zod produced, so this attempt
520527
// can never change WHY an export is skipped, and so never the
521528
// `cause` recorded for it in unemitted-schemas.baseline.json.
522-
const projected = projectByPruningUnionBranches(value, { target: 'draft-2020-12' });
529+
const projected = projectByPruningUnionBranches(value, {
530+
target: 'draft-2020-12',
531+
override: refinementProjectionOverride,
532+
});
523533
if (!projected) throw inputError;
524534
jsonSchema = projected.schema;
525535
io = projected.io;
@@ -550,15 +560,21 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
550560
branchPrunedProjections.push({ namespace: namespaceName, exportKey: key, pruned: prunedBranches });
551561
}
552562

553-
// The refinements this projection DROPPED (#18670), named on the
563+
// The refinements this projection STILL drops (#18670), named on the
554564
// artifact for the same reason `x-unprojectable-branches` is: a reader
555565
// of this file — an author, a reference page, an AI validating a
556566
// document against it — can otherwise not tell that the contract
557567
// underneath carries rules this file does not state. It is an
558568
// annotation and nothing more: `x-` keywords are ignored by every
559569
// validator, so the set of documents this schema ACCEPTS is unchanged
560-
// by it. Narrowing the published shape to match the Zod type is a
561-
// public-contract change and is deliberately NOT done here.
570+
// by it.
571+
//
572+
// What DID narrow (#18670 item 2) is the closed list in
573+
// `src/shared/refinement-projection.ts`, applied by the `override`
574+
// above: a refinement declared through it is emitted as real
575+
// keywords, so it never reaches `census.dropped` and never reaches
576+
// this annotation. ⛔ The two are exclusive by construction — a site
577+
// cannot be both stated and annotated as unstated.
562578
const census = collectDroppedRefinements(`${categorySlug}/${schemaName}`, value);
563579
refinementCensus.push(census);
564580
if (census.dropped.length > 0) {
@@ -3421,9 +3437,16 @@ if (unemittedSkips.length > 0) {
34213437
// no. Measured on this tree at the change that added this block: 682 refinement
34223438
// sites across 237 published schemas, zero of which projected anything.
34233439
//
3424-
// ⛔ It does NOT narrow any published shape and does not touch the refinements
3425-
// themselves — the runtime rule is correct. It makes the population declared,
3426-
// so the next one arrives as a line in a diff instead of as nothing at all.
3440+
// ⛔ This ratchet still narrows nothing by itself and touches no refinement —
3441+
// the runtime rule is correct. It makes the remaining population declared, so
3442+
// the next gap arrives as a line in a diff instead of as nothing at all.
3443+
//
3444+
// The narrowing is the CLOSED list in `src/shared/refinement-projection.ts`
3445+
// (#18670 item 2), emitted by the `override` this generator passes to every
3446+
// projection. It and this ratchet compose in one direction: a site the list
3447+
// emits is `projected` and its ledger row is deleted in the same PR; every
3448+
// other site is `dropped` and stays declared. So the ledger is shrink-only in
3449+
// the strong sense — a repair is the only thing that shortens it.
34273450
const droppedRefinementsBaseline = readDroppedRefinementsBaseline(PKG_DIR);
34283451
if (!droppedRefinementsBaseline) {
34293452
console.error(`\n❌ ${DROPPED_REFINEMENTS_BASELINE_FILE} is missing — it is a committed, hand-edited ledger (#18670).`);
@@ -3544,17 +3567,42 @@ if (droppedSiteTotal > 0) {
35443567
`RUNTIME and not the published JSON Schema — all declared in ${DROPPED_REFINEMENTS_BASELINE_FILE} (#18670).`,
35453568
);
35463569
console.log(
3547-
` The published files are therefore WIDER than the Zod types they are generated from:\n` +
3548-
` a document one of them accepts can still be refused at parse time. Each affected file\n` +
3549-
` names its own sites as \`x-dropped-refinements\`. Narrowing the published shape to match\n` +
3550-
` is a public-contract change and is NOT what this ratchet does.`,
3570+
` Those files are therefore still WIDER than the Zod types they are generated from:\n` +
3571+
` a document one of them accepts can be refused at parse time. Each affected file names\n` +
3572+
` its own remaining sites as \`x-dropped-refinements\`. Closing one means teaching the\n` +
3573+
` CLOSED list in src/shared/refinement-projection.ts a NAMED pattern — ⛔ never deleting\n` +
3574+
` the refinement, and ⛔ never an open-ended translator over the whole population.`,
35513575
);
35523576
console.log(
35533577
` Also measured this run: ${projectedSiteTotal} refinement site(s) DID reach the file, ` +
35543578
`${undecidableSiteTotal} had no JSON form on either side to compare.`,
35553579
);
35563580
}
35573581

3582+
// Which projected sites got there through which arm of the closed list (#18670
3583+
// item 2). Printed per pattern rather than as one total, for the reason the
3584+
// ledger records sites rather than a count: a total cannot tell "one arm stopped
3585+
// emitting" from "somebody deleted a refinement", and the two have opposite
3586+
// remedies. A site that projects with NO declared pattern is reported on its own
3587+
// line — it means zod started emitting something by itself, which is news.
3588+
if (projectedSiteTotal > 0) {
3589+
const byPattern = new Map<string, number>();
3590+
for (const entry of refinementCensus) {
3591+
for (const site of entry.projected) {
3592+
const key = site.declaredPatterns.length > 0
3593+
? site.declaredPatterns.join('+')
3594+
: 'UNDECLARED — zod projected this on its own';
3595+
byPattern.set(key, (byPattern.get(key) ?? 0) + 1);
3596+
}
3597+
}
3598+
console.log(
3599+
`\n📣 ${projectedSiteTotal} refinement site(s) DO reach the published JSON Schema, by declared pattern:`,
3600+
);
3601+
for (const [pattern, n] of [...byPattern].sort((a, b) => b[1] - a[1])) {
3602+
console.log(` ${String(n).padStart(4)} ${pattern}`);
3603+
}
3604+
}
3605+
35583606
// ─── Generate Bundled Schema ─────────────────────────────────────────
35593607
// Single-file bundled schema containing all generated schemas for IDE autocomplete
35603608

‎packages/spec/scripts/dropped-refinements.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ describe('the differential isolates the refinement, not the node', () => {
225225
});
226226

227227
describe('the ratchet adjudicates against the ledger', () => {
228-
const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const });
228+
const site = (path: string) => ({ path, nodeType: 'string', count: 1, aborting: false, verdict: 'dropped' as const, declaredPatterns: [] });
229229
const census = (defKey: string, paths: string[]) => ({
230230
defKey,
231231
dropped: paths.map(site),

‎packages/spec/scripts/lib/dropped-refinements.ts‎

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,17 @@
3030
*
3131
* It is a **visibility ratchet**, exactly like `unemitted-schemas.ts`: it
3232
* reports what is already true and refuses GROWTH of the population. It
33-
* narrows no published shape, removes no refinement, and changes nothing about
34-
* what the runtime accepts — the baseline is anchored to the tree as it stands,
35-
* so it is green the moment it lands.
33+
* narrows nothing itself and removes no refinement — the baseline is anchored
34+
* to the tree as it stands.
3635
*
37-
* It is **not** a fix. Teaching the projection to emit what a refinement
38-
* constrains, or declaring the published artifact a floor, both change the
39-
* published contract and are a maintainer's decision, not a generator's.
36+
* The **fix** is a separate module and a separate decision, taken for #18670
37+
* item 2: `refinement-projection.ts` publishes a CLOSED, named list of
38+
* refinements, and this module now measures against that same projection (see
39+
* `projectOrNull`). So the two halves compose in one direction only — a rule the
40+
* closed list emits reads `projected` here and its ledger row goes; every other
41+
* rule reads `dropped` and stays declared. ⛔ Neither half may be used to talk
42+
* the other out of its reading: a site is `dropped` because THIS build's file
43+
* says nothing about it, not because a PR body says the projection handles it.
4044
*
4145
* ## Why the verdict is MEASURED per instance, never assumed
4246
*
@@ -77,18 +81,30 @@
7781
import fs from 'fs';
7882
import path from 'path';
7983
import { z } from 'zod';
84+
// The closed list of refinements that DO reach the published file (#18670 item
85+
// 2), and the zod-check primitives both halves turn on. Imported rather than
86+
// re-derived because the differential below is only a statement about the real
87+
// published artifact if it runs the generator's own projection — see that
88+
// module's header.
89+
import {
90+
CUSTOM_CHECK_KIND,
91+
checkKindOf,
92+
customChecksOf,
93+
projectableRefinementsOf,
94+
refinementProjectionOverride,
95+
zodDefOf,
96+
} from './refinement-projection';
8097

8198
/** File name of the committed ledger, resolved against the package root. */
8299
export const DROPPED_REFINEMENTS_BASELINE_FILE = 'dropped-refinements.baseline.json';
83100

84101
/**
85-
* The check kind `.refine()`, `.superRefine()` and a bare `.check(fn)` all
86-
* compile to in zod 4. Named once here because it is the ONE string this whole
87-
* instrument turns on: a zod release that renames it must fail as "no
88-
* refinements found anywhere" against the lit control in the census, never as a
89-
* quiet zero.
102+
* Re-exported so this module stays the one import for the census vocabulary.
103+
* Its declaration — and the argument for why it is the ONE string this whole
104+
* instrument turns on — lives in `refinement-projection.ts`, beside the two
105+
* other readers of a zod check.
90106
*/
91-
export const CUSTOM_CHECK_KIND = 'custom';
107+
export { CUSTOM_CHECK_KIND };
92108

93109
/** How deep the graph walk goes before it stops descending. */
94110
const MAX_DEPTH = 14;
@@ -122,6 +138,17 @@ export interface RefinementSite {
122138
* comparison has no two sides. Reported, never counted as a gap.
123139
*/
124140
readonly verdict: 'dropped' | 'projected' | 'undecidable';
141+
/**
142+
* Which arms of the closed projectable list (#18670 item 2) this node's
143+
* refinements were DECLARED as, in declaration order — empty for every rule
144+
* outside that list, which is what keeps it `dropped`.
145+
*
146+
* Reported so the generator can say which PATTERN closed a site rather than
147+
* only that the count moved: a projection arm that silently stops emitting
148+
* shows up here as a site that went back to `dropped` with its pattern still
149+
* named, which reads differently from a refinement somebody deleted.
150+
*/
151+
readonly declaredPatterns: readonly string[];
125152
}
126153

127154
/** Every refinement site under one published schema. */
@@ -160,28 +187,11 @@ export interface DroppedRefinementsBaseline {
160187
readonly entries: Readonly<Record<string, DroppedRefinementsEntry>>;
161188
}
162189

163-
function defOf(schema: z.ZodType): Record<string, unknown> | null {
164-
const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def;
165-
return def && typeof def === 'object' ? (def as Record<string, unknown>) : null;
166-
}
167-
168-
function checkKind(check: unknown): string | null {
169-
const kind = (check as { _zod?: { def?: { check?: unknown } } } | null)?._zod?.def?.check;
170-
return typeof kind === 'string' ? kind : null;
171-
}
172-
173190
function checkAborts(check: unknown): boolean {
174191
const def = (check as { _zod?: { def?: Record<string, unknown> } } | null)?._zod?.def;
175192
return def?.abort === true || def?.fatal === true;
176193
}
177194

178-
function customChecksOf(schema: z.ZodType): unknown[] {
179-
const def = defOf(schema);
180-
const checks = def?.checks;
181-
if (!Array.isArray(checks)) return [];
182-
return checks.filter((c) => checkKind(c) === CUSTOM_CHECK_KIND);
183-
}
184-
185195
/**
186196
* The same node with its `custom` checks removed.
187197
*
@@ -196,9 +206,9 @@ function customChecksOf(schema: z.ZodType): unknown[] {
196206
* question.
197207
*/
198208
function withoutCustomChecks(schema: z.ZodType): z.ZodType | null {
199-
const def = defOf(schema);
209+
const def = zodDefOf(schema);
200210
if (!def || !Array.isArray(def.checks)) return null;
201-
const kept = def.checks.filter((c) => checkKind(c) !== CUSTOM_CHECK_KIND);
211+
const kept = def.checks.filter((c) => checkKindOf(c) !== CUSTOM_CHECK_KIND);
202212
const cloneable = schema as unknown as { clone?: (d: unknown) => z.ZodType };
203213
if (typeof cloneable.clone !== 'function') return null;
204214
const stripped = cloneable.clone({ ...def, checks: kept });
@@ -207,11 +217,24 @@ function withoutCustomChecks(schema: z.ZodType): z.ZodType | null {
207217
return stripped;
208218
}
209219

210-
/** `toJSONSchema` in the generator's own io ladder, or `null` when neither side has a JSON form. */
220+
/**
221+
* `toJSONSchema` in the generator's own io ladder, or `null` when neither side
222+
* has a JSON form.
223+
*
224+
* ⭐ It passes the generator's `override` (#18670 item 2). Without it this
225+
* function would measure a projection nothing publishes: a node whose rule the
226+
* closed list DOES emit would read byte-identical on both sides of the
227+
* differential and stay in the ledger for ever, and the shrink-only ledger's
228+
* whole use — a row deletion is the observable proof a site closed — would be
229+
* unreachable. With it, `dropped` means "this build's own published file states
230+
* nothing about this rule".
231+
*/
211232
function projectOrNull(schema: z.ZodType): string | null {
212233
for (const io of ['output', 'input'] as const) {
213234
try {
214-
return JSON.stringify(z.toJSONSchema(schema, { target: 'draft-2020-12', io }));
235+
return JSON.stringify(
236+
z.toJSONSchema(schema, { target: 'draft-2020-12', io, override: refinementProjectionOverride }),
237+
);
215238
} catch {
216239
// Try the other direction — the generator does the same, for the same reason.
217240
}
@@ -241,7 +264,7 @@ function verdictFor(schema: z.ZodType): RefinementSite['verdict'] {
241264
*/
242265
function labelledChildren(schema: z.ZodType): Array<{ label: string; schema: z.ZodType }> {
243266
const out: Array<{ label: string; schema: z.ZodType }> = [];
244-
const def = defOf(schema);
267+
const def = zodDefOf(schema);
245268
if (!def) return out;
246269
const seen = new Set<unknown>();
247270
const walk = (label: string, value: unknown): void => {
@@ -375,10 +398,11 @@ export function collectDroppedRefinements(defKey: string, root: z.ZodType): Refi
375398
if (customs.length > 0) {
376399
const site: RefinementSite = {
377400
path: readablePath(path),
378-
nodeType: String(defOf(schema)?.type ?? 'unknown'),
401+
nodeType: String(zodDefOf(schema)?.type ?? 'unknown'),
379402
count: customs.length,
380403
aborting: customs.some(checkAborts),
381404
verdict: verdictFor(schema),
405+
declaredPatterns: projectableRefinementsOf(schema).map((declared) => declared.pattern),
382406
};
383407
if (site.verdict === 'dropped') dropped.push(site);
384408
else if (site.verdict === 'projected') projected.push(site);

0 commit comments

Comments
 (0)