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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
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
44 changes: 44 additions & 0 deletions .changeset/lint-suggest-name-consolidation-tier2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/lint": patch
---

fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)

`validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
`validate-searchable-fields.ts` each carried a private `suggest`/`distance`
pair, byte-for-byte re-deriving the edit-distance-only budget that
`object-graph.ts` already exports as `suggestName` (the shared helper
#14268/#14575 consolidated three other rules onto). All three now import
`suggestName` from `./object-graph` and their private copies are deleted.

`validate-ai-tool-references.ts` and `validate-translation-references.ts`
each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
tool-family prefix, and a snake_case namespace-segment match — that is
rule-local knowledge, not the shared helper's business. Both keep that
pre-pass and now delegate the fallback to `suggestName` instead of a private
Levenshtein copy.

The shared helper's containment pre-pass (a candidate that contains the
target, or vice versa, scores ahead of any edit-distance match) is now every
one of these five rules' behaviour too, so a hint may now appear where one was
previously absent — it never removes a hint the private copy gave. Per site:

- `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
(17 edits, over budget) now gets a hint; unaffected cases unchanged.
- `validate-chart-bindings.ts` — the issue's own headline example,
`amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
a raw-field-instead-of-measure binding.
- `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
gets a hint on a stale `searchableFields` entry.
- `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
unchanged and still wins first; a miss with no prefix match now also
reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
`search_knowledge_base`), where the old private copy gave nothing.
- `validate-translation-references.ts` — the namespace-segment pre-pass is
unchanged and still wins first; a miss with no segment match now also
reaches `suggestName`'s containment scan (e.g. `amount` →
`amountsummary`), where the old private copy gave nothing.

`object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
`validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
— both are a different contract on purpose (see #14577's triage).
15 changes: 15 additions & 0 deletions packages/lint/src/validate-action-name-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,21 @@ describe('validateActionNameRefs — list view bulk/row actions', () => {
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "crm_convert_lead"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `archive` → `archive_completed_deals` is 17 edits
// apart, far outside the `max(2, floor(len/3))` budget. Now delegating to
// the shared `suggestName` (#14268), the containment pre-pass catches it —
// the same class of drift as the issue's `amount` → `sum_amount` example.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateActionNameRefs({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'archive_completed_deals', label: 'Archive', type: 'script' }],
views: [{ name: 'crm_lead', list: { bulkActions: ['archive'] } }],
});
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "archive_completed_deals"?');
});
});

// These fixtures use the REAL page shape. An earlier version of this suite
Expand Down
34 changes: 2 additions & 32 deletions packages/lint/src/validate-action-name-refs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
* miss; it is called out in the hint rather than guessed at.
*/

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

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

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];
}

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}"?` : '';
}

/** Every action name defined in the stack (global + object-embedded). */
function collectActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
Expand DownExpand Up@@ -164,7 +134,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
`${surface} names action "${name}", which is defined by no action in this stack ` +
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
suggest(name, known),
suggestName(name, known),
hint:
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
`${placement}, remove the reference, or ignore this if the ` +
Expand Down
16 changes: 16 additions & 0 deletions packages/lint/src/validate-ai-tool-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,22 @@ describe('validate-ai-tool-references', () => {
expect(findings[0].message).toContain('Did you mean "action_triage_case"?');
});

// #14577 — the `action_<name>` prefix pre-pass stays rule-local (it is
// knowledge about ADR-0109's materialised family, not something the shared
// helper should know), but a miss now falls through to `suggestName`
// (#14268) instead of a private Levenshtein copy. This case has no
// `action_`-prefixed match at all, so it pins that the fallback still fires
// — via containment, the class the private copy could not reach.
it('falls through to suggestName (containment) when the prefix pre-pass misses', () => {
const stack = {
tools: [{ name: 'search_knowledge_base', label: 'Search KB', description: 'x' }],
skills: [{ name: 's', tools: ['knowledge_base'] }],
};
const findings = validateAiToolReferences(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Did you mean "search_knowledge_base"?');
});

it('resolves trailing-wildcard families against the universe', () => {
const withActions = {
objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }],
Expand Down
34 changes: 5 additions & 29 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,8 @@

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

import { suggestName } from './object-graph.js';

export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';

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

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];
}

