diff --git a/.changeset/18572-suggester-opposite-pole.md b/.changeset/18572-suggester-opposite-pole.md new file mode 100644 index 00000000000..2392f02962f --- /dev/null +++ b/.changeset/18572-suggester-opposite-pole.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': patch +--- + +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). + +Clause-②: no + +`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: + +```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 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. + +- **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. +- ⛔ **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. +- **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). +- **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`. diff --git a/packages/spec/src/shared/alias-integrity.test.ts b/packages/spec/src/shared/alias-integrity.test.ts index c99df6091ed..5214fab3440 100644 --- a/packages/spec/src/shared/alias-integrity.test.ts +++ b/packages/spec/src/shared/alias-integrity.test.ts @@ -123,6 +123,7 @@ import ts from 'typescript'; import { aliasProbe } from './alias-probe'; import { acceptsNothing, strictObjectDeclarations, type StrictObjectDeclaration } from './strict-object'; +import { POLARITY_AXES } from './polarity-axes'; import { keySetMatches } from './suggestions.zod'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -1164,6 +1165,47 @@ describe('alias integrity — every table is a true claim about its schema', () expect(handwrittenMapSites(field)).toEqual([]); }); + it('every declared POLARITY axis is attested by a real sibling pair', () => { + // `polarity-axes.ts` is a table of claims about these shapes, so it earns + // the same judgement as `aliases` and `guidance`: an axis no shape declares + // both ends of can never match, and a row nothing can match is a row + // nothing judges. It would read as coverage of a trap that, on this + // protocol, does not exist. + // + // ⛔ This is NOT a general antonym dictionary and must not grow into one. + // A row arrives with the shape that needs it; when the last shape + // declaring both ends of an axis loses one, this fails and the row goes. + const attested = new Map(POLARITY_AXES.map((axis) => [axis.join('/'), [] as string[]])); + const tokens = (key: string) => key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + + for (const s of SURFACES) { + const keys = Object.keys(s.shape).filter((k) => !acceptsNothing(s.shape[k])); + const spelled = new Map(keys.map((k) => [k, tokens(k)])); + for (const key of keys) { + const t = spelled.get(key)!; + for (let i = 0; i < t.length; i++) { + for (const axis of POLARITY_AXES) { + if (t[i] !== axis[0] && t[i] !== axis[1]) continue; + const other = t[i] === axis[0] ? axis[1] : axis[0]; + const wanted = t.slice(); wanted[i] = other; + const sibling = keys.find( + (o) => o !== key && spelled.get(o)!.join('|') === wanted.join('|'), + ); + if (sibling) attested.get(axis.join('/'))!.push(`${s.options.surface}: ${key} / ${sibling}`); + } + } + } + } + const dead = [...attested.entries()].filter(([, hits]) => hits.length === 0).map(([axis]) => axis); + expect(dead, 'axis rows nothing in this package declares both ends of').toEqual([]); + // The control: the search DOES find pairs, so an empty `dead` is a reading + // rather than a walk that matched nothing at all. + expect([...attested.values()].flat().length).toBeGreaterThan(POLARITY_AXES.length); + }); + it('no guidance key is itself a declared key (the same dead entry, other channel)', () => { // `guidance` is consulted from the same `unrecognized_keys` path, so a // prescription filed under a key the shape DECLARES is unreachable in diff --git a/packages/spec/src/shared/opposite-pole-suggestion.test.ts b/packages/spec/src/shared/opposite-pole-suggestion.test.ts new file mode 100644 index 00000000000..11d7bf0b7c5 --- /dev/null +++ b/packages/spec/src/shared/opposite-pole-suggestion.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The axis-silent key trap — `dateField` answered with `endDateField` while + * every consumer folds `dateField` onto the event's START. + * + * What makes this defect worth its own file rather than a row in + * `suggestions.test.ts` is that none of its parts is wrong on its own. + * `findClosestMatches` ranks correctly, the budget is the right budget, and + * `endDateField` is a real declared key the runtime honours perfectly. The + * defect is the COMPOSITION: on a shape declaring both ends of a range, the + * cheaper-spelled end wins a question the author never asked, and the answer + * parses. So the legs below are written to fail for different reasons — the + * arithmetic leg re-measures the premise, the bright leg pins that the raw + * suggester still WOULD name the wrong end, the main leg pins what ships, and + * the dark legs pin everything that must not have moved. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { levenshteinDistance, findClosestMatches } from './suggestions.zod'; +import { strictObject } from './strict-object'; +import { POLARITY_AXES, oppositePoleAmbiguity } from './polarity-axes'; +import { CalendarConfigSchema, GanttConfigSchema, TimelineConfigSchema } from '../ui/view.zod'; + +/** The budget `strictUnknownKeyError` actually spends, reproduced, not imported. */ +const budget = (key: string) => Math.max(2, Math.floor(key.length / 3)); +/** The fold `findClosestMatches` scores under. */ +const fold = (v: string) => v.toLowerCase().replace(/[-\s]/g, '_'); + +const RANGE_SURFACES = { + 'this calendar configuration': CalendarConfigSchema, + 'this gantt configuration': GanttConfigSchema, + 'this timeline configuration': TimelineConfigSchema, +} as const; + +/** The one `unrecognized_keys` message a probe object produces, or `undefined`. */ +function rejectionMessage(schema: z.ZodType, doc: unknown): string | undefined { + const result = schema.safeParse(doc); + if (result.success) return undefined; + return result.error.issues.find((i) => i.code === 'unrecognized_keys')?.message; +} + +/** A probe that is valid except for the one key under test. */ +const CANONICAL = { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' } as const; +const probe = (key: string) => ({ ...CANONICAL, [key]: 'due_date' }); + +// --------------------------------------------------------------------------- +// LEG 1 — the arithmetic, RE-MEASURED. Never 3 and 5 as literals. +// --------------------------------------------------------------------------- + +describe('the distance arithmetic that creates the trap', () => { + // Filed as "`datefield` is 3 from `enddatefield` and 5 from `startdatefield`, + // against a 9-character key's budget of 3". Those two numbers are the premise + // the whole card rests on, and a premise copied forward is how this card came + // to carry a mechanism its own filer had retracted. So the relation is + // recomputed here on every run and the numbers are never written down: if a + // rename, a fold change or a budget change moves it, this fails and names the + // measurement rather than leaving a stale comment behind. + it('re-derives that the WRONG end is reachable and the right one is not', () => { + const authored = 'dateField'; + const toEnd = levenshteinDistance(fold(authored), fold('endDateField')); + const toStart = levenshteinDistance(fold(authored), fold('startDateField')); + const allowed = budget(authored); + + expect(toEnd).toBeLessThanOrEqual(allowed); + expect(toStart).toBeGreaterThan(allowed); + // The asymmetry is pure spelling: `end` is a 3-letter token, `start` a + // 5-letter one. Nothing semantic separates them, which is exactly why the + // ranking cannot be trusted to choose between them. + expect(toStart - toEnd).toBe('start'.length - 'end'.length); + }); + + it('re-derives that `endField` is out of budget — the dark control is structural', () => { + // The filer's own control table observed "no hint for `endField`" without + // knowing why. This is why: an 8-character key buys a budget of 2 and the + // nearest declared key is further than that. + const authored = 'endField'; + const allowed = budget(authored); + for (const candidate of ['endDateField', 'startDateField']) { + expect(levenshteinDistance(fold(authored), fold(candidate))).toBeGreaterThan(allowed); + } + }); +}); + +// --------------------------------------------------------------------------- +// LEG 2 — BRIGHT CONTROL. The raw suggester still points at the wrong end. +// --------------------------------------------------------------------------- + +describe('bright control: the trap candidate is still what the ranking produces', () => { + // This is the leg that keeps the main leg honest. If `endDateField` ever + // stopped being the nearest-within-budget candidate, the main leg below would + // pass for a reason that has nothing to do with the guard, and deleting the + // guard would leave every test green. Pin the input to the guard, not just + // its output. + it.each(Object.keys(RANGE_SURFACES))('%s: unguarded ranking answers `endDateField`', (surface) => { + const schema = RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES]; + const declared = Object.keys((schema as unknown as { _zod: { def: { shape: object } } })._zod.def.shape); + expect(declared).toContain('startDateField'); + expect(declared).toContain('endDateField'); + expect(findClosestMatches('dateField', declared, budget('dateField'), 1)[0]).toBe('endDateField'); + }); +}); + +// --------------------------------------------------------------------------- +// LEG 3 — MAIN. What actually ships to the author. +// --------------------------------------------------------------------------- + +describe('main: an axis-silent key is answered with BOTH ends, never one', () => { + it.each(Object.keys(RANGE_SURFACES))('%s: `dateField` names both ends', (surface) => { + const message = rejectionMessage(RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES], probe('dateField')); + expect(message).toBeDefined(); + // The defect, stated as the assertion that would have failed before: + // a single rename pointing at the end of the event. + expect(message).not.toContain('→ `endDateField`'); + expect(message).not.toMatch(/Did you mean/); + // What replaces it names both ends, in axis order. + expect(message).toContain('`startDateField`'); + expect(message).toContain('`endDateField`'); + expect(message!.indexOf('`startDateField`')).toBeLessThan(message!.indexOf('`endDateField`')); + // And says out loud that the wrong choice is a SILENT one — the property + // that made this worth a card rather than a note. + expect(message).toContain('both parse'); + }); + + it('gantt: the second attested pair on the same surface is covered too', () => { + // `baselineStartField` / `baselineEndField`, found by census rather than by + // reading the card: `baselineField` is 3 from `baselineendfield` inside a + // 13-character key's budget of 4, and 5 from `baselinestartfield`. + const declared = Object.keys( + (GanttConfigSchema as unknown as { _zod: { def: { shape: object } } })._zod.def.shape, + ); + expect(findClosestMatches('baselineField', declared, budget('baselineField'), 1)[0]) + .toBe('baselineEndField'); + const message = rejectionMessage(GanttConfigSchema, { ...CANONICAL, baselineField: 'x' }); + expect(message).not.toContain('→ `baselineEndField`'); + expect(message).toContain('`baselineStartField`'); + expect(message).toContain('`baselineEndField`'); + }); + + it('⛔ the accepted key set does NOT move — `dateField` is still refused', () => { + // The stop condition on this card is that declaring `dateField` as an alias + // would widen the accepted surface. Nothing here accepts it: before the + // change it was rejected with a misleading hint, after the change it is + // rejected with an honest one. + for (const [surface, schema] of Object.entries(RANGE_SURFACES)) { + const withAxisSilentKey = schema.safeParse(probe('dateField')); + expect(withAxisSilentKey.success, surface).toBe(false); + expect( + withAxisSilentKey.success ? [] : withAxisSilentKey.error.issues.map((i) => i.code), + ).toContain('unrecognized_keys'); + // The canonical document still parses, so nothing was narrowed either. + expect(schema.safeParse(CANONICAL).success, surface).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// LEG 4 — DARK CONTROLS. Refusal code AND text unchanged. +// --------------------------------------------------------------------------- + +describe('dark controls: keys that had no hint before still have none', () => { + it.each(Object.keys(RANGE_SURFACES))('%s: `endField` and a nonsense key are untouched', (surface) => { + const schema = RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES]; + const nonsense = rejectionMessage(schema, probe('zzqqwx')); + const endField = rejectionMessage(schema, probe('endField')); + expect(nonsense).toBeDefined(); + expect(endField).toBeDefined(); + for (const message of [nonsense!, endField!]) { + expect(message).not.toMatch(/Did you mean/); + expect(message).not.toContain('•'); + } + // Stronger than "no hint": byte-identical to the no-suggestion shape, with + // only the key name differing. This pins the refusal TEXT, not just the + // absence of a suggestion, without transcribing the surface's `history` + // sentence into this file where it would rot. + expect(endField).toBe(nonsense!.replace('`zzqqwx`', '`endField`')); + }); +}); + +// --------------------------------------------------------------------------- +// LEG 5 — the guard's boundaries, on synthetic shapes. +// --------------------------------------------------------------------------- + +describe('the guard fires on omission and NOT on a typo', () => { + const RangeSurface = strictObject( + { surface: 'this probe surface', history: 'History.' }, + { + minLength: z.number().optional(), + maxLength: z.number().optional(), + startDateField: z.string().optional(), + endDateField: z.string().optional(), + titleField: z.string().optional(), + }, + ); + + it('keeps the rename for a dropped character in a pole-carrying key', () => { + // `axLength` is one deleted character from `maxLength` and names no pole, + // so conditions 1-3 of the guard all hold. Condition 4 — "closer to the + // stripped key than to the key itself" — is what tells a typo apart from + // an omission, and it is the reason this suggestion survives. + const message = rejectionMessage(RangeSurface, { axLength: 1 }); + expect(message).toContain('Did you mean `axLength` → `maxLength`?'); + }); + + it('suppresses the rename when the author simply omitted the axis', () => { + const message = rejectionMessage(RangeSurface, { dateField: 'x' }); + expect(message).not.toMatch(/Did you mean/); + expect(message).toContain('`startDateField`'); + expect(message).toContain('`endDateField`'); + }); + + it('leaves a key that NAMES its end alone', () => { + // `startDatField` carries `start`, so the author already answered the + // question the guard exists to stop the suggester from answering for them. + const message = rejectionMessage(RangeSurface, { startDatField: 'x' }); + expect(message).toContain('→ `startDateField`'); + }); + + it('never screens a DECLARED alias, even onto one end of an axis', () => { + // Precedence, pinned from the direction that matters: a human statement + // about one spelling outranks this guard. ⛔ No in-repo surface declares + // `dateField` — the stop condition on #18572 forbids exactly that — so the + // precedence is proven on a synthetic table instead of by adding one. + const Declared = strictObject( + { surface: 'this declaring surface', history: 'History.', aliases: { dateField: 'startDateField' } }, + { startDateField: z.string().optional(), endDateField: z.string().optional() }, + ); + expect(rejectionMessage(Declared, { dateField: 'x' })) + .toContain('Did you mean `dateField` → `startDateField`?'); + }); + + it('does nothing when only ONE end of the axis is declared', () => { + // Condition 3. With no sibling there is no coin flip — the suggestion is + // the only reading available and stays. + const OneEnded = strictObject( + { surface: 'this one-ended surface', history: 'History.' }, + { endDateField: z.string().optional(), titleField: z.string().optional() }, + ); + expect(rejectionMessage(OneEnded, { dateField: 'x' })).toContain('→ `endDateField`'); + }); + + it('screens the guessed candidate only — the helper is a pure predicate', () => { + const candidates = ['startDateField', 'endDateField', 'titleField']; + expect(oppositePoleAmbiguity('dateField', 'endDateField', candidates)?.poles) + .toEqual(['startDateField', 'endDateField']); + expect(oppositePoleAmbiguity('dateField', 'titleField', candidates)).toBeUndefined(); + expect(oppositePoleAmbiguity('endDatField', 'endDateField', candidates)).toBeUndefined(); + expect(oppositePoleAmbiguity('dateField', 'endDateField', ['endDateField'])).toBeUndefined(); + }); +}); + +describe('the axis table', () => { + it('declares each pair once, in a stable naming order, with distinct tokens', () => { + const seen = new Set(); + for (const [low, high] of POLARITY_AXES) { + expect(low).not.toBe(high); + for (const token of [low, high]) { + expect(seen.has(token), `\`${token}\` appears on two axes`).toBe(false); + seen.add(token); + } + } + }); +}); diff --git a/packages/spec/src/shared/polarity-axes.ts b/packages/spec/src/shared/polarity-axes.ts new file mode 100644 index 00000000000..d84a67c5f49 --- /dev/null +++ b/packages/spec/src/shared/polarity-axes.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The AXIS vocabulary — the token pairs that name the two ends of one range, + * and the predicate that recognises when the edit-distance suggester is about + * to answer an axis-silent key with one arbitrary end of it. + * + * ## The defect this exists for + * + * `findClosestMatches` ranks by edit distance and nothing else, so on a shape + * that declares BOTH ends of a range it answers a key that names neither end + * with whichever end is spelled more cheaply. Measured on `main`, folded, at + * the budget `strictUnknownKeyError` actually spends: + * + * ```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` is a five-letter one. That spelling + * accident is the entire reason the author is sent to the end of the event + * rather than its start; nobody declared the mapping. And the answer is not + * refused downstream — `endDateField` is a declared key, so an author who does + * what the protocol told them gets a document the runtime ACCEPTS with the axis + * bound to the wrong end. The obedient reader is the one it punishes. + * + * ## What this module changes, and what it deliberately does not + * + * It changes ONE thing: 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. The accepted key set is untouched — the + * axis-silent key is rejected before this module is consulted and is still + * rejected after it. ⛔ No alias is declared: `dateField` does not acquire a + * meaning here, it acquires an honest answer. + * + * Naming both ends rather than picking one is not invented here. `field.zod.ts` + * already answers `visible` that way, by hand, for the same reason in its own + * words — 「the two answers have opposite polarity … Naming both is the only + * answer that cannot be acted on wrongly」. What a hand-written `guidance` entry + * cannot do is cover the keys nobody thought to enumerate, which is precisely + * the set a fuzzy suggester answers. + * + * ## Precedence — this module is the LAST resort, never the first + * + * A declared `aliases` entry is a human statement about one spelling and always + * wins; so does an exact `guidance` entry and a `guidanceSets` match. This + * predicate is consulted only on the candidate the DISTANCE fallback produced, + * because a guess is the only thing here that can be a coin flip. `this field` + * declares `length: 'maxLength'` beside `size: 'maxLength'` against a declared + * `minLength` sibling: that is a decision, it reads as an axis collision, and it + * is left exactly as written. + * + * ## Why this is a leaf module and not part of `suggestions.zod.ts` + * + * Same reason as `alias-probe.ts`: **two readers must agree.** + * `strictUnknownKeyError` reads {@link oppositePoleAmbiguity} to answer an + * author, and `alias-integrity.test.ts` reads {@link POLARITY_AXES} to prove no + * row in it is dead. A second copy of either in the gate would fail silently. + * Like `alias-probe.ts` it is deliberately **not** re-exported from + * `shared/index.ts` — an internal seam the audit reaches by relative path, not + * part of the `@objectstack/spec` contract. + */ + +/** + * Pairs of tokens that name opposite ends of ONE axis. + * + * ⚠️ **Every row must be ATTESTED** — some `strictObject` shape in this package + * declares both poles of it as sibling keys. `alias-integrity.test.ts` asserts + * that, the same way it refuses a dead `aliases` entry: a row nothing can ever + * match is a claim nothing judges. So this table is NOT a general antonym + * dictionary, and rows are not added ahead of the shape that needs them. + * + * Attested on 2026-09-20 over **389 unique authoring surfaces** — the population + * `alias-integrity.test.ts`'s own forcing walk registers, deduplicated by its + * own key (surface + alias table + sorted shape keys). ⚠️ State which reading + * you are quoting: the same walk also yields **421** raw `strictObject` + * registrations (factories that build one shape from several call sites) and + * **388** distinct surface STRINGS (two surfaces share a name). They are three + * different facts and only the first is the count of surfaces this table was + * judged against. + * + * Pair counts below are that measurement, one row per axis: + * + * | axis | pairs | attested by | + * |:-------------------|------:|:-------------------------------------------------| + * | `start` / `end` | 5 | `startDateField` / `endDateField` on calendar, | + * | | | gantt and timeline; `baselineStartField` / | + * | | | `baselineEndField` on gantt; `start` / `end` on | + * | | | the gantt shift band | + * | `min` / `max` | 11 | `minLength` / `maxLength` and `min` / `max` on | + * | | | form field and on field; `minRows` / `maxRows` on | + * | | | subform; `minZoom` / `maxZoom` on the ER diagram; | + * | | | `minDate` / `maxDate` on `object-timeline`; and | + * | | | `min` / `max` on screen field, chart axis, zoom | + * | | | settings and the datasource pool config | + * | `input` / `output` | 2 | `inputMapping` / `outputMapping` on API endpoint; | + * | | | `isInput` / `isOutput` on a flow variable | + * | `read` / `write` | 2 | `read` / `write` on the `api` data source; | + * | | | `readScope` / `writeScope` on object permission | + * + * ⛔ These counts are a reading, not a floor: the gate that keeps them honest is + * the attestation test, which re-derives them from the shapes on every run. + * + * The order inside a pair is the order the two ends are NAMED IN, so a message + * reads `startDateField` before `endDateField` however the candidates happened + * to be declared. It carries no claim that the first is the better guess — + * there is no better guess, which is the whole point. + */ +export const POLARITY_AXES: readonly (readonly [string, string])[] = [ + ['start', 'end'], + ['min', 'max'], + ['input', 'output'], + ['read', 'write'], +]; + +/** + * Split a key into its lower-cased word tokens. + * + * `startDateField`, `start_date_field` and `Start-Date-Field` all tokenize to + * `['start', 'date', 'field']`: the same case/separator fold `foldForScoring` + * and `aliasProbe` apply, one level finer so a token can be compared as a word + * rather than as a substring. Word-level is load-bearing — a substring test + * would find `in` inside `minLength` and `to` inside `total`. + */ +function keyTokens(key: string): string[] { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** + * Levenshtein distance over the folded spelling, duplicated from + * `suggestions.zod.ts` only in the sense that both measure the same thing — + * this module imports nothing from it, because `suggestions.zod.ts` imports + * THIS one and the package already carries two import cycles through + * `field.zod` that its own docblocks describe at length. + */ +function distance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + let prev = new Array(b.length + 1); + let curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} + +/** One end of an axis the author did not choose between. */ +export interface OppositePoleAmbiguity { + /** The candidate the distance fallback named. */ + readonly named: string; + /** Its opposite-pole sibling, declared on the same shape. */ + readonly opposite: string; + /** The axis, as {@link POLARITY_AXES} declares it. */ + readonly axis: readonly [string, string]; + /** Both keys ordered by the axis — `['startDateField', 'endDateField']`. */ + readonly poles: readonly [string, string]; +} + +/** + * True-ish when naming `candidate` back to the author of `input` would be a + * coin flip on the axis rather than a correction of a typo. + * + * All four conditions hold, and the fourth is the one that keeps this narrow: + * + * 1. `candidate` carries an axis token as one of its WORDS; + * 2. `input` carries neither end of that axis — the author asked no question + * about which end, so an answer that picks one is inventing the question; + * 3. the shape also declares the opposite-pole sibling, so both ends really are + * reachable spellings and the choice really is between two live keys; + * 4. `input` is at least as close to `candidate` MINUS its axis token as it is + * to `candidate` itself — the authored spelling is better explained as "the + * key without the axis" than as "the key with a typo in it". + * + * Condition 4 is what separates this from a blanket suppression, and it was + * measured rather than reasoned. Without it, `axLength` — an ordinary dropped + * character in `maxLength` — loses its suggestion, because `minLength` is + * declared right beside it and `axLength` names no pole either. With it: + * `axLength` is distance 1 from `maxlength` and 2 from the stripped `length`, + * so it reads as the typo it is and keeps its rename, while `dateField` is + * distance 0 from the stripped `datefield` and 3 from `enddatefield`, and reads + * as the axis-silent key it is. + */ +export function oppositePoleAmbiguity( + input: string, + candidate: string, + candidates: readonly string[], +): OppositePoleAmbiguity | undefined { + const candTokens = keyTokens(candidate); + // A key that is NOTHING but a pole (`start` / `end` on the gantt shift band) + // has no axis-silent spelling to be confused with — there is no remainder. + if (candTokens.length < 2) return undefined; + const inputTokens = new Set(keyTokens(input)); + const folded = keyTokens(input).join(''); + + for (let i = 0; i < candTokens.length; i++) { + const axis = POLARITY_AXES.find((pair) => pair[0] === candTokens[i] || pair[1] === candTokens[i]); + if (!axis) continue; + // (2) The author named an end. Whatever else is wrong with their spelling, + // it is not that they failed to choose. + if (inputTokens.has(axis[0]) || inputTokens.has(axis[1])) continue; + const other = candTokens[i] === axis[0] ? axis[1] : axis[0]; + const wanted = candTokens.slice(); + wanted[i] = other; + const wantedKey = wanted.join(''); + // (3) Both ends must be live keys on this shape. + const opposite = candidates.find( + (c) => c !== candidate && keyTokens(c).join('') === wantedKey, + ); + if (!opposite) continue; + // (4) Omission, not typo. + const stripped = candTokens.filter((_, t) => t !== i).join(''); + if (distance(folded, stripped) > distance(folded, candTokens.join(''))) continue; + const poles: readonly [string, string] = + candTokens[i] === axis[0] ? [candidate, opposite] : [opposite, candidate]; + return { named: candidate, opposite, axis, poles }; + } + return undefined; +} + +/** + * The author-facing bullet an {@link OppositePoleAmbiguity} is answered with. + * + * It names both ends and prescribes nothing between them, because there is + * nothing to prescribe: the key the author wrote does not carry the + * information. The last clause is the part that matters most to an AI author, + * which will otherwise read a rejection as "any accepted key will do" — both + * spellings parse, so the failure this replaces was silent. + */ +export function oppositePolePrescription(key: string, ambiguity: OppositePoleAmbiguity): string { + const [first, second] = ambiguity.poles; + return ( + `\`${key}\` does not say which end of the range it binds, and this surface declares both ` + + `\`${first}\` and \`${second}\` — opposite ends of one axis. Write the one you mean: both ` + + `parse, so guessing binds the wrong end silently.` + ); +} diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index 2887029fe28..2fdc252271c 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -4,6 +4,10 @@ import type { z } from 'zod'; import { FieldType } from '../data/field.zod'; import { aliasProbe } from './alias-probe'; +import { + oppositePoleAmbiguity, + oppositePolePrescription, +} from './polarity-axes'; /** * "Did you mean?" Suggestion Utilities @@ -378,6 +382,13 @@ export interface StrictUnknownKeyErrorOptions { * length-relative edit-distance fallback (matching `suggestKey` in * `data/object.zod.ts`: a flat distance of 3 is noise on a short key). * + * The fallback's answer is screened once more before it ships, because a + * distance ranking cannot see meaning: on a shape that declares both ends of a + * range, an axis-silent key is answered with whichever end is spelled more + * cheaply, and that end PARSES. Such a guess is replaced by a prescription + * naming both ends — `polarity-axes.ts`, which also explains why a declared + * alias is never screened. + * * Wire it as the object's `error` alongside `.strict()`: * * ```ts @@ -465,8 +476,25 @@ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z. continue; } const maxDistance = Math.max(2, Math.floor(key.length / 3)); - const canonical = - aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]; + const declared = aliases[aliasProbe(key)]; + const guessed = declared ? undefined : findClosestMatches(key, knownKeys, maxDistance, 1)[0]; + // Screen the GUESS, never the declaration. `findClosestMatches` ranks by + // edit distance and nothing else, so on a shape declaring both ends of a + // range it answers a key that names neither end with whichever end is + // spelled more cheaply — `dateField` lands on `endDateField` at distance + // 3 while `startDateField` sits at 5, outside the budget, unreachable. + // Both parse, so the author who takes the advice binds the wrong end of + // the event and is told nothing. A declared `aliases` entry is a human + // statement about that one spelling and is left exactly as written; only + // a coin flip is replaced, and it is replaced by naming BOTH ends rather + // than by silence. `polarity-axes.ts` carries the four conditions, the + // measurement, and why the accepted key set does not move. + const ambiguity = guessed ? oppositePoleAmbiguity(key, guessed, knownKeys) : undefined; + if (ambiguity) { + prescriptions.push(oppositePolePrescription(key, ambiguity)); + continue; + } + const canonical = declared ?? guessed; if (canonical && canonical !== key) renames.push(`\`${key}\` → \`${canonical}\``); } // Order: WHICH KEY IS WRONG → HOW TO FIX IT → why it used to be silent.