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
72 changes: 72 additions & 0 deletions .changeset/19922-console-manifest-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
'@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)

<!-- adr-0087: not-required (already-registered ui-html-page-div-refused) The semantic ledger entry for this narrowing landed on main before this change, in its own pull request, so this diff adds none. `objectstack migrate meta --from 17` lists it among the manual changes, with `box` as the replacement for `div`. -->

**BREAKING for `kind: 'html'` pages in projects without their own manifest.**

**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.

**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

| you wrote | write instead |
|:--|:--|
| `<div>` … `</div>` in a `kind: 'html'` page | `<box>` … `</box>`, 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`.

**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.
136 changes: 126 additions & 10 deletions packages/cli/src/utils/sdui-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, realpathSync, 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,
Expand Down Expand Up @@ -59,10 +62,11 @@ const PACKAGE_CARRIED: Record<string, Record<string, unknown>> = {

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)',
};
Expand All @@ -82,13 +86,17 @@ 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 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, 'not-an-absolute-origin.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],
});
});

Expand All @@ -103,6 +111,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);
});
Expand All @@ -115,6 +124,113 @@ 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(() => {
// 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);
});
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
Expand Down
Loading
Loading