diff --git a/.changeset/sdui-jsx-gate-manifest.md b/.changeset/sdui-jsx-gate-manifest.md new file mode 100644 index 0000000000..7f1c0fb0ed --- /dev/null +++ b/.changeset/sdui-jsx-gate-manifest.md @@ -0,0 +1,6 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +ADR-0080 M3b① (consumption seam): the `os build` / `os validate` JSX gate now does **full component/prop validation** (unknown component, missing/wrong prop, bad enum, bindings) when a `sdui.manifest.json` is present at the project root — falling back to parse-level otherwise. `validateJsxPages` accepts an optional manifest; the validate command loads the file when present. Generating + shipping that manifest from the registry's public tier remains a build/CI step. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index c24f1a6f71..523392c275 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import chalk from 'chalk'; import { ZodError } from 'zod'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; @@ -170,7 +172,18 @@ export default class Validate extends Command { // 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); + // Optional component manifest (ADR-0080): if the project ships a + // `sdui.manifest.json` (generated from the registry's public tier), the + // gate does full component/prop validation; otherwise parse-level. + let sduiManifest: unknown; + try { + const mp = join(process.cwd(), 'sdui.manifest.json'); + if (existsSync(mp)) sduiManifest = JSON.parse(readFileSync(mp, 'utf8')); + } catch { /* fall back to parse-level */ } + const jsxFindings = validateJsxPages( + result.data as Record, + sduiManifest ? { manifest: sduiManifest as never } : {}, + ); const jsxErrors = jsxFindings.filter((f) => f.severity === 'error'); const jsxWarnings = jsxFindings.filter((f) => f.severity === 'warning'); diff --git a/packages/lint/src/validate-jsx-pages.test.ts b/packages/lint/src/validate-jsx-pages.test.ts index d240aad296..269673b3c0 100644 --- a/packages/lint/src/validate-jsx-pages.test.ts +++ b/packages/lint/src/validate-jsx-pages.test.ts @@ -24,3 +24,22 @@ describe('validateJsxPages (ADR-0080 build gate)', () => { expect(validateJsxPages({ pages: [{ name: 'full', kind: 'full', regions: [] }] })).toEqual([]); }); }); + +describe('validateJsxPages — full validation with a manifest', () => { + const manifest = { + components: { + flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [] }, + 'object-table': { type: 'object-table', namespace: 'plugin-grid', inputs: [{ name: 'object', type: 'string', required: true }] }, + }, + }; + it('catches unknown components and missing required props', () => { + const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + const f = validateJsxPages(stack, { manifest } as never); + expect(f.some((x) => x.rule === 'jsx-missing-required-prop')).toBe(true); + expect(f.some((x) => x.rule === 'jsx-forbidden-tag')).toBe(true); + }); + it('passes when components + required props are satisfied', () => { + const stack = { pages: [{ name: 'cc', kind: 'jsx', source: '' }] }; + expect(validateJsxPages(stack, { manifest } as never)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-jsx-pages.ts b/packages/lint/src/validate-jsx-pages.ts index fd66c59521..7cfd6311f1 100644 --- a/packages/lint/src/validate-jsx-pages.ts +++ b/packages/lint/src/validate-jsx-pages.ts @@ -14,7 +14,7 @@ // 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'; +import { parseJsx, compile, type Manifest } from '@objectstack/sdui-parser'; export type JsxPageSeverity = 'error' | 'warning'; @@ -32,7 +32,7 @@ export interface JsxPageFinding { type AnyRec = Record; const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); -export function validateJsxPages(stack: AnyRec): JsxPageFinding[] { +export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = {}): JsxPageFinding[] { const findings: JsxPageFinding[] = []; const pages = asArray(stack.pages); for (let p = 0; p < pages.length; p++) { @@ -52,7 +52,9 @@ export function validateJsxPages(stack: AnyRec): JsxPageFinding[] { }); continue; } - const { diagnostics } = parseJsx(source); + // With a component manifest, do full validation (unknown component, missing/ + // wrong prop, bad enum, bindings); without it, parse-level (syntax/structure). + const { diagnostics } = opts.manifest ? compile(source, opts.manifest) : parseJsx(source); for (const d of diagnostics) { findings.push({ severity: d.severity,