diff --git a/.changeset/lint-suggest-name-consolidation.md b/.changeset/lint-suggest-name-consolidation.md new file mode 100644 index 0000000000..c80792821a --- /dev/null +++ b/.changeset/lint-suggest-name-consolidation.md @@ -0,0 +1,26 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): consolidate the four hand-copied "Did you mean?" helpers into `object-graph.ts`'s shared `nearestName`/`suggestName` (#14268) + +`validate-object-references.ts`, `validate-sortable-fields.ts` and +`validate-widget-bindings.ts` each carried a private `suggest`/`distance` (or +`didYouMean`/`levenshtein`) pair, byte-for-byte re-deriving the same +edit-distance budget `object-graph.ts` already exported on the package barrel +as `nearestName`/`suggestName` — the same drift #4330 fixed one constant over +for `SYSTEM_FIELDS`. All three now import `suggestName` (and `nearestName` +where a rule needs the bare name) from `./object-graph` and their private +copies are deleted. + +The one decision the consolidation forced: `validate-widget-bindings.ts` +scored a containment match (e.g. `amount` → `sum_amount`, the ADR-0021 +base-column → prefixed-measure-name drift) ahead of edit distance; the other +two rules had no such pre-pass and suggested nothing for the same class of +typo. That containment pre-pass is now `nearestName`'s behaviour for every +caller — it only ever *adds* a suggestion where the edit-distance budget +previously returned none, so a "Did you mean?" hint may now appear where one +was previously absent. The full `@objectstack/lint` suite (93 files / 2812 +tests) was run against the pre-change and post-change trees and produced +identical results, so no existing suggestion assertion was affected in +practice. diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index c384fff45d..35b0a44a80 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -358,23 +358,36 @@ export function isUnjudgeable(verdict: FieldPathVerdict | undefined): boolean { /** * Nearest declared name for a typo'd reference, or `undefined` when nothing is - * close enough. The budget — `max(2, floor(len/3))` — is the one - * `validate-sortable-fields.ts` and `validate-object-references.ts` already - * use, restated here rather than imported from either because neither exports - * it; consolidating the three copies is recorded as a follow-up rather than - * done under this card's scope. + * close enough. The sole shared helper behind every "Did you mean?" hint in + * this package (issue #14268 consolidated the four hand-copied instances that + * had drifted apart into this one). + * + * Containment is checked first, ahead of edit distance: the ADR-0021 cutover's + * canonical drift is base column → prefixed measure name (`amount` → + * `sum_amount`), which is far in edit distance but obvious to a human. A + * candidate that contains the target (or vice versa, when both are at least 3 + * characters) scores on the length delta instead and always wins over an + * edit-distance match; otherwise the target must be within budget — + * `max(2, floor(len/3))` — of a candidate to be offered at all. */ export function nearestName(target: string, known: Iterable): string | undefined { let best: string | undefined; let bestScore = Infinity; for (const candidate of known) { - const d = distance(target, candidate); - if (d < bestScore) { - bestScore = d; + let score: number; + if (target.length >= 3 && (candidate.includes(target) || target.includes(candidate))) { + score = Math.abs(candidate.length - target.length); + } else { + const d = distance(target, candidate); + if (d > Math.max(2, Math.floor(target.length / 3))) continue; + score = 100 + d; + } + if (score < bestScore) { + bestScore = score; best = candidate; } } - return best && bestScore <= Math.max(2, Math.floor(target.length / 3)) ? best : undefined; + return best; } /** ` Did you mean "x"?`, or the empty string — the platform's message shape. */ diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index b377729e7e..e4f9de9dd8 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -69,7 +69,9 @@ import { PLATFORM_PROVIDED_OBJECT_NAMES, } from '@objectstack/spec/system'; -/** Materialized once for the repeated edit-distance scans in `suggest`. */ +import { suggestName } from './object-graph.js'; + +/** Materialized once for the repeated edit-distance scans in `suggestName`. */ const PLATFORM_NAMES: readonly string[] = [...PLATFORM_PROVIDED_OBJECT_NAMES]; export const OBJECT_REFERENCE_UNKNOWN = 'object-reference-unknown'; @@ -126,39 +128,6 @@ function isInterpolated(target: string): boolean { return open !== -1 && target.indexOf('}', open + 2) !== -1; } -/** Levenshtein-bounded "did you mean?" over the known names. */ -function suggest(target: string, known: Iterable): string { - let best: string | undefined; - let bestScore = Infinity; - for (const candidate of known) { - const d = distance(target, candidate); - if (d < bestScore) { - bestScore = d; - best = candidate; - } - } - // Only offer a suggestion that is plausibly the same identifier mistyped. - const limit = Math.max(2, Math.floor(target.length / 3)); - return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; -} - -function distance(a: string, b: string): number { - const m = a.length; - const n = b.length; - if (m === 0) return n; - if (n === 0) return m; - let prev = Array.from({ length: n + 1 }, (_, j) => j); - for (let i = 1; i <= m; i++) { - const curr = [i, ...new Array(n).fill(0)]; - for (let j = 1; j <= n; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); - } - prev = curr; - } - return prev[n]; -} - /** * Validate every object-name reference on the surfaces listed in the module * header. Returns findings (empty = clean). @@ -203,7 +172,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { `package, official plugin, or cloud runtime object registers that name — ` + `and this stack does not define it either. If nothing provides it at runtime ` + `the reference resolves to nothing and fails silently.` + - suggest(name, PLATFORM_NAMES), + suggestName(name, PLATFORM_NAMES), hint: `Check the spelling against the object the providing package actually registers ` + `(e.g. "sys_approval_request", not "sys_approval_process" — the process object ` + @@ -223,7 +192,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { message: `${subject} "${name}" resolves to no object defined in this stack. ` + `The reference is inert at runtime — nothing reports the miss.` + - suggest(name, ownObjects), + suggestName(name, ownObjects), hint: `Point it at one of this stack's objects, or at a platform object by its full ` + `name (the platform user object is "sys_user", not "user"). ${fix}` + diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts index 12607f8e7e..2c899d1e7b 100644 --- a/packages/lint/src/validate-sortable-fields.ts +++ b/packages/lint/src/validate-sortable-fields.ts @@ -233,6 +233,8 @@ */ import { isVirtualSearchField } from '@objectstack/spec/data'; + +import { suggestName } from './object-graph.js'; import { SYSTEM_FIELDS, indexUnprovisionedAnchors, @@ -338,38 +340,6 @@ function readSortKeys(declared: unknown): SortKey[] { return []; } -/** Levenshtein-bounded "did you mean?" over the object's own field names. */ -function suggest(target: string, known: Iterable): string { - let best: string | undefined; - let bestScore = Infinity; - for (const candidate of known) { - const d = distance(target, candidate); - if (d < bestScore) { - bestScore = d; - best = candidate; - } - } - const limit = Math.max(2, Math.floor(target.length / 3)); - return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; -} - -function distance(a: string, b: string): number { - const m = a.length; - const n = b.length; - if (m === 0) return n; - if (n === 0) return m; - let prev = Array.from({ length: n + 1 }, (_, j) => j); - for (let i = 1; i <= m; i++) { - const curr = [i, ...new Array(n).fill(0)]; - for (let j = 1; j <= n; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); - } - prev = curr; - } - return prev[n]; -} - /** * Check ONE authored `sort` declaration against the object it is bound to — * the shared core behind every list-view surface that declares an ordering. @@ -467,7 +437,7 @@ export function checkSortDeclaration( `"${objectName}". The runtime refuses the sort rather than dropping it: ` + `every load of this view answers 400 INVALID_SORT (#6994), because a sort ` + `is the view's FIRST fetch and not an optional interaction.` + - (dotted ? '' : suggest(head, known)), + (dotted ? '' : suggestName(head, known)), hint: (dotted ? `'sort' reaches only whole columns of "${objectName}" itself, never a ` + diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index d7cb997e8a..ecaa6b6359 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -10,6 +10,7 @@ import { isUnjudgeable, joinablePrefixes, resolveFieldPath, + suggestName, type ObjectGraph, } from './object-graph.js'; import { @@ -296,51 +297,6 @@ const CHART_TYPES = new Set( ChartTypeSchema.options.filter(t => !MEASURE_EXEMPT_CHART_TYPES.has(t)), ); -function levenshtein(a: string, b: string): number { - const m = a.length, n = b.length; - let prev = Array.from({ length: n + 1 }, (_, j) => j); - for (let i = 1; i <= m; i++) { - const cur = [i]; - for (let j = 1; j <= n; j++) { - cur[j] = Math.min( - prev[j] + 1, - cur[j - 1] + 1, - prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), - ); - } - prev = cur; - } - return prev[n]; -} - -/** - * Nearest declared name for a typo'd/stale reference, or undefined when - * nothing is close. Containment is checked first because the cutover's - * canonical drift is base column → prefixed measure name (`amount` → - * `sum_amount`), which is far in edit distance but obvious to a human. - */ -function didYouMean(input: string, candidates: Iterable): string | undefined { - let best: string | undefined; - let bestScore = Infinity; - for (const c of candidates) { - let score: number; - if (input.length >= 3 && (c.includes(input) || input.includes(c))) { - score = Math.abs(c.length - input.length); - } else { - const d = levenshtein(input, c); - if (d > Math.max(2, Math.floor(input.length / 3))) continue; - score = 100 + d; - } - if (score < bestScore) { bestScore = score; best = c; } - } - return best; -} - -function suggest(input: string, candidates: Iterable): string { - const s = didYouMean(input, candidates); - return s ? ` Did you mean "${s}"?` : ''; -} - function list(names: Iterable): string { const arr = [...names]; return arr.length > 0 ? arr.join(', ') : '(none)'; @@ -577,7 +533,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { rule: WIDGET_DATASET_UNKNOWN, message: `dataset "${dsName}" does not resolve to a declared dataset.`, hint: - `Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` + + `Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` + `Define the dataset with defineDataset() or fix the reference (ADR-0021).`, }); } @@ -683,10 +639,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { hint: eff.explicit ? `Point filterBindings: { ${def.name}: '' } at a field that exists on ` + `\`${datasetObject}\`, or opt out with filterBindings: { ${def.name}: false }.` + - `${suggest(field, base.names)} Object fields: ${list(base.names)}.` + `${suggestName(field, base.names)} Object fields: ${list(base.names)}.` : `Set filterBindings: { ${def.name}: false } on this widget to opt out, or ` + `re-target to an existing field with filterBindings: { ${def.name}: '' }.` + - `${suggest(field, base.names)} Object fields: ${list(base.names)}.`, + `${suggestName(field, base.names)} Object fields: ${list(base.names)}.`, }); continue; } @@ -833,7 +789,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `dimensions[${k}] "${dims[k]}" is not a dimension of dataset ` + `"${dsName}" (declared dimensions: ${list(dimensionNames)}).`, hint: - `Widgets select dataset dimensions BY NAME.${suggest(dims[k], dimensionNames)} ` + + `Widgets select dataset dimensions BY NAME.${suggestName(dims[k], dimensionNames)} ` + `Add the dimension to the dataset or fix the reference.`, }); } @@ -850,7 +806,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `"${dsName}" (declared measures: ${list(measures.keys())}).`, hint: `Widgets select dataset measures BY NAME, not by base column.` + - `${suggest(values[k], measures.keys())} ` + + `${suggestName(values[k], measures.keys())} ` + `Add the measure to the dataset or fix the reference.`, }); } @@ -896,9 +852,9 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { hint: declaredButUnselected ? `Add "${sortBy}" to this widget's ${dimensionNames.has(sortBy) ? 'dimensions' : 'values'}, ` + `or order by something it already selects.` + - `${suggest(sortBy, selected)}` + `${suggestName(sortBy, selected)}` : `Point options.sortBy at one of this widget's selected names (${list(selected)}).` + - `${suggest(sortBy, selected)} Ordering is applied to the query RESULT, so it ` + + `${suggestName(sortBy, selected)} Ordering is applied to the query RESULT, so it ` + `can only name a column that result carries.`, }); } @@ -927,7 +883,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { message: `chartConfig.xAxis.field "${xAxis.field}" does not resolve to a ` + `dimension of dataset "${dsName}" (declared dimensions: ${list(dimensionNames)}).`, - hint: `Point xAxis.field at a dataset dimension name.${suggest(xAxis.field, dimensionNames)}`, + hint: `Point xAxis.field at a dataset dimension name.${suggestName(xAxis.field, dimensionNames)}`, }); } @@ -946,7 +902,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { hint: declaredButUnselected ? `Add "${field}" to the widget's values, or bind the chart to a selected measure.` : `Post-cutover data is keyed by the dataset's measure NAME, not the ` + - `base column.${suggest(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`, + `base column.${suggestName(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`, }); };