Skip to content

[finding] suggest / distance ("did you mean?") is hand-copied four times across packages/lint — the same shape #4330 already fixed for SYSTEM_FIELDS #14268

Description

@baozhoutao

Recorded while implementing #14105 (PR #14267). Not fixed there: consolidating it touches rules that card does not otherwise change, so it is filed rather than ridden along.

What was measured

Four independent copies of the same Levenshtein "did you mean?" helper live in packages/lint/src, none of them exported, each re-deriving the same edit-distance budget:

file shape
validate-object-references.ts suggest(target, known) + distance(a, b), budget max(2, floor(len/3))
validate-sortable-fields.ts suggest(target, known) + distance(a, b), byte-for-byte the same budget
validate-widget-bindings.ts didYouMean(input, candidates) + levenshtein(a, b) + suggest(...), with an extra containment pre-pass for the ADR-0021 amount to sum_amount drift
object-graph.ts (added by #14267) nearestName(target, known) + suggestName(...) + distance(a, b), the same budget again

The fourth copy is mine, and I wrote it as a copy deliberately rather than importing one of the three: none of them exports its helper, and widening one rule's private function into a cross-rule dependency was outside #14105's declared file surface. The copy is annotated at its definition in object-graph.ts pointing here.

Why this is worth a card rather than a shrug

This is exactly the shape #4330 recorded and fixed one constant over — "five hand-copied SYSTEM_FIELDS lists in packages/lint should derive from the spec's declarations" — and the argument carries: with N copies, the next author improves one of N and the survivors keep the old behaviour. That is not hypothetical here, because the copies have already diverged: validate-widget-bindings.ts scores containment ahead of edit distance, so it suggests sum_amount for amount (11 edits apart, obvious to a human) where the other three suggest nothing at all. Whether that pre-pass is right is a real question — but it is currently a per-rule accident rather than a decision, and an author gets a different quality of suggestion depending on which position they typo'd.

Two more copies exist and were checked rather than assumed to be the same question: @objectstack/formula's unknown-function suggestion and the spec's strict-object alias map are keyed on curated tables, not edit distance, so they are deliberately out of this finding's scope.

Shape of the fix

object-graph.ts already exports nearestName / suggestName on the package barrel, so the mechanical route is to point the other three at it and delete their private copies — plus one decision the consolidation forces into the open: whether the containment pre-pass becomes everyone's behaviour or stays declared as a widget-binding-specific one. Each rule's existing suggestion assertions are the regression net; validate-widget-bindings.test.ts in particular pins the sum_amount suggestion, so a naive merge that drops the pre-pass fails loudly rather than silently degrading messages.

No behaviour is wrong today; this is drift-shaped, so it is filed as an observation and left unassigned for triage rather than graded here.

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage (R+89, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): graded → pm:queue · priority:p3 · domain:devx · type Task. finding removed.

    Lands in: packages/lint/src — point validate-object-references.ts, validate-sortable-fields.ts and validate-widget-bindings.ts at the helper object-graph.ts already exports on the barrel, and delete the three private copies. The one decision the consolidation forces is ruled here so the implementer does not have to: the containment pre-pass becomes the shared helper's behaviour for every caller. Rationale: it only ever adds a suggestion where the edit-distance budget returns none (suggestion text; no accept set moves), and "a different quality of hint depending on which position you typo'd" is the drift the card exists to end. validate-widget-bindings.test.ts's sum_amount pin stays as the regression net; the other three rules' suggestion assertions must stay green byte-for-byte except where the pre-pass now supplies a hint that was previously absent — list those in the PR body.

    Size/model suggestion: S mechanical, sonnet/opus.


    Generated by Claude Code

  2. added theissue type on Sep 2, 2026
  3. claude commented on Sep 2, 2026

    @claude
    Contributor

    Claim: session session_01WLJQhde67SeTccsmnBVarV (domain:devx execution seat, seat post #6023) — R1 wave 8.

    • Branch: claude/issue-14268-lint-suggest-consolidation
    • Worktree: ../objectstack-14268 (dedicated per-task worktree, off origin/main)
    • Domain: domain:devx per the triage (packages/lint is a devx package; no packages/spec change)
    • File surface (the three rules the ruling names + the helper + their tests): packages/lint/src/object-graph.ts (nearestName gains the containment pre-pass — the ruled shared behaviour; its distance stays the one edit-distance body), packages/lint/src/validate-object-references.ts, packages/lint/src/validate-sortable-fields.ts, packages/lint/src/validate-widget-bindings.ts (each imports suggestName from ./object-graph and deletes its private suggest/distance/levenshtein/didYouMean), plus their .test.ts files and object-graph.test.ts only where the ruling's "previously absent hint now supplied" clause applies (every such assertion listed in the PR body). ⛔ packages/lint/src/index.ts unchanged (nearestName/suggestName are already on the barrel at :533-534). ⛔ The other private copies in packages/lint/src (see serial constraints) are NOT in this card's surface — report them, do not consolidate them here.
    • Premise re-verified on origin/main fed4fa409: object-graph.ts:367 export function nearestName, :381 export function suggestName, :386 private distance, barrel index.ts:533-534; private copies at validate-object-references.ts:130/:145, validate-sortable-fields.ts:342/:356, validate-widget-bindings.ts:299/:322/:339 (the containment pre-pass is didYouMean :322-337: input.length >= 3 && (c.includes(input) || input.includes(c)) scored by length delta, else Levenshtein within max(2, floor(len/3)) scored 100 + d); the sum_amount pin is validate-widget-bindings.test.ts:134-135 (Did you mean "sum_amount"? for amount). Premise holds.
    • Container & model: subagent lane (S), sonnet.
    • Clause ②: not reached — suggestion text only; no accept set moves (the ruling says so explicitly).
    • Serial constraints: no in-flight PR of this seat touches packages/lint/src (PR fix(lint): wire test layer into check:test-typecheck (#14173) #14494, the last one that did, merged as fa7292c43). Census note for the PR body / report: git grep -n "^function suggest\|^function distance\|^function levenshtein" origin/main -- packages/lint/src also finds copies in validate-action-name-refs.ts, validate-ai-tool-references.ts, validate-chart-bindings.ts, validate-searchable-fields.ts, validate-translation-references.ts — the card counted four, the tree has more; those stay out of scope (the ruling names three rules) and go into out_of_scope_findings. Changeset: @objectstack/lint publishes ⇒ a patch changeset is required (hint text may gain a suggestion where none was offered before).

    Generated by Claude Code

  4. claude commented on Sep 2, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14268,
      "status": "done",
      "branch": "claude/issue-14268-lint-suggest-consolidation",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14575",
      "premise_still_valid": true,
      "summary": "Ported the containment pre-pass (from validate-widget-bindings.ts's private didYouMean) into object-graph.ts's nearestName, per the ruling's decision that it becomes the shared helper's behaviour for every caller. Deleted the three private suggest/distance (or didYouMean/levenshtein) copies in validate-object-references.ts, validate-sortable-fields.ts and validate-widget-bindings.ts; all three now import suggestName from ./object-graph. index.ts unchanged (nearestName/suggestName already on the barrel). A/B test comparison on the pre-merge base tree (fed4fa409): full @objectstack/lint suite run with the four files at origin/main state (93 files / 2812 passed, 5 skipped) and again with the port applied -- byte-identical results, confirmed by restoring via `git checkout HEAD -- PATHS` after the A run (git diff HEAD empty). Zero suggestion assertions flipped, so the 'Hints newly supplied' list in the PR body is empty -- none of the three rules' existing fixtures happen to exercise a containment-eligible typo the edit-distance budget didn't already catch. Re-verified on the final merged tree (after two origin/main merges, one of which touched packages/spec's platform-object-names registry validate-object-references.ts reads): 93 files / 2817 passed, 0 skipped, clean build and typecheck.",
      "tests": "pnpm --filter @objectstack/lint build -- PASS (dist emitted, check-dts-emitted 4/4). pnpm --filter @objectstack/lint typecheck -- PASS (tsc --noEmit + check:test-typecheck). pnpm --filter @objectstack/lint test -- PASS, 93 files / 2817 tests passed, 0 skipped, on final merged HEAD 17d9e56a0. A/B reverse-verification: same suite run against the four files restored to origin/main (fed4fa409) state -- 93 files / 2812 passed / 5 skipped, byte-identical to the post-port run on the same base; restoration confirmed via `git diff HEAD` empty after `git checkout HEAD -- PATHS`. git grep -n \"function suggest|function distance|function levenshtein|function didYouMean\" on the three rule files -- zero hits. pnpm lint narrowed to the 5 touched files (--format json): 0 errors, 1 informational warning (.changeset/*.md has no ESLint config, not a finding). eslint.config.mjs enables no type-aware linting anywhere (no parserOptions.project), so the narrowed run is a complete reading of this diff's lint surface. Note: the gate union run, build/typecheck/test cycle and eslint run were all performed with the changeset file already present on disk (git-add'ed and committed immediately after, with no content change) -- HEAD at gate-run time was 4eaea4919, final HEAD after the trivial changeset-commit is 17d9e56a0; no tracked content differs between the two besides the changeset file's git status.",
      "mcp_calls": 0,
      "gates": {
        "node scripts/check-adr-0087-registration.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-changeset-no-major.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-ci-filter-parity.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-comment-mask-adoption.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-cross-package-test-inputs.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-empty-changeset.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-keyed-text-bounds.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-plugin-teardown-shape.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-shard-attestation.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-system-context-census.mjs": "RAN-PASS (exit 0)",
        "node scripts/check-test-completeness.mjs": "NOT-MEASURED (exit 3, PREREQUISITE NOT MET -- needs a saved `turbo run test` log CI produces; unreachable locally by design)",
        "node scripts/check-undeclared-dep-imports.mjs": "RAN-PASS (exit 0)",
        "node scripts/docs-audit/check-affected-docs.mjs": "RAN-PASS (exit 0)",
        "node scripts/docs-audit/check-drift-comment.mjs": "RAN-PASS (exit 0)",
        "node scripts/pm/check-half-states.mjs": "RAN-PASS (exit 0)",
        "node scripts/pm/release-rehearsal-clone.mjs --self-test": "RAN-PASS (exit 0)",
        "pnpm check:changeset-gate-self-tests": "RAN-PASS (exit 0)",
        "pnpm check:cross-package-test-inputs": "RAN-PASS (exit 0)",
        "pnpm check:doc-authoring": "RAN-PASS (exit 0)",
        "pnpm check:dual-build-cjs-loads": "NOT-MEASURED (exit 3, PREREQUISITE NOT MET -- needs a full monorepo `pnpm build` of ~78 packages' dist/; out of this PR's local scope)",
        "pnpm check:logger-receiver-detach": "RAN-PASS (exit 0)",
        "pnpm check:objectui-changeset": "RAN-PASS (exit 0)",
        "pnpm check:page-declaration-shape": "RAN-PASS (exit 0)",
        "pnpm check:pm-half-states": "RAN-PASS (exit 0)",
        "pnpm check:published-files": "RAN-PASS (exit 0)",
        "pnpm check:slot-lookup": "RAN-PASS (exit 0)",
        "pnpm check:test-source-alias": "RAN-PASS (exit 0)",
        "pnpm check:type-source-resolution": "RAN-PASS (exit 0)",
        "pnpm lint (narrowed to 5 touched files)": "RAN-PASS (0 errors, 1 informational warning on .changeset/*.md -- no ESLint config for .md, not a finding)"
      },
      "files_touched": [
        "packages/lint/src/object-graph.ts",
        "packages/lint/src/validate-object-references.ts",
        "packages/lint/src/validate-sortable-fields.ts",
        "packages/lint/src/validate-widget-bindings.ts",
        ".changeset/lint-suggest-name-consolidation.md"
      ],
      "changeset": "lint-suggest-name-consolidation.md -- @objectstack/lint: patch",
      "open_questions": [],
      "out_of_scope_findings": [
        "packages/lint/src/validate-action-name-refs.ts:84 (distance) / :101 (suggest) -- private copy, not in this card's surface per the ruling",
        "packages/lint/src/validate-ai-tool-references.ts:70 (distance) / :87 (suggest) -- same",
        "packages/lint/src/validate-chart-bindings.ts:83 (distance) / :100 (suggest) -- same",
        "packages/lint/src/validate-searchable-fields.ts:275 (suggest) / :289 (distance) -- same",
        "packages/lint/src/validate-translation-references.ts:159 (distance) / :186 (suggest) -- same",
        "packages/lint/src/validate-react-page-props.ts:167 (nearestKnown) -- same shape, different name",
        "packages/lint/src/validate-rule-schema-formats.ts:266 (nearestRegisteredFormat) -- same shape, different name"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  5. claude commented on Sep 2, 2026

    @claude
    Contributor

    ACCEPT — PR #14575 merged to main as d7548298a (out of the merge queue 14:13:52Z; added_to_merge_queue 13:48:01Z). Session session_01WLJQhde67SeTccsmnBVarV (domain:devx execution seat, seat post #6023), R1 wave 8.

    Close-out probes on origin/main 996cb1d48 (verify worktree, d7548298a an ancestor):

    • git grep -n "function suggest\|function distance\|function levenshtein\|function didYouMean" origin/main -- packages/lint/src/validate-object-references.ts packages/lint/src/validate-sortable-fields.ts packages/lint/src/validate-widget-bindings.ts → 0 hits (the three private copies are gone).
    • git grep -n "suggestName" origin/main -- packages/lint/src/… → object-graph.ts:394 export function suggestName(target, known) (the shared helper, with the containment pre-pass inside nearestName); imported at validate-object-references.ts:72, validate-sortable-fields.ts:237, validate-widget-bindings.ts:13 and called at every former private-helper site (:175/:195, :440, :642/:645/:809/:855/:857/:905).
    • patch changeset for @objectstack/lint in the merge; the sum_amount pin survives per the PR's test run.

    Against triage 5502887598: the three rules go through the barrel helper; the containment pre-pass is now the SHARED behaviour; no accept-set change. The five remaining private copies elsewhere in packages/lint/src are #14577's (filed as the follow-up, awaiting triage). Zero rework.

    pm:dispatched removed with this comment; the card closes via the PR's Fixes #14268.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions