From 4b1377fc1142e3f5f40cfa59b061f3c37be222e3 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:55:17 +0800 Subject: [PATCH 1/6] fix(cli): the JSX gate's console manifest fallback resolves @objectstack/console/package.json from the CLI's own location The fallback asked for the shipped manifest by its own subpath, which the console's exports map does not publish, so it threw, was swallowed, and a project with no manifest of its own was always checked at parse level. It now resolves the console's package.json from the CLI's location and joins dist/sdui.manifest.json. A damaged shipped copy is refused with a reinstall remedy instead of being read as not found. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/cli/src/utils/sdui-manifest.test.ts | 132 +++++++++++++++++-- packages/cli/src/utils/sdui-manifest.ts | 126 ++++++++++++------ 2 files changed, 211 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index 3a242b54e0c..e49b0f81341 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -14,15 +14,18 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { validateJsxPages } from '@objectstack/lint'; import { - CONSOLE_SDUI_MANIFEST_SPECIFIER, + CONSOLE_SDUI_MANIFEST, JSX_PARSE_LEVEL_ONLY_RULE, PROJECT_SDUI_MANIFEST_FILE, SduiManifestRefusalError, + consoleSduiManifestPath, countJsxGatePages, jsxGateStacks, printJsxGateNotices, @@ -59,10 +62,11 @@ const PACKAGE_CARRIED: Record> = { const ABSENT: SduiManifestResolution = { status: 'absent', - lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST_SPECIFIER], + lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST], }; const UNUSABLE: SduiManifestResolution = { status: 'unusable', + source: 'project', path: '/proj/sdui.manifest.json', reason: 'it is not valid JSON (x)', }; @@ -82,13 +86,14 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { expect(r).toEqual({ status: 'resolved', manifest: MANIFEST, path: join(dir, PROJECT_SDUI_MANIFEST_FILE) }); }); - // Holds in any checkout: `packages/console/dist/` is gitignored and absent - // unless the console is built, and today the specifier is not in the - // console's `exports` either. Both legs are named, in order. - it('absent: names the project path, then the console specifier', () => { - expect(resolveSduiManifest(dir)).toEqual({ + // Hermetic: the console is located from an origin inside this empty + // directory, where `@objectstack/console` does not resolve — so the answer + // does not depend on whether this checkout has built the console. Both legs + // are named, in order. + it('absent: names the project path, then the console copy', () => { + expect(resolveSduiManifest(dir, pathToFileURL(join(dir, 'cli.mjs')))).toEqual({ status: 'absent', - lookedAt: [join(dir, PROJECT_SDUI_MANIFEST_FILE), CONSOLE_SDUI_MANIFEST_SPECIFIER], + lookedAt: [join(dir, PROJECT_SDUI_MANIFEST_FILE), CONSOLE_SDUI_MANIFEST], }); }); @@ -103,6 +108,7 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { const r = resolveSduiManifest(dir); expect(r.status).toBe('unusable'); if (r.status !== 'unusable') return; + expect(r.source).toBe('project'); expect(r.path).toBe(join(dir, PROJECT_SDUI_MANIFEST_FILE)); expect(r.reason).toMatch(reason); }); @@ -115,6 +121,112 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { }); }); +/** + * The `package.json` of the REAL `@objectstack/console` this package depends + * on, resolved the way any installed dependency is — through `node_modules` — + * so the layouts below carry the console's actual `exports` map, which is what + * decides whether a subpath resolves at all. + */ +const REAL_CONSOLE_PACKAGE_JSON = createRequire(import.meta.url).resolve('@objectstack/console/package.json'); + +/** + * An installed-package layout under `root`: `node_modules/@objectstack/console` + * carrying the real console `package.json`, plus a `dist/sdui.manifest.json` + * with `body` (`null` for a console that ships none, as 17.0.0 to 17.4.0 did). + * Returns the origin a CLI installed beside it resolves from. + */ +function installConsole(root: string, body: string | null): URL { + const pkgDir = join(root, 'node_modules', '@objectstack', 'console'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + writeFileSync(join(pkgDir, 'package.json'), readFileSync(REAL_CONSOLE_PACKAGE_JSON, 'utf8')); + if (body !== null) writeFileSync(join(pkgDir, 'dist', 'sdui.manifest.json'), body); + return pathToFileURL(join(root, 'node_modules', '@objectstack', 'cli', 'dist', 'index.js')); +} + +describe('the console leg — the copy @objectstack/console ships is reached, through its package.json', () => { + let root = ''; + let project = ''; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'os-sdui-console-')); + project = join(root, 'project'); + mkdirSync(project); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + // The production default: from the CLI's OWN location, where its declared + // dependency lives. Asking for the file by its own subpath resolves nothing + // (the console's `exports` publishes `./package.json` alone), so this reds + // the moment the leg goes back to that spelling. + it("locates the CLI's own @objectstack/console dependency, whether or not it is built", () => { + const path = consoleSduiManifestPath(); + expect(path).toBeDefined(); + expect(path!.endsWith(join('dist', 'sdui.manifest.json'))).toBe(true); + const owner = JSON.parse(readFileSync(join(dirname(dirname(path!)), 'package.json'), 'utf8')); + expect(owner.name).toBe('@objectstack/console'); + }); + + it('resolved: a project with no manifest of its own is checked against the shipped copy', () => { + const origin = installConsole(root, JSON.stringify(MANIFEST)); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'resolved', + manifest: MANIFEST, + path: join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + }); + }); + + it("resolved: the project's own manifest is read first", () => { + const origin = installConsole(root, JSON.stringify({ components: {} })); + writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST)); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'resolved', + manifest: MANIFEST, + path: join(project, PROJECT_SDUI_MANIFEST_FILE), + }); + }); + + it('absent: a console that ships no manifest is named by the absolute path looked at', () => { + const origin = installConsole(root, null); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'absent', + lookedAt: [ + join(project, PROJECT_SDUI_MANIFEST_FILE), + join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + ], + }); + }); + + it('unusable: a damaged shipped copy is refused with its own remedy, never read as "not found"', () => { + const origin = installConsole(root, '{ "components": [ oops'); + const r = resolveSduiManifest(project, origin); + expect(r).toMatchObject({ + status: 'unusable', + source: 'console', + path: join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + }); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + let thrown: unknown; + try { + resolveJsxGateManifest(HTML_STACK, r); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(SduiManifestRefusalError); + const e = thrown as SduiManifestRefusalError; + expect(e.message).toContain(join('@objectstack', 'console', 'dist', 'sdui.manifest.json')); + // The remedy names the package to reinstall, not the file to edit. + const remedy = e.hints[e.hints.length - 1]; + expect(remedy).toContain('@objectstack/console'); + expect(remedy).not.toContain(PROJECT_SDUI_MANIFEST_FILE); + } finally { + errSpy.mockRestore(); + } + }); +}); + describe('countJsxGatePages — the pages the JSX gate checks against a manifest', () => { // The kind set is `validateJsxPages`'s own, read by DRIVING it: with a // manifest that declares no components, every page the gate compiles against diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index 41c4549ffe8..be1f8d4ad33 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -29,12 +29,15 @@ * untouched on every face, `--strict` included: failing here * would break every project that has no manifest of its own, * and such a project has no remedy but to author one. - * - `unusable` → a manifest the PROJECT put in place that cannot be read, - * parsed, or carries no `components` map. REFUSED (exit 1), - * never degraded: its author asked for full validation, and - * the `{}` shape already crashed the gate with a bare - * TypeError while `{ oops` passed it silently — one rule now - * covers both, with the file and the reason named. + * - `unusable` → a manifest that is present but cannot be read, parsed, or + * carries no `components` map. REFUSED (exit 1), never + * degraded: its author asked for full validation, and the + * `{}` shape already crashed the gate with a bare TypeError + * while `{ oops` passed it silently — one rule now covers + * both, with the file and the reason named. The same rule + * holds for the copy `@objectstack/console` ships (below): + * a damaged install is refused with that remedy, never read + * as "not found". * * Both the notice and the refusal fire only when the run has a page the JSX * gate actually checks. With none, the manifest is read by nothing, so a @@ -48,16 +51,22 @@ * layout got no notice and a broken manifest passed at exit 0: the silent * degradation this module exists to end, one layout over (#20113 round 1). * - * ⛔ The console leg's FAILURE semantics are deliberately unchanged: the - * specifier below resolves to nothing today (the console's `exports` map does - * not publish that subpath), and whatever makes it reachable owns what a broken - * shipped copy should do. Until then a failure there reads as "not found", and - * the `absent` notice names the location, so it is not silent either. + * ## The console leg is reached through `package.json` (#19922) + * + * The second place looked is the copy `@objectstack/console` ships in its + * `dist/` — objectui's public-tier registry at the pinned commit, copied in by + * `scripts/build-console.sh`. This leg used to ask for that file by its own + * subpath, which the console's `exports` map does not publish (it publishes + * `./package.json` alone): the resolve threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, a + * `catch` swallowed it, and a project with no manifest of its own was checked + * at parse level even where the console shipped the file. It now resolves the + * console's `package.json` from the CLI's OWN location and joins the file's + * path to it ({@link consoleSduiManifestPath}), which keeps `exports` closed. */ import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import chalk from 'chalk'; import type { AuthoringFinding } from '@objectstack/lint'; import { artifactPackages, packageBodyAsStack } from './artifact-packages.js'; @@ -67,8 +76,21 @@ import { authoringRuleUnionStack } from './stack-collections.js'; /** The file the project provides, looked for in the working directory. */ export const PROJECT_SDUI_MANIFEST_FILE = 'sdui.manifest.json'; -/** The copy shipped inside `@objectstack/console` — the second place looked. */ -export const CONSOLE_SDUI_MANIFEST_SPECIFIER = '@objectstack/console/dist/sdui.manifest.json'; +/** + * The copy shipped inside `@objectstack/console` — the second place looked — + * named as a package-relative path. ⛔ A name, not a specifier to resolve: the + * console's `exports` map publishes `./package.json` alone, so resolving this + * subpath throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. {@link consoleSduiManifestPath} + * reaches the file; this spelling names it only when `@objectstack/console` + * itself cannot be resolved. + */ +export const CONSOLE_SDUI_MANIFEST = '@objectstack/console/dist/sdui.manifest.json'; + +/** The one subpath the console's `exports` map publishes. */ +const CONSOLE_PACKAGE_JSON = '@objectstack/console/package.json'; + +/** Where `scripts/build-console.sh` puts the manifest, relative to the console package root. */ +const CONSOLE_MANIFEST_IN_PACKAGE = 'dist/sdui.manifest.json'; /** * The rule id the parse-level notice carries on every face: the `rule` of the @@ -88,12 +110,18 @@ export type SduiManifestResolution = } | { readonly status: 'absent'; - /** Every place looked, in order: an absolute path, then a package specifier. */ + /** + * Every place looked, in order: the project's absolute path, then the + * console copy's — absolute when `@objectstack/console` resolves, else + * {@link CONSOLE_SDUI_MANIFEST}. + */ readonly lookedAt: readonly string[]; } | { readonly status: 'unusable'; - /** The project manifest that exists but cannot be used. */ + /** Whose file: the project's own, or the copy `@objectstack/console` ships. */ + readonly source: 'project' | 'console'; + /** The manifest file that exists but cannot be used. */ readonly path: string; /** Why, as a clause: `it is not valid JSON (…)`. */ readonly reason: string; @@ -111,48 +139,70 @@ function isRecord(value: unknown): value is AnyRec { * manifest.components)`), so it is the shape floor — deeper checking is the * gate's own business once it has a manifest to check against. */ -function readManifestFile(path: string): SduiManifestResolution { +function readManifestFile(path: string, source: 'project' | 'console'): SduiManifestResolution { let text: string; try { text = readFileSync(path, 'utf8'); } catch (error) { - return { status: 'unusable', path, reason: `it could not be read (${(error as Error).message})` }; + return { status: 'unusable', source, path, reason: `it could not be read (${(error as Error).message})` }; } let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { - return { status: 'unusable', path, reason: `it is not valid JSON (${(error as Error).message})` }; + return { status: 'unusable', source, path, reason: `it is not valid JSON (${(error as Error).message})` }; } if (!isRecord(parsed) || !isRecord(parsed.components)) { - return { status: 'unusable', path, reason: 'it is not a JSON object with a `components` map' }; + return { status: 'unusable', source, path, reason: 'it is not a JSON object with a `components` map' }; } return { status: 'resolved', manifest: parsed, path }; } /** - * The manifest for the project in `cwd`, or the reason there is none. Never + * Where `@objectstack/console` keeps the manifest it ships, as an absolute + * path, or `undefined` when that package cannot be resolved from `origin`. + * Whether the file exists there is the caller's question. + * + * Reached through the console's `package.json` plus a join — the way + * `resolveConsolePath()` already locates this static-asset package — because + * the file's own subpath is not in the console's `exports` (see + * {@link CONSOLE_SDUI_MANIFEST}). + * + * `origin` defaults to THIS module, so the console found is the CLI's own + * declared dependency, released in one fixed version group with it. ⛔ Not + * `cwd`: under pnpm a project that does not itself depend on + * `@objectstack/console` cannot resolve it from its own directory, and the + * gate's strength would then depend on hoisting. The parameter exists for the + * pins, which drive an installed-package layout. + */ +export function consoleSduiManifestPath(origin: string | URL = import.meta.url): string | undefined { + let packageJson: string; + try { + packageJson = createRequire(origin).resolve(CONSOLE_PACKAGE_JSON); + } catch { + return undefined; + } + return join(dirname(packageJson), CONSOLE_MANIFEST_IN_PACKAGE); +} + +/** + * The manifest for the project in `cwd`, or the reason there is none: the + * project's own file first, then the copy `@objectstack/console` ships + * (located from `consoleOrigin`, see {@link consoleSduiManifestPath}). Never * throws: what an `unusable` answer costs is the caller's decision * ({@link resolveJsxGateManifest} refuses it; `init`'s scaffold check, which * reads the INVOKER's directory rather than the project's, does not). */ -export function resolveSduiManifest(cwd: string = process.cwd()): SduiManifestResolution { +export function resolveSduiManifest( + cwd: string = process.cwd(), + consoleOrigin: string | URL = import.meta.url, +): SduiManifestResolution { const projectManifest = join(cwd, PROJECT_SDUI_MANIFEST_FILE); - if (existsSync(projectManifest)) return readManifestFile(projectManifest); + if (existsSync(projectManifest)) return readManifestFile(projectManifest, 'project'); - // Fall back to the manifest shipped inside @objectstack/console (built from - // objectui's public-tier registry; the CLI already depends on it). See the - // header: this leg's failure semantics are not this module's to change. - try { - const consoleManifest = createRequire(import.meta.url).resolve(CONSOLE_SDUI_MANIFEST_SPECIFIER); - if (existsSync(consoleManifest)) { - const fromConsole = readManifestFile(consoleManifest); - if (fromConsole.status === 'resolved') return fromConsole; - } - } catch { - /* not found — reported below as a place looked */ - } - return { status: 'absent', lookedAt: [projectManifest, CONSOLE_SDUI_MANIFEST_SPECIFIER] }; + const consoleManifest = consoleSduiManifestPath(consoleOrigin); + if (consoleManifest !== undefined && existsSync(consoleManifest)) return readManifestFile(consoleManifest, 'console'); + return { status: 'absent', lookedAt: [projectManifest, consoleManifest ?? CONSOLE_SDUI_MANIFEST] }; } /** @@ -276,7 +326,9 @@ export function resolveJsxGateManifest( const hints = [ ` The JSX page gate reads this file to check the components and props of ${pages} kind:'html' ` + `page(s), and it does not fall back to parse-level checking while the file is present.`, - ' Fix the file (a JSON object with a `components` map), or remove it to check those pages at parse level only.', + resolution.source === 'project' + ? ' Fix the file (a JSON object with a `components` map), or remove it to check those pages at parse level only.' + : ' It is the copy @objectstack/console ships, so that install is damaged: reinstall @objectstack/console.', ]; printErrorToStderr(message); console.error(''); From f61057cc9e2d04d4fc02c0fb62d18cb39ab09e2e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:57:51 +0800 Subject: [PATCH 2/6] test(cli): pin the console manifest leg through a real installed-package layout, hermetic under pnpm's NODE_PATH Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/cli/src/utils/sdui-manifest.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index e49b0f81341..255996b1346 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -14,7 +14,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -86,12 +86,15 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { expect(r).toEqual({ status: 'resolved', manifest: MANIFEST, path: join(dir, PROJECT_SDUI_MANIFEST_FILE) }); }); - // Hermetic: the console is located from an origin inside this empty - // directory, where `@objectstack/console` does not resolve — so the answer - // does not depend on whether this checkout has built the console. Both legs - // are named, in order. + // Hermetic: the console is located from an origin nothing resolves from + // (`createRequire` refuses a relative one), so the answer does not depend on + // whether this checkout has built the console. ⚠️ An absolute origin in an + // empty directory is NOT that: a runner started through pnpm's `.bin` shim + // inherits a NODE_PATH carrying the virtual store's hoisted packages, and + // `@objectstack/console` resolves from anywhere through it. Both legs are + // named, in order; the console-leg block below pins the absolute spelling. it('absent: names the project path, then the console copy', () => { - expect(resolveSduiManifest(dir, pathToFileURL(join(dir, 'cli.mjs')))).toEqual({ + expect(resolveSduiManifest(dir, 'not-an-absolute-origin.mjs')).toEqual({ status: 'absent', lookedAt: [join(dir, PROJECT_SDUI_MANIFEST_FILE), CONSOLE_SDUI_MANIFEST], }); @@ -147,7 +150,8 @@ describe('the console leg — the copy @objectstack/console ships is reached, th let root = ''; let project = ''; beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'os-sdui-console-')); + // Real path: module resolution answers with one (`/var` is `/private/var` on macOS). + root = realpathSync(mkdtempSync(join(tmpdir(), 'os-sdui-console-'))); project = join(root, 'project'); mkdirSync(project); }); From 76284b17459e5023fef6069a659d098ed5589d7b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:02:27 +0800 Subject: [PATCH 3/6] test(cli): the e2e fixtures the live console manifest refuses move from div to box, and the notice cases name their precondition Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- ...build-json-failure-conversions.e2e.test.ts | 6 ++++- .../test/jsx-gate-manifest-notice.e2e.test.ts | 23 ++++++++++++++++--- .../test/lint-conversion-notices.e2e.test.ts | 4 +++- ...idate-json-failure-conversions.e2e.test.ts | 6 ++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index ac7dd2eae6a..c3d8de02471 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -123,6 +123,10 @@ function payloadOf(run: Run, label: string): Record { * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a * parameter so the negative control can run the identical shape with the * CANONICAL spelling, where there is nothing to convert. + * + * The page is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that + * would add an author-time error no exit here is about. */ function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '', extraTop = '' } = opts; @@ -132,7 +136,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket', diff --git a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts index 712a4720463..bbb81cd327e 100644 --- a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts +++ b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts @@ -42,17 +42,34 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFile } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; +import { consoleSduiManifestPath } from '../src/utils/sdui-manifest.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +/** + * Whether the manifest-less path is reachable in THIS checkout (#19922). With + * no project manifest the CLI falls back to the copy its own + * `@objectstack/console` dependency ships, located from the same module the + * spawned CLI runs (`src/`, through `bin/run-dev.js`). In the workspace that is + * `packages/console/dist/sdui.manifest.json`, which exists only where the + * console has been built — never in the CI job that runs this file, often on a + * developer's machine. Where it exists, a project without a manifest is fully + * validated against it and the notice has nothing to report, so the cases that + * need "no manifest anywhere" are SKIPPED, by name, rather than asserting a + * state this checkout cannot produce. Every rule they pin is also pinned, + * hermetically, in `src/utils/sdui-manifest.test.ts`. + */ +const CONSOLE_COPY = consoleSduiManifestPath(); +const NO_MANIFEST_UNREACHABLE = CONSOLE_COPY !== undefined && existsSync(CONSOLE_COPY); + /** `JSX_PARSE_LEVEL_ONLY_RULE` in `src/utils/sdui-manifest.ts`, spelled out as the published anchor. */ const RULE = 'sdui/jsx-parse-level-only'; @@ -259,7 +276,7 @@ afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); -describe('no manifest, kind:html pages — the notice, exit status unchanged', () => { +describe.skipIf(NO_MANIFEST_UNREACHABLE)('no manifest, kind:html pages — the notice, exit status unchanged', () => { it.each([ ['validate', '--strict'], ['build'], @@ -364,7 +381,7 @@ describe('[round 1] html pages carried only in packages[], beside a top-level pa COMMANDS.map((command) => [layout, command, notice, malformed] as const), ); - it.each(rows)('%s — os %s: the notice, counting the package page, exit 0', (_layout, command, notice) => { + it.skipIf(NO_MANIFEST_UNREACHABLE).each(rows)('%s — os %s: the notice, counting the package page, exit 0', (_layout, command, notice) => { const r = run(notice, command, '--json'); expect(r.code, r.stdout + r.stderr).toBe(0); const notices = noticesIn(payloadOf(r, `${notice} ${command}`)); diff --git a/packages/cli/test/lint-conversion-notices.e2e.test.ts b/packages/cli/test/lint-conversion-notices.e2e.test.ts index 99e7d66cf07..c5aa18a075e 100644 --- a/packages/cli/test/lint-conversion-notices.e2e.test.ts +++ b/packages/cli/test/lint-conversion-notices.e2e.test.ts @@ -123,13 +123,15 @@ function payloadOf(run: Run, label: string): Record { * * `source` is a single-root element: the `jsx-no-root` authoring rule rejects a * bare string, so a fixture that used one would never reach the exit under test. + * It is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922). */ function stack(ns: string, opts: { pageKind?: string } = {}): string { const { pageKind = 'jsx' } = opts; return ` export default { manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket', diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 524e3f1ffa4..ae678ba6fcc 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -152,6 +152,10 @@ function payloadOf(run: Run, label: string): Record { * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a * parameter so the negative control can run the identical shape with the * CANONICAL spelling, where there is nothing to convert. + * + * The page is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that + * would add an author-time error no exit here is about. */ function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '' } = opts; @@ -161,7 +165,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket', From 521b625f87246ded44e94ad12cc4ee50afcfa2d6 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:07:07 +0800 Subject: [PATCH 4/6] chore(changeset): the CLI console manifest fallback is a narrowing (minor, migration to box), and the one-producer note reads the file the way the CLI now does Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/19922-console-manifest-fallback.md | 43 +++++++++++++++++++ .changeset/sdui-manifest-one-producer.md | 10 ++--- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 .changeset/19922-console-manifest-fallback.md diff --git a/.changeset/19922-console-manifest-fallback.md b/.changeset/19922-console-manifest-fallback.md new file mode 100644 index 00000000000..9bb0c98781b --- /dev/null +++ b/.changeset/19922-console-manifest-fallback.md @@ -0,0 +1,43 @@ +--- +'@objectstack/cli': minor +--- + +fix(cli)!: a project with no `sdui.manifest.json` of its own has its `kind: 'html'` pages checked against the manifest `@objectstack/console` ships, so `div` and every other undeclared tag or prop is refused (#19922) + +Clause-②: no (narrowing) + + + +**BREAKING for `kind: 'html'` pages in projects without their own manifest.** + +**What changed.** `objectstack validate`, `objectstack compile` / `build` (which +`dev` and `start` run first) and `objectstack lint` check the `source` of a +`kind: 'html'` page against an SDUI component manifest: the `sdui.manifest.json` +in the directory the command runs in, then the copy `@objectstack/console` ships +as `dist/sdui.manifest.json`. The second lookup asked for that file by a subpath +the console package does not export, so it always failed, and a project with no +manifest of its own had its html pages checked at parse level only: syntax and +structure, never which components and props they use. The lookup now reaches the +shipped copy, and those pages get full component and prop validation. A tag or +prop the manifest does not declare is refused (`jsx-forbidden-tag`, +`jsx-unknown-component`, `jsx-unknown-prop`), naming the page and the tag, and +the command exits 1. Nothing refused these before: the parse-level check does +not know the component set, and the html-tier renderer still renders `div`, +deprecated there in favour of `box`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `
` … `
` in a `kind: 'html'` page | `` … ``, which takes the same `className` and children | +| any other tag or prop the command names | a component and prop the manifest declares | + +**What is not affected.** A project that keeps its own `sdui.manifest.json` is +checked against that file, as before. `kind: 'react'` pages and pages authored +as regions are not read by this gate. With no manifest reachable at all, the +pages are still checked at parse level, and the notice that says so is +unchanged. + +**A damaged install is refused, not skipped.** A shipped copy that is present +but cannot be read or parsed stops the command with exit 1, naming the file, +with the remedy: reinstall `@objectstack/console`. diff --git a/.changeset/sdui-manifest-one-producer.md b/.changeset/sdui-manifest-one-producer.md index f6b55f41eb1..913f3099bf8 100644 --- a/.changeset/sdui-manifest-one-producer.md +++ b/.changeset/sdui-manifest-one-producer.md @@ -13,8 +13,8 @@ browser-dumped copy into `dist/`, but the release build replaced `dist/` before none reached a tarball (17.0.0, 17.3.0 and 17.4.0 each list 0 matches). That browser dump is retired. It was byte-identical to the tracked file over the same built tree. -For now the file is only present in the tarball. This package's `exports` map exposes -`./package.json` and nothing else, so resolving `@objectstack/console/dist/sdui.manifest.json` -through `exports` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Anything that resolves through -`exports` cannot read the file yet. That includes the CLI's JSX-page manifest fallback, which -catches the error and keeps parse-level validation, as before. +The file is not an `exports` entry. This package's `exports` map exposes `./package.json` +and nothing else, so resolving `@objectstack/console/dist/sdui.manifest.json` through +`exports` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Read it the way the CLI's JSX-page +manifest fallback does: resolve `@objectstack/console/package.json` and join +`dist/sdui.manifest.json` to its directory. From d9dc0be3edcb02ea8341d415cdbdc57b06024c5b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:18:16 +0800 Subject: [PATCH 5/6] chore(changeset): the div narrowing's ADR-0087 disposition is a semantic ledger entry, not no-migration-prescription Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/19922-console-manifest-fallback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19922-console-manifest-fallback.md b/.changeset/19922-console-manifest-fallback.md index 9bb0c98781b..3c992d61023 100644 --- a/.changeset/19922-console-manifest-fallback.md +++ b/.changeset/19922-console-manifest-fallback.md @@ -6,7 +6,7 @@ fix(cli)!: a project with no `sdui.manifest.json` of its own has its `kind: 'htm Clause-②: no (narrowing) - + **BREAKING for `kind: 'html'` pages in projects without their own manifest.** From 77338a718672e62e4fe91e1df3dba43486b094bb Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:58:27 +0800 Subject: [PATCH 6/6] chore(changeset): the div ledger entry is already registered on main, the released 17.5.0 manifest note is corrected here, and two sentences say exactly when dev/start compile and what the console already refused Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/19922-console-manifest-fallback.md | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/.changeset/19922-console-manifest-fallback.md b/.changeset/19922-console-manifest-fallback.md index 3c992d61023..3e5132d0005 100644 --- a/.changeset/19922-console-manifest-fallback.md +++ b/.changeset/19922-console-manifest-fallback.md @@ -6,24 +6,34 @@ fix(cli)!: a project with no `sdui.manifest.json` of its own has its `kind: 'htm Clause-②: no (narrowing) - + **BREAKING for `kind: 'html'` pages in projects without their own manifest.** -**What changed.** `objectstack validate`, `objectstack compile` / `build` (which -`dev` and `start` run first) and `objectstack lint` check the `source` of a -`kind: 'html'` page against an SDUI component manifest: the `sdui.manifest.json` -in the directory the command runs in, then the copy `@objectstack/console` ships -as `dist/sdui.manifest.json`. The second lookup asked for that file by a subpath +**What changed.** `objectstack validate`, `objectstack compile` / `build` and +`objectstack lint` check the `source` of a `kind: 'html'` page against an SDUI +component manifest. (`dev` and `start` run `compile` before they boot when +`dist/objectstack.json` is missing or `--compile` is passed, and `dev`'s default +watch mode reruns it when a watched file changes.) They look first for the +`sdui.manifest.json` in the directory the command runs in, then for the copy +`@objectstack/console` ships as `dist/sdui.manifest.json`. The second lookup asked for that file by a subpath the console package does not export, so it always failed, and a project with no manifest of its own had its html pages checked at parse level only: syntax and structure, never which components and props they use. The lookup now reaches the shipped copy, and those pages get full component and prop validation. A tag or prop the manifest does not declare is refused (`jsx-forbidden-tag`, `jsx-unknown-component`, `jsx-unknown-prop`), naming the page and the tag, and -the command exits 1. Nothing refused these before: the parse-level check does -not know the component set, and the html-tier renderer still renders `div`, -deprecated there in favour of `box`. +the command exits 1. + +**What was refused before, and what is new.** The console has refused a `div` on +a `kind: 'html'` page since `@objectstack/console` 17.5.0: when the page renders, +its in-browser html compile answers `forbidden-tag`, naming `box`. These commands +now give that answer while you author. What they refuse that nothing refused +before is every other tag the manifest does not declare. The console's html +compile accepts every component its registry knows that is not deprecated there, +while the published manifest declares only the public component contract and the +html tier's intrinsic tags. So a page using, for example, `avatar` or `checkbox` +renders in the console and is refused here. ## FROM → TO @@ -41,3 +51,22 @@ unchanged. **A damaged install is refused, not skipped.** A shipped copy that is present but cannot be read or parsed stops the command with exit 1, naming the file, with the remedy: reinstall `@objectstack/console`. + +**A correction to the 17.5.0 note on this manifest.** The `@objectstack/console` +17.5.0 patch entry `28ce612`, the one that says the prebuilt Console dist now +ships `dist/sdui.manifest.json`, ends with a paragraph that this release changes, +sentence by sentence: + +- "For now the file is only present in the tarball." No longer true: the CLI + reads it, as described above. +- "This package's `exports` map exposes `./package.json` and nothing else, so + resolving `@objectstack/console/dist/sdui.manifest.json` through `exports` + fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`." Still true: the `exports` map is + unchanged. +- "Anything that resolves through `exports` cannot read the file yet." Still + true. To read the file, resolve `@objectstack/console/package.json` and join + `dist/sdui.manifest.json` to its directory. +- "That includes the CLI's JSX-page manifest fallback, which catches the error + and keeps parse-level validation, as before." True of the 17.5.0 CLI, false + from this release: the fallback now reads the file that way, so a project + without its own manifest is checked against the shipped copy.