Skip to content

Commit 72c1640

Browse files
os-elon-muskclaude
andauthored
feat(spec)!: publish the dependentRequired rule, and make the projection's two halves one call (#19005)
Part of #18670 — item 2, the **third** of the ruling's four named arms. #18670 remains open: banned keys is still untaken, and this body deliberately carries no closing keyword for that number. 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`」. Continues PR #18952 (squash `5e5ec9fa42194723cc523a274e7221c8447c4487`), which landed `required-one-of` and `non-blank-string`. ## 1. The arm: `dependentRequired` `data/SSLConfig`'s refinement is `hasCert === hasKey` — precisely `dependentRequired { cert: ['key'], key: ['cert'] }`. It is emitted through the same closed-vocabulary mechanism the previous arm built: `src/shared/refinement-projection.ts` declares, `scripts/lib/refinement-projection.ts` emits. No second mechanism was introduced. **Exact, not approximate.** A key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on PRESENCE — so a key present with any JSON value, `null` included, arms its dependency exactly as the predicate's `!== undefined` does. The dependency map is read once into the declaration and the predicate reads it from there, so the published keyword and the enforced rule cannot name different keys. ### Ledger: the rows retired, by name `packages/spec/dropped-refinements.baseline.json`, **201 entries / 553 sites → 200 / 551**: | row | before | after | |:---|:---|:---| | `data/SSLConfig` | `sites: [""]` | **deleted** — drops nothing now | | `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed | 1 row deleted, 1 row shrunk, **2 sites closed, 0 sites added anywhere**; the ledger diff is deletions only. Generator census after: 551 dropped across 200 published schemas, **199 projected** — 137 `required-one-of`, 60 `non-blank-string`, **2 `dependent-required`** — 3 undecidable. `data/SQLDriverConfig`'s remaining `""` site is its **own** separate rule, "`sslConfig` is required when `ssl` is **true**". That judges a VALUE, is `if`/`then` rather than this arm, and correctly stays dropped and annotated. ### Banned keys (`propertyNames` / `not`) — NOT taken, and not forced Confirmed against the tree, not assumed: the nearest sites judge a banned VALUE on a string (`FILTER_ARRAY_LOGIC_KEYWORDS`) or an allowed key set that is data-dependent (`ai.paramHints` against the action's own params). Neither is mechanically derivable, so **no candidate was constructed**. This is why the body says `Part of` and carries no closing keyword. ## 2. Mechanism fix A — the verdict is per NODE, the rules are per CHECK `verdictFor` compared a node with ALL custom checks against the node with NONE, so any one declared arm marked the whole node `projected`. Reproduced on the landed code before changing it: ``` mixed(declared+undeclared) dropped: [] projected: [{count: 2, declaredPatterns: ['non-blank-string']}] undeclared-alone (lit) dropped: [{count: 1, declaredPatterns: []}] projected: [] declared-alone (lit) dropped: [] projected: [{count: 1, declaredPatterns: ['non-blank-string']}] ``` A second refinement on a declared node was therefore neither ledgered nor annotated, and the generator's UNDECLARED line could not see it — silently violating the ruling's own 「A refinement that is not one of these named patterns stays dropped and annotated」. **Fix:** `projected` now requires `customs.length === declaredPatterns.length`; anything else is `dropped` conservatively. The RAW differential is kept as a new `projectionMoved` field so the detector still MEASURES rather than asserts — collapsing it would have made the instrument blind to the zod upgrade it exists to notice — and the generator prints partially-stated sites on their own line. **Ablation, both directions** (anchor-verified on disk, `scripts/ablation-replace.mjs`): | leg | blob | result | |:---|:---|:---| | mutated — drop the `total === stated` guard | `54ed82dbe4c2` to `2c1bff777363` | **1 test red**, 42 green: "a DECLARED arm beside an UNDECLARED rule stays `dropped`" | | restored | back to `54ed82dbe4c2`, `git diff HEAD` empty | **43 / 43 green**; mutant text on disk 0, guard text 1 | ## 3. Mechanism fix B — generator/detector coupling, by construction `build-schemas.ts` (three `toJSONSchema` calls) and `projectOrNull` each passed the `override` independently. **Measured on the pristine base** with only the generator's import stubbed out: | leg | gate exit | `shared/Expression.json` `allOf` | `x-dropped-refinements` | files carrying the non-blank pattern | |:---|:---|:---|:---|:---| | base, untouched (dark control) | 0 | present | absent | **35** | | base, generator-side override dropped | **0 — GREEN** | **absent (wide)** | **absent (SILENT)** | **0** | Census identical to an untouched run (553 / 201 / 197). That is the item-1 silence restored, standing behind a green ratchet — worse than the state the card was filed about, because the ledger now certifies it. A merge-conflict resolution was enough to cause it. **Chosen fix: one shared projection helper** — `projectPublishedJsonSchema` in `scripts/lib/refinement-projection.ts`. All three generator calls, the union-branch projector behind the third, and the detector's differential now reach `z.toJSONSchema` through it, and `projectByPruningUnionBranches` no longer takes an `override` option at all. There is no argument left for a caller to forget. **Why the sandbox-builder pin was rejected**, not overlooked: a pin *detects* after the fact and can be skipped, deleted or made vacuous, and it leaves the two-argument shape in place so the next merge conflict can still separate them. The choke point makes the one-sided failure **unrepresentable** rather than caught. Both halves now lose the override together or not at all — which is what turns the ablation from silent into loud. The test file's own `publish()` helper was rewired through the same call for the same reason, so the unit pins measure the real seam rather than a re-spelling of it. **Ablation, both directions:** | leg | gate exit | `Expression.json` `allOf` | `x-dropped-refinements` | |:---|:---|:---|:---| | mutated — override removed from the ONE helper | **1 — RED**: 46 undeclared schemas + 76 miscounted ledger entries | absent (wide) | **present (annotated)** | | restored | 0 | present | absent | The contrast is the whole point: before, one-sided removal was green and silent; now it is red **and** the file confesses. ## 4. Contract: the published file narrows toward what the runtime already refuses **Whole published tree, base vs head:** 1530 of 1532 files byte-identical. The two that move are `data/SSLConfig.json` and `data/SQLDriverConfig.json`, each gaining `dependentRequired` and losing the matching `x-dropped-refinements` row. Nothing else in `packages/spec/json-schema/**` changed. **Parse-equivalence probe — 10,368 documents** (2,592 SSLConfig-shaped over the full presence lattice of 4 keys times 6 value shapes including `null`, a wrong type and an unrecognised extra key; 7,776 SQLDriverConfig documents embedding each of those under three `ssl` states). Published-side verdicts computed with ajv 8.20.0 (draft 2020-12) against the two real snapshots. | reading | SSLConfig | SQLDriverConfig | |:---|--:|--:| | documents | 2,592 | 7,776 | | runtime accepts | 60 | 180 | | published accepts, base | 27 | 54 | | published accepts, head | 15 | 30 | | **narrowed by this arm** | **12** | **24** | | widened | 0 | 0 | | **documents the runtime ACCEPTS that the published file now refuses** | **0** | **0** | **Runtime behaviour did not move.** The runtime verdict vector is byte-identical at merge base and head over all 10,368 documents — sha `9e7c848f04e0c687` (SSL) and `4f18f835d4d1a62e` (SQL) on both sides. The base leg was run against the real base blobs (`git checkout` of the two source files at `d8b12fca9`, blob hashes asserted both ways, restore proven by an empty `git diff HEAD`), not against a retyped predicate. **LIT CONTROL for that zero** — weakening the dependency map to one direction (`{ cert: ['key'] }`) moves **96 documents** (24 SSL + 72 SQL) and lifts runtime accepts from 60 to 84 and 180 to 252. The zero is a reading, not a silence. Note the published-accepts figures sit below runtime-accepts on both sides: `SSLConfig.json` is the OUTPUT shape and lists `rejectUnauthorized` as required because the runtime applies its `.default(true)`. That asymmetry is pre-existing, is the `x-io` convention, and is unchanged by this PR — it is reported rather than netted out. ## 5. Verification - **Gates:** derived from the merge base with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, re-derived after the `origin/main` merge (identical, **84 commands**). Every exit code captured by redirecting to a file first, never through a pipe. **80 exit 0, 0 findings.** The remaining 4 — `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — exit **3**, which those gates define as `PREREQUISITE NOT MET` ("Nothing was measured ... It is NOT a finding"): each reads BUILT output of packages outside this diff. They are **NOT MEASURED**, not red; the re-run against a full build is reported on the card. - The derivation's own caveats are carried, not netted out: 50 artifact-roster families score `silent` for every card in the tree, 11 declare a population too wide to place, 5 take a value from the workflow, and 5 path-scheduled CI jobs run 30 steps with no local invocation. None of those is a clearance, and CI owns them. - **`pnpm --filter @objectstack/spec check:generated`:** all 16 generated artifacts up to date. **`content/docs/references/**` does not move** — see acceptance notes. - **Targeted tests:** `scripts/refinement-projection.test.ts`, `scripts/dropped-refinements.test.ts`, `scripts/union-branch-projection.test.ts` — **91 / 91**. `packages/spec` typechecks clean (`tsc --noEmit` over both the package and `tsconfig.scripts.json`). The full `@objectstack/spec` suite reading is on the card. - **Lint, declared narrowing:** eslint run over the 9 changed lintable files, 0 errors / 0 warnings, file count read from `--format json`. The population is `eslint.config.mjs`'s own `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the config states in its own words that this repo "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", so this diff cannot move the verdict on a file it does not touch. The repo-wide sweep is CI's run. - `origin/main` merged through `bash scripts/pm/os-regen-merge.sh` (no rebase, no force-push). It brought one docs-only commit, #18979, overlapping none of this branch's paths and no `merge=os-regen` path. The previous arm's implementation body was asserted still present by quoted-exact-name `git grep` against `origin/main`, with a dark control at 0. ## Acceptance notes Noted, not filed — out of scope for this card and not one of the three filable classes: - `packages/spec/scripts/build-schemas.ts` (the authorable-surface docblock, near line 846) still names the retired `api-surface-signatures.json`. The previous seat handed this to "the next editor of `build-schemas.ts`", which is this PR. It is left untouched deliberately: it is a stale code comment, not a defect, a contract violation or an authoring trap, and the bounded in-place exemption requires the finding to be **the same defect class as this card**, which it is not. Carrier: the next PR that edits that docblock for its own reasons. - The dispatch's overlap warning — that a new keyword might move `content/docs/references/**`, four pages of which open PR #18985 edits — **measured FALSE**. `dependentRequired` is a sibling keyword the reference renderer does not read, `check:docs` is green and `check:generated` reports all 16 artifacts current. No reference page moves, so there is no collision with #18985 on that directory. Reported for the seat to file (a candidate class-(b) finding, deliberately NOT fixed here): - `packages/spec` ships `src/**/*.zod.ts` in `files[]`, and `scripts/check-published-files.mjs` allows it with the reason "The Zod schemas are themselves the contract (Prime Directive #1); **downstream code imports them directly**, so these sources are product rather than build input." Two measurements contradict that reason: (1) the package's `exports` map exposes no `./src/*` subpath and no wildcard, so no consumer can import those files at all; (2) 188 of the 202 shipped `*.zod.ts` files carry a relative import resolving to one of 35 modules under `src/` that the glob does NOT ship (`src/shared/lazy-schema.ts` alone is imported by 181 of them), so they would not resolve even if reachable. Overwhelmingly pre-existing and far outside this card; this PR adds the third importer of one of those 35. Not verified by `npm pack` and not by a real consumer import — that is the next step for whoever takes it. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d44fa19 commit 72c1640

