|
| 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 | +} |
0 commit comments