function suggest(target: string, known: Set<string>): string {
// The high-frequency near-miss first: naming the raw ACTION where the
// materialised TOOL (`action_<name>`) is meant. Edit distance cannot catch
// it (the prefix alone is 7 edits), and it is exactly the mistake the
// ADR-0109 default path invites from authors who know their action names.
// Rule-local knowledge (the tool-family prefixes), not the shared helper's
// business — it stays here and wraps `suggestName` for everything else.
for (const prefix of PLATFORM_TOOL_FAMILY_PREFIXES) {
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
}

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}"?` : '';
return suggestName(target, known);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,36 @@ describe('validateChartBindings — report charts', () => {
expect(findings[0].hint).toContain('est_hours');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint for the issue's own headline example: `amount` →
// `sum_amount` is 4 edits, over the `max(2, floor(len/3))` budget of 2. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it — a dataset measure name containing the raw base-column name is
// exactly the ADR-0021 cutover drift this rule exists to catch.
it('offers a did-you-mean via containment for the base-column → measure-name drift', () => {
const findings = validateChartBindings({
datasets: [
{
name: 'sales_metrics',
object: 'crm_opportunity',
dimensions: [{ name: 'stage', field: 'stage' }],
measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }],
},
],
reports: [
{
name: 'r',
dataset: 'sales_metrics',
values: ['sum_amount'],
chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
});

// The dashboard rule's `Array.isArray(yAxis)` guard would skip this shape.
it('handles the report string yAxis, not just the array form', () => {
const clean = validateChartBindings({
Expand Down
38 changes: 4 additions & 34 deletions packages/lint/src/validate-chart-bindings.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,7 @@ export interface ChartBindingFinding {
hint: string;
}

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

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

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];
}

function suggest(target: string, known: Iterable<string>): string {
let best: string | undefined;
let bestScore = Infinity;
for (const c of known) {
const d = distance(target, c);
if (d < bestScore) {
bestScore = d;
best = c;
}
}
const limit = Math.max(2, Math.floor(target.length / 3));
return best && bestScore <= limit ? ` Did you mean "${best}"?` : '';
}

function list(names: Iterable<string>): string {
const all = [...names].sort();
return all.length ? all.join(', ') : '(none)';
Expand DownExpand Up@@ -185,7 +155,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`binds dataset "${dsName}", which resolves to no declared dataset — ` +
`the chart has no data to render.`,
hint:
`Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} ` +
`Declared datasets: ${list(datasets.keys())}.${suggestName(dsName, datasets.keys())} ` +
`Define it with defineDataset() or fix the reference (ADR-0021).`,
});
return;
Expand All@@ -203,7 +173,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base ` +
`field, so this axis renders with no categories.`,
hint:
`Dataset dimensions: ${list(ds.dimensions)}.${suggest(name, ds.dimensions)} ` +
`Dataset dimensions: ${list(ds.dimensions)}.${suggestName(name, ds.dimensions)} ` +
`Declare the dimension on the dataset, or bind an existing one.`,
});
};
Expand All@@ -220,7 +190,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] {
`Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), ` +
`not the base field (e.g. "amount"), so this series comes back empty.`,
hint:
`Dataset measures: ${list(ds.measures)}.${suggest(name, ds.measures)} ` +
`Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` +
`Declare the measure on the dataset, or bind an existing one.`,
});
return;
Expand Down
19 changes: 19 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,25 @@ describe('validateSearchableFields — object declaration', () => {
expect(findings[0].message).toContain('Did you mean "billing_email"?');
});

// #14577 — this rule used to carry a private Levenshtein-only `suggest`,
// which gave NO hint here: `amount` → `sum_amount` is 4 edits, over the
// `max(2, floor(len/3))` budget of 2 — the issue's own headline example. Now
// delegating to the shared `suggestName` (#14268), the containment pre-pass
// catches it.
it('offers a did-you-mean via containment where edit distance alone would not', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_opportunity',
fields: { sum_amount: { type: 'number' } },
searchableFields: ['amount'],
},
],
});

expect(findings[0].message).toContain('Did you mean "sum_amount"?');
});

it('reports every stale entry, not just the first', () => {
const findings = validateSearchableFields({
objects: [
Expand Down
35 changes: 2 additions & 33 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,7 @@ import {
SEARCH_AUTO_EXCLUDED_FIELDS,
type SearchFieldMeta,
} from '@objectstack/spec/data';
import { suggestName } from './object-graph.js';
import {
SYSTEM_FIELDS,
indexUnprovisionedAnchors,
Expand DownExpand Up@@ -271,38 +272,6 @@ function resolveAllowedSet(target: ObjectSearchTarget): {
return { allowed: new Set(allowed), source, declaredList: allowed };
}

/** 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];
}

/**
* object name → the search-target slice. `null` marks an object with no
* readable field map, so "declared nothing" stays distinguishable from "not in
Expand DownExpand Up@@ -384,7 +353,7 @@ export function checkSearchableFieldList(
`The declaration is stale: searching it can never match, and the engine ` +
`silently drops it — leaving a narrower search than declared, or the ` +
`auto-default set once every entry is dropped.` +
(dotted ? '' : suggest(name, known)),
(dotted ? '' : suggestName(name, known)),
hint:
(dotted
? `'search' scans this object's own columns, so a related record's ` +
Expand Down
Loading
Loading