Skip to content

Commit f022c4d

Browse files
authored
refactor(lint): one reference-integrity entry point + the HotCRM residual audit (#3583 D5/§6.4) (#3818)
D5: six rules answering "does this name resolve?" were wired by hand into three CLI commands. `validateReferenceIntegrity(stack)` runs `REFERENCE_INTEGRITY_RULES` in order, so adding a rule is a one-line edit and `validate`/`lint`/`compile` pick it up unchanged. Membership is a written-out list mirrored by a test, and the suite test feeds one live instance per member so a member that stops being invoked fails loudly instead of reading as a clean stack. `os doctor` is deliberately not converted — named in the module doc so the asymmetry stays visible. §6.4: the residual re-run over the HotCRM corpus (8b28fa2, v2.2.2) — 23 findings, no false positives, mapping category-for-category onto #3583's list. Categories 4 and 5a report zero and the zero is verified non-vacuous (a probe injection moves the page rule 0 → 3). Accepted gaps are recorded with their evidence; the one buildable gap left is R7, split out as #3820 because it needs spec decisions D1/D2 first. Refs #3583
1 parent 4c0df37 commit f022c4d

8 files changed

Lines changed: 333 additions & 114 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
refactor(lint): one entry point for the reference-integrity suite (#3583 D5)
7+
8+
Six rules that answer the same question — "does this name resolve to anything?"
9+
— were wired by hand into three CLI commands, so landing a rule meant editing
10+
`validate`, `lint` and `compile`, and forgetting one meant the same stack got a
11+
different verdict depending on which command the author ran.
12+
13+
New public API on `@objectstack/lint`:
14+
15+
- `validateReferenceIntegrity(stack)` — runs every reference-integrity rule and
16+
returns the concatenated findings.
17+
- `REFERENCE_INTEGRITY_RULES` — the ordered list behind it (`validateObjectReferences`,
18+
`validateActionNameRefs`, `validatePageFieldBindings`, `validateChartBindings`,
19+
`validateNavAccess`, `validateTranslationReferences`).
20+
- `ReferenceIntegrityFinding` / `ReferenceIntegrityRule` / `ReferenceIntegritySeverity`
21+
— one finding type instead of a six-way union.
22+
23+
Adding a rule to that list reaches `validate`, `lint` and `compile` with no
24+
further wiring. The individual rule exports are unchanged, so nothing that
25+
imports them directly needs to move.
26+
27+
Behaviour-preserving: identical findings on the three example apps (zero) and
28+
on the HotCRM corpus (24, unchanged per rule). `os doctor` is deliberately not
29+
converted — it runs only `validateWidgetBindings` and is an environment health
30+
check rather than an authoring gate.

docs/audits/2026-07-app-metadata-reference-integrity-assessment.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Field-level checks inside a cross-package object stay S3 (skip) — we cannot ju
116116
- **D2 — runtime-registered skills/tools vs. static lint.** `ask`/`build` kernel agents register skills/tools via service at boot (same invisibility as plugin objects). Extend the Phase-1 registry with official skill/tool names, or run R7 at S2 advisory severity. Small decision; take it when R7 starts.
117117
- **D3 — where does nav-access belong?** R5-as-lint-warning (proposed) vs. folding into `compile`'s access-matrix snapshot gate. Lint wins on path symmetry (ADR-0078); the snapshot gate stays the drift detector.
118118
- **D4 — hook body writes are not statically checkable, say so.** The write set lives in opaque JS (`hook-body.zod.ts`). Options: (a) accept the gap and document it (hook writes are the one HotCRM category with no static answer); (b) a structured `writes: [field]` declaration on `HookSchema` that the runtime enforces (contract-first, but new spec surface + runtime work); (c) registration-time dev diagnostics (ADR-0078 §4, deferred/evidence-gated there). Recommendation: (a) now, file (b) as its own issue — do not let this category stall the other eight. **Decided 2026-07-27 (#3700): (a); revised 2026-07-28 — (b) dropped, #3700 closed as not planned.** The gap stays documented at the authoring surfaces (`content/docs/automation/hook-bodies.mdx` "Not statically checked: the write set"; `ScriptBodySchema` TSDoc); the documentation is the whole disposition, so ADR-0107, which only recorded it, was withdrawn. (b) will not be built — the declaration duplicates the write set in a second place the author must keep in sync, adding authoring friction and a new error surface (worst for AI authors); in practice, exercising hooks against a SQL driver (in-memory SQLite) covers the failure mode that schemaless `memory://` green lights hide. (c) remains deferred under ADR-0078 §4, unchanged.
119-
- **D5 — rule-suite wiring drift.** `validate`/`lint`/`compile`/`doctor` each hand-pick rule subsets. A shared "run the reference-integrity suite" entry point in `@objectstack/lint` would make the next rule's wiring a one-liner and end the drift. Worth doing with Phase 2, not before.
119+
- **D5 — rule-suite wiring drift. Done 2026-07-28.** `validateReferenceIntegrity` + `REFERENCE_INTEGRITY_RULES` (`packages/lint/src/reference-integrity-suite.ts`) is the single entry point; `validate`/`lint`/`compile` each call it once, so a new rule is a one-line edit to the list. Membership is a written-out list mirrored by a test (a rule joins by review, not by accident), and the suite test runs one live instance per member so a member that stops being invoked fails loudly instead of reading as a clean stack. `doctor` is deliberately NOT converted — it runs only `validateWidgetBindings` and is an environment health check, not an authoring gate, so adopting the suite there is a product decision rather than a wiring cleanup; the asymmetry is named in the module doc so it stays visible.
120120

121121
---
122122

@@ -127,7 +127,38 @@ Per ADR-0078 §5 and the audit discipline (`2026-06-metadata-functional-complete
127127
1. **HotCRM is the regression corpus.** Each of the ~20 shipped instances becomes a fixture in the owning rule's `.test.ts` (reduced to minimal stacks — HotCRM itself isn't vendored). A rule ships only when it catches its instances *and* runs clean over `examples/`, the showcase app, and the platform seed apps (zero false positives — the ADR-0072 D1 trust contract: one dead finding and authors stop trusting the linter).
128128
2. **Each Phase-2/3 rule lands separately** (one PR each), behind its verification pass. No omnibus "reference integrity" mega-PR.
129129
3. **Phase 1's registry gets conformance tests in the owning packages** the same day the list lands — an unhonest registry is worse than the prefix heuristic it replaces.
130-
4. Re-run the HotCRM audit (or its fixture corpus) after Phase 2 and record the residual: what still passes cleanly and *why* (D1/D2/D4 categories), so the issue can be closed with an explicit accepted-gap statement instead of silence.
130+
4. Re-run the HotCRM audit (or its fixture corpus) after Phase 2 and record the residual: what still passes cleanly and *why* (D1/D2/D4 categories), so the issue can be closed with an explicit accepted-gap statement instead of silence. **Done 2026-07-28 — see §6a.**
131+
132+
## 6a. Residual after Phases 0–3 (the §6.4 re-run, 2026-07-28)
133+
134+
**Method.** `objectstack-ai/hotcrm` @ `8b28fa2` (v2.2.2 — the same repo the original audit read, ~18k lines of shipped metadata), checked with the workspace build of the suite via `os lint` (the raw-config path). `os validate` cannot be the vehicle: HotCRM trips **24 Zod errors** against the current dev spec — `enable.trash` / `enable.mru` (removed in the 16.x line, #2377/ADR-0049) and `body` on non-`script` actions — so the parse aborts before any rule runs. That drift is pre-existing and unrelated to this work, but it is worth naming: **an app pinned to a published spec can be un-`validate`-able against `main`**, which is exactly why the lint path takes raw config.
135+
136+
**What the suite now catches on the real corpus — 23 findings, no false positives:**
137+
138+
| Rule | Findings | Issue category | The shipped instance |
139+
|---|---|---|---|
140+
| `object-reference-unknown` | 4 | 1b | `optionsFrom.object: 'user'` on four dashboards (the platform object is `sys_user`) |
141+
| `action-name-undefined` | 5 | 3 | `bulkActions: ['mass_update','mass_delete','assign_owner']` + `rowActions: ['edit','delete']` |
142+
| `chart-measure-unknown` | 4 | 7 | report `yAxis` naming raw fields (`amount`, `case_number`) instead of dataset measures |
143+
| `translation-target-unknown` | 6 | 9 | `apps.crm_enterprise.navigation.group_products` / `.group_analytics` × 3 locales; the app declares `group_sales` / `group_work` / `group_marketing` / `group_service` / `group_insights` |
144+
| `object-reference-unregistered-platform` | 2 | 2 | navigation `objectName` + `requiresObject` on `sys_approval_process`, which nothing registers |
145+
| `nav-object-ungranted` | 2 | 8 | `crm_knowledge_article`, `crm_forecast` exposed in navigation, granted by none of the 6 declared permission sets |
146+
147+
Categories **4** (page field bindings) and **5a** (hook conditions) report **zero** — and that zero is real, not an empty walk: injecting one bogus field into HotCRM's `record:highlights` components moves `validate-page-field-bindings` from 0 to 3 findings, so the walk reaches the pages and the corpus is simply clean there today (HotCRM has moved on since the audit).
148+
149+
**A false positive the corpus caught before release.** The first cut of R6 reported ~40 `_views` keys as orphans — all correct. A view record is a *container* (`list`, `listViews.<key>`, `formViews.<key>`) whose object binding lives at `list.data.object`, not at the record root, so the record resolved to nothing and was skipped whole. The examples missed it because none of their bundles carry `_views` keys at all: the walk ran, but that branch of the universe never did. **The §6.1 false-positive floor has to be read per branch, not per rule** — "zero findings on examples/" is only evidence for the branches those examples actually exercise.
150+
151+
**What still passes silently — the accepted gaps:**
152+
153+
| Gap | Status | Evidence on HotCRM |
154+
|---|---|---|
155+
| **5b — hook body write set** | Accepted, documented (§5 D4). Not statically checkable: the write set lives in an opaque JS body. Marked at the authoring surfaces (`content/docs/automation/hook-bodies.mdx`, `ScriptBodySchema` TSDoc). | 24 hooks, write sets unchecked by construction |
156+
| **6 — AI references** (R7 unbuilt) | **The one category with resolvable references and no rule.** | 2 agents / 6 skills declaring **8** agent→skill refs and **16** skill→tool refs — against a stack that declares **zero tools**, so every one of those 16 is dead today, exactly as the original audit reported |
157+
| **6 — knowledge indexes** | Blocked on **D1**: `agent.knowledge.indexes` has no definition site in a stack (`KnowledgeSourceSchema` is never referenced by `stack.zod.ts`), so a rule would institutionalise the gap. | 2 knowledge refs, unresolvable by construction |
158+
| **6 — skill hand-off** | Non-goal (§7): the hand-off lives in free-form prompt text, which has no structure to check. ||
159+
| **R8 — option-value literals in other metadata** | Tier-B candidate, unbuilt: kanban group values, `ChartConfig.colors` keys, filter literals. Needs a verification note before it becomes a rule (high false-positive surface — filters legitimately hold dynamic values). | not measured |
160+
161+
**Disposition for #3583.** Eight of the nine categories are now gated on `validate` / `lint` / `compile`; the ninth (5b) is an accepted, documented gap. The remaining buildable work is **R7** — and it is worth its own issue rather than keeping this one open, because it needs decision **D1** (a spec change or an `[EXPERIMENTAL]` marker) and **D2** (runtime-registered kernel skills/tools vs. static lint) first, neither of which is a lint question.
131162

132163
## 7. Non-goals
133164

packages/cli/src/commands/compile.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import { validateVisibilityPredicates } from '@objectstack/lint';
1313
import { validateWidgetBindings } from '@objectstack/lint';
1414
import { validateDashboardActionRefs } from '@objectstack/lint';
1515
import { validateFilterTokens } from '@objectstack/lint';
16-
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
17-
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
18-
import { validateNavAccess } from '@objectstack/lint';
19-
import { validateTranslationReferences } from '@objectstack/lint';
16+
import { validateReferenceIntegrity } from '@objectstack/lint';
2017
import { validateResponsiveStyles } from '@objectstack/lint';
2118
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2219
import { validateReadonlyFlowWrites } from '@objectstack/lint';
@@ -342,14 +339,7 @@ export default class Compile extends Command {
342339
// option keys written as the display label) — advisory throughout,
343340
// since an orphan key is inert rather than broken.
344341
if (!flags.json) printStep('Checking object & action references (#3583)...');
345-
const refFindings = [
346-
...validateObjectReferences(result.data as Record<string, unknown>),
347-
...validateActionNameRefs(result.data as Record<string, unknown>),
348-
...validatePageFieldBindings(result.data as Record<string, unknown>),
349-
...validateChartBindings(result.data as Record<string, unknown>),
350-
...validateNavAccess(result.data as Record<string, unknown>),
351-
...validateTranslationReferences(result.data as Record<string, unknown>),
352-
];
342+
const refFindings = validateReferenceIntegrity(result.data as Record<string, unknown>);
353343
const refErrors = refFindings.filter((f) => f.severity === 'error');
354344
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
355345
if (refErrors.length > 0) {

packages/cli/src/commands/lint.ts

Lines changed: 11 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.
1010
import { lintDataModel } from '../lint/data-model-rules.js';
1111
import { validateWidgetBindings } from '@objectstack/lint';
1212
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
13-
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
14-
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
15-
import { validateNavAccess } from '@objectstack/lint';
16-
import { validateTranslationReferences } from '@objectstack/lint';
13+
import { validateReferenceIntegrity } from '@objectstack/lint';
1714
import { collectAndLintDocs } from '../utils/collect-docs.js';
1815
import { scoreMetadata } from '../lint/score.js';
1916
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -493,90 +490,16 @@ export function lintConfig(config: any): LintIssue[] {
493490
});
494491
}
495492

496-
// ── Object-name references (issue #3583) ──
497-
// The reference sites `defineStack` does not cover: action-param
498-
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`, and
499-
// navigation `requiresObject` gates. An unprefixed miss (`user` for
500-
// `sys_user`) is a typo → error; a platform-prefixed name no known package
501-
// registers (`sys_approval_process`) is advisory, since a third-party
502-
// package may still provide it.
503-
for (const t of validateObjectReferences(config)) {
504-
issues.push({
505-
severity: t.severity,
506-
rule: t.rule,
507-
message: `${t.where}: ${t.message}`,
508-
path: t.path,
509-
fix: t.hint,
510-
});
511-
}
512-
513-
// ── Action-name references (issue #3583) ──
514-
// `bulkActions`/`rowActions`, page `quick_actions`, and nav action items bind
515-
// an action BY NAME. A name matching no defined action ships a button that
516-
// renders and does nothing — same failure `validate-dashboard-action-refs`
517-
// catches for dashboards, so the same severity.
518-
for (const t of validateActionNameRefs(config)) {
519-
issues.push({
520-
severity: t.severity,
521-
rule: t.rule,
522-
message: `${t.where}: ${t.message}`,
523-
path: t.path,
524-
fix: t.hint,
525-
});
526-
}
527-
528-
// ── Page component field bindings (issue #3583) ──
529-
// `PageComponent.properties` is an untyped bag, so a highlights strip, KPI
530-
// card, or details section can name a field the object does not have; the
531-
// component silently skips it. Advisory, matching `FORM_FIELD_UNKNOWN` —
532-
// every page consumer degrades rather than failing.
533-
for (const t of validatePageFieldBindings(config)) {
534-
issues.push({
535-
severity: t.severity,
536-
rule: t.rule,
537-
message: `${t.where}: ${t.message}`,
538-
path: t.path,
539-
fix: t.hint,
540-
});
541-
}
542-
543-
// ── Chart bindings outside dashboards (issue #3583) ──
544-
// `validate-widget-bindings` covers dashboard widgets only. Report charts,
545-
// list-view charts, and dataset-bound page chart components bind the same
546-
// semantic layer, where an axis naming a raw field instead of a dataset
547-
// measure renders an empty series (ADR-0021).
548-
for (const t of validateChartBindings(config)) {
549-
issues.push({
550-
severity: t.severity,
551-
rule: t.rule,
552-
message: `${t.where}: ${t.message}`,
553-
path: t.path,
554-
fix: t.hint,
555-
});
556-
}
557-
558-
// ── Navigation reachability vs. granted access (ADR-0090 D6, issue #3583) ──
559-
// Navigation and permissions are separate metadata, so an app can expose an
560-
// object no permission set grants read on: the entry renders and the click
561-
// fails permission-denied for every user, including admins. Advisory — the
562-
// grant may come from a set another installed package ships.
563-
for (const t of validateNavAccess(config)) {
564-
issues.push({
565-
severity: t.severity,
566-
rule: t.rule,
567-
message: `${t.where}: ${t.message}`,
568-
path: t.path,
569-
fix: t.hint,
570-
});
571-
}
572-
573-
// ── Translation-bundle references & option keys (issue #3583) ──
574-
// The i18n gate only ever asked "which expected keys are missing?". This is
575-
// the reverse the spec already names (`TranslationDiffStatus 'redundant'`):
576-
// keys pointing at metadata that no longer exists, and select-option keys
577-
// written as the display label instead of the stored value. Advisory — an
578-
// orphan key is inert, it just leaves one string untranslated.
579-
for (const t of validateTranslationReferences(config)) {
493+
// ── Reference integrity (issue #3583) ──
494+
// One suite, one call site: object-name references `defineStack` does not
495+
// cover, name-bound action surfaces, page-component field bindings, chart
496+
// axes outside dashboards, navigation vs. granted access, and translation
497+
// keys pointing at metadata that no longer exists. Every member resolves a
498+
// NAME against what the stack declares — the class the HotCRM audit found
499+
// shipping, where each instance parses, validates, and fails silently.
500+
// Adding a rule to `REFERENCE_INTEGRITY_RULES` reaches this path with no
501+
// edit here (assessment §5 D5 — the wiring drift this ends).
502+
for (const t of validateReferenceIntegrity(config)) {
580503
issues.push({
581504
severity: t.severity,
582505
rule: t.rule,

packages/cli/src/commands/validate.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ import { validateViewContainers } from '@objectstack/lint';
1414
import { validateWidgetBindings } from '@objectstack/lint';
1515
import { validateDashboardActionRefs } from '@objectstack/lint';
1616
import { validateFilterTokens } from '@objectstack/lint';
17-
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
18-
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
19-
import { validateNavAccess } from '@objectstack/lint';
20-
import { validateTranslationReferences } from '@objectstack/lint';
17+
import { validateReferenceIntegrity } from '@objectstack/lint';
2118
import { validateResponsiveStyles } from '@objectstack/lint';
2219
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
2320
import { validateCapabilityReferences } from '@objectstack/lint';
@@ -310,14 +307,7 @@ export default class Validate extends Command {
310307
// to nothing and renders the source string (advisory: inert, not
311308
// broken).
312309
if (!flags.json) printStep('Checking object & action references (#3583)...');
313-
const refFindings = [
314-
...validateObjectReferences(result.data as Record<string, unknown>),
315-
...validateActionNameRefs(result.data as Record<string, unknown>),
316-
...validatePageFieldBindings(result.data as Record<string, unknown>),
317-
...validateChartBindings(result.data as Record<string, unknown>),
318-
...validateNavAccess(result.data as Record<string, unknown>),
319-
...validateTranslationReferences(result.data as Record<string, unknown>),
320-
];
310+
const refFindings = validateReferenceIntegrity(result.data as Record<string, unknown>);
321311
const refErrors = refFindings.filter((f) => f.severity === 'error');
322312
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
323313
if (refErrors.length > 0) {

0 commit comments

Comments
 (0)