From 230b17472e6f5e473a2ede6c1f77e07ca18df2ac Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:24:35 +0800 Subject: [PATCH] feat(lint,cli): react-source prop validation gate (ADR-0081 Phase 2) 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: Claude Opus 4.8 --- .changeset/react-prop-gate.md | 14 ++ packages/cli/src/commands/validate.ts | 35 ++++- packages/lint/package.json | 8 +- packages/lint/src/index.ts | 2 + .../src/validate-react-page-props.test.ts | 42 ++++++ .../lint/src/validate-react-page-props.ts | 137 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 7 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 .changeset/react-prop-gate.md create mode 100644 packages/lint/src/validate-react-page-props.test.ts create mode 100644 packages/lint/src/validate-react-page-props.ts diff --git a/.changeset/react-prop-gate.md b/.changeset/react-prop-gate.md new file mode 100644 index 0000000000..ec97809316 --- /dev/null +++ b/.changeset/react-prop-gate.md @@ -0,0 +1,14 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +ADR-0081 Phase 2: a build-time prop check for `kind:'react'` pages. After the +syntax gate, `validateReactPageProps` parses the real JSX (TypeScript compiler) +and checks each usage of an injected block (``, ``, …) +against the react-tier contract (`REACT_BLOCKS` from `@objectstack/spec/ui`): +missing a required binding (e.g. `` with no `objectName`) is an +error; a near-miss prop (`onSucces` → `onSuccess`) is a warning. Wired into +`os validate`. Curated data props are not flagged (low false-positive); a spread +`{...props}` escapes the required check. (`typescript` moves to `@objectstack/lint` +dependencies so it externalizes instead of bundling into the CLI.) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a8596e0d10..7b59734fa5 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -11,7 +11,7 @@ import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; -import { validateJsxPages, validateReactPages } from '@objectstack/lint'; +import { validateJsxPages, validateReactPages, validateReactPageProps } from '@objectstack/lint'; import { printHeader, printKV, @@ -240,6 +240,39 @@ 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) { + console.log(JSON.stringify({ + valid: false, + errors: reactPropErrors, + warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...reactPropWarnings], + duration: timer.elapsed(), + }, null, 2)); + 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); + } + // 4. Collect and display stats const stats = collectMetadataStats(config); diff --git a/packages/lint/package.json b/packages/lint/package.json index d59400ff76..d470a3e91f 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/lint", "version": "11.4.0", "license": "Apache-2.0", - "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.", + "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.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -20,14 +20,14 @@ "test": "vitest run" }, "dependencies": { - "@objectstack/spec": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/sdui-parser": "workspace:*", - "sucrase": "^3.35.0" + "@objectstack/spec": "workspace:*", + "sucrase": "^3.35.0", + "typescript": "^6.0.3" }, "devDependencies": { "@types/node": "^26.0.1", - "typescript": "^6.0.3", "vitest": "^4.1.9" }, "keywords": [ diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f145019e5f..23d16f10ed 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -39,6 +39,8 @@ export { validateJsxPages } from './validate-jsx-pages.js'; export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js'; export { validateReactPages } from './validate-react-pages.js'; export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js'; +export { validateReactPageProps } from './validate-react-page-props.js'; +export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js'; export { validateRecordTitle, diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts new file mode 100644 index 0000000000..4380949ddf --- /dev/null +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { validateReactPageProps } from './validate-react-page-props.js'; + +const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] }); + +describe('validateReactPageProps (ADR-0081 Phase 2)', () => { + it('passes a correct ObjectForm usage', () => { + const f = validateReactPageProps(page('function Page(){ return {}} />; }')); + expect(f).toEqual([]); + }); + + it('flags a missing required binding (objectName)', () => { + const f = validateReactPageProps(page('function Page(){ return ; }')); + expect(f.some((x) => x.rule === 'react-prop-missing-required' && /objectName/.test(x.message))).toBe(true); + }); + + it('flags a typo of a contract prop (onSucces → onSuccess)', () => { + const f = validateReactPageProps(page('function Page(){ return {}} />; }')); + expect(f.some((x) => x.rule === 'react-prop-typo' && /onSuccess/.test(x.message))).toBe(true); + }); + + it('flags ListView onRowClik typo', () => { + const f = validateReactPageProps(page('function Page(){ return {}} />; }')); + expect(f.some((x) => x.rule === 'react-prop-typo' && /onRowClick/.test(x.message))).toBe(true); + }); + + it('does NOT flag a spread (props may come from it)', () => { + const f = validateReactPageProps(page('function Page(){ const p={objectName:"a"}; return ; }')); + expect(f).toEqual([]); + }); + + it('ignores unknown components (author HTML / own components)', () => { + const f = validateReactPageProps(page('function Page(){ return
; }')); + expect(f).toEqual([]); + }); + + it('does NOT false-flag a valid non-contract prop (no near match)', () => { + const f = validateReactPageProps(page('function Page(){ return ; }')); + expect(f).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts new file mode 100644 index 0000000000..51cae173c6 --- /dev/null +++ b/packages/lint/src/validate-react-page-props.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time prop check for `kind:'react'` pages (ADR-0081 Phase 2). The syntax +// gate (validate-react-pages) confirms the source parses; this confirms the +// AUTHOR USED THE COMPONENT CONTRACT correctly — it parses the real JSX with the +// TypeScript compiler, finds usages of the injected blocks (, +// , …), and checks each against the react-tier contract +// (REACT_BLOCKS in @objectstack/spec): +// +// - missing a required binding prop (e.g. with no objectName) +// → error. (Only the React-enforceable overlay props are required-checked; +// a spread `{...props}` escapes the check since props may come from it.) +// - a prop that is a near-miss (edit distance ≤ 2) of a known prop +// (e.g. `onSucces` → `onSuccess`) → warning. We do NOT flag arbitrary +// unknown props (the contract's data props are a curated subset) — only the +// likely typos, to keep false positives near zero. + +import ts from 'typescript'; +import { REACT_BLOCKS } from '@objectstack/spec/ui'; + +export type ReactPropSeverity = 'error' | 'warning'; + +export interface ReactPropFinding { + severity: ReactPropSeverity; + rule: string; + where: string; + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; +const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); + +interface BlockSpec { + requiredBindings: string[]; + knownProps: Set; +} +const BLOCKS: Map = new Map( + (REACT_BLOCKS as Array<{ tag: string; interactions: Array<{ name: string; required?: boolean }> }>).map((b) => [ + b.tag, + { + requiredBindings: b.interactions.filter((i) => i.required).map((i) => i.name), + knownProps: new Set(b.interactions.map((i) => i.name)), + }, + ]), +); + +function editDistance(a: string, b: string, cap = 2): number { + if (Math.abs(a.length - b.length) > cap) return cap + 1; + const dp = Array.from({ length: a.length + 1 }, (_, i) => i); + for (let j = 1; j <= b.length; j++) { + let prev = dp[0]; + dp[0] = j; + for (let i = 1; i <= a.length; i++) { + const tmp = dp[i]; + dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = tmp; + } + } + return dp[a.length]; +} + +function nearestKnown(prop: string, known: Set): string | null { + if (known.has(prop)) return null; + let best: string | null = null; + let bestD = 3; + for (const k of known) { + const d = editDistance(prop, k); + if (d < bestD) { bestD = d; best = k; } + } + return bestD <= 2 ? best : null; +} + +export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { + const findings: ReactPropFinding[] = []; + const pages = asArray(stack.pages); + for (let p = 0; p < pages.length; p++) { + const page = pages[p]; + if (!page || page.kind !== 'react') continue; + const source = page.source; + if (typeof source !== 'string' || source.trim() === '') continue; + const name = String(page.name ?? `#${p}`); + + let sf: ts.SourceFile; + try { + sf = ts.createSourceFile('page.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + } catch { + continue; // the syntax gate reports unparseable sources + } + + const visit = (node: ts.Node): void => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tag = node.tagName.getText(sf); + const block = BLOCKS.get(tag); + if (block) { + let hasSpread = false; + const used = new Set(); + for (const a of node.attributes.properties) { + if (ts.isJsxSpreadAttribute(a)) { hasSpread = true; continue; } + if (ts.isJsxAttribute(a)) used.add(a.name.getText(sf)); + } + const where = `page "${name}" › <${tag}>`; + const path = `pages[${p}].source`; + if (!hasSpread) { + for (const req of block.requiredBindings) { + if (!used.has(req)) { + findings.push({ + severity: 'error', + rule: 'react-prop-missing-required', + where, path, + message: `<${tag}> is missing the required prop "${req}".`, + hint: `Pass ${req}={…}. See the react-tier component contract.`, + }); + } + } + } + for (const u of used) { + const near = nearestKnown(u, block.knownProps); + if (near) { + findings.push({ + severity: 'warning', + rule: 'react-prop-typo', + where, path, + message: `<${tag}> has prop "${u}" — did you mean "${near}"?`, + hint: 'Likely a typo of a contract prop. Fix it or remove it.', + }); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + return findings; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3079677d9d..c9e1b03a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -795,13 +795,13 @@ importers: sucrase: specifier: ^3.35.0 version: 3.35.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 devDependencies: '@types/node': specifier: ^26.0.1 version: 26.0.1 - typescript: - specifier: ^6.0.3 - version: 6.0.3 vitest: specifier: ^4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.0.1)(typescript@6.0.3))(vite@8.0.16(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))