Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/lint-reference-integrity-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/lint": minor
"@objectstack/cli": patch
---

refactor(lint): one entry point for the reference-integrity suite (#3583 D5)

Six rules that answer the same question — "does this name resolve to anything?"
— were wired by hand into three CLI commands, so landing a rule meant editing
`validate`, `lint` and `compile`, and forgetting one meant the same stack got a
different verdict depending on which command the author ran.

New public API on `@objectstack/lint`:

- `validateReferenceIntegrity(stack)` — runs every reference-integrity rule and
returns the concatenated findings.
- `REFERENCE_INTEGRITY_RULES` — the ordered list behind it (`validateObjectReferences`,
`validateActionNameRefs`, `validatePageFieldBindings`, `validateChartBindings`,
`validateNavAccess`, `validateTranslationReferences`).
- `ReferenceIntegrityFinding` / `ReferenceIntegrityRule` / `ReferenceIntegritySeverity`
— one finding type instead of a six-way union.

Adding a rule to that list reaches `validate`, `lint` and `compile` with no
further wiring. The individual rule exports are unchanged, so nothing that
imports them directly needs to move.

Behaviour-preserving: identical findings on the three example apps (zero) and
on the HotCRM corpus (24, unchanged per rule). `os doctor` is deliberately not
converted — it runs only `validateWidgetBindings` and is an environment health
check rather than an authoring gate.
35 changes: 33 additions & 2 deletions docs/audits/2026-07-app-metadata-reference-integrity-assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Field-level checks inside a cross-package object stay S3 (skip) — we cannot ju
- **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.
- **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.
- **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.
- **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.
- **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.

---

Expand All @@ -127,7 +127,38 @@ Per ADR-0078 §5 and the audit discipline (`2026-06-metadata-functional-complete
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).
2. **Each Phase-2/3 rule lands separately** (one PR each), behind its verification pass. No omnibus "reference integrity" mega-PR.
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.
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.
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.**

## 6a. Residual after Phases 0–3 (the §6.4 re-run, 2026-07-28)

**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.

**What the suite now catches on the real corpus — 23 findings, no false positives:**

| Rule | Findings | Issue category | The shipped instance |
|---|---|---|---|
| `object-reference-unknown` | 4 | 1b | `optionsFrom.object: 'user'` on four dashboards (the platform object is `sys_user`) |
| `action-name-undefined` | 5 | 3 | `bulkActions: ['mass_update','mass_delete','assign_owner']` + `rowActions: ['edit','delete']` |
| `chart-measure-unknown` | 4 | 7 | report `yAxis` naming raw fields (`amount`, `case_number`) instead of dataset measures |
| `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` |
| `object-reference-unregistered-platform` | 2 | 2 | navigation `objectName` + `requiresObject` on `sys_approval_process`, which nothing registers |
| `nav-object-ungranted` | 2 | 8 | `crm_knowledge_article`, `crm_forecast` exposed in navigation, granted by none of the 6 declared permission sets |

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).

**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.

**What still passes silently — the accepted gaps:**

| Gap | Status | Evidence on HotCRM |
|---|---|---|
| **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 |
| **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 |
| **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 |
| **6 — skill hand-off** | Non-goal (§7): the hand-off lives in free-form prompt text, which has no structure to check. | — |
| **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 |

**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.

## 7. Non-goals

Expand Down
14 changes: 2 additions & 12 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { validateTranslationReferences } from '@objectstack/lint';
import { validateReferenceIntegrity } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
Expand Down Expand Up @@ -342,14 +339,7 @@ export default class Compile extends Command {
// option keys written as the display label) — advisory throughout,
// since an orphan key is inert rather than broken.
if (!flags.json) printStep('Checking object & action references (#3583)...');
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
...validateNavAccess(result.data as Record<string, unknown>),
...validateTranslationReferences(result.data as Record<string, unknown>),
];
const refFindings = validateReferenceIntegrity(result.data as Record<string, unknown>);
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
if (refErrors.length > 0) {
Expand Down
99 changes: 11 additions & 88 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.
import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { validateTranslationReferences } from '@objectstack/lint';
import { validateReferenceIntegrity } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -493,90 +490,16 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Object-name references (issue #3583) ──
// The reference sites `defineStack` does not cover: action-param
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`, and
// navigation `requiresObject` gates. An unprefixed miss (`user` for
// `sys_user`) is a typo → error; a platform-prefixed name no known package
// registers (`sys_approval_process`) is advisory, since a third-party
// package may still provide it.
for (const t of validateObjectReferences(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Action-name references (issue #3583) ──
// `bulkActions`/`rowActions`, page `quick_actions`, and nav action items bind
// an action BY NAME. A name matching no defined action ships a button that
// renders and does nothing — same failure `validate-dashboard-action-refs`
// catches for dashboards, so the same severity.
for (const t of validateActionNameRefs(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Page component field bindings (issue #3583) ──
// `PageComponent.properties` is an untyped bag, so a highlights strip, KPI
// card, or details section can name a field the object does not have; the
// component silently skips it. Advisory, matching `FORM_FIELD_UNKNOWN` —
// every page consumer degrades rather than failing.
for (const t of validatePageFieldBindings(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Chart bindings outside dashboards (issue #3583) ──
// `validate-widget-bindings` covers dashboard widgets only. Report charts,
// list-view charts, and dataset-bound page chart components bind the same
// semantic layer, where an axis naming a raw field instead of a dataset
// measure renders an empty series (ADR-0021).
for (const t of validateChartBindings(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Navigation reachability vs. granted access (ADR-0090 D6, issue #3583) ──
// Navigation and permissions are separate metadata, so an app can expose an
// object no permission set grants read on: the entry renders and the click
// fails permission-denied for every user, including admins. Advisory — the
// grant may come from a set another installed package ships.
for (const t of validateNavAccess(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Translation-bundle references & option keys (issue #3583) ──
// The i18n gate only ever asked "which expected keys are missing?". This is
// the reverse the spec already names (`TranslationDiffStatus 'redundant'`):
// keys pointing at metadata that no longer exists, and select-option keys
// written as the display label instead of the stored value. Advisory — an
// orphan key is inert, it just leaves one string untranslated.
for (const t of validateTranslationReferences(config)) {
// ── Reference integrity (issue #3583) ──
// One suite, one call site: object-name references `defineStack` does not
// cover, name-bound action surfaces, page-component field bindings, chart
// axes outside dashboards, navigation vs. granted access, and translation
// keys pointing at metadata that no longer exists. Every member resolves a
// NAME against what the stack declares — the class the HotCRM audit found
// shipping, where each instance parses, validates, and fails silently.
// Adding a rule to `REFERENCE_INTEGRITY_RULES` reaches this path with no
// edit here (assessment §5 D5 — the wiring drift this ends).
for (const t of validateReferenceIntegrity(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
Expand Down
14 changes: 2 additions & 12 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import { validateViewContainers } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateNavAccess } from '@objectstack/lint';
import { validateTranslationReferences } from '@objectstack/lint';
import { validateReferenceIntegrity } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
Expand Down Expand Up @@ -310,14 +307,7 @@ export default class Validate extends Command {
// to nothing and renders the source string (advisory: inert, not
// broken).
if (!flags.json) printStep('Checking object & action references (#3583)...');
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
...validateNavAccess(result.data as Record<string, unknown>),
...validateTranslationReferences(result.data as Record<string, unknown>),
];
const refFindings = validateReferenceIntegrity(result.data as Record<string, unknown>);
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
if (refErrors.length > 0) {
Expand Down
Loading
Loading