-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathreference-integrity-suite.ts
More file actions
211 lines (204 loc) · 12 KB
/
Copy pathreference-integrity-suite.ts
File metadata and controls
211 lines (204 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* The reference-integrity suite — one entry point for the rules that answer
* "does this name resolve to anything?" (issue #3583, assessment §5 D5).
*
* ## Why this exists
*
* These rules were wired **by hand** into each CLI entry point that runs them.
* `os validate`, `os lint` and `os compile` each grew their own import list and
* their own call site, so landing a rule meant remembering three places — and
* the assessment's §2.2 already named the resulting drift as the enemy: the
* same stack, checked by a different rule subset depending on which command
* the author happened to run.
*
* The suite makes the next rule's wiring a ONE-LINE edit here, and makes the
* question "which rules run on this path?" answerable by reading one list.
*
* ## What belongs in it
*
* A rule belongs here when it resolves a NAME written in metadata against the
* things a stack actually declares — objects, actions, fields, measures,
* permissions, translation keys. That is the family the HotCRM audit found
* shipping broken: every instance parsed, validated, and failed silently at
* runtime because nothing checked that the name pointed at anything.
*
* `validateFlowTemplatePaths` is a member for exactly that reason: a
* `{record.<field>}` token is a field name written in metadata, resolved
* against the bound object's declared fields. It was wired by hand into
* `os validate` alone — the drift this suite exists to end — so `os lint` and
* `os compile` accepted a flow the runtime refuses. Its findings carry BOTH
* severities (see that module: a filter-position miss gates, every other
* position advises), which is why the suite's contract is severity-agnostic.
*
* `validateSearchableFields` is a member on the same reading, one layer in: an
* ADR-0061 `searchableFields` entry is a field name written in metadata,
* resolved against the object's own declared fields. It gates (`error`) because
* the engine's tolerance for a stale entry — silently filtering it out — either
* narrows the searched set below what the object declares or, once every entry
* is stale, falls through to the auto-default and searches a set the author
* never wrote. See that module for why the other field-existence rules stay
* advisory and this one does not.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
*
* ## Known remaining asymmetry
*
* `os doctor` runs only `validateWidgetBindings` and is NOT converted here: it
* is an environment health check (node version, config presence, circular
* lookups), not an authoring gate, so adopting the suite there is a product
* decision about what `doctor` is for — not a wiring cleanup. It is named here
* so the gap stays visible instead of being rediscovered.
*/
import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
import { validateNavAccess } from './validate-nav-access.js';
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
import { validateTranslationReferences } from './validate-translation-references.js';
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
import { validateAiToolReferences } from './validate-ai-tool-references.js';
import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
import { validateActionBodyWrites } from './validate-action-body-writes.js';
import { validateFlowNodeWrites } from './validate-flow-node-writes.js';
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
import { validateReactPageProps } from './validate-react-page-props.js';
export type ReferenceIntegritySeverity = 'error' | 'warning';
/**
* The shape every rule in the suite already returns. Declared here so callers
* can hold one type instead of a six-way union.
*/
export interface ReferenceIntegrityFinding {
/** `error` = the reference is dead; `warning` = it may resolve elsewhere, or the miss is inert. */
severity: ReferenceIntegritySeverity;
/** Diagnostic rule id (stable; used by allowlists and docs). */
rule: string;
/** Human-readable location. */
where: string;
/** Config path. */
path: string;
/** What is wrong. */
message: string;
/** How to fix it. */
hint: string;
}
/** One member of the suite. `name` is the exported function's name — the id a wiring test can assert on. */
export interface ReferenceIntegrityRule {
name: string;
run: (stack: Record<string, unknown>) => ReferenceIntegrityFinding[];
}
/**
* Every reference-integrity rule, in the order their findings are reported.
*
* ADDING A RULE: append it here and it runs on `validate`, `lint` and
* `compile` at once. Do not re-wire the commands.
*/
export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateObjectReferences', run: validateObjectReferences },
{ name: 'validateSearchableFields', run: validateSearchableFields },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
{ name: 'validateNavAccess', run: validateNavAccess },
// Nav targets that are NOT object names — page/report/dashboard. Restores the
// coverage `defineStack`'s own cross-reference block switches off whenever the
// stack declares none of that collection (`pageNames.size > 0 && …`), which is
// exactly the state a stack is in when the target was never written.
// `action` is deliberately absent (validateActionNameRefs owns it) and so is
// `component` (an unregistered ref renders a named diagnostic, not silence).
{ name: 'validateNavTargetRefs', run: validateNavTargetRefs },
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
{ name: 'validateAiToolReferences', run: validateAiToolReferences },
{ name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring },
// Field names WRITTEN by an L2 hook body (`ctx.input.x = …`,
// `ctx.api.object('y').update({ x })`), resolved against the target object's
// declared fields — the write-side counterpart of validateFlowTemplatePaths'
// read-side membership (#4271). Lazy: only a hook that actually carries a
// `language:'js'` body loads the TypeScript parser.
{ name: 'validateHookBodyWrites', run: validateHookBodyWrites },
// The same check on the other surface that carries a `HookBodySchema` body:
// action bodies, run by the same sandbox. Only the `ctx.api` write family
// carries over — an action's `ctx.input` is its params bag, not a record
// (see that module's ledger). Lazy on the same terms.
//
// The first member here to emit more than one rule id (`validateReactPageProps`
// below is the other, and carries the most). Besides resolving `ctx.api`
// writes against declared fields (`action-body-write-unknown-field`), it
// reports a `ctx.record` write that can reach nothing
// (`action-record-write-discarded`, #4345) — not a resolution question, so
// by the charter above it does not belong in the suite. It rides along
// anyway because it falls out of the SAME parse of the SAME source: a
// separate member would parse every action body twice to say two things
// about one walk, and hand-wiring it into the CLI instead is exactly the
// drift this suite exists to end — which `validateReadonlyFlowWrites` was
// the standing proof of, until it joined the suite below.
{ name: 'validateActionBodyWrites', run: validateActionBodyWrites },
// The third surface that writes a record field set: a flow `update_record`
// node's `config.fields`. Same question as the two rules above, but the map
// is structural metadata rather than parsed JS, so a finding is a certainty
// and gates (`error`) — see that module for why, and why the docs' long-
// standing "prefer a flow node, it's checked" advice was the least true of
// the three until it landed.
{ name: 'validateFlowNodeWrites', run: validateFlowNodeWrites },
// The OTHER question about that same `config.fields` map: not "does this
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
// writing a static-`readonly` field is stripped by the engine and the step
// still reports success (#2948/#3425). It walks the identical map the rule
// above walks, so the two splitting call sites was never defensible: hand-
// wired into `validate` and `compile` only, it left `os lint` PASSING a flow
// `os validate` refuses — and this one gates, so the divergence shipped a
// build the other command would have stopped. Joining the suite is the whole
// fix; the two hand-wired call sites are deleted with it (#4345 follow-up).
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
// `<ObjectForm fields>`, `<Block type="element:…">` through the SAME
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
// `searchableFields` (#4329). Squarely the charter's question, on the surface
// where it had no answer at all.
//
// It also carries `react-block-needs-record-context` (#4413) — a BINDING
// question rather than a resolution one: the `record:*` family reads its
// record from a record page's context, so on THIS surface the binding does
// not exist at all and the props the contract published for it were read by
// no renderer. This rule used to resolve those props' field names against
// the object they named — lint standing guard over a binding that never ran.
// It rejects the blocks now, out of the same parse.
//
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
// accepted a react page whose every field binding was stale — including the
// gating ones (a missing required binding, a filter position naming no field:
// the predicate can never match and the list comes back empty). That is
// `validateReadonlyFlowWrites`' divergence again, one surface over, and it is
// the reason this entry exists rather than a fourth hand-wiring.
//
// Like `validateActionBodyWrites` above, it emits ids that are not resolution
// questions — `react-prop-missing-required` and `react-prop-typo` are shape,
// and by the charter belong outside. They ride along for the same reason: they
// fall out of the SAME TypeScript parse of the SAME page source, and splitting
// them into a second member would parse every react page twice to say two
// things about one walk. Lazy on the same terms as the hook/action body rules
// — only a page that is actually `kind:'react'` loads the compiler.
{ name: 'validateReactPageProps', run: validateReactPageProps },
];
/**
* Run every reference-integrity rule over a stack. Returns the concatenated
* findings (empty = clean). Pure: no I/O, safe on both the schema-parsed stack
* and the raw/normalized config the `lint` path carries.
*/
export function validateReferenceIntegrity(stack: Record<string, unknown>): ReferenceIntegrityFinding[] {
const findings: ReferenceIntegrityFinding[] = [];
for (const rule of REFERENCE_INTEGRITY_RULES) {
findings.push(...rule.run(stack));
}
return findings;
}