diff --git a/.changeset/strict-object-aliases-two-roles.md b/.changeset/strict-object-aliases-two-roles.md new file mode 100644 index 0000000000..b924bb8095 --- /dev/null +++ b/.changeset/strict-object-aliases-two-roles.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach". + +`strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job. + +The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks. + +Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules. + +The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place. + +Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 8e570be830..2f05f4687a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -138,8 +138,13 @@ schema needs: lazySchema(() => strictObject({ surface, history, aliases?, guidance? }, { ...shape })) ``` -- `aliases` — semantic near-misses edit distance cannot reach (`visibleWhen` → - `visible`, `from` → `source`, `read` → `allowRead`). +- `aliases` — curated near-miss answers, consulted before the distance fallback + and preferred over it, so the table carries both the near-misses distance + **cannot reach** (`visibleWhen` → `visible`, `from` → `source`, `read` → + `allowRead`) and the ones it **reaches and answers wrongly** (`hosts` → + `network`, 2 edits from the declared `hooks` against a budget of 2 — #16859). + Measured 2026-09-11 (#17361): of 1910 entries, 1658 unreachable, 211 + reachable-and-agreeing, 41 overruling a wrong hit. - `guidance` — exact-key prescriptions: **tombstones for retired keys** (the rejection carries the upgrade — AGENTS.md Post-Task Checklist #3) and wrong-layer pointers (`apiOperations` is response-side; `objectName` belongs diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts index 963b15686d..24c9e672f3 100644 --- a/packages/spec/src/shared/strict-object.ts +++ b/packages/spec/src/shared/strict-object.ts @@ -29,10 +29,30 @@ * `aliases` and `guidance` stay hand-written, because they are the part that * carries judgement rather than transcription: * - * - `aliases` — semantic near-misses edit distance cannot reach. The one that - * proves the category is `visibleWhen → visible`: ADR-0089 made `visibleWhen` - * the correct spelling on view/page, so an author borrowing it on a different - * surface is not making a typo, and only a human-written entry can catch it. + * - `aliases` — the curated answer for a semantic near-miss, a different *word* + * for the same intent. It is consulted BEFORE the distance fallback and wins + * outright (`aliases[aliasProbe(key)] ?? findClosestMatches(…)` in + * `suggestions.zod.ts`), which is what gives it **two** jobs, not one: + * - **filling a gap** — the near-miss distance cannot reach. The proving case + * is `visibleWhen → visible`: ADR-0089 made `visibleWhen` the correct + * spelling on view/page, so an author borrowing it on a different surface + * is not making a typo, and only a human-written entry can catch it. + * - **overruling a wrong hit** — the near-miss distance CAN reach, and + * answers with the wrong key. The proving case is `hosts → network` on the + * plugin `permissions` block (#16859): the budget is `max(2, len/3)` = 2 + * for a five-character key and `hosts` is exactly 2 from the declared + * `hooks`, so without the entry the author is sent to lifecycle hooks on + * the one block that also grants network access. + * + * ⚠️ Neither job is the rare one, and this bullet claimed only the first + * until #17361 measured it. Over every registered surface on 2026-09-11: + * 1910 alias entries, 1658 of them unreachable by distance and **252 + * reachable** — 211 where the fallback would have answered identically, and + * **41 where it answers a different key** the entry overrules. ⛔ So do not + * read this option as *only* for what distance cannot reach: that reading + * tells an adopter holding a confident wrong suggestion — the case the + * option is most needed for — that `aliases` is not their tool, which is + * this campaign's own finding-7 shape (see {@link acceptsNothing}). * - `guidance` — tombstones for retired keys (the rejection must carry the * upgrade) and wrong-layer pointers. * @@ -114,8 +134,18 @@ export interface StrictObjectOptions { /** One sentence: what silently happened before this shape was closed. */ history: string; /** - * Semantic near-misses edit distance cannot reach — a different *word* for - * the same intent, usually correct on a neighbouring surface. + * Curated answers for semantic near-misses — a different *word* for the same + * intent, usually correct on a neighbouring surface. + * + * Looked up BEFORE the edit-distance fallback and preferred over it, so an + * entry is the right tool in **both** directions: the word distance cannot + * reach (`visibleWhen → visible`), and the word distance *does* reach and + * gets WRONG (`hosts → network`, where `hosts` is 2 edits from the declared + * `hooks` against a budget of 2 — #16859). ⛔ Not "only for what distance + * cannot reach": that was this line until #17361, and it sends an author + * holding a confidently wrong suggestion away from the option that fixes it. + * Plain case / underscore slips still need no entry — the fallback folds + * those already, and a second spelling of a covered probe is a dead row. */ aliases?: Readonly>; /** diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index e0dc2cf66c..2887029fe2 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -328,9 +328,15 @@ export interface StrictUnknownKeyErrorOptions { /** * Semantic near-misses: a different *word* for the same intent, usually * borrowed from a neighbouring schema or product where that word is correct. - * Edit distance cannot reach these, so they are named explicitly; plain - * case/underscore slips are left to {@link findClosestMatches}. Map keys are - * matched case-insensitively with `_` / `-` / space separators removed. + * Consulted BEFORE {@link findClosestMatches} and preferred over it, so an + * entry answers both the near-miss distance cannot reach AND the one it + * reaches and gets wrong — `hosts → network` is 2 edits from the declared + * `hooks` against a budget of 2, and the entry is what keeps the author off + * lifecycle hooks (#16859; this line asserted only the first half until + * #17361 measured 41 overruling entries across the spec). Plain + * case/underscore slips are still left to the fallback, which folds them. + * Map keys are matched case-insensitively with `_` / `-` / space separators + * removed. */ aliases?: Readonly>; /**