Skip to content

Commit 4b58dcf

Browse files
os-steveclaude
andauthored
fix(spec): answer an axis-silent key with both ends of the range, not the cheaper-spelled one (#19318)
Fixes #18572 Clause-②: no ⛔ **The card body's mechanism is retracted by its own filer** (comment 5707586601) and the grading that recited it is superseded by comment 5747478673. There is no alias table; options (i) and (ii) on the body are void. This PR implements the re-graded, suggester-side scope, and every number below was re-derived here rather than carried from the card. ## What is actually wrong `findClosestMatches` ranks by edit distance and nothing else. On a shape that declares **both ends of a range**, a key that names neither end is therefore answered with whichever end happens to be spelled more cheaply: ```text authored `dateField` (9 chars, budget max(2, 9/3) = 3) -> `endDateField` distance 3 INSIDE the budget [ANSWERED] -> `startDateField` distance 5 outside the budget [UNREACHABLE] ``` `end` is a three-letter token and `start` a five-letter one. That spelling accident is the entire reason the protocol told an author to bind the **end** of the event. Nobody declared the mapping. And the suggested key is a **declared key the runtime honours**, so an author who copied the remedy got a document that **parses**, with the calendar axis silently on the wrong date. Every objectui read site folds `dateField` onto `startDateField`. The trap punishes the reader who did what the protocol said. ## The change One screen, on the **guess** only. When the candidate the distance fallback would name carries an axis token the authored key does not, and the shape also declares that candidate's opposite-pole sibling, the rename is replaced by a prescription naming **both** ends: ```text Unrecognized key(s) on this calendar configuration: `dateField`. • `dateField` does not say which end of the range it binds, and this surface declares both `startDateField` and `endDateField` — opposite ends of one axis. Write the one you mean: both parse, so guessing binds the wrong end silently. Until these shapes were closed an unknown key was dropped silently — … ``` - ⛔ **No alias was declared and the accepted key set does not move.** `dateField` was refused before and is refused after; only the sentence the refusal carries changed. This is the fork the card's stop condition names, and it is not taken — no `dateField → startDateField` entry exists anywhere in this diff. - **A declared `aliases` entry is never screened.** A human statement about one spelling outranks the guard; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares it. - **Naming both ends is this repo's own answer, generalised.** `field.zod.ts` already writes it by hand for `visible`: 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」. What a hand-written entry cannot do is cover the keys nobody enumerated — which is the set a fuzzy suggester answers. ### Omission vs typo — the condition that keeps it narrow The guard fires only when the authored key is **at least as close to the candidate minus its axis token as to the candidate itself**. Without that condition, `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. Measured: | authored | to the candidate | to the candidate minus its axis token | reads as | verdict | |:--|--:|--:|:--|:--| | `axLength` vs `maxLength` | 1 | 2 (`length`) | a typo | rename kept | | `dateField` vs `endDateField` | 3 | 0 (`datefield`) | the axis-silent key | both ends named | ## Census — four instances, not one Measured over **389** registered `strictObject` surfaces (the audit's own dedup key — surface + alias table + sorted shape keys; the same walk also reads **421** raw registrations and **388** distinct surface strings), by deriving each declared key's axis-silent spelling and asking the real error map what it answers. Four fuzzy instances exist and all four are fixed here: | surface | authored | answered before | now | |:--|:--|:--|:--| | this calendar configuration | `dateField` | `endDateField` | both ends named | | this timeline configuration | `dateField` | `endDateField` | both ends named | | this gantt configuration | `dateField` | `endDateField` | both ends named | | this gantt configuration | `baselineField` | `baselineEndField` | both ends named | Timeline and Gantt are covered, as the dispatch asked. The gantt `baselineStartField` / `baselineEndField` row was found by the census, not by the card. A fifth row the census surfaced is **not** a defect and is deliberately untouched: `this field` answers `length` with `maxLength` against a declared `minLength`, and that is a **declared** alias sitting beside `size: 'maxLength'`. It is a decision, so the guard leaves it exactly as written — which is also the precedence pin in the tests. ## The axis table is judged, not just declared `POLARITY_AXES` has four rows, each attested by a real sibling pair in this package. `alias-integrity.test.ts` now fails on a row no surface declares both ends of — the same dead-entry judgement it already applies to `aliases` and `guidance` — with a lit control so an empty verdict is a reading rather than a walk that matched nothing. It is deliberately **not** a general antonym dictionary. ## Evidence **Bright control (the premise, re-measured every run).** The arithmetic is never written down as `3` and `5`: the test recomputes the distances and asserts the relation — the wrong end inside the budget, the right end outside it — so a rename, a fold change or a budget change reds and names the measurement. A second leg pins that the *unguarded* ranking still produces `endDateField` on all three surfaces, so the main leg cannot pass for a reason unrelated to the guard. **Dark controls.** `endField` (out of budget at 8 chars, exactly as the filer's control table observed without knowing why) and a nonsense key are asserted **byte-identical** to each other with only the key name differing — pinning the refusal text, not merely the absence of a hint. **Ablation.** Guard removed via `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob `2fdc252271c2` to `f1228fa36275`), tests re-run, restored with `blob == HEAD` and `git diff HEAD` empty: ```text Tests 5 failed | 15 passed (20) FAIL main > this calendar configuration: `dateField` names both ends FAIL main > this gantt configuration: `dateField` names both ends FAIL main > this timeline configuration: `dateField` names both ends FAIL main > gantt: the second attested pair on the same surface is covered too FAIL the guard fires on omission and NOT on a typo > suppresses the rename … ``` The bright, dark, arithmetic and pure-predicate legs stay green under the ablation — they measure different things, and the dark controls really are dark. The new audit was ablated too: adding an unattested axis row reds it by name (`expected [ 'zzleftward/zzrightward' ] to deeply equal []`). **The declaration-shard diff is controlled, not assumed.** Adding a module to the import graph reshuffles TypeScript's declaration-emit order for enum members — 19 declarations across four shards, and the **whole** diff is three lines (`read`, `edit`, `update`) changing position. With `polarity-axes.ts` out of the graph and `suggestions.zod.ts` restored to the merge base, a fresh build reports `declaration text unchanged (17 entry points, 5364 declarations)`. The reshuffle is this branch's, so the artifact is regenerated here. ## Verification | leg | result | |:--|:--| | `pnpm --filter @objectstack/spec test` | 501 files / 14664 tests passed | | `pnpm --filter @objectstack/spec typecheck` | exit 0 (src, scripts and the test layer) | | `pnpm --filter @objectstack/spec build` | exit 0 | | `pnpm --filter @objectstack/spec check:generated` | all 16 generated artifacts up to date | | derived gate families (`scripts/pm/dispatch-gates.mjs`) | 83 derived, **81 run green, 0 unrun** | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0, clean, at `9c2725b69d` | Two of the 83 are **NOT MEASURED**, both refusing their own prerequisite with exit 3 rather than reporting a verdict — `check:dual-build-cjs-loads` (83 packages with no `dist/`) and `check:type-check-debt` (27 workspace dependencies unbuilt). Both need a whole-repo build, which is `Build Core`'s output in CI. ⛔ Neither is a pass and neither is a finding. Reconciliation: `83 derived famil(ies) accounted for — 81 run, 2 NOT-MEASURED`. ## Acceptance notes - **`packages/lint` has four direct `findClosestMatches` call sites that this guard does not reach**, because it lives in `strictUnknownKeyError` and those sites call the ranker directly, over user **data field names**, with the flat default budget of 3 rather than the length-relative one. Measured against `{start_date, end_date, min_amount, max_amount, name}`: `the_date` still resolves to `end_date`, while `amount` and `date` do not reach the trap (a snake_case name charges the separator too). Same mechanism, second read site, narrow but non-zero reachability. Not fixed here: it adds `packages/lint` to the affected set and a verification surface this card does not own. Filed for triage rather than silently carried. - `data/object.zod.ts`'s `suggestKey` is a second suggester with the same length-relative budget. Measured: `ObjectSchema` declares 43 top-level keys and **zero** polarity sibling pairs, so the trap is unreachable there today and nothing guards it if a range pair is ever added. noted, not filed — 承接者: the next card that adds a range pair to `ObjectSchema`. - `this field`'s `length: 'maxLength'` beside a declared `minLength` reads as an axis collision but is a declared alias, deliberately placed next to `size: 'maxLength'`. noted, not filed — 承接者:无, it is a declaration rather than a defect, and the guard's precedence is pinned so it stays one. - ⛔ `packages/spec/src/ui/view.zod.ts` was **not** touched — a concurrent card (#19088) holds it. The calendar, timeline and gantt shapes are read by the tests, never edited. --- > ⭐ **Seat correction, 2026-09-20T13:10Z — the surface count in this body was wrong and is fixed above.** It read **136**; re-derived with `alias-integrity.test.ts`'s own instrument at head `72bf22a2e1`, the reading is **389**. The **136** came from a one-off census script written for this card whose forcing walk was weaker in four ways: it imported only `*.zod.ts` plus `index.ts` (**225 of 1012** modules), returned early on function-valued schemas, capped the walk at depth 12 instead of 40, and never invoked a deferred error map — so every surface that registers on first use never registered. Re-running that old script at this head still prints `136`, so the figure is reproducible from the wrong instrument and from nothing else. > > ⛔ The **conclusion is unchanged**: four fuzzy instances, all guarded; one declared-alias row (`length` → `maxLength`) left alone. The larger population surfaced one row the undercount had hidden — on `this object-timeline`, an authored `date` draws `data`, a declared key one edit away and **not a pole**, so the guard is correctly silent — and it is not this card's class. The attestation table gained the pairs the undercount hid (`min`/`max` is 11 pairs, not 3); all four axes stay attested. > > The docblock and the changeset were corrected by the implementer on `72bf22a2e1`. This body is the seat's to edit — the dev's contract writes a PR body once and ⛔ never `PATCH`es it, and it flagged that conflict rather than quietly choosing a side, which was the right call. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ed1ab9 commit 4b58dcf

5 files changed

Lines changed: 608 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The unknown-key suggester no longer answers an axis-silent key with one arbitrary end of a range — `dateField` on a calendar, timeline or gantt config is told about `startDateField` **and** `endDateField` instead of being sent to the end of the event (#18572).
6+
7+
Clause-②: no
8+
9+
`findClosestMatches` ranks by edit distance and nothing else. On a shape that declares both ends of a range, a key naming neither end is therefore answered with whichever end is spelled more cheaply — re-derived here rather than taken from the card:
10+
11+
```text
12+
authored `dateField` (9 chars, budget max(2, 9/3) = 3)
13+
-> `endDateField` distance 3 INSIDE the budget <- answered
14+
-> `startDateField` distance 5 outside the budget <- unreachable
15+
```
16+
17+
`end` is a three-letter token and `start` a five-letter one; that spelling accident was the whole reason the protocol told an author to bind the **end** of the event. And the suggested key is a declared key the runtime honours, so an author who copied the remedy got a document that **parses**, with the axis silently on the wrong date. ⛔ Nobody had declared that mapping — a generic fuzzy matcher picked one sibling out of two.
18+
19+
- **The fallback's answer is screened; a declared `aliases` entry never is.** When the guessed candidate carries an axis token the authored key does not, and the shape also declares its opposite-pole sibling, the rename is replaced by a prescription naming both ends: *"`dateField` does not say which end of the range it binds, and this surface declares both `startDateField` and `endDateField` — opposite ends of one axis. Write the one you mean: both parse, so guessing binds the wrong end silently."* A human-written alias is a statement about one spelling and outranks this; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares.
20+
- ⛔ **No alias was added and the accepted key set does not move.** `dateField` was refused before this change and is refused after it; what changed is the sentence the refusal carries. Naming both ends rather than picking one is the answer `field.zod.ts` already writes by hand for `visible` — 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」 — generalised to the keys nobody thought to enumerate, which is the set a fuzzy suggester answers.
21+
- **The guard separates an omission from a typo, and that condition was measured.** It fires only when the authored key is at least as close to the candidate MINUS its axis token as to the candidate itself. Without it `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. With it, `axLength` reads as the typo it is (distance 1 vs 2) and `dateField` as the axis-silent key it is (distance 3 vs 0).
22+
- **Census, not just the filed case.** Over **389 unique authoring surfaces** — the population `alias-integrity.test.ts`'s forcing walk registers, deduplicated by its own key (surface + alias table + sorted shape keys); the same walk also yields 421 raw `strictObject` registrations and 388 distinct surface strings, which are different facts — the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`.

‎packages/spec/src/shared/alias-integrity.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import ts from 'typescript';
123123

124124
import { aliasProbe } from './alias-probe';
125125
import { acceptsNothing, strictObjectDeclarations, type StrictObjectDeclaration } from './strict-object';
126+
import { POLARITY_AXES } from './polarity-axes';
126127
import { keySetMatches } from './suggestions.zod';
127128

128129
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -1164,6 +1165,47 @@ describe('alias integrity — every table is a true claim about its schema', ()
11641165
expect(handwrittenMapSites(field)).toEqual([]);
11651166
});
11661167

1168+
it('every declared POLARITY axis is attested by a real sibling pair', () => {
1169+
// `polarity-axes.ts` is a table of claims about these shapes, so it earns
1170+
// the same judgement as `aliases` and `guidance`: an axis no shape declares
1171+
// both ends of can never match, and a row nothing can match is a row
1172+
// nothing judges. It would read as coverage of a trap that, on this
1173+
// protocol, does not exist.
1174+
//
1175+
// ⛔ This is NOT a general antonym dictionary and must not grow into one.
1176+
// A row arrives with the shape that needs it; when the last shape
1177+
// declaring both ends of an axis loses one, this fails and the row goes.
1178+
const attested = new Map(POLARITY_AXES.map((axis) => [axis.join('/'), [] as string[]]));
1179+
const tokens = (key: string) => key
1180+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1181+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
1182+
.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
1183+
1184+
for (const s of SURFACES) {
1185+
const keys = Object.keys(s.shape).filter((k) => !acceptsNothing(s.shape[k]));
1186+
const spelled = new Map(keys.map((k) => [k, tokens(k)]));
1187+
for (const key of keys) {
1188+
const t = spelled.get(key)!;
1189+
for (let i = 0; i < t.length; i++) {
1190+
for (const axis of POLARITY_AXES) {
1191+
if (t[i] !== axis[0] && t[i] !== axis[1]) continue;
1192+
const other = t[i] === axis[0] ? axis[1] : axis[0];
1193+
const wanted = t.slice(); wanted[i] = other;
1194+
const sibling = keys.find(
1195+
(o) => o !== key && spelled.get(o)!.join('|') === wanted.join('|'),
1196+
);
1197+
if (sibling) attested.get(axis.join('/'))!.push(`${s.options.surface}: ${key} / ${sibling}`);
1198+
}
1199+
}
1200+
}
1201+
}
1202+
const dead = [...attested.entries()].filter(([, hits]) => hits.length === 0).map(([axis]) => axis);
1203+
expect(dead, 'axis rows nothing in this package declares both ends of').toEqual([]);
1204+
// The control: the search DOES find pairs, so an empty `dead` is a reading
1205+
// rather than a walk that matched nothing at all.
1206+
expect([...attested.values()].flat().length).toBeGreaterThan(POLARITY_AXES.length);
1207+
});
1208+
11671209
it('no guidance key is itself a declared key (the same dead entry, other channel)', () => {
11681210
// `guidance` is consulted from the same `unrecognized_keys` path, so a
11691211
// prescription filed under a key the shape DECLARES is unreachable in

0 commit comments

Comments
 (0)