Skip to content

fix(lint): resolve an ADR-0021 dataset's base object, include paths, dimension/measure fields and filter keys at validate/build - #14267

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-14105-dataset-ref-resolution
Sep 1, 2026
Merged

fix(lint): resolve an ADR-0021 dataset's base object, include paths, dimension/measure fields and filter keys at validate/build#14267
baozhoutao merged 1 commit into
mainfrom
claude/issue-14105-dataset-ref-resolution

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #14105

An ADR-0021 dataset could name a base object that does not exist, join a relationship that does not exist, and bind every dimension and measure to fields that do not exist, and objectstack validate exited 0 with ✓ Validation passed. build also exited 0 and wrote the dangling dataset into dist/objectstack.json. The author-time pass already walked those exact nodes — filter-token-unknown stands at datasets[i].measures[j].filter.KEY.$lt and reasons about the value — so the traversal and the machinery were both present, and only the reference resolution was missing.

Premise re-check — the card stands

Verified against fresh origin/main (035951faf) before writing anything, with the positive controls the dispatch named:

premise_still_valid: true.

What shipped, against the card's four scope items

item status verdict
1 — resolve Dataset.object shipped object-reference-unknown / object-reference-unregistered-platform, at datasets[N].object
2 — resolve include[] hop by hop shipped dataset-include-unknown
3 — resolve dimensions[].field / measures[].field, incl. the include-prefix clause shipped dataset-field-unknown + dataset-field-not-included
4 — resolve filter KEYS in Dataset.filter and measures[].filter shipped dataset-filter-field-unknown

All four items shipped; item 2's hop-walking did not turn out disproportionate, because the hop walk is the same mechanism item 3 needs for a dotted field path — writing it once served both.

Item 1 lands on a different rule than the dispatch predicted — deliberately, and stated here rather than silently

The dispatch's mechanism-hypothesis 3 asked that a different locus be reported rather than the surface silently widened. datasets[].object is a new reference site on validateObjectReferences, not a sixth id on the new rule. That rule's charter is literally "object-name reference sites that are plain z.string() and therefore ship whatever the author typed", and putting it there buys the curated cross-package severity ladder instead of a second, naive one.

That is not a style preference — measured: the platform's own packages/platform-objects/src/apps/dashboards/system.datasets.ts declares five datasets over sys_user, sys_organization, sys_session, sys_package_installation and sys_audit_log, three of which live in packages a stack compiling plugin-auth alone cannot see. All five resolve through PLATFORM_PROVIDED_OBJECT_NAMES (ladder rung ③). A local "not in this stack ⇒ error" check inside the new rule would have reported every one of them. Pinned both ways in validate-object-references.test.ts.

The corollary is the division of labour: when the base object does not resolve, validateDatasetReferences skips the whole dataset, so one typo yields one finding rather than one per dimension, per measure and per filter key.

The reusable-helper seam for the serial follow-ups

#14148 (widget filter keys + sortBy) and #14107 (list-view field positions) are queued behind this card and must reuse one mechanism. Neither surface is implemented here. What is here for them, exported from the package barrel and pinned by its own test file (object-graph.test.ts), is the two halves that are not dataset-specific:

  • packages/lint/src/object-graph.tsindexObjectGraph(stack) and resolveFieldPath(graph, object, path), answering "what does this relationship[.relationship].field path resolve to?". The answer is a discriminated verdict union, not a boolean, on purpose: a caller that cannot tell hop-not-relationship from hop-unknown from field-unknown cannot write the prescription an author needs. isUnjudgeable() spells the skip so a verdict added later defaults to being reported loudly rather than silently swallowed. Plus nearestName / suggestName / listNames.
  • walkFilterFieldKeys in packages/lint/src/filter-walk.ts — the FIELD-KEY half of a filter subtree, placed beside the subtree-finding half that module already owns, and written from that module's own argument about N copies of a traversal. It handles all three authored shapes (Mongo condition object, { field, operator, value } rules, [field, op, value] triples) because a reader that handles only one shape is the exact bug Dashboard widget filters do not interpolate {current_user_id} — user-scoped widgets silently render 0 #3574 was filed against, and it composes a nested condition object into one relationship path so { account: { region: … } } reports account.region rather than a bare region resolved against the wrong object.

