Skip to content

Commit b4ad984

Browse files
os-zhuangclaude
andauthored
fix(spec): 未知键建议对两侧折叠大小写 —— camelCase 键不再白扣编辑距离 (#4990) (#5363)
`findClosestMatches()` 只把输入小写化、候选不做同样处理,于是候选键里每一个 大写字母都要额外付一次编辑距离。叠加 `strictUnknownKeyError` 长度相对的预算 (短键为 2),短 camelCase 键上一个普通笔误就够不着建议:`hideOn` 对 `hiddenOn` 真实距离 2、加大写罚分后 3、超预算返回空;而同一个词写成全小写的 `hiddenon` 反而拿得到建议。 现在打分对两侧做同样的归一化,回显仍用候选原始拼写。另两处附带修正: - 折叠后距离为 0 的候选(只差大小写)不再被 `distance > 0` 丢掉 —— 那是最有 把握的一条建议。过滤改为只排除作者逐字写过的字符串,顺带修掉一个未记录的 同源缺陷:旧实现会把作者写对的键原样回显成「你是不是想写」。 - 折叠后打平时以作者自己的大小写作次级排序(`yxAis` 同距 `yAxis`/`xAxis`)。 325 组真实候选集实测:329 例从「没有建议」变为有建议(328 例正确),0 例失去 建议,31 例改变选中项(30 例更准)。批 13 的逐例 `hideOn` alias 随之退役,实测 值改由 responsive.test.ts 断言保存。`data/object.zod.ts` 的 `suggestKey` 经核查 不同病(本就对两侧小写化),补测试锁定防止分叉。 Claude-Session: https://claude.ai/code/session_01FTszibd6C8sUCCZnM4VcrL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d25303 commit b4ad984

6 files changed

Lines changed: 217 additions & 18 deletions

File tree

.changeset/olive-donkeys-repeat.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
修正未知键「你是不是想写」兜底对 camelCase 键的系统性偏弱 (#4990)
6+
7+
`findClosestMatches()` 此前只把**输入**小写化,**候选不做同样处理**,于是候选键里的每一个大写字母都要额外付一次编辑距离。叠加 `strictUnknownKeyError` 长度相对的预算(短键即 2),短 camelCase 键上一个普通笔误就够不着建议了:`hideOn``hiddenOn` 真实距离 2、加大写罚分后 3、超预算返回空,而同一个词写成全小写的 `hiddenon` 反而拿得到建议 —— 把键写错大小写的作者,比写对了大小写、只错一两个字母的作者得到更好的诊断。
8+
9+
现在打分对两侧做同样的归一化(大小写 + `-`/空格 → `_`),回显仍用候选的原始拼写。两处附带修正:
10+
11+
- 折叠后距离为 0 的候选(只有大小写之差,例如 `hiddenon``hiddenOn`)不再被 `distance > 0` 过滤丢掉 —— 那是兜底能给出的最有把握的一条建议。过滤改为只排除作者逐字写过的那个字符串;顺带修掉一个未被记录的同源缺陷:旧实现会把作者写对的键原样回显成「你是不是想写」。
12+
- 折叠后打平时,以作者自己写的大小写作为**次级**排序依据(`yxAis` 同时距 `yAxis``xAxis` 为 2,大写 A 指向前者)。
13+
14+
由于「TS config keys → camelCase」是全仓约定,这条影响 #4001 战役已落地的每一条未知键错误信息。在全部 325 组真实候选集上按单字符笔误实测:329 例从「没有建议」变为有建议(328 例正确),**0 例失去建议**,31 例改变选中项(30 例更准)。`ui/responsive.zod.ts` 中批 13 为此写的逐例 `hideOn: 'hiddenOn'` alias 已随之退役,其实测值改由 `responsive.test.ts` 的断言保存。
15+
16+
`data/object.zod.ts``suggestKey` 经核查**不同病**(它本来就对两侧都做了小写化),已补测试锁定,防止两处再次分叉。

packages/spec/src/data/object.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,34 @@ describe('ObjectSchema.create()', () => {
892892
expect(message).toContain('#1535');
893893
});
894894

895+
// #4990 note 1 asked whether this file's own `suggestKey` shares the
896+
// camelCase weakness that `findClosestMatches` had. It does NOT: it already
897+
// lowercases BOTH sides (`editDistance(unknown.toLowerCase(),
898+
// key.toLowerCase())`), so a declared key's capitals were never charged to
899+
// the author here. Pinning it means the two suggesters cannot drift apart
900+
// again — this is the property #4990 fixed in the other one.
901+
it('suggestKey judges a typo identically in either case (#4990 note 1)', () => {
902+
const bullet = (key: string): string => {
903+
try {
904+
ObjectSchema.create({
905+
name: 'demo',
906+
fields: {},
907+
[key]: 1,
908+
} as Record<string, unknown> as Parameters<typeof ObjectSchema.create>[0]);
909+
} catch (e) {
910+
return ((e as Error).message.split('\n').find((l) => l.trim().startsWith('•')) ?? '').trim();
911+
}
912+
throw new Error(`expected ObjectSchema.create to reject \`${key}\``);
913+
};
914+
// A camelCase key the fallback CAN reach, and its all-lowercase twin:
915+
// both must land on the same canonical key.
916+
expect(bullet('nameFeild')).toContain('did you mean `nameField`');
917+
expect(bullet('namefeild')).toContain('did you mean `nameField`');
918+
// And one it cannot reach — the verdict must again not depend on case.
919+
expect(bullet('primaryFeild')).not.toContain('did you mean');
920+
expect(bullet('primaryfeild')).not.toContain('did you mean');
921+
});
922+
895923
// Tombstones: a RETIRED key's rejection must carry the upgrade
896924
// prescription — the compile/validation error is the one channel every
897925
// upgrading consumer (human or agent) is guaranteed to hit.

packages/spec/src/shared/suggestions.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,120 @@ describe('findClosestMatches', () => {
5858
});
5959
});
6060

