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
35 changes: 35 additions & 0 deletions .changeset/react-page-props-joins-the-suite.md
Original file line number Diff line number Diff line change
@@ -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 违规(`<ListView filters={['no_such_col','=',stage]}>`
—— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测:

```
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、
一套命令集合」。

规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就
传 `result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工
接一条规则时直接报错。

`validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites`
的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。
19 changes: 15 additions & 4 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`) | ✓ | ✓ |
Expand All @@ -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:

Expand All @@ -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...
Expand Down
41 changes: 8 additions & 33 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>);
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
Expand Down
30 changes: 26 additions & 4 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('reference-integrity suite — membership', () => {
'validateActionBodyWrites',
'validateFlowNodeWrites',
'validateReadonlyFlowWrites',
'validateReactPageProps',
]);
});

Expand Down Expand Up @@ -107,6 +108,17 @@ describe('reference-integrity suite — every member actually runs', () => {
},
],
},
// validateReactPageProps: the same component family one surface over,
// authored as JSX. `<ObjectForm>` 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 <ObjectForm mode="edit" />; }',
},
],
datasets: [
{
Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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', () => {
Expand Down
27 changes: 26 additions & 1 deletion packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) — `<ListView columns>`,
// `<ObjectForm fields>`, the `record:*` family 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 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 },
];

/**
Expand Down
Loading