Skip to content

Commit 8db4587

Browse files
os-zhuangclaude
andauthored
fix(lint,cli): os lint / os compile 不再放行一个 os validate 会拒绝的 react 页面 (#4340 后续) (#4417)
* fix(lint,cli): os lint / os compile 不再放行一个 os validate 会拒绝的 react 页面 (#4340 后续) `validateReactPageProps` 只手工接在 `os validate` 上,`os lint` 和 `os compile` 从来没跑过它。在 showcase 的 react 页面植入一处 gating 违规 (`<ListView filters={['no_such_col','=',stage]}>` —— 谓词命中不了任何行, SqlDriver 吞掉 "no such column" 后返回 [],列表回空且与「本来就没数据」无法区分) 实测: os lint os compile os validate 修复前 exit 0 放行 exit 0 放行 exit 1 拒绝 修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝 #4340 之后这条规则已经是整个 react 页面表面唯一的字段解析闸门:<ListView> 的 columns/fields/sort/grouping/userFilters、<ObjectForm> 的 fields/initialValues/sections/subforms、record:* 一族(与元数据表面共用同一张 COMPONENT_FIELD_SPECS)、<ObjectChart> 的 aggregate/axes,以及 searchableFields。 所以漏接不是少几条警告 —— 而是这些绑定在 build 路径上完全没人看,包括其中会 gate 的那些。 现接入 REFERENCE_INTEGRITY_RULES,`os validate` 那处手工接线随之删除,三个命令的 答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是 validateReadonlyFlowWrites 在 #4394 里刚走过的同一条路:那次是「一张 map、 两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、一套命令集合」。 按 charter,react-prop-missing-required / react-prop-typo 是 shape 而非解析问题, 本不属于 suite。它们随行的理由与 validateActionBodyWrites 完全相同(其注释已写明): 落在同一次 parse 的同一份 source 上,拆成第二个 member 就要把每个 react 页面 parse 两遍,而改回手工接线正是 suite 要终结的那种漂移。同时更正了那条注释里 "The ONE member here that emits two rule ids" 的说法 —— 现在不止一个。 规则行为零变化:id、严重级、文案都不动;输入也不变(`os validate` 原本就传 result.data,suite 拿到的是同一个)。#4402 的接线守卫会在下次有人想再手工接一条 规则时直接报错——本次删除 validate.ts 的直接 import 也正是被它逼出来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rK2McPogrTJBKdUsThrGs * docs: validating-metadata 反映 react 页面字段绑定现在三个命令都跑 本 PR 把 react 页面的字段绑定检查从「只在 os validate」变成「suite 成员,三个命令 都跑」,这份文档有三处随之失真 —— 而 docs-drift 的包级扇出恰好没点到它(它按 @objectstack/cli / @objectstack/lint 列了 20 份,漏了语义上唯一真正耦合的这份)。 - "one gate, two entry points" 表补一行 React page block field bindings(两列都 ✓)。 - 表下那句把 "JSX/React page sources" 整体算作 os validate 独有 —— 现在只有 「源码能否 parse」这个 SHAPE 闸门是独有的,里面的字段绑定属于 reference-integrity, suite 跑到哪它就跑到哪(含 os lint)。改写为分开陈述,并记下这里曾经的漂移与 #4384 的接线守卫。 - 示例 os validate 输出里的 `→ Checking React-source page props (ADR-0081)...` 随手工接线一并删除,已按真实 CLI 输出核对:示例里的每一行 step 现在都能在 showcase 上逐字复现(comm 比对为空)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rK2McPogrTJBKdUsThrGs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4384921 commit 8db4587

5 files changed

Lines changed: 110 additions & 42 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/lint": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(lint,cli): `os lint` / `os compile` 不再放行一个 `os validate` 会拒绝的 react 页面
7+
8+
`validateReactPageProps` 只手工接在 `os validate` 上,另外两个命令从来没跑过它。
9+
在 showcase 的 react 页面上植入一处 gating 违规(`<ListView filters={['no_such_col','=',stage]}>`
10+
—— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测:
11+
12+
```
13+
os lint os compile os validate
14+
修复前 exit 0 放行 exit 0 放行 exit 1 拒绝
15+
修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝
16+
```
17+
18+
这条规则在 #4340 之后已经是**整个 react 页面表面唯一**的字段解析闸门:
19+
`<ListView>` 的 columns/fields/sort/grouping/userFilters、`<ObjectForm>`
20+
fields/initialValues/sections/subforms、`record:*` 一族(与元数据表面共用同一张
21+
`COMPONENT_FIELD_SPECS`)、`<ObjectChart>` 的 aggregate/axes、以及 `searchableFields`
22+
漏接不是少几条警告 —— 而是这些绑定在 build 路径上**完全没人看**,包括其中会 gate 的那些。
23+
24+
现接入 `REFERENCE_INTEGRITY_RULES`,`os validate` 里那处手工接线随之删除,三个命令的
25+
答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是
26+
`validateReadonlyFlowWrites`#4394 里刚走过的同一条路 —— 那次的教训是
27+
「一张 map、两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、
28+
一套命令集合」。
29+
30+
规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就
31+
`result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工
32+
接一条规则时直接报错。
33+
34+
`validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites`
35+
的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。

content/docs/deployment/validating-metadata.mdx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ another package defines.
330330
| Dashboard action/route references (ADR-0049) |||
331331
| Object & action name references (#3583) |||
332332
| Page-component field bindings (#3583) |||
333+
| React page block field bindings — §10 (#4340) |||
333334
| Chart bindings outside dashboards (#3583) |||
334335
| Navigation vs. granted access (ADR-0090 D6) |||
335336
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) |||
@@ -346,9 +347,20 @@ will not fail `os build` on schema/predicate/binding grounds — a test in the C
346347
asserts that every gate `os build` runs is also run by `os validate`, so the two
347348
cannot drift apart again (#3782). Both entry points
348349
also check SDUI styling (ADR-0065), and `os validate` additionally runs a set of
349-
view- and page-shape checks — list-view navigation modes (ADR-0053), view
350-
container shape, and JSX/React page sources (ADR-0080/0081) — that catch UI
351-
metadata which would otherwise be silently dropped.
350+
view- and page-SHAPE checks — list-view navigation modes (ADR-0053), view
351+
container shape, and whether a JSX/React page source parses at all
352+
(ADR-0080/0081) — that catch UI metadata which would otherwise be silently
353+
dropped.
354+
355+
The field bindings INSIDE a react page source are a different matter: they are
356+
reference-integrity, so they run wherever the suite runs. `os lint` gets them
357+
too — it shares the same `REFERENCE_INTEGRITY_RULES` list, which is why the
358+
table's reference rows are the ones a cheap pre-flight can rely on. That was not
359+
always true: the react-page prop gate was hand-wired into `os validate` alone
360+
until #4340's follow-up, so `os lint` and `os build` accepted a page whose every
361+
field binding was stale — the same divergence #4394 closed for readonly flow
362+
writes. A CLI test now asserts no command reaches for a suite member directly
363+
(#4384).
352364

353365
A clean run walks each gate and reports timing:
354366

@@ -367,7 +379,6 @@ A clean run walks each gate and reports timing:
367379
→ Checking SDUI styling (ADR-0065)...
368380
→ Checking JSX-source pages (ADR-0080)...
369381
→ Checking React-source pages (ADR-0081)...
370-
→ Checking React-source page props (ADR-0081)...
371382
→ Checking source-page styling (ADR-0065)...
372383
→ Checking capability references (ADR-0066)...
373384
→ Checking flow trigger wiring...

packages/cli/src/commands/validate.ts

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { validateDashboardActionRefs } from '@objectstack/lint';
2323
import { validateFilterTokens } from '@objectstack/lint';
2424
import { validateReferenceIntegrity } from '@objectstack/lint';
2525
import { validateResponsiveStyles } from '@objectstack/lint';
26-
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
26+
import { validateJsxPages, validateReactPages, validatePageSourceStyling } from '@objectstack/lint';
2727
import { validateCapabilityReferences } from '@objectstack/lint';
2828
import { validateVisibilityPredicates } from '@objectstack/lint';
2929
import { validateSecurityPosture, validateOrgAxisRedLines } from '@objectstack/lint';
@@ -454,38 +454,13 @@ export default class Validate extends Command {
454454
this.exit(1);
455455
}
456456

457-
// 3d. React-source pages — prop usage against the component contract
458-
// (ADR-0081 Phase 2): missing required bindings (error) + likely
459-
// prop typos (warning), parsed from the real JSX.
460-
if (!flags.json) printStep('Checking React-source page props (ADR-0081)...');
461-
const reactPropFindings = validateReactPageProps(result.data as Record<string, unknown>);
462-
const reactPropErrors = reactPropFindings.filter((f) => f.severity === 'error');
463-
const reactPropWarnings = reactPropFindings.filter((f) => f.severity === 'warning');
464-
if (!flags.json) {
465-
for (const w of reactPropWarnings.slice(0, 50)) {
466-
console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`));
467-
console.log(chalk.dim(` ${w.hint}`));
468-
}
469-
}
470-
if (reactPropErrors.length > 0) {
471-
if (flags.json) {
472-
await emitJson({
473-
valid: false,
474-
errors: reactPropErrors,
475-
warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...reactPropWarnings],
476-
duration: timer.elapsed(),
477-
});
478-
this.exit(1);
479-
}
480-
console.log('');
481-
printError(`React-source page prop check failed (${reactPropErrors.length} issue${reactPropErrors.length > 1 ? 's' : ''})`);
482-
for (const f of reactPropErrors.slice(0, 50)) {
483-
console.log(` \u2022 ${f.where}: ${f.message}`);
484-
console.log(chalk.dim(` ${f.hint}`));
485-
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
486-
}
487-
this.exit(1);
488-
}
457+
// 3d. React-source page PROPS are checked by `REFERENCE_INTEGRITY_RULES`
458+
// (step 3a above), not from here (#4340 follow-up). They ran from
459+
// this call site ALONE, so `os lint` and `os compile` accepted a
460+
// react page whose every field binding was stale — including the
461+
// gating ones. That is `validateReadonlyFlowWrites`' divergence
462+
// (#4394) one surface over. The input is unchanged: the suite is
463+
// handed the same `result.data` this block passed.
489464

