Skip to content

Commit 8ea1f4f

Browse files
os-zhuangclaude
andauthored
feat(lint,cli): os build gate parses kind:'jsx' page source (ADR-0080 M3b②) (#2440)
New validateJsxPages lint rule parses a jsx page's source via @objectstack/sdui-parser and surfaces parse/structural/forbidden-construct errors at author time (loud-not-silent, ADR-0078). Wired into os validate (and os build) alongside the expression/widget/style gates. Parse-level until the registry manifest is threaded through for full prop validation. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 012c046 commit 8ea1f4f

7 files changed

Lines changed: 142 additions & 3 deletions

File tree

.changeset/sdui-jsx-build-gate.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
ADR-0080 M3b②: `os validate` / `os build` now parse `kind:'jsx'` page `source` via `@objectstack/sdui-parser` (new `validateJsxPages` lint rule) — malformed JSX fails loudly at author time (ADR-0078) instead of being stored and breaking only at render. Parse-level for now (syntax, tag matching, forbidden constructs like event handlers / dangerouslySetInnerHTML); full component/prop whitelist validation arrives once the registry manifest is threaded through `compile()`.

packages/cli/src/commands/validate.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadConfig } from '../utils/config.js';
88
import { validateStackExpressions } from '@objectstack/lint';
99
import { validateWidgetBindings } from '@objectstack/lint';
1010
import { validateResponsiveStyles } from '@objectstack/lint';
11+
import { validateJsxPages } from '@objectstack/lint';
1112
import {
1213
printHeader,
1314
printKV,
@@ -164,6 +165,35 @@ export default class Validate extends Command {
164165
this.exit(1);
165166
}
166167

168+
// 3b. JSX-source pages (ADR-0080) — a kind:'jsx' page's `source` is
169+
// parsed (never executed) and compiled to the SDUI tree at save
170+
// time. Parse it now so malformed source fails loudly (ADR-0078)
171+
// instead of being stored and breaking only at render.
172+
if (!flags.json) printStep('Checking JSX-source pages (ADR-0080)...');
173+
const jsxFindings = validateJsxPages(result.data as Record<string, unknown>);
174+
const jsxErrors = jsxFindings.filter((f) => f.severity === 'error');
175+
const jsxWarnings = jsxFindings.filter((f) => f.severity === 'warning');
176+
177+
if (jsxErrors.length > 0) {
178+
if (flags.json) {
179+
console.log(JSON.stringify({
180+
valid: false,
181+
errors: jsxErrors,
182+
warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings],
183+
duration: timer.elapsed(),
184+
}, null, 2));
185+
this.exit(1);
186+
}
187+
console.log('');
188+
printError(`JSX-source page check failed (${jsxErrors.length} issue${jsxErrors.length > 1 ? 's' : ''})`);
189+
for (const f of jsxErrors.slice(0, 50)) {
190+
console.log(` \u2022 ${f.where}: ${f.message}`);
191+
console.log(chalk.dim(` ${f.hint}`));
192+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
193+
}
194+
this.exit(1);
195+
}
196+
167197
// 4. Collect and display stats
168198
const stats = collectMetadataStats(config);
169199