61+
describe('camelCase parity in the distance fallback (#4990)', () => {
62+
// The budget `strictUnknownKeyError` actually spends. Reproduced rather than
63+
// imported because the point of these tests is the INTERACTION between the
64+
// budget and the scoring — a test that shared the constant could not show it.
65+
const budget = (key: string) => Math.max(2, Math.floor(key.length / 3));
66+
const suggest = (input: string, cands: readonly string[]): string | undefined =>
67+
findClosestMatches(input, cands, budget(input), 1)[0];
68+
69+
it('re-measures the four rows the issue tabled, at their real budgets', () => {
70+
// Before the fix, row 1 was `[]`: `hideOn` scored 3 against a budget of 2,
71+
// because `hiddenOn`'s capital O was charged to the author as an edit.
72+
expect(suggest('hideOn', ['hiddenOn'])).toBe('hiddenOn');
73+
expect(suggest('hiddenon', ['hiddenOn'])).toBe('hiddenOn');
74+
expect(suggest('hiddenOnn', ['hiddenOn'])).toBe('hiddenOn');
75+
expect(suggest('maxLenght', ['maxLength'])).toBe('maxLength');
76+
});
77+
78+
// THE INVARIANT, and the substance of #4990.
79+
//
80+
// Stating it took one correction worth recording. The issue phrases it as
81+
// "the all-lowercase form must not get a better suggestion than the correctly
82+
// cased one", and the obvious encoding — compare `suggest(T)` with
83+
// `suggest(T.toLowerCase())` — is VACUOUS against the buggy code, which
84+
// lowercased the input as its first act: both calls collapsed to the same
85+
// one and agreed trivially, on 462 probes, while the bug was fully present.
86+
//
87+
// The asymmetry is not between two spellings of the INPUT. It is between two
88+
// spellings of the DECLARED KEY: the identical typo was judged one way
89+
// against `hiddenOn` and another against `hiddenon`, because only the
90+
// candidate kept its capitals and each one cost the author an edit. That is
91+
// the title's "systematically weak on camelCase keys", and it is what this
92+
// pins: a key's capitalisation must not change the verdict on a typo.
93+
//
94+
// Measured against the pre-fix implementation, this corpus breaks parity 55
95+
// times in 462 probes — in BOTH directions (`bordeRradius` resolved against
96+
// camelCase `borderRadius` but not against flat `borderradius`, the capital
97+
// paying off by luck). Case must decide nothing either way.
98+
it('judges a typo the same whether the declared key is camelCase or flat', () => {
99+
const corpus = [
100+
'hiddenOn', 'maxLength', 'iteratorVariable', 'primaryField', 'defaultValue',
101+
'borderRadius', 'customVars', 'referenceTo', 'maxIterations', 'displayName',
102+
'onDelete', 'sortOrder', 'isRequired', 'allowedPaths', 'triggerPhrases',
103+
'xAxis', 'viewName', 'pluginId',
104+
];
105+
const failures: string[] = [];
106+
for (const key of corpus) {
107+
const flat = key.toLowerCase();
108+
// How an author actually mistypes: dropped character, swapped pair,
109+
// dropped pair — spelled with the camelCase they were aiming for.
110+
const variants = new Set<string>();
111+
for (let i = 1; i < key.length; i++) variants.add(key.slice(0, i) + key.slice(i + 1));
112+
for (let i = 1; i < key.length - 1; i++) {
113+
variants.add(key.slice(0, i) + key[i + 1] + key[i] + key.slice(i + 2));
114+
variants.add(key.slice(0, i) + key.slice(i + 2));
115+
}
116+
variants.delete(key);
117+
for (const typo of variants) {
118+
// Skip the degenerate comparison: when the typo lowercases to the flat
119+
// key itself (`bordeRradius` → `borderradius`), the flat side is the
120+
// author echoing their own string and is correctly refused, while the
121+
// camelCase side is a real — and maximally confident — suggestion.
122+
// That asymmetry is the self-match rule, not a case-parity break.
123+
if (typo.toLowerCase() === flat) continue;
124+
const againstCamel = suggest(typo, [key]) === key;
125+
const againstFlat = suggest(typo.toLowerCase(), [flat]) === flat;
126+
if (againstCamel !== againstFlat) {
127+
failures.push(
128+
`${key}: '${typo}' resolves=${againstCamel} but flat '${flat}' resolves=${againstFlat}`,
129+
);
130+
}
131+
}
132+
}
133+
expect(
134+
failures,
135+
`a declared key's capitalisation changed the verdict:\n${failures.join('\n')}`,
136+
).toEqual([]);
137+
});
138+
139+
// The issue's own headline comparison, kept as a named case because it is the
140+
// sentence the bug was reported in: the author who wrote the key ALL LOWERCASE
141+
// was served better than the author who cased it right and slipped two letters.
142+
it('serves the correctly-cased author no worse than the all-lowercase one', () => {
143+
expect(suggest('hiddenon', ['hiddenOn'])).toBe('hiddenOn'); // was already fine
144+
expect(suggest('hideOn', ['hiddenOn'])).toBe('hiddenOn'); // was `undefined`
145+
});
146+
147+
it('suggests a candidate that differs from the input ONLY in case', () => {
148+
// Folding both sides makes such a candidate distance 0, and the old
149+
// `distance > 0` filter discarded it along with the true self-match. It is
150+
// the single most confident suggestion the fallback can make.
151+
expect(suggest('hiddenon', ['hiddenOn'])).toBe('hiddenOn');
152+
expect(suggest('MAXLENGTH', ['maxLength'])).toBe('maxLength');
153+
expect(suggest('reference_to', ['referenceTo'])).toBe('referenceTo');
154+
});
155+
156+
it('still refuses to echo back the exact string the author typed', () => {
157+
expect(findClosestMatches('maxLength', ['maxLength', 'minLength'], 3, 3))
158+
.not.toContain('maxLength');
159+
});
160+
161+
it('breaks a folded tie on the author\'s own capitalisation', () => {
162+
// `allowedaPths` is equidistant from `allowedPaths` and `allowedAPIs` once
163+
// case is folded away. The capitals the author did type are the only
164+
// evidence left, and they point at `allowedPaths`.
165+
expect(suggest('allowedaPths', ['allowedAPIs', 'allowedPaths'])).toBe('allowedPaths');
166+
});
167+
168+
it('leaves genuinely unrelated keys unsuggested — the fold is not a widening', () => {
169+
const keys = ['hiddenOn', 'columns', 'order', 'breakpoint'];
170+
expect(suggest('workflows', keys)).toBeUndefined();
171+
expect(suggest('responsiveStyles', keys)).toBeUndefined();
172+
});
173+
});
174+
61175
describe('suggestFieldType', () => {
62176
it('should suggest via alias map for common alternatives', () => {
63177
expect(suggestFieldType('string')).toEqual(['text']);

packages/spec/src/shared/suggestions.zod.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,28 @@ export function levenshteinDistance(a: string, b: string): number {
5252
return prev[lb];
5353
}
5454

55+
/**
56+
* Fold away the differences an author is *never* signalling with: letter case
57+
* and the dash/space spellings of an underscore separator.
58+
*
59+
* Applied to BOTH sides of the comparison in {@link findClosestMatches}. Folding
60+
* only the input was a real defect (#4990): candidates are camelCase across most
61+
* of the spec (AGENTS.md Prime Directive #3, "TS config keys → camelCase"), so
62+
* every capital in a declared key charged the author one extra substitution
63+
* against a budget that is only `max(2, len/3)`. The observable symptom was
64+
* inverted quality — `hiddenon` (all-lowercase, plain wrong) resolved to
65+
* `hiddenOn` at distance 1, while `hideOn` (correctly cased, one real typo)
66+
* scored 3 against a budget of 2 and got no suggestion at all.
67+
*/
68+
const foldForScoring = (value: string): string => value.toLowerCase().replace(/[-\s]/g, '_');
69+
5570
/**
5671
* Find the closest matches from a list of candidates.
5772
*
73+
* Scoring is case- and separator-insensitive on both sides; the returned
74+
* strings are the candidates' ORIGINAL spelling, because that spelling is what
75+
* the author has to type back.
76+
*
5877
* @param input - The user-provided (possibly invalid) value
5978
* @param candidates - Array of valid values to compare against
6079
* @param maxDistance - Maximum edit distance to consider (default: 3)
@@ -67,15 +86,28 @@ export function findClosestMatches(
6786
maxDistance = 3,
6887
maxResults = 3,
6988
): string[] {
70-
const normalized = input.toLowerCase().replace(/[-\s]/g, '_');
89+
const normalized = foldForScoring(input);
7190

7291
const scored = candidates
7392
.map((candidate) => ({
7493
value: candidate,
75-
distance: levenshteinDistance(normalized, candidate),
94+
distance: levenshteinDistance(normalized, foldForScoring(candidate)),
95+
// Tie-break only. Folding is right for RANKING (case is not what the
96+
// author meant to signal), but when two declared keys are equidistant
97+
// under the fold the author's own capitalisation is the last piece of
98+
// evidence left about which one they were reaching for — `yxAis` ties
99+
// `yAxis` and `xAxis` at 2 folded, and the capital A picks the intended
100+
// one. Kept strictly secondary so it can never resurrect the #4990 bug
101+
// of case outranking a real edit.
102+
cased: levenshteinDistance(input, candidate),
76103
}))
77-
.filter((s) => s.distance <= maxDistance && s.distance > 0)
78-
.sort((a, b) => a.distance - b.distance);
104+
// Drop only the candidate the author ALREADY typed verbatim — suggesting a
105+
// string back to the author who wrote it is noise. A folded distance of 0
106+
// on a differently-spelled candidate (`hiddenon` vs `hiddenOn`) is not that
107+
// case: it is the strongest suggestion available, and pre-#4990 the
108+
// `distance > 0` test threw it away together with the true self-match.
109+
.filter((s) => s.distance <= maxDistance && s.value !== input)
110+
.sort((a, b) => a.distance - b.distance || a.cased - b.cased);
79111

80112
return scored.slice(0, maxResults).map((s) => s.value);
81113
}

packages/spec/src/ui/responsive.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,22 @@ describe('unknown keys are rejected, not stripped (#4001 batch 13)', () => {
190190

191191
it('reaches `hiddenOn` from both wrong spellings', () => {
192192
// `hidden` is objectui's RESOLVED spelling (`useResponsiveConfig` returns
193-
// `{ hidden, columns, order, breakpoint }`). `hideOn` is the same word,
194-
// and the distance fallback measurably cannot reach it — it lowercases
195-
// the input but not the candidates, so the capital in `hiddenOn` costs an
196-
// extra edit against a budget of 2 (filed as #4990).
193+
// `{ hidden, columns, order, breakpoint }`) — a different WORD, which no
194+
// edit distance can reach, so it keeps its entry in the alias table.
197195
expect(unknownKeyIssue(ResponsiveConfigSchema, { hidden: true })!.message)
198196
.toContain('`hidden` → `hiddenOn`');
197+
// `hideOn` is the SAME word and had an alias entry of its own until #4990,
198+
// because the fallback charged the author for `hiddenOn`'s capital O:
199+
// `hideOn` scored 3 against a budget of 2 and returned nothing, while the
200+
// all-lowercase `hiddenon` scored 1 and resolved. #4990 folds case on both
201+
// sides, so the alias was retired and this now rides the fallback alone.
202+
// These two assertions ARE batch 13's measurement, kept executable — the
203+
// first fails if the general fix regresses, the second is the comparison
204+
// that made the old behaviour indefensible.
199205
expect(unknownKeyIssue(ResponsiveConfigSchema, { hideOn: ['xs'] })!.message)
200206
.toContain('`hideOn` → `hiddenOn`');
207+
expect(unknownKeyIssue(ResponsiveConfigSchema, { hiddenon: ['xs'] })!.message)
208+
.toContain('`hiddenon` → `hiddenOn`');
201209
});
202210
});
203211

packages/spec/src/ui/responsive.zod.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,17 @@ export const ResponsiveConfigSchema = lazySchema(() => strictObject(
206206
// `useResponsiveConfig.ts`). `hidden` is that RESULT's spelling of the
207207
// authored `hiddenOn`, which is where the wrong word comes from.
208208
hidden: 'hiddenOn',
209-
// `hideOn` is not a different word — it is the SAME word, and the
210-
// distance fallback still cannot reach it. Measured, not assumed:
211-
// `findClosestMatches('hideOn', ['hiddenOn'], 2)` returns `[]`, because
212-
// the fallback lowercases the INPUT but not the CANDIDATES, so every
213-
// capital in a declared key costs one extra edit — `hideOn` scores 3
214-
// against a budget of 2 while the all-lowercase `hiddenon` scores 1 and
215-
// resolves fine. That asymmetry is general to camelCase keys (i.e. to
216-
// most of the spec, per AGENTS.md naming) and is filed as #4990; this
217-
// entry covers the one instance this file owns.
218-
hideOn: 'hiddenOn',
209+
// `hideOn` USED to need an entry here. It is not a different word — it is
210+
// the same word, and the distance fallback could not reach it only
211+
// because of #4990: the fallback lowercased the INPUT but not the
212+
// CANDIDATES, so `hiddenOn`'s capital O cost an extra edit and `hideOn`
213+
// scored 3 against a budget of 2, while the all-lowercase `hiddenon`
214+
// scored 1 and resolved fine. #4990 fixed that at the source by folding
215+
// case on both sides, so this per-case workaround is retired: `hideOn`
216+
// now reaches `hiddenOn` on distance alone. The measurement that
217+
// justified the entry is preserved as an assertion in `responsive.test.ts`
218+
// ("reaches `hiddenOn` from both wrong spellings") rather than as a
219+
// comment, so it fails if the general fix ever regresses.
219220
},
220221
guidance: {
221222
...BREAKPOINT_AT_TOP_LEVEL,

0 commit comments

Comments
 (0)