490465
// 3e. Source-tier page styling (ADR-0065): Tailwind className in a
491466
// kind:'html'/'react' page source silently no-ops (the build never

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('reference-integrity suite — membership', () => {
3030
'validateActionBodyWrites',
3131
'validateFlowNodeWrites',
3232
'validateReadonlyFlowWrites',
33+
'validateReactPageProps',
3334
]);
3435
});
3536

@@ -107,6 +108,17 @@ describe('reference-integrity suite — every member actually runs', () => {
107108
},
108109
],
109110
},
111+
// validateReactPageProps: the same component family one surface over,
112+
// authored as JSX. `<ObjectForm>` with no `objectName` binds nothing, so
113+
// this GATES — and it gated on `os validate` alone until this member
114+
// joined the suite. A separate page from `lead_detail` on purpose: the
115+
// metadata walk above must stay the only source of `page-field-unknown`
116+
// here, or this member could go silent behind it.
117+
{
118+
name: 'lead_console',
119+
kind: 'react',
120+
source: 'function Page(){ return <ObjectForm mode="edit" />; }',
121+
},
110122
],
111123
datasets: [
112124
{
@@ -221,11 +233,21 @@ describe('reference-integrity suite — every member actually runs', () => {
221233
expect(rules).toContain('agent-authoring-withdrawn');
222234
expect(rules).toContain('hook-body-write-unknown-field');
223235
expect(rules).toContain('action-body-write-unknown-field');
224-
// The one member that emits a second rule id — see the suite's comment on
225-
// why it rides along instead of becoming its own entry.
236+
// Members that emit a rule id beyond their resolution one — see the suite's
237+
// comments on why these ride along instead of becoming their own entries.
226238
expect(rules).toContain('action-record-write-discarded');
227239
expect(rules).toContain('flow-node-write-unknown-field');
228240
expect(rules).toContain('flow-update-readonly-field');
241+
expect(rules).toContain('react-prop-missing-required');
242+
});
243+
244+
it('carries a gating react-page-prop finding through the suite (#4340)', () => {
245+
const findings = validateReferenceIntegrity(stack);
246+
const react = findings.find((f) => f.rule === 'react-prop-missing-required');
247+
// Must reach the CLI as an ERROR: `os lint` and `os compile` saw NOTHING
248+
// from this rule until it joined the suite, so a react page binding nothing
249+
// sailed through the build the way a readonly flow write did (#4394).
250+
expect(react?.severity).toBe('error');
229251
});
230252

231253
it('carries a gating flow-template finding through the suite (#3810)', () => {
@@ -247,9 +269,9 @@ describe('reference-integrity suite — every member actually runs', () => {
247269
expect(typeof f.message).toBe('string');
248270
expect(typeof f.hint).toBe('string');
249271
}
250-
// Object references run first, readonly flow writes last.
272+
// Object references run first, react page props last.
251273
expect(findings[0].rule).toBe('object-reference-unknown');
252-
expect(findings[findings.length - 1].rule).toBe('flow-update-readonly-field');
274+
expect(findings[findings.length - 1].rule).toBe('react-prop-missing-required');
253275
});
254276

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

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ 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';
7171
import { validateReadonlyFlowWrites } from './validate-readonly-flow-writes.js';
72+
import { validateReactPageProps } from './validate-react-page-props.js';
7273

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

@@ -126,7 +127,8 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
126127
// carries over — an action's `ctx.input` is its params bag, not a record
127128
// (see that module's ledger). Lazy on the same terms.
128129
//
129-
// The ONE member here that emits two rule ids. Besides resolving `ctx.api`
130+
// The first member here to emit more than one rule id (`validateReactPageProps`
131+
// below is the other, and carries the most). Besides resolving `ctx.api`
130132
// writes against declared fields (`action-body-write-unknown-field`), it
131133
// reports a `ctx.record` write that can reach nothing
132134
// (`action-record-write-discarded`, #4345) — not a resolution question, so
@@ -154,6 +156,29 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
154156
// build the other command would have stopped. Joining the suite is the whole
155157
// fix; the two hand-wired call sites are deleted with it (#4345 follow-up).
156158
{ name: 'validateReadonlyFlowWrites', run: validateReadonlyFlowWrites },
159+
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
160+
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
161+
// `<ObjectForm fields>`, the `record:*` family through the SAME
162+
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
163+
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
164+
// `searchableFields` (#4329). Squarely the charter's question, on the surface
165+
// where it had no answer at all.
166+
//
167+
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
168+
// accepted a react page whose every field binding was stale — including the
169+
// gating ones (a missing required binding, a filter position naming no field:
170+
// the predicate can never match and the list comes back empty). That is
171+
// `validateReadonlyFlowWrites`' divergence again, one surface over, and it is
172+
// the reason this entry exists rather than a fourth hand-wiring.
173+
//
174+
// Like `validateActionBodyWrites` above, it emits ids that are not resolution
175+
// questions — `react-prop-missing-required` and `react-prop-typo` are shape,
176+
// and by the charter belong outside. They ride along for the same reason: they
177+
// fall out of the SAME TypeScript parse of the SAME page source, and splitting
178+
// them into a second member would parse every react page twice to say two
179+
// things about one walk. Lazy on the same terms as the hook/action body rules
180+
// — only a page that is actually `kind:'react'` loads the compiler.
181+
{ name: 'validateReactPageProps', run: validateReactPageProps },
157182
];
158183

159184
/**

0 commit comments

Comments
 (0)