Skip to content

Commit 3a5b8c9

Browse files
claude[bot]claude
andauthored
fix(lint): consolidate five more private "Did you mean?" copies onto suggestName (#14757)
Follow-up to #14268/#14575. Five more validate-*.ts rules in packages/lint carried a private suggest/distance pair, byte-for-byte re-deriving the edit-distance-only budget object-graph.ts already exports as suggestName. Tier 1 (validate-action-name-refs, validate-chart-bindings, validate-searchable-fields): delete the private pair, import suggestName. Tier 2 (validate-ai-tool-references, validate-translation-references): keep the rule's own one-line pre-pass (the action_<name> tool-family prefix, and a snake_case namespace-segment match — rule-local knowledge), then delegate the fallback to suggestName instead of a private Levenshtein copy. object-graph.ts's helper is untouched (#14268 already ruled it). validate-react-page-props.ts and validate-rule-schema-formats.ts stay fenced out — both are a different contract on purpose per #14577's triage. Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc648a2 commit 3a5b8c9

11 files changed

Lines changed: 161 additions & 160 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)
6+
7+
`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
8+
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
9+
pair, byte-for-byte re-deriving the edit-distance-only budget that
10+
`object-graph.ts` already exports as `suggestName` (the shared helper
11+
#14268/#14575 consolidated three other rules onto). All three now import
12+
`suggestName` from `./object-graph` and their private copies are deleted.
13+
14+
`validate-ai-tool-references.ts` and `validate-translation-references.ts`
15+
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
16+
tool-family prefix, and a snake_case namespace-segment match — that is
17+
rule-local knowledge, not the shared helper's business. Both keep that
18+
pre-pass and now delegate the fallback to `suggestName` instead of a private
19+
Levenshtein copy.
20+
21+
The shared helper's containment pre-pass (a candidate that contains the
22+
target, or vice versa, scores ahead of any edit-distance match) is now every
23+
one of these five rules' behaviour too, so a hint may now appear where one was
24+
previously absent — it never removes a hint the private copy gave. Per site:
25+
26+
- `validate-action-name-refs.ts``archive``archive_completed_deals`
27+
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
28+
- `validate-chart-bindings.ts` — the issue's own headline example,
29+
`amount``sum_amount` (4 edits, over the budget of 2) now gets a hint on
30+
a raw-field-instead-of-measure binding.
31+
- `validate-searchable-fields.ts``amount``sum_amount` (4 edits) now
32+
gets a hint on a stale `searchableFields` entry.
33+
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
34+
unchanged and still wins first; a miss with no prefix match now also
35+
reaches `suggestName`'s containment scan (e.g. `knowledge_base`
36+
`search_knowledge_base`), where the old private copy gave nothing.
37+
- `validate-translation-references.ts` — the namespace-segment pre-pass is
38+
unchanged and still wins first; a miss with no segment match now also
39+
reaches `suggestName`'s containment scan (e.g. `amount`
40+
`amountsummary`), where the old private copy gave nothing.
41+
42+
`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
43+
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
44+
— both are a different contract on purpose (see #14577's triage).

packages/lint/src/validate-action-name-refs.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
8282
expect(findings).toHaveLength(1);
8383
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
8484
});
85+
86+
// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
87+
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
88+
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
89+
// the shared `suggestName` (#14268), the containment pre-pass catches it —
90+
// the same class of drift as the issue's `amount` → `sum_amount` example.
91+
it('offers a did-you-mean via containment where edit distance alone would not', () => {
92+
const findings = validateActionNameRefs({
93+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
94+
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
95+
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
96+
});
97+
expect(findings).toHaveLength(1);
98+
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
99+
});
85100
});
86101

87102
// These fixtures use the REAL page shape. An earlier version of this suite

packages/lint/src/validate-action-name-refs.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
* miss; it is called out in the hint rather than guessed at.
4343
*/
4444

45+
import { suggestName } from './object-graph.js';
4546
import { walkPageComponents } from './page-walk.js';
4647

4748
export const ACTION_NAME_UNDEFINED = 'action-name-undefined';
@@ -81,37 +82,6 @@ function strList(v: unknown): string[] {
8182
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.length > 0) : [];
8283
}
8384