@@ -172,7 +202,7 @@ export default class Validate extends Command {
172202
valid: true,
173203
manifest: config.manifest,
174204
stats,
175-
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings],
205+
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings],
176206
duration: timer.elapsed(),
177207
}, null, 2));
178208
return;
@@ -190,6 +220,9 @@ export default class Validate extends Command {
190220
for (const f of styleWarnings) {
191221
warnings.push(`${f.where}: ${f.message}`);
192222
}
223+
for (const f of jsxWarnings) {
224+
warnings.push(`${f.where}: ${f.message}`);
225+
}
193226
if (stats.objects === 0) {
194227
warnings.push('No objects defined — this stack has no data model');
195228
}

packages/lint/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/lint",
33
"version": "11.1.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",
@@ -21,7 +21,8 @@
2121
},
2222
"dependencies": {
2323
"@objectstack/spec": "workspace:*",
24-
"@objectstack/formula": "workspace:*"
24+
"@objectstack/formula": "workspace:*",
25+
"@objectstack/sdui-parser": "workspace:*"
2526
},
2627
"devDependencies": {
2728
"@types/node": "^26.0.0",

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ export {
3535
STYLE_UNKNOWN_TOKEN,
3636
} from './validate-responsive-styles.js';
3737
export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js';
38+
export { validateJsxPages } from './validate-jsx-pages.js';
39+
export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { validateJsxPages } from './validate-jsx-pages.js';
4+
5+
describe('validateJsxPages (ADR-0080 build gate)', () => {
6+
it('passes a well-formed jsx page', () => {
7+
const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '<flex><text content="hi" /></flex>' }] };
8+
expect(validateJsxPages(stack)).toEqual([]);
9+
});
10+
it('flags an empty source on a jsx page', () => {
11+
expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true);
12+
});
13+
it('flags malformed jsx (mismatched close) loudly, at the source path', () => {
14+
const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: '<flex><card>oops</flex>' }] });
15+
expect(f.some((x) => x.severity === 'error')).toBe(true);
16+
expect(f.every((x) => x.path === 'pages[0].source')).toBe(true);
17+
});
18+
it('rejects event handlers and dangerouslySetInnerHTML at parse level', () => {
19+
const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: '<flex onClick="x()" dangerouslySetInnerHTML={{}} />' }] })
20+
.filter((x) => x.rule === 'jsx-forbidden-attr');
21+
expect(f).toHaveLength(2);
22+
});
23+
it('ignores non-jsx pages', () => {
24+
expect(validateJsxPages({ pages: [{ name: 'full', kind: 'full', regions: [] }] })).toEqual([]);
25+
});
26+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Build-time diagnostics for AI-authored JSX-source pages (ADR-0080).
4+
//
5+
// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` / `os
6+
// build`. A `kind:'jsx'` page's `source` is a constrained JSX/Tailwind string
7+
// compiled (parsed, never executed) to the SDUI tree at save time. This gate
8+
// parses it at author time so malformed source fails loudly (ADR-0078) instead
9+
// of being stored and breaking only at render.
10+
//
11+
// Scope: parse-level — syntax, tag matching, and forbidden constructs (event
12+
// handlers, dangerouslySetInnerHTML). Full component/prop whitelist validation
13+
// needs the registry manifest (a cross-repo artifact); when that is wired,
14+
// thread it through `compile()` here. Until then this catches the structural
15+
// class of error an AI author is most likely to emit.
16+
17+
import { parseJsx } from '@objectstack/sdui-parser';
18+
19+
export type JsxPageSeverity = 'error' | 'warning';
20+
21+
export interface JsxPageFinding {
22+
severity: JsxPageSeverity;
23+
rule: string;
24+
/** Human-readable location, e.g. `page "command_center" › <flex>`. */
25+
where: string;
26+
/** Config path, e.g. `pages[3].source`. */
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+
export function validateJsxPages(stack: AnyRec): JsxPageFinding[] {
36+
const findings: JsxPageFinding[] = [];
37+
const pages = asArray(stack.pages);
38+
for (let p = 0; p < pages.length; p++) {
39+
const page = pages[p];
40+
if (!page || page.kind !== 'jsx') continue;
41+
const name = String(page.name ?? `#${p}`);
42+
const source = page.source;
43+
if (typeof source !== 'string' || source.trim() === '') {
44+
// (PageSchema's superRefine also covers this; keep it for the build path.)
45+
findings.push({
46+
severity: 'error',
47+
rule: 'jsx-page-empty-source',
48+
where: `page "${name}"`,
49+
path: `pages[${p}].source`,
50+
message: "kind:'jsx' page has no `source`.",
51+
hint: 'Author the page as a constrained JSX/Tailwind string in `source`.',
52+
});
53+
continue;
54+
}
55+
const { diagnostics } = parseJsx(source);
56+
for (const d of diagnostics) {
57+
findings.push({
58+
severity: d.severity,
59+
rule: `jsx-${d.code}`,
60+
where: d.tag ? `page "${name}" › <${d.tag}>` : `page "${name}"`,
61+
path: `pages[${p}].source`,
62+
message: d.message,
63+
hint: 'The source is parsed (never executed) and compiled to the SDUI tree at save time — fix the JSX.',
64+
});
65+
}
66+
}
67+
return findings;
68+
}

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)