Skip to content

Commit f26fb8e

Browse files
os-billclaude
andauthored
fix(spec,objectql): correct six false edit distance cannot reach citations (#19126)
Fixes #17664 Clause-②: no `aliases` has **two** jobs, not one: filling a gap the distance fallback leaves empty, and **overruling** a hit the fallback reaches and gets wrong. The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]` — the table is consulted first and wins outright — and the budget is `Math.max(2, Math.floor(key.length / 3))`. A sentence saying distance "cannot reach" the cited case denies the second job. In three of the six places corrected here, the cited case is itself an example of it. ## The three sites the card names — three separate verdicts Measured against the **real** `findClosestMatches` and each shape's own key list (read off `.shape`, never transcribed), at `origin/main` `1124c4564`. ### 1. `packages/objectql/src/validation/record-validator.ts` — FALSE, high confidence Cited: *"The rename IS the prescription, and edit distance cannot reach it (`latitude` -> `lat`)"*. `latitude` is 8 characters, so the budget is 2. `lat` is 5 edits away and out of reach — but the fallback is **not silent**: the declared `altitude` is exactly 2 edits away, inside the budget. The refusal an author would see without the alias, printed by the ablation below: ``` Unrecognized key(s) on this location value: `latitude`. Did you mean `latitude` -> `altitude`? ``` So the entry is overruling a confident wrong answer, and the sentence cites it as proof that no such category exists. Confidence: **high** — the ablation reproduces the wrong answer verbatim from the running schema. ### 2. `packages/spec/src/data/default-value-shape.ts` — FALSE, high confidence, plus a second error Same sentence, same measurement, so the same verdict. This site carries an **additional** error the card does not name: the docblock's own worked example is `postal_code -> postalCode` on `AddressValueSchema`, and it attributes that output to the curated `aliases` map. `postal_code` is not an alias key on that shape at all — scoring folds case and separators on **both** sides, so it is **1** edit against a budget of 3 and the example is the plain fallback's own answer. The corrected text names both channels and says which produced which. Confidence: **high** (both halves re-measured). ### 3. `packages/spec/src/data/driver/turso.zod.ts` — FALSE, high confidence, and in a different position from the other two Two things set this one apart, and they are the same distinction read twice. **Position.** Sites 1 and 2 cite a single example inline, as proof of a rule. This one is a *blanket header standing above a table*: *"Semantic near-misses only — the spellings edit distance cannot reach."* The word doing the damage is **only**, and it is quantified over six rows. **Bucket.** Sites 1 and 2 are in the **overrule** bucket (reachable, fallback answers a *different* key). `uri` is in the **agrees** bucket: 3 characters, budget 2, and `uri`/`url` differ by **1**, so the bare fallback already answers `url` — identically. Five of the six rows (`connectionstring`, `dsn`, `database`, `databaseurl`, `syncinterval`) are genuine gaps; `uri` is not, and the row's real value is that it keeps answering `url` if this shape ever gains a key within 2 of `uri`. Confidence: **high** for the `uri` row; the other five rows are re-measured and the header's claim holds for them. ## Three more carriers of the *same* claim, fixed in place The `latitude -> lat` sentence has five carriers, not two. Three more were corrected here under the bounded in-place-fix exemption, and all four of its conditions are named and shown: 1. **Same defect class as the card** — literally the same false sentence about the same alias entry on the same shape, falsified by the same measurement. `packages/spec/src/data/field-value.zod.ts` (the table's *own* comment), `packages/spec/src/data/field-value.test.ts`, `packages/spec/src/data/default-value-shape.test.ts`. 2. **Mechanical, with the form already pinned** — the correction form is the one PR #17662 landed in `packages/spec/src/kernel/manifest.zod.ts`: name the budget formula and where it lives, give the exact distances, say which of the two roles the entry plays, and name the pin test. Prose only. 3. **No other claim holds the file** — the open-PR holder map was rebuilt from scratch over all **33** open PRs (281 file rows). Lit control: it names #19119 holding 10 files, matching the dispatch's own reading. Dark control: a fabricated path matches 0 rows. All five carrier paths read FREE. 4. **Same gate family, no new verification surface** — every one is under `packages/spec/src/data/**`, already inside this card's declared file surface, and adds no gate. The two new `describe` blocks live in test files the card's own sites already depend on. Leaving them would have made the repository contradict itself inside one alias table's documentation. The boundary held elsewhere: the same claim shape about a *different* table is reported below, not fixed. ## Pins `aliases` entries are now asserted **by role**, because the two roles fail differently: - `packages/spec/src/data/field-value.test.ts` — `longitude` (GAP: nothing within budget), `latitude` (OVERRULE, with the negative half asserted: the reachable wrong answer must **not** appear), and `altitud` as the control that a plain typo still rides the fallback. - `packages/spec/src/data/driver/turso.test.ts` — `dsn` (GAP) beside `uri` (reachable, agreeing). ## Population re-derivation, its radius, and one target outside it The card reports the phrase pattern occurring **19 times across 17 tracked files** and roughly a dozen unclassified. Re-derived at `origin/main` `1124c4564`, printing every hit, never a count: | instrument | flatten | result | |---|---|---| | the card's own pattern | leading JSDoc star stripped per line | 20 hits / 17 files | | this PR's pattern | leading JSDoc star **and** leading `//` stripped per line | **24 hits / 20 files** | **The card's instrument has a demonstrable blind spot, and it hides carriers of this very defect.** The card's prescription names the JSDoc-star wrap and stops there. A **line-comment** wrap is invisible to it: when `//` falls between "edit" and "distance", or between "distance" and "cannot", the flattened text reads `edit // distance cannot reach` and no pattern anchored on the adjacent phrase can see it. Three hits in two files surface only under the comment-aware flatten, and two of them are further carriers of the false `latitude -> lat` claim — including `field-value.zod.ts`, the file that **owns** the alias. **Radius.** This instrument sees: tracked files at one commit of `objectstack-ai/objectstack`, matching the literal words "edit distance" plus a negated modal plus "reach". It does **not** see: other repositories, untracked files, published npm tarballs, GitHub issue and PR bodies, or any **paraphrase** that does not spell "edit distance". **One named target outside it:** `packages/spec/src/automation/builtin-node-config.zod.ts` says *"`object` -> `objectName` is four edits against a threshold of two, so the suggester would say nothing at all for the single most common wrong spelling on this surface"*. That is exactly the same claim about exactly the same mechanism, and both patterns score that file **0**. Lit control on the same file: the paraphrase itself matches twice. (Re-measured: `object -> objectName` really is a GAP on every CRUD node config, so it is true — but the radius, not the truth, is the point.) The sibling repo `objectui` was also swept and carries no member of this pattern. ## Acceptance notes — noted, not filed Four members of the population are **FALSE** and left alone, with the reason each is out of scope: - `packages/spec/src/ui/action.zod.ts` — *"Edit distance cannot reach these (`visibleWhen` -> `visible` is 4 apart)"* heads a ~50-row table containing **6 overruling** rows (`path -> target` with the fallback reaching `patch` at 1; also `args`, `success`, `style`, `op`, `acl`) and 5 agreeing ones. The cited example is itself a genuine GAP, so this is a false *blanket*, not a false example. Different table, different lane (`packages/spec/src/ui/**`, outside this card's file surface). - `packages/spec/src/data/object-strictness-batch20.test.ts` — the `describe` title *"aliases — semantic near-misses edit distance cannot reach"* blankets three cases, and `export -> exportCsv` is an **overrule** (the fallback reaches `import` at 2). Inside the file surface, but a different alias table: fixing it would widen the diff past the coherence boundary above. - `packages/spec/CHANGELOG.md` — *"Three spelled-out near-misses that edit distance cannot reach are curated as aliases: `filesystem` and `paths` point at `fs`, and `hosts` points at `network`"*. `hosts` is an **overrule** (the fallback reaches `hooks` at 2) — the very row PR #17662 corrected in the source. RELEASE-OWNED: AGENTS.md prescribes amending a released entry in a dedicated docs-only PR, never as a rider on code changes. - `packages/objectql/CHANGELOG.md` — carries the `latitude -> lat` claim. RELEASE-OWNED, same remedy. One more, true but worth knowing: `packages/spec/src/data/field-value.zod.ts`'s **second** citation (`zipCode -> postalCode`, a genuine GAP) sits above a table whose `postcode -> postalCode` row is in the **agrees** bucket. The sentence is scoped to `zipCode` and is therefore true; it is noted because it is one edit away from being the same blanket error as the turso header. Also noted: the whole class would be closed by a guard rather than by sweeps — an assertion that no comment attached to a `strictObject` options literal claims unreachability for a row that measures reachable. `shared/alias-integrity.test.ts` already forces every table and already computes what is needed. That is a new gate and a decision, not a rider on this PR. ## Evidence - **Ablation** (one-off; the tree was restored and the restore proved): removing `latitude: 'lat'` from `LocationValueSchema`'s table through `scripts/ablation-replace.mjs` — anchor hit 1 -> 0, blob `ac90b42d814a` -> `2610f8d9477b` — turns the new pin **red** and prints the runtime refusal quoted in site 1 above. Restored: blob back to `ac90b42d814a`, equal to HEAD, `git diff HEAD` empty. - **Tests**: `packages/spec` 494 files / 14526 tests pass; `packages/objectql` 299 files / 5003 tests pass. All five new pins confirmed to have run by name. - **Typecheck**: `pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck` exit 0. - **Gates**: all **85** families derived by `scripts/pm/dispatch-gates.mjs` for this change set are accounted for — 83 exit 0, 2 NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`), both of which printed `PREREQUISITE NOT MET — nothing was measured` because they need a full-repo build. Those are CI's run; neither can be moved by a comment-and-test diff. - **Lint**: the repo-wide run, not a narrowing — `pnpm lint` exit 0 at `f61417bb2d`, **6878** files linted, 0 errors, 0 warnings, and all 7 edited source files verified present in the linted population. ## What on the card turned out otherwise - Still present, not already fixed: all three cited sites were re-derived against `origin/main` before anything was written. - The card's census is an **undercount** for a named mechanical reason (the `//` wrap), and two of the hits it misses are carriers of the defect it is about. - The card's "roughly a dozen unclassified" re-derives to **17** hits, of which 7 are false — 3 the card names, 3 fixed here as the same claim, 4 reported above. - The card's own numbers have moved with the tree: its census read 384 surfaces / 1910 alias entries / 1658 gap / 211 agreeing / 41 overruling; the same sweep today reads **419 / 2139 / 1862 / 228 / 49**. Lit control `visibleWhen` still classifies GAP in 4 tables; a fabricated alias token matches 0 rows. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70e1a82 commit f26fb8e

8 files changed

Lines changed: 188 additions & 17 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
Correct six `edit distance cannot reach` citations that are measurably false, and pin the role each alias entry actually plays.
7+
8+
`aliases` has two jobs, not one: filling a gap the distance fallback leaves empty, and overruling a hit the fallback reaches and gets wrong. The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]` — the table is consulted first and wins outright — and the budget is `Math.max(2, Math.floor(key.length / 3))`. A sentence saying distance "cannot reach" the cited case denies the second job, and in three places the cited case is itself an example of it.
9+
10+
- **`latitude` → `lat` is an OVERRULE, not a gap** (`data/field-value.zod.ts`, `data/default-value-shape.ts`, `data/field-value.test.ts`, `data/default-value-shape.test.ts`, objectql `validation/record-validator.ts`). `latitude` is 8 characters, so the budget is 2; `lat` is 5 edits away and out of reach, but the declared `altitude` is exactly 2 — so without the curated entry the bare fallback answers `latitude` → `altitude` and points an author who wrote a GPS latitude at the elevation member. Four docblocks cited this pair as proof that aliases exist only where distance reaches nothing.
11+
- **`postal_code` → `postalCode` never involved an alias at all** (`data/default-value-shape.ts`). Scoring folds case and separators on both sides, so it is 1 edit against a budget of 3 — the worked example rendered in that docblock is the fallback's own answer, not the `AddressValueSchema` table's.
12+
- **`uri` → `url` is reachable and agreeing** (`data/driver/turso.zod.ts`). The block was headed "the spellings edit distance cannot reach"; that is true of five of its six rows and false of `uri`, which is 1 edit from `url` against a budget of 2. The row is a pin on an answer the fallback already gets right, not a gap-filler.
13+
14+
Prose plus new pins. No alias is added or removed, no schema, key list, strictness, suggestion or error message changes: `Clause-②: no`. The three roles are now asserted — `longitude` (gap), `latitude` (overrule, with the negative half), `altitud` (a plain typo still riding the fallback) in `data/field-value.test.ts`, and `dsn` (gap) beside `uri` (reachable) in `data/driver/turso.test.ts`.

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,17 @@ export function coerceBooleanFields<T extends Record<string, unknown>>(
555555
* [1] invalid_type lng Invalid input: expected number, received undefined
556556
* [2] unrecognized_keys ... Did you mean `latitude` -> `lat`, `longitude` -> `lng`? ...
557557
*
558-
* The rename IS the prescription, and edit distance cannot reach it
559-
* (`latitude` -> `lat`), which is exactly why `LocationValueSchema` curates an
560-
* `aliases` map. Reading positionally builds that hint and then discards it,
558+
* The rename IS the prescription, and the distance fallback will not hand it
559+
* over: `latitude` -> `lat` is 5 edits against a budget of
560+
* `Math.max(2, Math.floor(key.length / 3))` = 2 (spec `shared/suggestions.zod.ts`).
561+
* What the fallback hands over instead is not silence — `altitude` is a
562+
* declared member of the same shape, 2 edits away and inside the budget — so
563+
* `LocationValueSchema`'s `aliases` entry is OVERRULING a confident wrong
564+
* answer rather than filling a gap: the lookup is
565+
* `aliases[aliasProbe(key)] ?? findClosestMatches(…)`, table first and winning
566+
* outright. That is the entry's real job here, and spec's
567+
* `data/field-value.test.ts` pins both halves of it. Reading positionally
568+
* builds that hint and then discards it,
561569
* leaving an operator running `os migrate value-shapes` to derive the rename
562570
* themselves while the identically-shaped `address` case is handed it.
563571
*

‎packages/spec/src/data/default-value-shape.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,14 @@ describe('#7127 checkLiteralDefaultValue — the shared stored-form literal chec
138138
expect(v.detail).toContain('`latitude` \u2192 `lat`');
139139
expect(v.detail).toContain('`longitude` \u2192 `lng`');
140140
expect(v.detail).not.toContain('expected number, received undefined');
141-
// Edit distance cannot reach `latitude` -> `lat`; the curated `aliases`
142-
// map is the only thing that can, which is why discarding it cost the
143-
// author the whole prescription.
141+
// `latitude` -> `lat` is 5 edits against a budget of 2, so the curated
142+
// `aliases` map is the only thing that produces THIS rename. Note what the
143+
// fallback does instead of nothing: `altitude` is a declared member 2
144+
// edits away, so without the entry the author would be pointed at the
145+
// elevation member — the alias overrules a wrong answer here rather than
146+
// filling a silent gap (`field-value.test.ts` pins both halves). Either
147+
// way the prescription rides the unrecognized-keys issue, which is why
148+
// discarding it cost the author the whole thing.
144149
});
145150

146151
it('#16077 prefers the rename over a WRONG-TYPED-member error (address)', () => {

‎packages/spec/src/data/default-value-shape.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,27 @@ export interface LiteralDefaultValueVerdict {
119119
* [0] invalid_type street Invalid input: expected string, received number
120120
* [1] unrecognized_keys ... Did you mean `postal_code` -> `postalCode`? ...
121121
*
122-
* The rename IS the prescription, and edit distance cannot reach it
123-
* (`latitude` -> `lat`), which is exactly why `LocationValueSchema` and
124-
* `AddressValueSchema` curate an `aliases` map. Reading positionally built that
125-
* hint and threw it away, handing the author a missing-member type error about
122+
* The rename IS the prescription. Which channel produced it depends on the
123+
* key, and the two cases this function is pinned against are one of each — so
124+
* "the curated `aliases` map is what makes the rename possible" is true of only
125+
* half of them. The fallback budget is `Math.max(2, Math.floor(key.length / 3))`
126+
* (`shared/suggestions.zod.ts`), and the lookup is
127+
* `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]`
128+
* — the table is consulted first and wins outright.
129+
*
130+
* - `postal_code` -> `postalCode`, the example rendered above, is the
131+
* FALLBACK's own answer: scoring folds case and separators on both sides, so
132+
* it is 1 edit against a budget of 3, and `AddressValueSchema`'s alias table
133+
* never sees the key.
134+
* - `latitude` -> `lat` is 5 edits against a budget of 2 and does come from
135+
* `LocationValueSchema`'s curated entry — but not because the fallback is
136+
* silent. `altitude` is a declared member 2 edits away, inside the budget,
137+
* so the bare fallback answers `latitude` -> `altitude` and this alias
138+
* OVERRULES a confident wrong answer. That is an alias's second job, and
139+
* `field-value.test.ts` pins both halves of it.
140+
*
141+
* Reading positionally built that hint and threw it away, handing the author a
142+
* missing-member type error about
126143
* a member they never wrote — and which of the two they got depended on whether
127144
* some unrelated member happened to also be wrong, which nobody chose and
128145
* nobody can see.

‎packages/spec/src/data/driver/turso.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { describe, expect, it } from 'vitest';
1313

14+
import { findClosestMatches } from '../../shared/suggestions.zod';
1415
import { DatasourceSchema } from '../datasource.zod';
1516
import { validateDriverConfig } from './config-registry.zod';
1617
import { TursoConfigSchema, TursoDriverSpec } from './turso.zod';
@@ -70,6 +71,42 @@ describe('TursoConfigSchema', () => {
7071
expect(result.success).toBe(false);
7172
expect(JSON.stringify(result.error?.issues)).toContain('url');
7273
});
74+
75+
// The alias block above this shape's keys used to be headed "the spellings
76+
// edit distance cannot reach", which is true of five of its six rows and
77+
// false of `uri`. The two roles are pinned separately because they fail
78+
// differently: drop a GAP row and the author gets silence, drop the `uri`
79+
// row and nothing observable changes today — its value is that it keeps
80+
// answering `url` if this shape ever gains a key within 2 of `uri`.
81+
describe('the alias table, by role', () => {
82+
const rename = (key: string, value: unknown) => {
83+
const result = TursoConfigSchema.safeParse({ url: 'libsql://x.turso.io', [key]: value });
84+
expect(result.success).toBe(false);
85+
const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys');
86+
expect(issue, 'the refusal carries an unrecognized-keys issue').toBeDefined();
87+
return issue!.message;
88+
};
89+
90+
it('`dsn` is the GAP case — 3 edits from `url` against a budget of 2', () => {
91+
// Nothing declared on this shape is within 2 of `dsn`, so without the
92+
// row the refusal names the key and stops.
93+
expect(rename('dsn', 'libsql://x.turso.io')).toContain('`dsn` → `url`');
94+
});
95+
96+
it('`uri` is NOT — the bare fallback already reaches `url` at distance 1', () => {
97+
// `uri` is 3 characters, so the budget is `Math.max(2, floor(3 / 3))` = 2
98+
// and `uri`/`url` differ by exactly 1. The row and the fallback agree, so
99+
// this message is what an author sees either way — which is the whole
100+
// point: the entry is not what makes the suggestion possible.
101+
//
102+
// `shape` is a conservative SUPERSET of the candidate list the error map
103+
// spends (that one drops the `authToken`/`timeout` tombstones via
104+
// `acceptsNothing`); extra candidates can only crowd `url` out, never
105+
// help it, so a pass here holds for the real list too.
106+
expect(findClosestMatches('uri', Object.keys(TursoConfigSchema.shape), 2, 1)).toEqual(['url']);
107+
expect(rename('uri', 'libsql://x.turso.io')).toContain('`uri` → `url`');
108+
});
109+
});
73110
});
74111

75112
describe('turso is a known driver to the config registry now (#6345)', () => {

‎packages/spec/src/data/driver/turso.zod.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,17 @@ export type TursoTransportMode = z.input<typeof TursoTransportModeSchema>;
8080
export const TursoConfigSchema = lazySchema(() => strictObject(
8181
{
8282
surface: "this turso datasource's config",
83-
// Semantic near-misses only — the spellings edit distance cannot reach.
83+
// Semantic near-misses — a different WORD for a declared key. FIVE of the
84+
// six are the unreachable case: `connectionstring`, `dsn`, `database`,
85+
// `databaseurl` and `syncinterval` all score past their budget against
86+
// every declared key, so without the entry the author gets no suggestion
87+
// at all. `uri` is NOT, and the entry is worth keeping for the opposite
88+
// reason: the budget is `Math.max(2, Math.floor(key.length / 3))`
89+
// (`shared/suggestions.zod.ts`), so a 3-character key gets 2, and `uri`
90+
// differs from `url` by 1 — the bare fallback already answers `url`. That
91+
// row is a PIN on an answer the fallback happens to get right, not a
92+
// gap-filler, and it keeps answering `url` if this shape ever gains a key
93+
// within 2 of `uri`. `turso.test.ts` pins the distinction.
8494
// Case and underscore variants of a DECLARED key (`encryption_key`,
8595
// `sync_url`) are deliberately absent: the unknown-key probe already
8696
// normalizes those onto the declared name, so entries for them would be

‎packages/spec/src/data/field-value.test.ts‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,37 @@ import {
3030
valueSchemaFor,
3131
referenceTargetOf,
3232
referenceCarrierOf,
33+
LocationValueSchema,
3334
} from './field-value.zod';
35+
import { findClosestMatches } from '../shared/suggestions.zod';
3436

3537
const ok = (def: Parameters<typeof valueSchemaFor>[0], v: unknown, form?: 'stored' | 'expanded') =>
3638
expect(valueSchemaFor(def, form).safeParse(v).success).toBe(true);
3739
const bad = (def: Parameters<typeof valueSchemaFor>[0], v: unknown, form?: 'stored' | 'expanded') =>
3840
expect(valueSchemaFor(def, form).safeParse(v).success).toBe(false);
3941

42+
/**
43+
* The candidate list the `location` error map actually spends its budget
44+
* against, read off the schema rather than transcribed beside it — a second
45+
* copy of a key list is the shape `shared/alias-integrity.test.ts` exists to
46+
* refuse. No member of this shape is a tombstone, so every declared key is a
47+
* candidate.
48+
*/
49+
const LOCATION_KEYS: readonly string[] = Object.keys(LocationValueSchema.shape);
50+
51+
/** The fallback budget, verbatim from `strictUnknownKeyError`. */
52+
const budgetFor = (key: string) => Math.max(2, Math.floor(key.length / 3));
53+
54+
/** The `unrecognized_keys` issue a stored `location` value rejection carries. */
55+
const firstLocationIssue = (value: Record<string, unknown>) => {
56+
const r = valueSchemaFor({ type: 'location' }, 'stored').safeParse(value);
57+
expect(r.success).toBe(false);
58+
const issues = (r as { error: { issues: Array<{ code: string; message: string }> } }).error.issues;
59+
const issue = issues.find((i) => i.code === 'unrecognized_keys');
60+
expect(issue, 'the rejection carries an unrecognized-keys issue').toBeDefined();
61+
return issue!;
62+
};
63+
4064
describe('semantic type classes', () => {
4165
it('every class member is a declared FieldType', () => {
4266
const all = new Set<string>(FieldType.options);
@@ -388,9 +412,11 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
388412
expect(geo.message).toContain('this location value');
389413
expect(geo.message).not.toMatch(/#\d+/);
390414

391-
// The retired spec-only spelling carries its rename (an alias — edit
392-
// distance cannot reach `latitude` → `lat`). It is ALSO a missing-pair
393-
// rejection; the unrecognized-keys issue is the one that names the fix.
415+
// The retired spec-only spelling carries its rename from the curated
416+
// alias, not from the distance fallback — and for `latitude` that alias is
417+
// OVERRULING the fallback rather than filling a gap (pinned in the next
418+
// test). It is ALSO a missing-pair rejection; the unrecognized-keys issue
419+
// is the one that names the fix.
394420
const retired = valueSchemaFor({ type: 'location' }, 'stored').safeParse({ latitude: 1, longitude: 2 });
395421
expect(retired.success).toBe(false);
396422
const unknown = (retired as { error: { issues: Array<{ code: string; message: string }> } }).error.issues
@@ -412,4 +438,43 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
412438
ok({ type: 'formula' }, 31.5);
413439
ok({ type: 'autonumber' }, 'INV-0001');
414440
});
441+
442+
// An alias entry has TWO jobs, not one, and `LocationValueSchema` ships one
443+
// of each — which is why three docblocks around this shape used to cite
444+
// `latitude` → `lat` as proof that aliases exist only where distance reaches
445+
// nothing. The fallback budget is `Math.max(2, Math.floor(key.length / 3))`
446+
// (`shared/suggestions.zod.ts`), and the lookup is
447+
// `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, budget, 1)[0]`
448+
// — table first, winning outright.
449+
describe('the `location` alias entries, one per role', () => {
450+
it('`longitude` fills a GAP — no declared member is within budget', () => {
451+
// 9 characters, so the budget is 3; `lng` is 6 edits away and nothing
452+
// else on the shape is closer. Drop the entry and the author gets no
453+
// suggestion at all, which is the role the prose always described.
454+
expect(findClosestMatches('longitude', LOCATION_KEYS, budgetFor('longitude'), 1)).toEqual([]);
455+
const issue = firstLocationIssue({ lat: 1, lng: 2, longitude: 3 });
456+
expect(issue.message).toContain('`longitude` → `lng`');
457+
});
458+
459+
it('`latitude` OVERRULES a live wrong answer — the bare fallback reaches `altitude`', () => {
460+
// 8 characters, so the budget is 2. `lat` is 5 edits away and out of
461+
// reach, but the declared `altitude` is exactly 2 — inside the budget —
462+
// so the fallback is not silent here, it is WRONG. Without the entry the
463+
// refusal would read ``Did you mean `latitude` → `altitude`?`` and point
464+
// an author who wrote a GPS latitude at the elevation member. That is
465+
// why the negative half is asserted and not only the positive one.
466+
expect(findClosestMatches('latitude', LOCATION_KEYS, budgetFor('latitude'), 1)).toEqual(['altitude']);
467+
const issue = firstLocationIssue({ lat: 1, lng: 2, latitude: 3 });
468+
expect(issue.message, 'the curated target is offered').toContain('`latitude` → `lat`');
469+
expect(issue.message, 'the reachable wrong answer is not').not.toContain('`latitude` → `altitude`');
470+
});
471+
472+
it('a plain typo still rides the fallback — the table is not in its way', () => {
473+
// The control that keeps the two assertions above from passing against a
474+
// fallback that answers nothing at all: `altitud` is one edit from the
475+
// declared `altitude` and no alias mentions it.
476+
const issue = firstLocationIssue({ lat: 1, lng: 2, altitud: 3 });
477+
expect(issue.message).toContain('`altitud` → `altitude`');
478+
});
479+
});
415480
});

‎packages/spec/src/data/field-value.zod.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,24 @@ export const LocationValueSchema = lazySchema(() => strictObject(
398398
history: 'Until this shape was closed, an undeclared key on a location value was silently '
399399
+ 'dropped — the value parsed green with the key gone, so the mistake surfaced only as a '
400400
+ 'blank on screen.',
401-
// The retired spec-only spelling (see the module header). Edit distance
402-
// cannot reach `latitude` → `lat`; the value contract has refused the pair
403-
// since ADR-0104 D1, so the rename is the one an author actually needs.
401+
// The retired spec-only spelling (see the module header). The two entries
402+
// play DIFFERENT roles and neither is optional. The fallback budget is
403+
// `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`).
404+
//
405+
// - `longitude` → `lng` is the unreachable case: 6 edits against a budget
406+
// of 3, and no other declared member is closer, so without this entry
407+
// the author gets no suggestion at all.
408+
// - `latitude` → `lat` is 5 edits against a budget of 2 — also past the
409+
// budget — but the fallback does NOT stay silent for it: `altitude` is
410+
// a declared member 2 edits away, inside the budget, so the bare
411+
// fallback answers `latitude` → `altitude`. This entry OVERRULES a
412+
// confident wrong answer rather than filling a gap, which is the other
413+
// job an alias does: `aliases[aliasProbe(key)] ?? findClosestMatches(…)`
414+
// consults the table first and wins outright.
415+
//
416+
// The value contract has refused the pair since ADR-0104 D1, so the rename
417+
// is the one an author actually needs. `field-value.test.ts` pins both
418+
// roles, including the negative half for `latitude`.
404419
aliases: { latitude: 'lat', longitude: 'lng' },
405420
},
406421
{

0 commit comments

Comments
 (0)