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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
isUnjudgeable,
joinablePrefixes,
resolveFieldPath,
suggestName,
type ObjectGraph,
} from './object-graph.js';
import {
Expand DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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