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
14 changes: 14 additions & 0 deletions .changeset/react-prop-gate.md
Original file line number Diff line number Diff line change
@@ -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 (`<ObjectForm>`, `<ListView>`, …)
against the react-tier contract (`REACT_BLOCKS` from `@objectstack/spec/ui`):
missing a required binding (e.g. `<ObjectForm>` 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.)
35 changes: 34 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<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) {
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);

Expand Down
8 changes: 4 additions & 4 deletions packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions packages/lint/src/validate-react-page-props.test.ts
Original file line number Diff line number Diff line change
@@ -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 <ObjectForm objectName="account" mode="edit" onSuccess={()=>{}} />; }'));
expect(f).toEqual([]);
});

it('flags a missing required binding (objectName)', () => {
const f = validateReactPageProps(page('function Page(){ return <ObjectForm mode="edit" />; }'));
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 <ObjectForm objectName="a" onSucces={()=>{}} />; }'));
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 <ListView objectName="a" onRowClik={()=>{}} />; }'));
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 <ObjectForm {...p} />; }'));
expect(f).toEqual([]);
});

it('ignores unknown components (author HTML / own components)', () => {
const f = validateReactPageProps(page('function Page(){ return <div className="x"><MyThing foo="bar" /></div>; }'));
expect(f).toEqual([]);
});

it('does NOT false-flag a valid non-contract prop (no near match)', () => {
const f = validateReactPageProps(page('function Page(){ return <ListView objectName="a" striped={true} />; }'));
expect(f).toEqual([]);
});
});
137 changes: 137 additions & 0 deletions packages/lint/src/validate-react-page-props.ts
Original file line number Diff line number Diff line change
@@ -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 (<ObjectForm>,
// <ListView>, …), and checks each against the react-tier contract
// (REACT_BLOCKS in @objectstack/spec):
//
// - missing a required binding prop (e.g. <ObjectForm> 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<string, unknown>;
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);

interface BlockSpec {
requiredBindings: string[];
knownProps: Set<string>;
}
const BLOCKS: Map<string, BlockSpec> = 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>): 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<string>();
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;
}
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.