Both hold mechanism only — no rule ids, no severities, no findings. The judgement stays with the rule that asks.

Severity

All five verdicts are error. Each clears the bar this package states ("gate when no reading of the metadata behaves as written"): a dimension bound to a column that does not exist cannot group by anything, a measure bound to one cannot aggregate anything, and a filter key naming nothing either widens the scope or empties it — every one of those reaching a human as a chart that rendered successfully. Same call dashboard-filter-field-unknown (#3365) makes one layer up on the identical question.

dataset-field-not-included is the card's "second real check": a dotted path that resolves but whose relationship prefix was never declared in include. ADR-0021 D-C joins only declared paths, so the column is out of the query's reach however real it is. Where a position carries both defects, exactly one finding is emitted — the existence one, because it carries the "did you mean".

False-positive floor (ADR-0072 D1)

Four skips, each a verdict the resolver returns rather than a rule-local guess: an object this stack does not define; an object with no readable field map (ADR-0015 external / introspected); a registry-injected system column (showcase_task_metrics's { field: 'created_at' } is the live case, resolved per object via injectedColumnsFor, never the object-independent SYSTEM_FIELDS union); and any hop through one — an injected owner_id is a lookup at the registry whose target is invisible here, so owner_id.name is unanswerable rather than a miss.

Verification

Union run on the final commit, 8ffaec15b.

End-to-end on a shipped app, both directions, on the real CLI. Mutation applied and confirmed on disk (old spelling 0 occurrences, new spelling 1), restore proven by blob hash rather than by an exit code:

HEAD blob:      54b60a3abf490e97c3660a8c562862888b776e5b
worktree blob:  54b60a3abf490e97c3660a8c562862888b776e5b
baseline (unmutated) `objectstack validate`  -> CLEAN_EXIT=0
mutate examples/app-crm .. opportunity.dataset.ts: dimension field 'stage' -> 'stagee'
  old spelling count: 0   new spelling count: 1   mutated blob: 4b53649602..
mutated `objectstack validate`               -> MUTATED_EXIT=1
worktree blob after restore: 54b60a3abf490e97c3660a8c562862888b776e5b   (git diff HEAD --stat: empty)

The finding, verbatim — the message shape the card asked for, with the platform's "did you mean" precedent:

✗ Author-time rules failed (1 issue)
  • dataset "opportunity_metrics" › dimension "stage": dimension field "stagee" is not a
    field on object "crm_opportunity". Did you mean "stage"? The path is compiled into the
    analytics query as written, so it addresses a column that does not exist: the surface
    renders successfully with empty or wrong numbers, and nothing reports the miss.
      Bind the dimension to a field on "crm_opportunity", or to a
      `relationship[.relationship].field` path whose prefix is declared in `include`.
      Fields on "crm_opportunity": account, amount, approval_status, close_date, ...
      rule: dataset-field-unknown  at datasets[0].dimensions[0].field

No shipped metadata moves. objectstack validate on all three example apps, unmutated: app-crm exit 0, app-todo exit 0 (✓ Validation passed), app-showcase exit 0 (✓ Validation passed). The full workspace build (turbo run build, 70/70 tasks) runs objectstack build on the examples and passed, so the gating rules do not break any shipped artifact.

Tests. pnpm --filter @objectstack/lint test90 files, 2528 passed | 5 skipped, exit 0. New: validate-dataset-references.test.ts (34 cases: the six measured rows with the clean spelling asserted beside each, the joinability clause, include-must-be-a-relationship, all three filter shapes, the four skips, the name-keyed stack shape, and the shipped opportunity_metrics / showcase_invoice_metrics datasets transcribed verbatim and asserted silent) and object-graph.test.ts (22 cases pinning the verdict union and the filter-key walk directly, because the follow-ups consume the verdicts rather than this rule's findings).

Gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives 33 families for this surface (29 by path, 6 by change kind, 2 reached both ways). All 33 run, plus the three named in the dispatch regardless of derivation. Green: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-authoring, check:dual-build-cjs-loads, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage, check:type-check-debt, check:type-source-resolution, check:where-matcher, check:ratchet-remedy-authority, check:declared-population-live, check:nul-bytes, and the 13 direct node scripts/… families. Repo-wide pnpm lint (eslint . --no-inline-config): exit 0. pnpm --filter @objectstack/lint typecheck: exit 0.

One family reports NOT MEASURED, not a red: check-test-completeness.mjs exits 3 (PREREQUISITE NOT MET) because it grades a saved turbo run test log that only CI produces.

check:type-check-debt --re-measure is the reading that matters for the new test files, and it is worth naming because the obvious local reading is a false green: packages/lint/tsconfig.json excludes **/*.test.ts, so pnpm --filter @objectstack/lint typecheck passing says nothing about them (confirmed with tsc --listFiles: 0 hits for the three test files, 2 for the two new source files). The ratchet does cover them — "27 ledger entries re-measured in 261.0s, 1217 raw tsc errors total, none above its recorded number. surplus: none — every entry sits exactly at its measurement" — so the new test files add zero errors to the TEST_DEBT entry.

Scope

packages/lint/src/** and one changeset, exactly the file surface the PM claim declared. No packages/spec/src/** edits. No content/docs/releases/**. Changeset is minor (@objectstack/lint), matching the precedent for a rule addition that both narrows accept behaviour and adds published exports.

One follow-up recorded rather than done: suggest/distance now exists in four copies across this package (validate-object-references.ts, validate-sortable-fields.ts, validate-widget-bindings.ts, and object-graph.ts). Consolidating them onto the seam's nearestName is a pure refactor across rules this card does not otherwise touch, so it is out of scope here and noted in object-graph.ts at the definition.


Generated by Claude Code

…build

A dataset could name a base object that does not exist, join a relationship
that does not exist, and bind every dimension and measure to fields that do not
exist, and `objectstack validate` exited 0 with "Validation passed"; `build`
wrote the dangling dataset into dist/objectstack.json. The author-time rule
pass already walked those exact nodes — `filter-token-unknown` stands at
`datasets[i].measures[j].filter.<key>.$lt` and reasons about the VALUE — so the
traversal and the machinery were both present and only the resolution was
missing.

Adds `validateDatasetReferences` to the reference-integrity suite with four
gating rule ids (dataset-include-unknown, dataset-field-unknown,
dataset-field-not-included, dataset-filter-field-unknown), and adds the base
object as a `datasets[].object` reference site on `validateObjectReferences`,
where the curated cross-package severity ladder already lives.

Two reusable seams ship with it, exported and pinned by their own tests:
`object-graph.ts` (indexObjectGraph / resolveFieldPath, a discriminated verdict
union rather than a boolean) and `walkFilterFieldKeys` in `filter-walk.ts` (the
field-key half of a filter subtree, across all three authored shapes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 35 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9e5cd71b2cb99ddda30c520eae72d7f38072a227.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 33 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9e5cd71b2cb99ddda30c520eae72d7f38072a227packageMentionDocs.

Which tree this was computed on

This run read content/docs from f2930eef0bc84e020dde47b95be6c9f6bfbacf72 — the merge of head 8ffaec15b504b9326fd2ea63bb158a3e708ad68c into base 9e5cd71b2cb99ddda30c520eae72d7f38072a227, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f2930eef0bc84e020dde47b95be6c9f6bfbacf72 && git checkout f2930eef0bc84e020dde47b95be6c9f6bfbacf72
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e5cd71b2cb99ddda30c520eae72d7f38072a227 8ffaec15b504b9326fd2ea63bb158a3e708ad68c && git checkout -B drift-repro 9e5cd71b2cb99ddda30c520eae72d7f38072a227 && git merge --no-ff 8ffaec15b504b9326fd2ea63bb158a3e708ad68c

node scripts/docs-audit/affected-docs.mjs --json 9e5cd71b2cb99ddda30c520eae72d7f38072a227

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9e5cd71b2cb99ddda30c520eae72d7f38072a227 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 18:30
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 345fc33 Sep 1, 2026
34 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14105-dataset-ref-resolution branch September 1, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants