Skip to content

Commit 5a5bf61

Browse files
os-zhuangclaude
andauthored
feat(lint,cli): react-source prop validation gate (ADR-0081 Phase 2) (#2482)
validateReactPageProps parses the JSX of a kind:'react' page and checks each injected-block usage against REACT_BLOCKS (the spec-sourced contract): missing required binding -> error; near-miss prop (onSucces->onSuccess) -> warning. Wired into os validate after the syntax gate. Verified: 7 unit tests; the 5 real showcase react pages pass (no false positives); an injected onSucces typo is caught end-to-end. typescript moved to lint deps so it externalizes (lint dist 10MB -> 36KB; fixes the CLI 'Dynamic require of fs' ESM-bundle break). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d31bf1 commit 5a5bf61

7 files changed

Lines changed: 236 additions & 8 deletions

File tree

.changeset/react-prop-gate.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
ADR-0081 Phase 2: a build-time prop check for `kind:'react'` pages. After the
7+
syntax gate, `validateReactPageProps` parses the real JSX (TypeScript compiler)
8+
and checks each usage of an injected block (`<ObjectForm>`, `<ListView>`, …)
9+
against the react-tier contract (`REACT_BLOCKS` from `@objectstack/spec/ui`):
10+
missing a required binding (e.g. `<ObjectForm>` with no `objectName`) is an
11+
error; a near-miss prop (`onSucces``onSuccess`) is a warning. Wired into
12+
`os validate`. Curated data props are not flagged (low false-positive); a spread
13+
`{...props}` escapes the required check. (`typescript` moves to `@objectstack/lint`
14+
dependencies so it externalizes instead of bundling into the CLI.)

packages/cli/src/commands/validate.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateWidgetBindings } from '@objectstack/lint';
1313
import { validateResponsiveStyles } from '@objectstack/lint';
14-
import { validateJsxPages, validateReactPages } from '@objectstack/lint';
14+
import { validateJsxPages, validateReactPages, validateReactPageProps } from '@objectstack/lint';
1515
import {
1616
printHeader,
1717
printKV,
@@ -240,6 +240,39 @@ export default class Validate extends Command {
240240
this.exit(1);
241241
}
242242

243+
// 3d. React-source pages — prop usage against the component contract
244+
// (ADR-0081 Phase 2): missing required bindings (error) + likely
245+
// prop typos (warning), parsed from the real JSX.
246+
if (!flags.json) printStep('Checking React-source page props (ADR-0081)...');
247+
const reactPropFindings = validateReactPageProps(result.data as Record<string, unknown>);
248+
const reactPropErrors = reactPropFindings.filter((f) => f.severity === 'error');
249+
const reactPropWarnings = reactPropFindings.filter((f) => f.severity === 'warning');
250+
if (!flags.json) {
251+
for (const w of reactPropWarnings.slice(0, 50)) {
252+
console.log(chalk.yellow(` \u26a0 ${w.where}: ${w.message}`));
253+
console.log(chalk.dim(` ${w.hint}`));
254+
}
255+
}
256+
if (reactPropErrors.length > 0) {
257+
if (flags.json) {
258+
console.log(JSON.stringify({
259+
valid: false,
260+
errors: reactPropErrors,
261+
warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...reactPropWarnings],
262+
duration: timer.elapsed(),
263+
}, null, 2));
264+
this.exit(1);
265+
}
266+
console.log('');
267+
printError(`React-source page prop check failed (${reactPropErrors.length} issue${reactPropErrors.length > 1 ? 's' : ''})`);
268+
for (const f of reactPropErrors.slice(0, 50)) {
269+
console.log(` \u2022 ${f.where}: ${f.message}`);
270+
console.log(chalk.dim(` ${f.hint}`));
271+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
272+
}
273+
this.exit(1);
274+
}
275+
243276
// 4. Collect and display stats
244277
const stats = collectMetadataStats(config);
245278

packages/lint/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/lint",
33
"version": "11.4.0",
44
"license": "Apache-2.0",
5-
"description": "Static, build-time validation for an ObjectStack metadata graph dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.",
5+
"description": "Static, build-time validation for an ObjectStack metadata graph \u2014 dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.",
66
"type": "module",
77
"main": "dist/index.js",
88
"types": "dist/index.d.ts",
@@ -20,14 +20,14 @@
2020
"test": "vitest run"
2121
},
2222
"dependencies": {
23-
"@objectstack/spec": "workspace:*",
2423
"@objectstack/formula": "workspace:*",
2524
"@objectstack/sdui-parser": "workspace:*",
26-
"sucrase": "^3.35.0"
25+
"@objectstack/spec": "workspace:*",
26+
"sucrase": "^3.35.0",
27+
"typescript": "^6.0.3"
2728
},
2829
"devDependencies": {
2930
"@types/node": "^26.0.1",
30-
"typescript": "^6.0.3",
3131
"vitest": "^4.1.9"
3232
},
3333
"keywords": [

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export { validateJsxPages } from './validate-jsx-pages.js';
3939
export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js';
4040
export { validateReactPages } from './validate-react-pages.js';
4141
export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js';
42+
export { validateReactPageProps } from './validate-react-page-props.js';
43+
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
4244

4345
export {
4446
validateRecordTitle,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { validateReactPageProps } from './validate-react-page-props.js';
4+
5+
const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] });
6+
7+
describe('validateReactPageProps (ADR-0081 Phase 2)', () => {
8+
it('passes a correct ObjectForm usage', () => {
9+
const f = validateReactPageProps(page('function Page(){ return <ObjectForm objectName="account" mode="edit" onSuccess={()=>{}} />; }'));
10+
expect(f).toEqual([]);
11+
});
12+
13+
it('flags a missing required binding (objectName)', () => {
14+
const f = validateReactPageProps(page('function Page(){ return <ObjectForm mode="edit" />; }'));
15+
expect(f.some((x) => x.rule === 'react-prop-missing-required' && /objectName/.test(x.message))).toBe(true);
16+
});
17+
18+
it('flags a typo of a contract prop (onSucces → onSuccess)', () => {
19+
const f = validateReactPageProps(page('function Page(){ return <ObjectForm objectName="a" onSucces={()=>{}} />; }'));
20+
expect(f.some((x) => x.rule === 'react-prop-typo' && /onSuccess/.test(x.message))).toBe(true);
21+
});
22+
23+
it('flags ListView onRowClik typo', () => {
24+
const f = validateReactPageProps(page('function Page(){ return <ListView objectName="a" onRowClik={()=>{}} />; }'));
25+
expect(f.some((x) => x.rule === 'react-prop-typo' && /onRowClick/.test(x.message))).toBe(true);
26+
});
27+
28+
it('does NOT flag a spread (props may come from it)', () => {
29+
const f = validateReactPageProps(page('function Page(){ const p={objectName:"a"}; return <ObjectForm {...p} />; }'));
30+
expect(f).toEqual([]);
31+
});
32+
33+
it('ignores unknown components (author HTML / own components)', () => {
34+
const f = validateReactPageProps(page('function Page(){ return <div className="x"><MyThing foo="bar" /></div>; }'));
35+
expect(f).toEqual([]);
36+
});
37+
38+
it('does NOT false-flag a valid non-contract prop (no near match)', () => {
39+
const f = validateReactPageProps(page('function Page(){ return <ListView objectName="a" striped={true} />; }'));
40+
expect(f).toEqual([]);
41+
});
42+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Build-time prop check for `kind:'react'` pages (ADR-0081 Phase 2). The syntax
4+
// gate (validate-react-pages) confirms the source parses; this confirms the
5+
// AUTHOR USED THE COMPONENT CONTRACT correctly — it parses the real JSX with the
6+
// TypeScript compiler, finds usages of the injected blocks (<ObjectForm>,
7+
// <ListView>, …), and checks each against the react-tier contract
8+
// (REACT_BLOCKS in @objectstack/spec):
9+
//
10+
// - missing a required binding prop (e.g. <ObjectForm> with no objectName)
11+
// → error. (Only the React-enforceable overlay props are required-checked;
12+
// a spread `{...props}` escapes the check since props may come from it.)
13+
// - a prop that is a near-miss (edit distance ≤ 2) of a known prop
14+
// (e.g. `onSucces` → `onSuccess`) → warning. We do NOT flag arbitrary
15+
// unknown props (the contract's data props are a curated subset) — only the
16+
// likely typos, to keep false positives near zero.
17+
18+
import ts from 'typescript';
19+
import { REACT_BLOCKS } from '@objectstack/spec/ui';
20+
21+
export type ReactPropSeverity = 'error' | 'warning';
22+
23+
export interface ReactPropFinding {
24+
severity: ReactPropSeverity;
25+
rule: string;
26+
where: string;
27+
path: string;
28+
message: string;
29+
hint: string;
30+
}
31+
32+
type AnyRec = Record<string, unknown>;
33+
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);
34+
35+
interface BlockSpec {
36+
requiredBindings: string[];
37+
knownProps: Set<string>;
38+
}
39+
const BLOCKS: Map<string, BlockSpec> = new Map(
40+
(REACT_BLOCKS as Array<{ tag: string; interactions: Array<{ name: string; required?: boolean }> }>).map((b) => [
41+
b.tag,
42+
{
43+
requiredBindings: b.interactions.filter((i) => i.required).map((i) => i.name),
44+
knownProps: new Set(b.interactions.map((i) => i.name)),
45+
},
46+
]),
47+
);
48+
49+
function editDistance(a: string, b: string, cap = 2): number {
50+
if (Math.abs(a.length - b.length) > cap) return cap + 1;
51+
const dp = Array.from({ length: a.length + 1 }, (_, i) => i);
52+
for (let j = 1; j <= b.length; j++) {
53+
let prev = dp[0];
54+
dp[0] = j;
55+
for (let i = 1; i <= a.length; i++) {
56+
const tmp = dp[i];
57+
dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
58+
prev = tmp;
59+
}
60+
}
61+
return dp[a.length];
62+
}
63+
64+
function nearestKnown(prop: string, known: Set<string>): string | null {
65+
if (known.has(prop)) return null;
66+
let best: string | null = null;
67+
let bestD = 3;
68+
for (const k of known) {
69+
const d = editDistance(prop, k);
70+
if (d < bestD) { bestD = d; best = k; }
71+
}
72+
return bestD <= 2 ? best : null;
73+
}
74+
75+
export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
76+
const findings: ReactPropFinding[] = [];
77+
const pages = asArray(stack.pages);
78+
for (let p = 0; p < pages.length; p++) {
79+
const page = pages[p];
80+
if (!page || page.kind !== 'react') continue;
81+
const source = page.source;
82+
if (typeof source !== 'string' || source.trim() === '') continue;
83+
const name = String(page.name ?? `#${p}`);
84+
85+
let sf: ts.SourceFile;
86+
try {
87+
sf = ts.createSourceFile('page.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
88+
} catch {
89+
continue; // the syntax gate reports unparseable sources
90+
}
91+
92+
const visit = (node: ts.Node): void => {
93+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
94+
const tag = node.tagName.getText(sf);
95+
const block = BLOCKS.get(tag);
96+
if (block) {
97+
let hasSpread = false;
98+
const used = new Set<string>();
99+
for (const a of node.attributes.properties) {
100+
if (ts.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
101+
if (ts.isJsxAttribute(a)) used.add(a.name.getText(sf));
102+
}
103+
const where = `page "${name}" › <${tag}>`;
104+
const path = `pages[${p}].source`;
105+
if (!hasSpread) {
106+
for (const req of block.requiredBindings) {
107+
if (!used.has(req)) {
108+
findings.push({
109+
severity: 'error',
110+
rule: 'react-prop-missing-required',
111+
where, path,
112+
message: `<${tag}> is missing the required prop "${req}".`,
113+
hint: `Pass ${req}={…}. See the react-tier component contract.`,
114+
});
115+
}
116+
}
117+
}
118+
for (const u of used) {
119+
const near = nearestKnown(u, block.knownProps);
120+
if (near) {
121+
findings.push({
122+
severity: 'warning',
123+
rule: 'react-prop-typo',
124+
where, path,
125+
message: `<${tag}> has prop "${u}" — did you mean "${near}"?`,
126+
hint: 'Likely a typo of a contract prop. Fix it or remove it.',
127+
});
128+
}
129+
}
130+
}
131+
}
132+
ts.forEachChild(node, visit);
133+
};
134+
visit(sf);
135+
}
136+
return findings;
137+
}

pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)