Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/lint-suggest-name-consolidation.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 22 additions & 9 deletions packages/lint/src/object-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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. */
Expand Down
41 changes: 5 additions & 36 deletions packages/lint/src/validate-object-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>): 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<number>(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).
Expand Down Expand Up @@ -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 ` +
Expand All @@ -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}` +
Expand Down
36 changes: 3 additions & 33 deletions packages/lint/src/validate-sortable-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@
*/

import { isVirtualSearchField } from '@objectstack/spec/data';

import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand Down Expand Up @@ -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>): 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<number>(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.
Expand Down Expand Up @@ -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 ` +
Expand Down
64 changes: 10 additions & 54 deletions packages/lint/src/validate-widget-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand Down Expand Up @@ -296,51 +297,6 @@ const CHART_TYPES = new Set<string>(
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>): 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>): string {
const s = didYouMean(input, candidates);
return s ? ` Did you mean "${s}"?` : '';
}

function list(names: Iterable<string>): string {
const arr = [...names];
return arr.length > 0 ? arr.join(', ') : '(none)';
Expand Down Expand Up @@ -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).`,
});
}
Expand Down Expand Up @@ -683,10 +639,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
hint: eff.explicit
? `Point filterBindings: { ${def.name}: '<field>' } 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}: '<field>' }.` +
`${suggest(field, base.names)} Object fields: ${list(base.names)}.`,
`${suggestName(field, base.names)} Object fields: ${list(base.names)}.`,
});
continue;
}
Expand Down Expand Up @@ -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.`,
});
}
Expand All @@ -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.`,
});
}
Expand Down Expand Up @@ -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.`,
});
}
Expand Down Expand Up @@ -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)}`,
});
}

Expand All @@ -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())}`,
});
};

Expand Down
Loading