Uh oh!
There was an error while loading. Please reload this page.
fix(lint): resolve a dashboard widget's own filter keys and options.sortBy at validate/build - #14276
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
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 4acf3c1f3ab8017c7066fdb36c8410a51cd8873c && git checkout 4acf3c1f3ab8017c7066fdb36c8410a51cd8873c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eb8e3cc5022c4b1d0007962220444f4f947036d 5216886e2b62f59830dd01a8b98ea486a5de9c6d && git checkout -B drift-repro 6eb8e3cc5022c4b1d0007962220444f4f947036d && git merge --no-ff 5216886e2b62f59830dd01a8b98ea486a5de9c6d
node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036d
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14148
A dashboard widget could filter by a column that does not exist, and order by a name it never selected, and
objectstack validateexited 0 with "Validation passed";build— the publish gate — wrote the dashboard intodist/objectstack.json. The widget then rendered empty, and per the card that is the expensive part: the board it was measured on leads with a "not moving" tile, and "an empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for." The failure is silent in the direction the reader wants to believe.Premise re-check — measured on this branch's base, not assumed
Base is
345fc33ad(PR #14267, the #14105 family opener). Verified before writing code:packages/lint/src/index.tsexportswalkFilterFieldKeys(filter-walk.ts) andindexObjectGraph/resolveFieldPath(object-graph.ts); the export-site comment names this card as an intended consumerfilterKEYS unresolvedvalidate-widget-bindings.tsat base readsw.dataset,w.dimensions,w.values,w.chartConfig,w.filterBindings— and neverw.filteroptions.sortByuncheckedvalidate-sortable-fields.tsexplicitly defers it: "Dashboard widget sort config. Verified present but out of this predicate's domain … Judging those needs the dataset's measure index, which isvalidateChartBindings' family, not this one." Nothing in that family judged itThe card's six-row "what IS caught" table is a positive control, and it still holds at base:
widget-dataset-unknown,widget-dimension-unknown,widget-measure-unknown,filter-token-unknownanddashboard-filter-field-unknownall live and gating. So the two zeros were readings, not an unread file.premise_still_valid: trueon all three.What lands
Three gating rule ids, all at the site that already emits
widget-dataset-unknown/dashboard-filter-field-unknown:widget-filter-field-unknown(limb A) — a key of the widget's ownfilterresolves to no column on the bound dataset's object graph. Reported path-precise atdashboards[i].widgets[j].filter.KEY.widget-filter-field-not-included(limb A, second clause) — the key resolves, but its relationship prefix is not declared in the dataset'sinclude.widget-sortby-unselected(limb B) —options.sortBynames neither adimensions[]nor avalues[]entry of the widget, reported atdashboards[i].widgets[j].options.sortBy.All three name dashboard, widget, key and object, and print the object's field list (limb A) or what the widget selects (limb B).
The open sub-question, answered explicitly: a dotted path through a declared
includeis RESOLVEDThe card required this be stated rather than passed through silently, since a third silent pass-through would reproduce the card. Decision: resolve it, under the same two clauses the dataset-level sibling applies one level down — existence first, then ADR-0021 joinability.
The reasoning is mechanical rather than aesthetic. A widget's
filteris ANDed into the dataset query asruntimeFilter(DashboardWidgetSchema.filter;dataset-executor.ts—combineFilters(compiled.filter, selection.runtimeFilter)), and that compiled query carries only the joinsincludedeclared (dataset-compiler.tsderives every join alias frominclude). So a dotted key is exactly as decidable here as at a dataset dimension.And the runtime is not a backstop for the second clause:
dataset-compiler.ts'sassertDeclaredis called at two sites only —assertDeclared(d.field, 'dimension', …)andassertDeclared(m.field, 'measure', …)— never overruntimeFilter. Nothing between the author and the empty tile asked this question.Note this deliberately differs from the neighbouring
dashboard-filter-field-unknown, which still skips a dotted field (if (field.includes('.')) continue;). That skip was correct when nothing in this package could walk hops; it is now a real gap, and migrating it is filed rather than smuggled in here (see Follow-ups).Reuse, not a third implementation
Triage was explicit: "generalise the one working implementation, not three parallel rules." No second hop-walker and no second filter-key reader were written. Both limbs are built on
walkFilterFieldKeys(which already handles all three authored filter shapes — Mongo condition objects,{ field, operator, value }rules, and[field, op, value]triples) andindexObjectGraph/resolveFieldPath.Two helpers that were local to
validate-dataset-references.tsmoved intoobject-graph.tsand are now exported:joinablePrefixes(how ADR-0021 expands anincludeinto joinable prefixes) anddescribeFieldPathVerdict(how one verdict reads in prose, renamed fromexistenceMessagenow that it is shared). Copying either would have been the second implementation the seam exists to prevent, one release after it was written to prevent it. The sibling's behaviour is unchanged — its 526-line test file passes untouched.The three skips are inherited unchanged, so an object this stack does not define, an ADR-0015
externalobject with no readable field map, and a registry-injected system column are never reported. Injected columns resolve per object via the graph rather than through the flatSYSTEM_FIELDSunion the neighbouring rule uses, which is strictly better: a reference toowner_idon anownership: 'none'object stays a real finding.Verification
Ratchet and gate readings below were taken on the final commit,
5216886e2.Acceptance criterion pinned end-to-end, not inferred. The card's binding criterion is that both limbs fail
validateANDbuild— a validate-only fix was not acceptable. Rather than assert this from the registry entry'scommands: ALL, six tests driverunAuthoringRules('validate' | 'build', …)+splitBySeverityover the repro stacks and assert the rule id appears inerrors, plus a clean shape passing on both. Nothing else in the file would notice if that entry'scommandswere narrowed later.Ablation — the new tests genuinely fail without the implementation. Both limb bodies were disabled at their guards (implementation committed first; mutation confirmed on disk by injected-marker count
2, original-anchor count0, and a blob hash differing from theHEADblob). Result: 13 failed / 75 passed, including all fourvalidate/buildacceptance pins. Restored viagit checkout HEAD -- ABSOLUTE_PATHunder anEXIT INT TERMtrap and proven byte-identical:git diff HEADempty,git hash-objectback to4c3379d74c386d50f13e6d7bf7c07133816f745f, zero markers remaining. No rebuild leg is skipped or needed here, and that is a property rather than an omission: the test reaches the rule through a relative source import (./validate-widget-bindings.js), resolved by vitest tosrc/, never through the packageexports/dist.Note the "clean shape" tests stay green under ablation, which is correct — an ablated rule reports nothing — so the 75 passes are not evidence of anything.
Runs:
pnpm --filter @objectstack/lint test— 90 files, 2558 tests, all passpnpm --filter @objectstack/lint typecheck— clean (tsc --noEmit)pnpm lint(repo-scaleeslint . --no-inline-config) — exit 0, no narrowingscripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— 33 families (29 by path + 6 by kind, 2 overlapping) — 31 greencheck-ratchet-remedy-authority(green, 183 scripts swept) andcheck:declared-population-live(green, 156/200 families reach the tree)check:type-check-coveragegreen;check:type-check-debt --re-measuregreen after building the closure — "27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number"check:dual-build-cjs-loadsgreen after the same build (it had reported exit 3 PREREQUISITE NOT MET beforehand)check:nul-bytesgreen; diff additionally self-scanned for raw control bytes — cleanOne family NOT MEASURED locally, by its own instruction:
scripts/check-test-completeness.mjsrequires a savedturbo run testlog that CI tees and this invocation has none, so it exits 3 and its own text says "running the family locally, record this gate as NOT MEASURED … It is not a red, and there is nothing here to fix." Recorded as NOT MEASURED rather than as a pass.A note on the ratchet the type gates could not see
packages/lint/tsconfig.jsonexcludes**/*.test.ts, sopnpm --filter @objectstack/lint typechecksays nothing about the test file this PR adds. That is measured, not assumed —tsc --listFilesreturns the three source files and not the test. The tests here were therefore type-checked separately under a temporary config that includes them (removed again): zero errors attributable to this diff, against 22 pre-existing errors in seven untouched test files. That separate run is what caught a genuine defect in this PR's own test helper — a non-genericidsOfthat erased the finding type — which vitest cannot see and which would otherwise have shipped. The structural half is accounted for:check:type-check-coveragerecords this package under TEST_DEBT, and the ratchet re-measure is green.Scope
test/dashboard.test.tsinobjectstack-ai/dulyis the downstream stopgap, written to be deleted when this lands. Deleting it is a follow-up in that repo, not this PR's scope — this PR does not touchduly.validateWidgetBindingsregistry entry, whosesurfacesis per-RULE. As with the six error ids already on it, the three new ones therefore also reach the runtime publish gate fordashboardwrites. That is safe in the direction that matters:RuntimeStackContextcarriesobjects, so the graph resolves there; were it ever not carried,resolveFieldPathreturnsunknowableand the rules go silent rather than inventing findings.Follow-ups
dashboard-filter-field-unknownstill skips every dotted filter field and resolves against the flatSYSTEM_FIELDSunion — the seam next to it now answers both #14275 (filed by this PR) —dashboard-filter-field-unknown, in this same file, still skips every dotted filter field and resolves against the flatSYSTEM_FIELDSunion rather than the per-object injected set. Both are now cheaply closable on the seam. Not folded in here: it narrows the accept set of a different, already-shipped gating rule and needs its own false-positive pass over the shipped dashboards.packages/linthas no tsc program that compiles its tests. That is the pre-existing condition behind the type-gate note above; this PR's separate measurement adds a current reading to it (22 errors across seven test files, none from this diff).Generated by Claude Code