fix(lint): resolve an ADR-0021 dataset's base object, include paths, dimension/measure fields and filter keys at validate/build - #14267
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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 validateexited 0 with✓ Validation passed.buildalso exited 0 and wrote the dangling dataset intodist/objectstack.json. The author-time pass already walked those exact nodes —filter-token-unknownstands atdatasets[i].measures[j].filter.KEY.$ltand 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:filter-token-unknown— present,packages/lint/src/validate-filter-tokens.ts:65.dashboard-filter-field-unknown— present,packages/lint/src/validate-widget-bindings.ts:101.chart-dataset-unknownandwidget-dataset-unknown, i.e. the consumer end (The metadata door accepts a dashboard widget dataset binding that names nothing — 200 on both save and publish, referential integrity enforced only at runtime #7529/Refuse a dashboard widget dataset binding that names nothing at publish (#7529) #8902). No rule resolvedDataset.object,include[],dimensions[].field,measures[].field, or a filter key.git grep -ohE "'dataset-[a-z-]+'" -- packages/lint/srcreturned nothing.premise_still_valid: true.What shipped, against the card's four scope items
Dataset.objectobject-reference-unknown/object-reference-unregistered-platform, atdatasets[N].objectinclude[]hop by hopdataset-include-unknowndimensions[].field/measures[].field, incl. the include-prefix clausedataset-field-unknown+dataset-field-not-includedDataset.filterandmeasures[].filterdataset-filter-field-unknownAll 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
fieldpath — 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[].objectis a new reference site onvalidateObjectReferences, not a sixth id on the new rule. That rule's charter is literally "object-name reference sites that are plainz.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.tsdeclares five datasets oversys_user,sys_organization,sys_session,sys_package_installationandsys_audit_log, three of which live in packages a stack compiling plugin-auth alone cannot see. All five resolve throughPLATFORM_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 invalidate-object-references.test.ts.The corollary is the division of labour: when the base object does not resolve,
validateDatasetReferencesskips 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.ts—indexObjectGraph(stack)andresolveFieldPath(graph, object, path), answering "what does thisrelationship[.relationship].fieldpath resolve to?". The answer is a discriminated verdict union, not a boolean, on purpose: a caller that cannot tellhop-not-relationshipfromhop-unknownfromfield-unknowncannot write the prescription an author needs.isUnjudgeable()spells the skip so a verdict added later defaults to being reported loudly rather than silently swallowed. PlusnearestName/suggestName/listNames.walkFilterFieldKeysinpackages/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: … } }reportsaccount.regionrather than a bareregionresolved 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 calldashboard-filter-field-unknown(#3365) makes one layer up on the identical question.dataset-field-not-includedis the card's "second real check": a dotted path that resolves but whose relationship prefix was never declared ininclude. 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 viainjectedColumnsFor, never the object-independentSYSTEM_FIELDSunion); and any hop through one — an injectedowner_idis a lookup at the registry whose target is invisible here, soowner_id.nameis 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:
The finding, verbatim — the message shape the card asked for, with the platform's "did you mean" precedent:
No shipped metadata moves.
objectstack validateon all three example apps, unmutated:app-crmexit 0,app-todoexit 0 (✓ Validation passed),app-showcaseexit 0 (✓ Validation passed). The full workspace build (turbo run build, 70/70 tasks) runsobjectstack buildon the examples and passed, so the gating rules do not break any shipped artifact.Tests.
pnpm --filter @objectstack/lint test— 90 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 shippedopportunity_metrics/showcase_invoice_metricsdatasets transcribed verbatim and asserted silent) andobject-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/objectstackderives 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 directnode scripts/…families. Repo-widepnpm 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.mjsexits 3 (PREREQUISITE NOT MET) because it grades a savedturbo run testlog that only CI produces.check:type-check-debt --re-measureis 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.jsonexcludes**/*.test.ts, sopnpm --filter @objectstack/lint typecheckpassing says nothing about them (confirmed withtsc --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 theTEST_DEBTentry.Scope
packages/lint/src/**and one changeset, exactly the file surface the PM claim declared. Nopackages/spec/src/**edits. Nocontent/docs/releases/**. Changeset isminor(@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/distancenow exists in four copies across this package (validate-object-references.ts,validate-sortable-fields.ts,validate-widget-bindings.ts, andobject-graph.ts). Consolidating them onto the seam'snearestNameis a pure refactor across rules this card does not otherwise touch, so it is out of scope here and noted inobject-graph.tsat the definition.Generated by Claude Code