diff --git a/.changeset/react-page-props-joins-the-suite.md b/.changeset/react-page-props-joins-the-suite.md new file mode 100644 index 0000000000..adda0f9f64 --- /dev/null +++ b/.changeset/react-page-props-joins-the-suite.md @@ -0,0 +1,35 @@ +--- +"@objectstack/lint": patch +"@objectstack/cli": patch +--- + +fix(lint,cli): `os lint` / `os compile` 不再放行一个 `os validate` 会拒绝的 react 页面 + +`validateReactPageProps` 只手工接在 `os validate` 上,另外两个命令从来没跑过它。 +在 showcase 的 react 页面上植入一处 gating 违规(`` +—— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测: + +``` + os lint os compile os validate + 修复前 exit 0 放行 exit 0 放行 exit 1 拒绝 + 修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝 +``` + +这条规则在 #4340 之后已经是**整个 react 页面表面唯一**的字段解析闸门: +`` 的 columns/fields/sort/grouping/userFilters、`` 的 +fields/initialValues/sections/subforms、`record:*` 一族(与元数据表面共用同一张 +`COMPONENT_FIELD_SPECS`)、`` 的 aggregate/axes、以及 `searchableFields`。 +漏接不是少几条警告 —— 而是这些绑定在 build 路径上**完全没人看**,包括其中会 gate 的那些。 + +现接入 `REFERENCE_INTEGRITY_RULES`,`os validate` 里那处手工接线随之删除,三个命令的 +答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是 +`validateReadonlyFlowWrites` 在 #4394 里刚走过的同一条路 —— 那次的教训是 +「一张 map、两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、 +一套命令集合」。 + +规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就 +传 `result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工 +接一条规则时直接报错。 + +`validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites` +的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。 diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 9acce34f0f..5cadf05f09 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -330,6 +330,7 @@ another package defines. | Dashboard action/route references (ADR-0049) | ✓ | ✓ | | Object & action name references (#3583) | ✓ | ✓ | | Page-component field bindings (#3583) | ✓ | ✓ | +| React page block field bindings — §10 (#4340) | ✓ | ✓ | | Chart bindings outside dashboards (#3583) | ✓ | ✓ | | Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ | | 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 asserts that every gate `os build` runs is also run by `os validate`, so the two cannot drift apart again (#3782). Both entry points also check SDUI styling (ADR-0065), and `os validate` additionally runs a set of -view- and page-shape checks — list-view navigation modes (ADR-0053), view -container shape, and JSX/React page sources (ADR-0080/0081) — that catch UI -metadata which would otherwise be silently dropped. +view- and page-SHAPE checks — list-view navigation modes (ADR-0053), view +container shape, and whether a JSX/React page source parses at all +(ADR-0080/0081) — that catch UI metadata which would otherwise be silently +dropped. + +The field bindings INSIDE a react page source are a different matter: they are +reference-integrity, so they run wherever the suite runs. `os lint` gets them +too — it shares the same `REFERENCE_INTEGRITY_RULES` list, which is why the +table's reference rows are the ones a cheap pre-flight can rely on. That was not +always true: the react-page prop gate was hand-wired into `os validate` alone +until #4340's follow-up, so `os lint` and `os build` accepted a page whose every +field binding was stale — the same divergence #4394 closed for readonly flow +writes. A CLI test now asserts no command reaches for a suite member directly +(#4384). A clean run walks each gate and reports timing: @@ -367,7 +379,6 @@ A clean run walks each gate and reports timing: → Checking SDUI styling (ADR-0065)... → Checking JSX-source pages (ADR-0080)... → Checking React-source pages (ADR-0081)... - → Checking React-source page props (ADR-0081)... → Checking source-page styling (ADR-0065)... → Checking capability references (ADR-0066)... → Checking flow trigger wiring... diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index ae5d462beb..1c0d0007cf 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -23,7 +23,7 @@ import { validateDashboardActionRefs } from '@objectstack/lint'; import { validateFilterTokens } from '@objectstack/lint'; import { validateReferenceIntegrity } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; -import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; +import { validateJsxPages, validateReactPages, validatePageSourceStyling } from '@objectstack/lint'; import { validateCapabilityReferences } from '@objectstack/lint'; import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateSecurityPosture, validateOrgAxisRedLines } from '@objectstack/lint'; @@ -454,38 +454,13 @@ export default class Validate extends Command { this.exit(1); } - // 3d. React-source pages — prop usage against the component contract - // (ADR-0081 Phase 2): missing required bindings (error) + likely - // prop typos (warning), parsed from the real JSX. - if (!flags.json) printStep('Checking React-source page props (ADR-0081)...'); - const reactPropFindings = validateReactPageProps(result.data as Record); - const reactPropErrors = reactPropFindings.filter((f) => f.severity === 'error'); - const reactPropWarnings = reactPropFindings.filter((f) => f.severity === 'warning'); - if (!flags.json) { - for (const w of reactPropWarnings.slice(0, 50)) { - console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`)); - console.log(chalk.dim(` ${w.hint}`)); - } - } - if (reactPropErrors.length > 0) { - if (flags.json) { - await emitJson({ - valid: false, - errors: reactPropErrors, - warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...reactPropWarnings], - duration: timer.elapsed(), - }); - this.exit(1); - } - console.log(''); - printError(`React-source page prop check failed (${reactPropErrors.length} issue${reactPropErrors.length > 1 ? 's' : ''})`); - for (const f of reactPropErrors.slice(0, 50)) { - console.log(` \u2022 ${f.where}: ${f.message}`); - console.log(chalk.dim(` ${f.hint}`)); - console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); - } - this.exit(1); - } + // 3d. React-source page PROPS are checked by `REFERENCE_INTEGRITY_RULES` + // (step 3a above), not from here (#4340 follow-up). They ran from + // this call site ALONE, so `os lint` and `os compile` accepted a + // react page whose every field binding was stale — including the + // gating ones. That is `validateReadonlyFlowWrites`' divergence + // (#4394) one surface over. The input is unchanged: the suite is + // handed the same `result.data` this block passed. // 3e. Source-tier page styling (ADR-0065): Tailwind className in a // kind:'html'/'react' page source silently no-ops (the build never diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 3072c75959..7ae1b17586 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -30,6 +30,7 @@ describe('reference-integrity suite — membership', () => { 'validateActionBodyWrites', 'validateFlowNodeWrites', 'validateReadonlyFlowWrites', + 'validateReactPageProps', ]); }); @@ -107,6 +108,17 @@ describe('reference-integrity suite — every member actually runs', () => { }, ], }, + // validateReactPageProps: the same component family one surface over, + // authored as JSX. `` with no `objectName` binds nothing, so + // this GATES — and it gated on `os validate` alone until this member + // joined the suite. A separate page from `lead_detail` on purpose: the + // metadata walk above must stay the only source of `page-field-unknown` + // here, or this member could go silent behind it. + { + name: 'lead_console', + kind: 'react', + source: 'function Page(){ return ; }', + }, ], datasets: [ { @@ -221,11 +233,21 @@ describe('reference-integrity suite — every member actually runs', () => { expect(rules).toContain('agent-authoring-withdrawn'); expect(rules).toContain('hook-body-write-unknown-field'); expect(rules).toContain('action-body-write-unknown-field'); - // The one member that emits a second rule id — see the suite's comment on - // why it rides along instead of becoming its own entry. + // Members that emit a rule id beyond their resolution one — see the suite's + // comments on why these ride along instead of becoming their own entries. expect(rules).toContain('action-record-write-discarded'); expect(rules).toContain('flow-node-write-unknown-field'); expect(rules).toContain('flow-update-readonly-field'); + expect(rules).toContain('react-prop-missing-required'); + }); + + it('carries a gating react-page-prop finding through the suite (#4340)', () => { + const findings = validateReferenceIntegrity(stack); + const react = findings.find((f) => f.rule === 'react-prop-missing-required'); + // Must reach the CLI as an ERROR: `os lint` and `os compile` saw NOTHING + // from this rule until it joined the suite, so a react page binding nothing + // sailed through the build the way a readonly flow write did (#4394). + expect(react?.severity).toBe('error'); }); it('carries a gating flow-template finding through the suite (#3810)', () => { @@ -247,9 +269,9 @@ describe('reference-integrity suite — every member actually runs', () => { expect(typeof f.message).toBe('string'); expect(typeof f.hint).toBe('string'); } - // Object references run first, readonly flow writes last. + // Object references run first, react page props last. expect(findings[0].rule).toBe('object-reference-unknown'); - expect(findings[findings.length - 1].rule).toBe('flow-update-readonly-field'); + expect(findings[findings.length - 1].rule).toBe('react-prop-missing-required'); }); it('returns nothing for an empty stack', () => { diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 49cb040d93..e31fa0daa1 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -69,6 +69,7 @@ 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'; @@ -126,7 +127,8 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // 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 ONE member here that emits two rule ids. Besides resolving `ctx.api` + // 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 @@ -154,6 +156,29 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // 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) — ``, + // ``, the `record:*` family through the SAME + // `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface + // over, plus ``'s aggregate/axes (#3701/#3729) and + // `searchableFields` (#4329). Squarely the charter's question, on the surface + // where it had no answer at all. + // + // 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 }, ]; /**