84-
function distance(a: string, b: string): number {
85-
const m = a.length;
86-
const n = b.length;
87-
if (m === 0) return n;
88-
if (n === 0) return m;
89-
let prev = Array.from({ length: n + 1 }, (_, j) => j);
90-
for (let i = 1; i <= m; i++) {
91-
const curr = [i, ...new Array<number>(n).fill(0)];
92-
for (let j = 1; j <= n; j++) {
93-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
94-
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
95-
}
96-
prev = curr;
97-
}
98-
return prev[n];
99-
}
100-
101-
function suggest(target: string, known: Iterable<string>): string {
102-
let best: string | undefined;
103-
let bestScore = Infinity;
104-
for (const candidate of known) {
105-
const d = distance(target, candidate);
106-
if (d < bestScore) {
107-
bestScore = d;
108-
best = candidate;
109-
}
110-
}
111-
const limit = Math.max(2, Math.floor(target.length / 3));
112-
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
113-
}
114-
11585
/** Every action name defined in the stack (global + object-embedded). */
11686
function collectActionNames(stack: AnyRec): Set<string> {
11787
const names = new Set<string>();
@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
164134
`${surface} names action "${name}", which is defined by no action in this stack ` +
165135
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
166136
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
167-
suggest(name, known),
137+
suggestName(name, known),
168138
hint:
169139
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
170140
`${placement}, remove the reference, or ignore this if the ` +

packages/lint/src/validate-ai-tool-references.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
124124
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
125125
});
126126