11 files changed

Lines changed: 587 additions & 99 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the cert/key pairing rule on SSL driver configuration, so a validator reading the published files stops answering PASS on a half-configured client certificate the platform then refuses (#18670 item 2, the third of the ruling's four named arms).
6+
7+
Clause-②: yes (narrowing)
8+
9+
One named pattern joins the closed list, and only one:
10+
11+
- **`dependentRequired` — "whenever this key is present, those keys must be present too"**, emitted as JSON Schema's own `dependentRequired`. `SSLConfig`'s rule that a client certificate and its private key are provided together is precisely `dependentRequired { cert: ['key'], key: ['cert'] }`, so the file now states it.
12+
13+
**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 201 entries / 553 sites to **200 entries / 551 sites**:
14+
15+
| row | before | after |
16+
|:---|:---|:---|
17+
| `data/SSLConfig` | `sites: [""]` | **deleted** — the schema drops nothing now |
18+
| `data/SQLDriverConfig` | `sites: ["", "sslConfig"]` | `sites: [""]` — the `sslConfig` site closed |
19+
20+
2 sites closed, **0 sites added anywhere**, and the ledger diff is deletions only. `data/SQLDriverConfig`'s remaining `""` site is its own separate rule — "`sslConfig` is required when `ssl` is **true**" — which judges a VALUE rather than key presence, is `if`/`then` rather than this arm, and stays dropped and annotated as `x-dropped-refinements`.
21+
22+
**⛔ Not a behaviour change, and no document the runtime accepts becomes refused.** The arm is EXACT rather than approximate: a key absent from a JSON object is the only way for its value to read `undefined`, and `dependentRequired` triggers on presence, so a key present with any JSON value — `null` included — arms its dependency exactly as the predicate's `!== undefined` does. Measured over a 10,368-document corpus across both affected schemas: the runtime verdict vector is byte-identical before and after (lit control — weakening the dependency map to one direction moves 96 documents), and of the 36 documents the published files stop accepting, **zero** are documents the runtime accepts. Across the whole published tree, 1530 of 1532 files are byte-identical; the two that move gain `dependentRequired` and lose the matching `x-dropped-refinements` row.
23+
24+
**The list stays CLOSED.** `packages/spec/src/shared/refinement-projection.ts` declares the vocabulary and builds each predicate from its own declaration — the dependency map is read once and used by both the published keyword and the enforced rule — so the two cannot name different keys. A refinement outside the list stays unprojected and keeps its annotation. `propertyNames` / `not` for banned keys remains untaken: the tree carries no candidate whose rule is mechanically derivable, so no arm was constructed for it.
25+
26+
**Two mechanism repairs ship with it**, both invisible in the published output and both load-bearing from this arm onward. The detector's verdict was reached per NODE while refinements are per CHECK, so a node carrying a declared arm beside an undeclared rule read `projected` outright and the undeclared rule reached neither the ledger nor the annotation; `projected` now requires every check on the node to be declared, and the generator reports partially-stated sites on their own line. And the generator and the detector each passed the projection `override` for themselves — dropping it on the generator side alone left every site reading `projected` behind a green ledger while the published file silently went wide — so both now reach `z.toJSONSchema` through one shared call with no argument left to forget.
27+
28+
<!-- 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: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 201,
6-
"droppedRefinementSites": 553,
7-
"refinementSitesThatDidProject": 197,
5+
"publishedSchemasWithDroppedRefinements": 200,
6+
"droppedRefinementSites": 551,
7+
"refinementSitesThatDidProject": 199,
88
"refinementSitesWithNoJsonFormToCompare": 3
99
},
1010
"entries": {
@@ -738,12 +738,6 @@
738738
]
739739
},
740740
"data/SQLDriverConfig": {
741-
"sites": [
742-
"",
743-
"sslConfig"
744-
]
745-
},
746-
"data/SSLConfig": {
747741
"sites": [
748742
""
749743
]

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

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
// The ratchet below measures against this same override, so a rule the list
5050
// emits leaves the ledger and a rule it does not emit stays in it — see the
5151
// module header for why the two halves must not be read against each other.
52-
import { refinementProjectionOverride } from './lib/refinement-projection';
52+
import { projectPublishedJsonSchema } from './lib/refinement-projection';
5353
// The dropped-refinement ratchet (#18670). The mirror image of the branch
5454
// pruning above, and deliberately its own module for the same reason: the
5555
// pruner guards a projection NARROWER than the Zod type, this one the direction
@@ -498,19 +498,12 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
498498
let io: 'output' | 'input' = 'output';
499499
let prunedBranches: readonly PrunedBranch[] = [];
500500
try {
501-
jsonSchema = z.toJSONSchema(value, {
502-
target: 'draft-2020-12',
503-
override: refinementProjectionOverride,
504-
}) as Record<string, unknown>;
501+
jsonSchema = projectPublishedJsonSchema(value) as Record<string, unknown>;
505502
} catch (outputError) {
506503
if (!isKnownUnsupported(outputError)) throw outputError;
507504
io = 'input';
508505
try {
509-
jsonSchema = z.toJSONSchema(value, {
510-
target: 'draft-2020-12',
511-
io: 'input',
512-
override: refinementProjectionOverride,
513-
}) as Record<string, unknown>;
506+
jsonSchema = projectPublishedJsonSchema(value, { io: 'input' }) as Record<string, unknown>;
514507
} catch (inputError) {
515508
if (!isKnownUnsupported(inputError)) throw inputError;
516509
// THIRD attempt, #16431 (a): both directions above refuse the
@@ -526,10 +519,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) {
526519
// then re-thrown with the message Zod produced, so this attempt
527520
// can never change WHY an export is skipped, and so never the
528521
// `cause` recorded for it in unemitted-schemas.baseline.json.
529-
const projected = projectByPruningUnionBranches(value, {
530-
target: 'draft-2020-12',
531-
override: refinementProjectionOverride,
532-
});
522+
const projected = projectByPruningUnionBranches(value);
533523
if (!projected) throw inputError;
534524
jsonSchema = projected.schema;
535525
io = projected.io;
@@ -3603,6 +3593,41 @@ if (projectedSiteTotal > 0) {
36033593
}
36043594
}
36053595

3596+
// Nodes whose projection MOVED and which are still counted as dropped — the
3597+
// reading the per-node differential cannot express as a verdict (#18670 third
3598+
// arm). Two shapes reach this line and both are news:
3599+
//
3600+
// - a node carrying a DECLARED arm beside a rule the closed list does not
3601+
// cover, so part of it is stated in the file and part of it is not. It is
3602+
// ledgered and annotated conservatively, which is what the ruling's 「A
3603+
// refinement that is not one of these named patterns stays dropped and
3604+
// annotated」 requires — before the verdict was per-check-aware such a node
3605+
// read `projected` outright and its undeclared rule was recorded nowhere;
3606+
// - zod having started to project a `custom` check on its own, which is the
3607+
// upgrade this whole instrument is waiting for and must not swallow.
3608+
//
3609+
// Printed rather than fatal: the site is already held by the ledger as a drop,
3610+
// so a NEW one fails the ratchet above on its own. What this line adds is WHICH
3611+
// of the declared population is only half-stated, which no count can say.
3612+
const partiallyStated = refinementCensus.flatMap((entry) =>
3613+
entry.dropped
3614+
.filter((site) => site.projectionMoved)
3615+
.map((site) => ({ defKey: entry.defKey, site })),
3616+
);
3617+
if (partiallyStated.length > 0) {
3618+
console.log(
3619+
`\n🪢 ${partiallyStated.length} refinement site(s) are PARTIALLY stated by the published file — ` +
3620+
`the projection moved, yet not every \`custom\` check on the node is one the closed list declares, ` +
3621+
`so the node stays dropped and annotated (#18670).`,
3622+
);
3623+
for (const { defKey, site } of partiallyStated) {
3624+
const declared = site.declaredPatterns.length > 0
3625+
? site.declaredPatterns.join('+')
3626+
: 'nothing declared — zod projected this on its own';
3627+
console.log(` ${defKey} at "${site.path}": ${site.count} check(s), declared: ${declared}`);
3628+
}
3629+
}
3630+
36063631
// ─── Generate Bundled Schema ─────────────────────────────────────────
36073632
// Single-file bundled schema containing all generated schemas for IDE autocomplete
36083633

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,15 @@ 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, declaredPatterns: [] });
228+
const site = (path: string) => ({
229+
path,
230+
nodeType: 'string',
231+
count: 1,
232+
aborting: false,
233+
verdict: 'dropped' as const,
234+
declaredPatterns: [],
235+
projectionMoved: false,
236+
});
229237
const census = (defKey: string, paths: string[]) => ({
230238
defKey,
231239
dropped: paths.map(site),

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

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ import {
9090
CUSTOM_CHECK_KIND,
9191
checkKindOf,
9292
customChecksOf,
93+
projectPublishedJsonSchema,
9394
projectableRefinementsOf,
94-
refinementProjectionOverride,
9595
zodDefOf,
9696
} from './refinement-projection';
9797

@@ -149,6 +149,18 @@ export interface RefinementSite {
149149
* named, which reads differently from a refinement somebody deleted.
150150
*/
151151
readonly declaredPatterns: readonly string[];
152+
/**
153+
* The RAW differential this node's verdict was adjudicated from: true when
154+
* removing its `custom` checks changes the projection at all, i.e. SOMETHING
155+
* about them reached the published file.
156+
*
157+
* ⛔ Not a synonym for `verdict === 'projected'`. The pair
158+
* `verdict: 'dropped'` + `projectionMoved: true` is the one that carries
159+
* information neither field holds alone — some of this node's rules are
160+
* stated and at least one is not — and the pair
161+
* `verdict: 'projected'` + `projectionMoved: false` cannot occur.
162+
*/
163+
readonly projectionMoved: boolean;
152164
}
153165

154166
/** Every refinement site under one published schema. */
@@ -221,34 +233,83 @@ function withoutCustomChecks(schema: z.ZodType): z.ZodType | null {
221233
* `toJSONSchema` in the generator's own io ladder, or `null` when neither side
222234
* has a JSON form.
223235
*
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".
236+
* ⭐ It projects through `projectPublishedJsonSchema` — the SAME call the
237+
* generator reaches `z.toJSONSchema` through (#18670). Without the refinement
238+
* projection this function would measure a projection nothing publishes: a node
239+
* whose rule the closed list DOES emit would read byte-identical on both sides
240+
* of the differential and stay in the ledger for ever, and the shrink-only
241+
* ledger's whole use — a row deletion is the observable proof a site closed —
242+
* would be unreachable. With it, `dropped` means "this build's own published
243+
* file states nothing about this rule".
244+
*
245+
* ⛔ And it is reached through the shared helper rather than by passing
246+
* `override:` here, because the two halves agreeing was otherwise a convention:
247+
* dropped on the generator side alone it left every declared site reading
248+
* `projected` behind a green ledger while the published file went wide in
249+
* silence. The helper's own docblock carries that measurement.
231250
*/
232251
function projectOrNull(schema: z.ZodType): string | null {
233252
for (const io of ['output', 'input'] as const) {
234253
try {
235-
return JSON.stringify(
236-
z.toJSONSchema(schema, { target: 'draft-2020-12', io, override: refinementProjectionOverride }),
237-
);
254+
return JSON.stringify(projectPublishedJsonSchema(schema, { io }));
238255
} catch {
239256
// Try the other direction — the generator does the same, for the same reason.
240257
}
241258
}
242259
return null;
243260
}
244261

245-
function verdictFor(schema: z.ZodType): RefinementSite['verdict'] {
262+
/** One node's raw differential and the verdict adjudicated from it. */
263+
interface NodeProjectionReading {
264+
readonly verdict: RefinementSite['verdict'];
265+
readonly projectionMoved: boolean;
266+
}
267+
268+
/**
269+
* Measure one node, then adjudicate it.
270+
*
271+
* ## The differential is per NODE, and so is the ledger — but the RULES are not
272+
*
273+
* `withoutCustomChecks` removes ALL of a node's custom checks at once, so the
274+
* comparison answers "did ANY of them reach the file", never "did each". A node
275+
* carrying one DECLARED arm and one undeclared rule therefore moved the
276+
* differential on the strength of the declared arm alone, and reading that as
277+
* `projected` published the undeclared rule's silence: not in the ledger, not
278+
* in `x-dropped-refinements`, and invisible to the generator's UNDECLARED line,
279+
* which only sees sites with zero declared patterns. The ratchet stayed green
280+
* over a refinement the file says nothing about — exactly what the ruling's
281+
* 「A refinement that is not one of these named patterns stays dropped and
282+
* annotated」 forbids. Measured before this fix, on the two-arm shape
283+
* `z.string().refine(NON_BLANK_STRING).refine((s) => s.startsWith('x'))`:
284+
* `dropped: []`, `projected: [{ count: 2, declaredPatterns: ['non-blank-string'] }]`.
285+
*
286+
* So `projected` now requires that EVERY custom check on the node is one the
287+
* closed list declared. Anything else is `dropped`, conservatively: the ledger
288+
* unit is the node, a node cannot be half-recorded, and over-recording costs a
289+
* row while under-recording costs the silence this whole instrument exists to
290+
* end.
291+
*
292+
* ## Why the raw differential is still reported
293+
*
294+
* Collapsing "the projection did not move" and "it moved for reasons this list
295+
* does not cover" into one `dropped` would make the detector assert the drop
296+
* instead of measuring it — the failure this module's header names, and the one
297+
* that would keep it reading as current through the zod upgrade that fixes the
298+
* gap. `projectionMoved` is the measurement; `verdict` is the adjudication. A
299+
* site with `verdict: 'dropped'` and `projectionMoved: true` is news either way
300+
* — a mixed node, or zod having started to project something on its own — and
301+
* the generator prints it on its own line.
302+
*/
303+
function readProjection(schema: z.ZodType): NodeProjectionReading {
246304
const stripped = withoutCustomChecks(schema);
247-
if (!stripped) return 'undecidable';
305+
if (!stripped) return { verdict: 'undecidable', projectionMoved: false };
248306
const before = projectOrNull(schema);
249307
const after = projectOrNull(stripped);
250-
if (before === null || after === null) return 'undecidable';
251-
return before === after ? 'dropped' : 'projected';
308+
if (before === null || after === null) return { verdict: 'undecidable', projectionMoved: false };
309+
if (before === after) return { verdict: 'dropped', projectionMoved: false };
310+
const stated = projectableRefinementsOf(schema).length;
311+
const total = customChecksOf(schema).length;
312+
return { verdict: total === stated ? 'projected' : 'dropped', projectionMoved: true };
252313
}
253314

254315
/**
@@ -396,13 +457,15 @@ export function collectDroppedRefinements(defKey: string, root: z.ZodType): Refi
396457

397458
const customs = customChecksOf(schema);
398459
if (customs.length > 0) {
460+
const reading = readProjection(schema);
399461
const site: RefinementSite = {
400462
path: readablePath(path),
401463
nodeType: String(zodDefOf(schema)?.type ?? 'unknown'),
402464
count: customs.length,
403465
aborting: customs.some(checkAborts),
404-
verdict: verdictFor(schema),
466+
verdict: reading.verdict,
405467
declaredPatterns: projectableRefinementsOf(schema).map((declared) => declared.pattern),
468+
projectionMoved: reading.projectionMoved,
406469
};
407470
if (site.verdict === 'dropped') dropped.push(site);
408471
else if (site.verdict === 'projected') projected.push(site);

0 commit comments

Comments
 (0)