-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathvalidate-react-page-props.ts
More file actions
176 lines (164 loc) · 6.76 KB
/
Copy pathvalidate-react-page-props.ts
File metadata and controls
176 lines (164 loc) · 6.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// 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 { createRequire } from 'node:module';
import type ts from 'typescript';
import { REACT_BLOCKS } from '@objectstack/spec/ui';
// The TypeScript compiler must NOT be imported at module top level: it is
// ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and
// @objectstack/lint sits on the kernel boot path — while this gate only runs
// when a `kind:'react'` page is actually validated (rare, trusted tier). An
// eager import also hard-crashes boot in deployments that prune the package
// from the image (cloud's Docker pruner did exactly that). So the compiler is
// loaded lazily, on first use, and stays a regular dependency in package.json.
// Guarded by lazy-typescript.test.ts.
//
// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static
// `createRequire` import survives bundling; the `createRequire(...)` call is
// deferred because `import.meta.url` is rewritten to an empty stub in the CJS
// build (same pattern as driver-sqlite-wasm's knex-wasm-dialect).
let cachedTs: typeof ts | null = null;
function loadTypeScript(): typeof ts {
if (cachedTs) return cachedTs;
const anchor =
typeof import.meta !== 'undefined' && import.meta.url
? import.meta.url
: typeof __filename !== 'undefined'
? __filename
: process.cwd() + '/';
try {
cachedTs = createRequire(anchor)('typescript') as typeof ts;
} catch (err) {
throw new Error(
`@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded ` +
`(${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint — ` +
`if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`,
);
}
return cachedTs;
}
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}`);
// Outside the try below on purpose: a missing compiler must surface as an
// error, not be swallowed as "unparseable source".
const tsc = loadTypeScript();
let sf: ts.SourceFile;
try {
sf = tsc.createSourceFile('page.tsx', source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX);
} catch {
continue; // the syntax gate reports unparseable sources
}
const visit = (node: ts.Node): void => {
if (tsc.isJsxOpeningElement(node) || tsc.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 (tsc.isJsxSpreadAttribute(a)) { hasSpread = true; continue; }
if (tsc.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.',
});
}
}
}
}
tsc.forEachChild(node, visit);
};
visit(sf);
}
return findings;
}