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
6 changes: 6 additions & 0 deletions .changeset/sdui-jsx-build-gate.md
Original file line number Diff line number Diff line change
@@ -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()`.
35 changes: 34 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>);
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);

Expand All @@ -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;
Expand All @@ -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');
}
Expand Down
5 changes: 3 additions & 2 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.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",
Expand All @@ -21,7 +21,8 @@
},
"dependencies": {
"@objectstack/spec": "workspace:*",
"@objectstack/formula": "workspace:*"
"@objectstack/formula": "workspace:*",
"@objectstack/sdui-parser": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.0.0",
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 @@ -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';
26 changes: 26 additions & 0 deletions packages/lint/src/validate-jsx-pages.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<flex><text content="hi" /></flex>' }] };
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: '<flex><card>oops</flex>' }] });
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: '<flex onClick="x()" dangerouslySetInnerHTML={{}} />' }] })
.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([]);
});
});
68 changes: 68 additions & 0 deletions packages/lint/src/validate-jsx-pages.ts
Original file line number Diff line number Diff line change
@@ -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" › <flex>`. */
where: string;
/** Config path, e.g. `pages[3].source`. */
path: string;
message: string;
hint: string;
}

type AnyRec = Record<string, unknown>;
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;
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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