Skip to content
Merged
22 changes: 22 additions & 0 deletions .changeset/18572-suggester-opposite-pole.md
Original file line number Diff line number Diff line change
@@ -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`.
42 changes: 42 additions & 0 deletions packages/spec/src/shared/alias-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading