Skip to content

Commit 7fec5d6

Browse files
os-zhuangclaude
andauthored
fix(lint,cli): os lint 不再放行另外两个命令拒绝的 flow (#4345 后续) (#4394)
`validateReadonlyFlowWrites` 手工接进了 `os validate` 和 `os compile`,唯独 漏了 `os lint`。在 showcase 上植入一处违规(`runAs:'user'` 的 update_record 写静态 readonly 字段)实测: os lint os validate 修复前 exit 0 放行 exit 1 拒绝 修复后 exit 1 拒绝 exit 1 拒绝 这条规则是 **gate** 级(静态 readonly + 字面字段是确定的 no-op:引擎从 UPDATE payload 里剥掉它,步骤照样报成功,#2948/#3425),所以漏接不是少一条 警告 —— 而是 `os lint` 放行了一个 `os validate` 会拦下的构建。 现接入 `REFERENCE_INTEGRITY_RULES`,两处手工接线随之删除,三个命令的答案 由构造保证一致,而不是靠三个人记得。这正是 suite 设立要终结的漂移 (#3583 §5 D5),而 suite 自己的头注释一直把这条规则当作该漂移的现行证据。 两件事让这个接线从"不整洁"变成"站不住": - `validateFlowNodeWrites`(#4369)走的是**同一张** `config.fields` map,问的 是同一问题的另一半("字段存在吗" vs "字段可写吗"),而它已经在 suite 里。 一张 map、两个检查、两套命令集合。 - 两处手工接线连喂进去的输入都不一致:`validate` 喂 pre-parse 的 `normalized`,`compile` 喂 post-parse 的 `result.data`。合并到 suite 的 post-parse 输入前已实测两者对本规则等价,不丢 finding。 规则行为零变化:id、严重级、文案都不动。 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fce14ab commit 7fec5d6

5 files changed

Lines changed: 77 additions & 76 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/lint": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(lint,cli): `os lint` no longer passes a flow the other two commands refuse
7+
8+
`validateReadonlyFlowWrites` was hand-wired into `os validate` and `os compile`
9+
and never into `os lint`. Measured on the showcase app with one planted
10+
violation — a `runAs:'user'` `update_record` writing a static-`readonly` field:
11+
12+
| | `os lint` | `os validate` |
13+
|---|---|---|
14+
| before | **exit 0 — passed** | exit 1 — refused |
15+
| after | exit 1 — refused | exit 1 — refused |
16+
17+
That rule **gates** (a static `readonly` + literal field is a certain no-op:
18+
the engine strips it from the UPDATE payload while the step still reports
19+
success, #2948/#3425), so the divergence was not a missing warning — `os lint`
20+
green-lit a build `os validate` stops.
21+
22+
It now joins `REFERENCE_INTEGRITY_RULES`, and both hand-wired call sites are
23+
deleted with it, so the three commands share one answer by construction rather
24+
than by three people remembering. This is the drift the suite was created to end
25+
(#3583 §5 D5) and which its own header cited this rule as the standing proof of.
26+
27+
Two things made the wiring indefensible rather than merely untidy:
28+
29+
- `validateFlowNodeWrites` (#4369) walks the **same** `config.fields` map to ask
30+
the other half of the question — "does this field exist?" against "is it
31+
writable?" — and is already a suite member. One map, two checks, two different
32+
command sets.
33+
- The two hand-wired sites did not even agree with each other on their input:
34+
`validate` passed the PRE-parse `normalized` stack, `compile` the POST-parse
35+
`result.data`. Verified equivalent for this rule before collapsing them onto
36+
the suite's post-parse input, so no finding is lost.
37+
38+
No rule behaviour changes: same ids, same severities, same messages.

packages/cli/src/commands/compile.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { validateFilterTokens } from '@objectstack/lint';
2323
import { validateReferenceIntegrity } from '@objectstack/lint';
2424
import { validateResponsiveStyles } from '@objectstack/lint';
2525
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
26-
import { validateReadonlyFlowWrites } from '@objectstack/lint';
2726
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
2827
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
2928
import { lintUniqueDeclarations } from '../lint/data-model-rules.js';
@@ -618,39 +617,6 @@ export default class Compile extends Command {
618617
}
619618
}
620619

621-
// 3e2. [#3425] Readonly flow-write guardrail. A `runAs:user` update_record
622-
// writing a static-`readonly` field is a silent no-op — the engine
623-
// strips it from the UPDATE payload (#2948) while the step reports
624-
// success. This GATES the build (shift-left of the #3407/#3413
625-
// run-time strip warning); `readonlyWhen` writes are per-record-state,
626-
// so they are advisory, printed dimmed and never fatal.
627-
if (!flags.json) printStep('Checking readonly flow writes (#3425)...');
628-
const readonlyWriteFindings = validateReadonlyFlowWrites(result.data as Record<string, unknown>);
629-
const readonlyWriteErrors = readonlyWriteFindings.filter((f) => f.severity === 'error');
630-
const readonlyWriteAdvisories = readonlyWriteFindings.filter((f) => f.severity !== 'error');
631-
if (readonlyWriteErrors.length > 0) {
632-
if (flags.json) {
633-
await emitJson({ success: false, error: 'readonly flow-write validation failed', issues: readonlyWriteErrors }, 0, { compact: true });
634-
this.exit(1);
635-
}
636-
console.log('');
637-
printError(`Readonly flow-write check failed (${readonlyWriteErrors.length} issue${readonlyWriteErrors.length > 1 ? 's' : ''})`);
638-
for (const f of readonlyWriteErrors.slice(0, 50)) {
639-
console.log(` • ${f.where}: ${f.message}`);
640-
console.log(chalk.dim(` ${f.hint}`));
641-
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
642-
}
643-
this.exit(1);
644-
}
645-
if (readonlyWriteAdvisories.length > 0 && !flags.json) {
646-
console.log('');
647-
for (const f of readonlyWriteAdvisories) {
648-
printWarning(`${f.where}: ${f.message}`);
649-
console.log(chalk.dim(` ${f.hint}`));
650-
console.log(chalk.dim(` rule: ${f.rule}`));
651-
}
652-
}
653-
654620
// 3f. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when
655621
// `access-matrix.json` sits next to the config, the (permission set
656622
// × object) capability matrix derived from THIS build must match it

packages/cli/src/commands/validate.ts

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { validateCapabilityReferences } from '@objectstack/lint';
2828
import { validateVisibilityPredicates } from '@objectstack/lint';
2929
import { validateSecurityPosture, validateOrgAxisRedLines } from '@objectstack/lint';
3030
import { validateFlowTriggerReadiness } from '@objectstack/lint';
31-
import { validateReadonlyFlowWrites } from '@objectstack/lint';
3231
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
3332
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
3433
import { lintUniqueDeclarations } from '../lint/data-model-rules.js';
@@ -540,41 +539,6 @@ export default class Validate extends Command {
540539
// flow whose filter token the runtime refuses (#3810) for as long as
541540
// this call site was the only one.
542541

543-
// 3e2. [#3425] Readonly flow-write guardrail. A `runAs:user` update_record
544-
// that writes a static-`readonly` field is a SILENT no-op — the engine
545-
// strips it from the UPDATE payload (#2948) yet the step reports
546-
// success. This is the shift-left of the run-time strip warning
547-
// (#3407/#3413): a static readonly + literal field is a certain no-op
548-
// → error (gates); a `readonlyWhen` field is per-record-state → advisory.
549-
if (!flags.json) printStep('Checking readonly flow writes (#3425)...');
550-
const readonlyWriteFindings = validateReadonlyFlowWrites(normalized as Record<string, unknown>);
551-
const readonlyWriteErrors = readonlyWriteFindings.filter((f) => f.severity === 'error');
552-
const readonlyWriteWarnings = readonlyWriteFindings.filter((f) => f.severity === 'warning');
553-
if (readonlyWriteErrors.length > 0) {
554-
if (flags.json) {
555-
await emitJson({
556-
valid: false,
557-
errors: readonlyWriteErrors,
558-
duration: timer.elapsed(),
559-
});
560-
this.exit(1);
561-
}
562-
console.log('');
563-
printError(`Readonly flow-write check failed (${readonlyWriteErrors.length} issue${readonlyWriteErrors.length > 1 ? 's' : ''})`);
564-
for (const f of readonlyWriteErrors.slice(0, 50)) {
565-
console.log(` • ${f.where}: ${f.message}`);
566-
console.log(chalk.dim(` ${f.hint}`));
567-
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
568-
}
569-
this.exit(1);
570-
}
571-
if (!flags.json) {
572-
for (const f of readonlyWriteWarnings.slice(0, 50)) {
573-
console.log(chalk.yellow(` ⚠ ${f.where}: ${f.message}`));
574-
console.log(chalk.dim(` ${f.hint}`));
575-
}
576-
}
577-
578542
// 3e3. [#3782] The four authoring lints that live in the CLI itself rather
579543
// than in `@objectstack/lint`. Every other gate on this command is a
580544
// `@objectstack/lint` import, so these four were only ever reachable
@@ -749,7 +713,7 @@ export default class Validate extends Command {
749713
// the suite's warnings, though the failure path (above) and the console
750714
// both did. Same shape of bug as the dropped errors — computed, then
751715
// discarded — so it is fixed rather than reproduced under a new name.
752-
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...readonlyWriteWarnings, ...authoringLintWarnings, ...unknownKeyWarnings, ...securityAdvisories, ...capProviderWarnings],
716+
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...authoringLintWarnings, ...unknownKeyWarnings, ...securityAdvisories, ...capProviderWarnings],
753717
conversions: conversionNotices,
754718
specVersionGap: specGap,
755719
duration: timer.elapsed(),

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('reference-integrity suite — membership', () => {
2929
'validateHookBodyWrites',
3030
'validateActionBodyWrites',
3131
'validateFlowNodeWrites',
32+
'validateReadonlyFlowWrites',
3233
]);
3334
});
3435

@@ -51,7 +52,10 @@ describe('reference-integrity suite — every member actually runs', () => {
5152
objects: [
5253
{
5354
name: 'crm_lead',
54-
fields: { name: { type: 'text', label: 'Name' } },
55+
fields: {
56+
name: { type: 'text', label: 'Name' },
57+
locked: { type: 'boolean', label: 'Locked', readonly: true },
58+
},
5559
// validateSearchableFields: `budget` is not a field on crm_lead, so the
5660
// ADR-0061 declaration is stale — the engine drops it and searches a
5761
// narrower set than the object declares.
@@ -157,6 +161,12 @@ describe('reference-integrity suite — every member actually runs', () => {
157161
{
158162
name: 'lead_followup',
159163
type: 'record_change',
164+
// validateReadonlyFlowWrites: a runAs:'user' update_record writing a
165+
// static-`readonly` field. The engine strips it and the step still
166+
// reports success (#2948/#3425) — a certainty, so it GATES. Walks the
167+
// same `config.fields` map validateFlowNodeWrites does, which is why
168+
// the two must not diverge across commands again.
169+
runAs: 'user',
160170
nodes: [
161171
{ id: 'start', type: 'start', config: { objectName: 'crm_lead', triggerType: 'record-created' } },
162172
// validateFlowTemplatePaths: `budget` is not a field on crm_lead. In a
@@ -178,6 +188,17 @@ describe('reference-integrity suite — every member actually runs', () => {
178188
type: 'update_record',
179189
config: { objectName: 'crm_lead', fields: { lead_score: 100 } },
180190
},
191+
// validateReadonlyFlowWrites: the OTHER question about that same
192+
// `config.fields` map — `locked` exists but is static-`readonly`, so
193+
// under this flow's `runAs:'user'` the engine strips it and the step
194+
// still reports success (#2948/#3425). A separate node from `stamp`
195+
// on purpose: one node carrying both defects would let either rule
196+
// go silent behind the other's finding.
197+
{
198+
id: 'lock',
199+
type: 'update_record',
200+
config: { objectName: 'crm_lead', fields: { locked: true } },
201+
},
181202
],
182203
},
183204
],
@@ -204,6 +225,7 @@ describe('reference-integrity suite — every member actually runs', () => {
204225
// why it rides along instead of becoming its own entry.
205226
expect(rules).toContain('action-record-write-discarded');
206227
expect(rules).toContain('flow-node-write-unknown-field');
228+
expect(rules).toContain('flow-update-readonly-field');
207229
});
208230

209231
it('carries a gating flow-template finding through the suite (#3810)', () => {
@@ -225,9 +247,9 @@ describe('reference-integrity suite — every member actually runs', () => {
225247
expect(typeof f.message).toBe('string');
226248
expect(typeof f.hint).toBe('string');
227249
}
228-
// Object references run first, flow-node writes last.
250+
// Object references run first, readonly flow writes last.
229251
expect(findings[0].rule).toBe('object-reference-unknown');
230-
expect(findings[findings.length - 1].rule).toBe('flow-node-write-unknown-field');
252+
expect(findings[findings.length - 1].rule).toBe('flow-update-readonly-field');
231253
});
232254

233255
it('returns nothing for an empty stack', () => {

packages/lint/src/reference-integrity-suite.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
6868
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
6969
import { validateActionBodyWrites } from './validate-action-body-writes.js';
7070
import { validateFlowNodeWrites } from './validate-flow-node-writes.js';
71+
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
7172

7273
export type ReferenceIntegritySeverity = 'error' | 'warning';
7374

@@ -133,8 +134,8 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
133134
// anyway because it falls out of the SAME parse of the SAME source: a
134135
// separate member would parse every action body twice to say two things
135136
// about one walk, and hand-wiring it into the CLI instead is exactly the
136-
// drift this suite exists to end (`validateReadonlyFlowWrites` is the
137-
// standing proof — wired into `validate` and `compile`, never into `lint`).
137+
// drift this suite exists to end — which `validateReadonlyFlowWrites` was
138+
// the standing proof of, until it joined the suite below.
138139
{ name: 'validateActionBodyWrites', run: validateActionBodyWrites },
139140
// The third surface that writes a record field set: a flow `update_record`
140141
// node's `config.fields`. Same question as the two rules above, but the map
@@ -143,6 +144,16 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
143144
// standing "prefer a flow node, it's checked" advice was the least true of
144145
// the three until it landed.
145146
{ name: 'validateFlowNodeWrites', run: validateFlowNodeWrites },
147+
// The OTHER question about that same `config.fields` map: not "does this
148+
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
149+
// writing a static-`readonly` field is stripped by the engine and the step
150+
// still reports success (#2948/#3425). It walks the identical map the rule
151+
// above walks, so the two splitting call sites was never defensible: hand-
152+
// wired into `validate` and `compile` only, it left `os lint` PASSING a flow
153+
// `os validate` refuses — and this one gates, so the divergence shipped a
154+
// build the other command would have stopped. Joining the suite is the whole
155+
// fix; the two hand-wired call sites are deleted with it (#4345 follow-up).
156+
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
146157
];
147158

148159
/**

0 commit comments

Comments
 (0)