diff --git a/.changeset/sdui-jsx-build-gate.md b/.changeset/sdui-jsx-build-gate.md new file mode 100644 index 0000000000..6a92e9bc4c --- /dev/null +++ b/.changeset/sdui-jsx-build-gate.md @@ -0,0 +1,6 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +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()`. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 1a25c1c917..c24f1a6f71 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -8,6 +8,7 @@ import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; +import { validateJsxPages } from '@objectstack/lint'; import { printHeader, printKV, @@ -164,6 +165,35 @@ export default class Validate extends Command { this.exit(1); } + // 3b. JSX-source pages (ADR-0080) — a kind:'jsx' page's `source` is + // parsed (never executed) and compiled to the SDUI tree at save + // time. Parse it now so malformed source fails loudly (ADR-0078) + // instead of being stored and breaking only at render. + if (!flags.json) printStep('Checking JSX-source pages (ADR-0080)...'); + const jsxFindings = validateJsxPages(result.data as Record); + const jsxErrors = jsxFindings.filter((f) => f.severity === 'error'); + const jsxWarnings = jsxFindings.filter((f) => f.severity === 'warning'); + + if (jsxErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ + valid: false, + errors: jsxErrors, + warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings], + duration: timer.elapsed(), + }, null, 2)); + this.exit(1); + } + console.log(''); + printError(`JSX-source page check failed (${jsxErrors.length} issue${jsxErrors.length > 1 ? 's' : ''})`); + for (const f of jsxErrors.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); @@ -172,7 +202,7 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings], + warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings], duration: timer.elapsed(), }, null, 2)); return; @@ -190,6 +220,9 @@ export default class Validate extends Command { for (const f of styleWarnings) { warnings.push(`${f.where}: ${f.message}`); } + for (const f of jsxWarnings) { + warnings.push(`${f.where}: ${f.message}`); + } if (stats.objects === 0) { warnings.push('No objects defined — this stack has no data model'); } diff --git a/packages/lint/package.json b/packages/lint/package.json index 58745c9aec..070c4e7499 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/lint", "version": "11.1.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", @@ -21,7 +21,8 @@ }, "dependencies": { "@objectstack/spec": "workspace:*", - "@objectstack/formula": "workspace:*" + "@objectstack/formula": "workspace:*", + "@objectstack/sdui-parser": "workspace:*" }, "devDependencies": { "@types/node": "^26.0.0", diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 205ec2f942..8f7ce8c797 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -35,3 +35,5 @@ export { STYLE_UNKNOWN_TOKEN, } from './validate-responsive-styles.js'; export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js'; +export { validateJsxPages } from './validate-jsx-pages.js'; +export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js'; diff --git a/packages/lint/src/validate-jsx-pages.test.ts b/packages/lint/src/validate-jsx-pages.test.ts new file mode 100644 index 0000000000..d240aad296 --- /dev/null +++ b/packages/lint/src/validate-jsx-pages.test.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { validateJsxPages } from './validate-jsx-pages.js'; + +describe('validateJsxPages (ADR-0080 build gate)', () => { + it('passes a well-formed jsx page', () => { + const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + expect(validateJsxPages(stack)).toEqual([]); + }); + it('flags an empty source on a jsx page', () => { + expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true); + }); + it('flags malformed jsx (mismatched close) loudly, at the source path', () => { + const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: 'oops' }] }); + expect(f.some((x) => x.severity === 'error')).toBe(true); + expect(f.every((x) => x.path === 'pages[0].source')).toBe(true); + }); + it('rejects event handlers and dangerouslySetInnerHTML at parse level', () => { + const f = validateJsxPages({ pages: [{ name: 'cc', kind: 'jsx', source: '' }] }) + .filter((x) => x.rule === 'jsx-forbidden-attr'); + expect(f).toHaveLength(2); + }); + it('ignores non-jsx pages', () => { + expect(validateJsxPages({ pages: [{ name: 'full', kind: 'full', regions: [] }] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-jsx-pages.ts b/packages/lint/src/validate-jsx-pages.ts new file mode 100644 index 0000000000..fd66c59521 --- /dev/null +++ b/packages/lint/src/validate-jsx-pages.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time diagnostics for AI-authored JSX-source pages (ADR-0080). +// +// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` / `os +// build`. A `kind:'jsx'` page's `source` is a constrained JSX/Tailwind string +// compiled (parsed, never executed) to the SDUI tree at save time. This gate +// parses it at author time so malformed source fails loudly (ADR-0078) instead +// of being stored and breaking only at render. +// +// Scope: parse-level — syntax, tag matching, and forbidden constructs (event +// handlers, dangerouslySetInnerHTML). Full component/prop whitelist validation +// needs the registry manifest (a cross-repo artifact); when that is wired, +// thread it through `compile()` here. Until then this catches the structural +// class of error an AI author is most likely to emit. + +import { parseJsx } from '@objectstack/sdui-parser'; + +export type JsxPageSeverity = 'error' | 'warning'; + +export interface JsxPageFinding { + severity: JsxPageSeverity; + rule: string; + /** Human-readable location, e.g. `page "command_center" › `. */ + where: string; + /** Config path, e.g. `pages[3].source`. */ + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; +const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); + +export function validateJsxPages(stack: AnyRec): JsxPageFinding[] { + const findings: JsxPageFinding[] = []; + const pages = asArray(stack.pages); + for (let p = 0; p < pages.length; p++) { + const page = pages[p]; + if (!page || page.kind !== 'jsx') continue; + const name = String(page.name ?? `#${p}`); + const source = page.source; + if (typeof source !== 'string' || source.trim() === '') { + // (PageSchema's superRefine also covers this; keep it for the build path.) + findings.push({ + severity: 'error', + rule: 'jsx-page-empty-source', + where: `page "${name}"`, + path: `pages[${p}].source`, + message: "kind:'jsx' page has no `source`.", + hint: 'Author the page as a constrained JSX/Tailwind string in `source`.', + }); + continue; + } + const { diagnostics } = parseJsx(source); + for (const d of diagnostics) { + findings.push({ + severity: d.severity, + rule: `jsx-${d.code}`, + where: d.tag ? `page "${name}" › <${d.tag}>` : `page "${name}"`, + path: `pages[${p}].source`, + message: d.message, + hint: 'The source is parsed (never executed) and compiled to the SDUI tree at save time — fix the JSX.', + }); + } + } + return findings; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb89012fc1..1624ffe78b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../formula + '@objectstack/sdui-parser': + specifier: workspace:* + version: link:../sdui-parser '@objectstack/spec': specifier: workspace:* version: link:../spec