127+
// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
128+
// knowledge about ADR-0109's materialised family, not something the shared
129+
// helper should know), but a miss now falls through to `suggestName`
130+
// (#14268) instead of a private Levenshtein copy. This case has no
131+
// `action_`-prefixed match at all, so it pins that the fallback still fires
132+
// — via containment, the class the private copy could not reach.
133+
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
134+
const stack = {
135+
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
136+
skills: [{ name: 's', tools: ['knowledge_base'] }],
137+
};
138+
const findings = validateAiToolReferences(stack);
139+
expect(findings).toHaveLength(1);
140+
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
141+
});
142+
127143
it('resolves trailing-wildcard families against the universe', () => {
128144
const withActions = {
129145
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],

packages/lint/src/validate-ai-tool-references.ts

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434

3535
import { PLATFORM_PROVIDED_TOOL_NAMES, PLATFORM_TOOL_FAMILY_PREFIXES } from '@objectstack/spec/system';
3636

37+
import { suggestName } from './object-graph.js';
38+
3739
export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';
3840

3941
export type AiToolRefSeverity = 'error' | 'warning';
@@ -67,43 +69,17 @@ function strName(v: unknown): string | undefined {
6769
return typeof v === 'string' && v.length > 0 ? v : undefined;
6870
}
6971

70-
function distance(a: string, b: string): number {
71-
const m = a.length;
72-
const n = b.length;
73-
if (m === 0) return n;
74-
if (n === 0) return m;
75-
let prev = Array.from({ length: n + 1 }, (_, j) => j);
76-
for (let i = 1; i <= m; i++) {
77-
const curr = [i, ...new Array<number>(n).fill(0)];
78-
for (let j = 1; j <= n; j++) {
79-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
80-
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
81-
}
82-
prev = curr;
83-
}
84-
return prev[n];
85-
}
86-
8772
function suggest(target: string, known: Set<string>): string {
8873
// The high-frequency near-miss first: naming the raw ACTION where the
8974
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
9075
// it (the prefix alone is 7 edits), and it is exactly the mistake the
9176
// ADR-0109 default path invites from authors who know their action names.
77+
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
78+
// business — it stays here and wraps `suggestName` for everything else.
9279
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
9380
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
9481
}
95-
96-
let best: string | undefined;
97-
let bestScore = Infinity;
98-
for (const candidate of known) {
99-
const d = distance(target, candidate);
100-
if (d < bestScore) {
101-
bestScore = d;
102-
best = candidate;
103-
}
104-
}
105-
const limit = Math.max(2, Math.floor(target.length / 3));
106-
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
82+
return suggestName(target, known);
10783
}
10884

10985
/**

packages/lint/src/validate-chart-bindings.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
4747
expect(findings[0].hint).toContain('est_hours');
4848
});
4949

50+
// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
51+
// which gave NO hint for the issue's own headline example: `amount` →
52+
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
53+
// delegating to the shared `suggestName` (#14268), the containment pre-pass
54+
// catches it — a dataset measure name containing the raw base-column name is
55+
// exactly the ADR-0021 cutover drift this rule exists to catch.
56+
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
57+
const findings = validateChartBindings({
58+
datasets: [
59+
{
60+
name: 'sales_metrics',
61+
object: 'crm_opportunity',
62+
dimensions: [{ name: 'stage', field: 'stage' }],
63+
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
64+
},
65+
],
66+
reports: [
67+
{
68+
name: 'r',
69+
dataset: 'sales_metrics',
70+
values: ['sum_amount'],
71+
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
72+
},
73+
],
74+
});
75+
expect(findings).toHaveLength(1);
76+
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
77+
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
78+
});
79+
5080
// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
5181
it('handles the report string yAxis, not just the array form', () => {
5282
const clean = validateChartBindings({

packages/lint/src/validate-chart-bindings.ts

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
5858
hint: string;
5959
}
6060

61+
import { suggestName } from './object-graph.js';
6162
import { walkPageComponents, type AnyRec } from './page-walk.js';
6263

6364
function asArray(v: unknown): AnyRec[] {
@@ -80,37 +81,6 @@ function isRec(v: unknown): v is AnyRec {
8081
return !!v && typeof v === 'object' && !Array.isArray(v);
8182
}
8283

83-
function distance(a: string, b: string): number {
84-
const m = a.length;
85-
const n = b.length;
86-
if (m === 0) return n;
87-
if (n === 0) return m;
88-
let prev = Array.from({ length: n + 1 }, (_, j) => j);
89-
for (let i = 1; i <= m; i++) {
90-
const curr = [i, ...new Array<number>(n).fill(0)];
91-
for (let j = 1; j <= n; j++) {
92-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
93-
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
94-
}
95-
prev = curr;
96-
}
97-
return prev[n];
98-
}
99-
100-
function suggest(target: string, known: Iterable<string>): string {
101-
let best: string | undefined;
102-
let bestScore = Infinity;
103-
for (const c of known) {
104-
const d = distance(target, c);
105-
if (d < bestScore) {
106-
bestScore = d;
107-
best = c;
108-
}
109-
}
110-
const limit = Math.max(2, Math.floor(target.length / 3));
111-
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
112-
}
113-
11484
function list(names: Iterable<string>): string {
11585
const all = [...names].sort();
11686
return all.length ? all.join(', ') : '(none)';
@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
185155
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
186156
`the chart has no data to render.`,
187157
hint:
188-
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
158+
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
189159
`Define it with defineDataset() or fix the reference (ADR-0021).`,
190160
});
191161
return;
@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
203173
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
204174
`field, so this axis renders with no categories.`,
205175
hint:
206-
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
176+
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
207177
`Declare the dimension on the dataset, or bind an existing one.`,
208178
});
209179
};
@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
220190
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
221191
`not the base field (e.g. "amount"), so this series comes back empty.`,
222192
hint:
223-
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
193+
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
224194
`Declare the measure on the dataset, or bind an existing one.`,
225195
});
226196
return;

packages/lint/src/validate-searchable-fields.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
9191
expect(findings[0].message).toContain('Did you mean "billing_email"?');
9292
});
9393

94+
// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
95+
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
96+
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
97+
// delegating to the shared `suggestName` (#14268), the containment pre-pass
98+
// catches it.
99+
it('offers a did-you-mean via containment where edit distance alone would not', () => {
100+
const findings = validateSearchableFields({
101+
objects: [
102+
{
103+
name: 'crm_opportunity',
104+
fields: { sum_amount: { type: 'number' } },
105+
searchableFields: ['amount'],
106+
},
107+
],
108+
});
109+
110+
expect(findings[0].message).toContain('Did you mean "sum_amount"?');
111+
});
112+
94113
it('reports every stale entry, not just the first', () => {
95114
const findings = validateSearchableFields({
96115
objects: [

packages/lint/src/validate-searchable-fields.ts

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
SEARCH_AUTO_EXCLUDED_FIELDS,
128128
type SearchFieldMeta,
129129
} from '@objectstack/spec/data';
130+
import { suggestName } from './object-graph.js';
130131
import {
131132
SYSTEM_FIELDS,
132133
indexUnprovisionedAnchors,
@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
271272
return { allowed: new Set(allowed), source, declaredList: allowed };
272273
}
273274

274-
/** Levenshtein-bounded "did you mean?" over the object's own field names. */
275-
function suggest(target: string, known: Iterable<string>): string {
276-
let best: string | undefined;
277-
let bestScore = Infinity;
278-
for (const candidate of known) {
279-
const d = distance(target, candidate);
280-
if (d < bestScore) {
281-
bestScore = d;
282-
best = candidate;
283-
}
284-
}
285-
const limit = Math.max(2, Math.floor(target.length / 3));
286-
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
287-
}
288-
289-
function distance(a: string, b: string): number {
290-
const m = a.length;
291-
const n = b.length;
292-
if (m === 0) return n;
293-
if (n === 0) return m;
294-
let prev = Array.from({ length: n + 1 }, (_, j) => j);
295-
for (let i = 1; i <= m; i++) {
296-
const curr = [i, ...new Array<number>(n).fill(0)];
297-
for (let j = 1; j <= n; j++) {
298-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
299-
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
300-
}
301-
prev = curr;
302-
}
303-
return prev[n];
304-
}
305-
306275
/**
307276
* object name → the search-target slice. `null` marks an object with no
308277
* readable field map, so "declared nothing" stays distinguishable from "not in
@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
384353
`The declaration is stale: searching it can never match, and the engine ` +
385354
`silently drops it — leaving a narrower search than declared, or the ` +
386355
`auto-default set once every entry is dropped.` +
387-
(dotted ? '' : suggest(name, known)),
356+
(dotted ? '' : suggestName(name, known)),
388357
hint:
389358
(dotted
390359
? `'search' scans this object's own columns, so a related record's ` +

0 commit comments

Comments
 (0)