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
56 changes: 56 additions & 0 deletions .changeset/20166-jsx-gate-manifest-beside-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@objectstack/cli': minor
---

fix(cli)!: `objectstack validate`, `objectstack build` and `objectstack lint` read the project's `sdui.manifest.json` beside the config they were given, not in the directory they were run from (#20166)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which manifest file judges a run whose config path names another directory. -->

**BREAKING for runs given a config path in another directory.**

**What changed.** These commands check the `source` of each `kind: 'html'` page
against an SDUI component manifest: the project's own `sdui.manifest.json` first,
then the copy `@objectstack/console` ships. They located everything else about a
project from the directory of its config, but looked for the project's own
manifest in the directory the command was run from. So
`objectstack validate path/to/app/objectstack.config.ts`, run from anywhere else,
never read `path/to/app/sdui.manifest.json`, and a manifest that happened to sit in
the directory it was run from judged a project it does not belong to. They now read
the manifest beside the config.

## Which manifest each run reads

| the run | the project manifest it read | the project manifest it reads now |
|:--|:--|:--|
| `objectstack validate` / `build` / `lint` with no config path, in the project's directory | `./sdui.manifest.json` | `./sdui.manifest.json` (unchanged) |
| the same commands given `path/to/app/objectstack.config.ts`, run from another directory | that other directory's `sdui.manifest.json` | `path/to/app/sdui.manifest.json` |

When the project carries no manifest of its own, both rows then fall back to the
copy `@objectstack/console` ships, as before.

**Which runs change, and which way.** Only runs whose config path names a directory
other than the one they run in. For those, the verdict can move in both directions:

- A page the project's own manifest does not declare is now refused
(`jsx-forbidden-tag`, `jsx-unknown-component`, `jsx-unknown-prop`, exit 1), where
the other directory's manifest, or the console's copy, used to admit it.
- A project manifest that is present but not usable is now refused (exit 1), naming
that file, where the run used to read some other file.
- A project with no manifest of its own is now checked against the console's copy,
where the other directory's manifest used to decide.
- In the other direction, a page the other directory's manifest refused, and that
the project's own manifest (or the console's copy) declares, is now admitted.

If such a run now fails, the manifest that belongs to the project is the one to
keep beside its config.

**What is not affected.** A run in the project's own directory, with or without a
config path, reads the same file as before. `objectstack init`'s check of a freshly
generated scaffold keeps reading the directory it was run from.

**A correction to this release's console-fallback entry.** That entry says these
commands "look first for the `sdui.manifest.json` in the directory the command runs
in". From this release they look first beside the config the command was given,
which is the same directory whenever the command runs in the project.
4 changes: 3 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,9 @@ export default class Compile extends Command {
// too; it never changes this command's exit status. A project
// manifest that exists but cannot be used is refused instead
// (already reported on stderr; the catch-all exits 1).
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
// [#20166] Read beside the config this run was given, never in the
// invoker's working directory.
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>, path.dirname(absolutePath));
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { dirname } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { bundleRequire } from 'bundle-require';
Expand Down Expand Up @@ -997,7 +998,9 @@ export default class Lint extends Command {
// reaches that function without a manifest and must not score a notice
// about the filesystem. A project manifest that exists but cannot be
// used is refused instead (already reported on stderr; exit 1).
const jsxGate = resolveJsxGateManifest(normalized as Record<string, unknown>);
// [#20166] Read beside the config this run was given, never in the
// invoker's working directory.
const jsxGate = resolveJsxGateManifest(normalized as Record<string, unknown>, dirname(absolutePath));
const issues = lintConfig(normalized, { sduiManifest: jsxGate.sduiManifest });
issues.push(...jsxGate.notices.map(authoringFindingToLintIssue));

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ export default class Validate extends Command {
// this to a failure under `--strict` would break every such project.
// A project manifest that exists but cannot be used is REFUSED
// instead (thrown, already reported on stderr; the catch-all exits 1).
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
// [#20166] The project's manifest is the one beside the config this
// run was given — the directory the capability preflight below reads
// too — never the invoker's working directory.
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>, dirname(absolutePath));
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
Expand Down
123 changes: 112 additions & 11 deletions packages/cli/src/utils/sdui-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* validate` / `os build` / `os lint`, and their exit statuses — are pinned by
* `test/jsx-gate-manifest-notice.e2e.test.ts`, which runs NIGHTLY (it spawns
* the CLI). This file is the per-PR guard, so every rule those faces read is
* pinned HERE too — the package-carried layout included.
* pinned HERE too — the package-carried layout included, and (#20166) the
* project directory the manifest is read in: the config's, not the invoker's.
*
* ⛔ Anchors, not prose: the notice is found by its `rule` id and asserted on
* the DATA it must carry (the page count and every place looked), never on the
Expand All @@ -18,7 +19,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { validateJsxPages } from '@objectstack/lint';
import {
CONSOLE_SDUI_MANIFEST,
Expand Down Expand Up @@ -60,6 +61,9 @@ const PACKAGE_CARRIED: Record<string, Record<string, unknown>> = {
},
};

/** The project directory the injected answers below were made for. */
const PROJECT_DIR = '/proj';

const ABSENT: SduiManifestResolution = {
status: 'absent',
lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST],
Expand Down Expand Up @@ -214,7 +218,7 @@ describe('the console leg — the copy @objectstack/console ships is reached, th
try {
let thrown: unknown;
try {
resolveJsxGateManifest(HTML_STACK, r);
resolveJsxGateManifest(HTML_STACK, project, r);
} catch (e) {
thrown = e;
}
Expand Down Expand Up @@ -338,13 +342,14 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
});

it('resolved: arms the gate and says nothing', () => {
const r = resolveJsxGateManifest(HTML_STACK, { status: 'resolved', manifest: MANIFEST, path: '/p' });
const r = resolveJsxGateManifest(HTML_STACK, PROJECT_DIR, { status: 'resolved', manifest: MANIFEST, path: '/p' });
expect(r).toEqual({ sduiManifest: MANIFEST, notices: [] });
});

it('absent with a page to check: parse level, and ONE notice carrying the count and every place looked', () => {
const r = resolveJsxGateManifest(
{ pages: [...HTML_STACK.pages, { name: 'b', kind: 'jsx', source: '<div />' }] },
PROJECT_DIR,
ABSENT,
);
expect(r.sduiManifest).toBeUndefined();
Expand All @@ -362,7 +367,7 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
it.each(Object.entries(PACKAGE_CARRIED))(
'absent, %s beside package-carried html pages: the notice, counting the package page',
(_label, stack) => {
const r = resolveJsxGateManifest(stack, ABSENT);
const r = resolveJsxGateManifest(stack, PROJECT_DIR, ABSENT);
expect(r.sduiManifest).toBeUndefined();
expect(r.notices).toHaveLength(1);
expect(r.notices[0]).toMatchObject({ severity: 'info', rule: JSX_PARSE_LEVEL_ONLY_RULE });
Expand All @@ -373,26 +378,30 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
it.each(Object.entries(PACKAGE_CARRIED))(
'unusable, %s beside package-carried html pages: refused, not waved through',
(_label, stack) => {
expect(() => resolveJsxGateManifest(stack, UNUSABLE)).toThrow(SduiManifestRefusalError);
expect(() => resolveJsxGateManifest(stack, PROJECT_DIR, UNUSABLE)).toThrow(SduiManifestRefusalError);
},
);

it('absent with nothing to check: silence is the true answer', () => {
expect(resolveJsxGateManifest(NO_PAGES_STACK, ABSENT)).toEqual({ sduiManifest: undefined, notices: [] });
expect(resolveJsxGateManifest(NO_PAGES_STACK, PROJECT_DIR, ABSENT)).toEqual({ sduiManifest: undefined, notices: [] });
expect(
resolveJsxGateManifest({ pages: [{ name: 'r', kind: 'react', source: 'export default () => null' }] }, ABSENT),
resolveJsxGateManifest(
{ pages: [{ name: 'r', kind: 'react', source: 'export default () => null' }] },
PROJECT_DIR,
ABSENT,
),
).toEqual({ sduiManifest: undefined, notices: [] });
});

it('unusable with nothing to check: not read by anything, so not refused', () => {
expect(resolveJsxGateManifest(NO_PAGES_STACK, UNUSABLE)).toEqual({ sduiManifest: undefined, notices: [] });
expect(resolveJsxGateManifest(NO_PAGES_STACK, PROJECT_DIR, UNUSABLE)).toEqual({ sduiManifest: undefined, notices: [] });
expect(errSpy).not.toHaveBeenCalled();
});

it('unusable with a page to check: refused, reported once on stderr, no minted code', () => {
let thrown: unknown;
try {
resolveJsxGateManifest(HTML_STACK, UNUSABLE);
resolveJsxGateManifest(HTML_STACK, PROJECT_DIR, UNUSABLE);
} catch (e) {
thrown = e;
}
Expand All @@ -409,13 +418,105 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
});
});

/**
* [#20166] The project leg is read in the project directory the command hands
* over — the config's own — and never in the invoker's working directory. The
* invoker's directory is played by a `process.cwd()` spy, so the pin is
* hermetic: a foreign directory that carries its OWN manifest is where a
* working-directory reading would land, and it must not win.
*/
describe('resolveJsxGateManifest — the project directory is the config’s, never the invoker’s cwd', () => {
const FOREIGN_MANIFEST = { components: { span: { type: 'span', inputs: [{ name: 'children', type: 'slot' }] } } };
let root = '';
let project = '';
let foreign = '';
let cwdSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'os-sdui-project-dir-')));
project = join(root, 'project');
foreign = join(root, 'foreign');
mkdirSync(project);
mkdirSync(foreign);
writeFileSync(join(foreign, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(FOREIGN_MANIFEST));
cwdSpy = vi.spyOn(process, 'cwd');
});
afterEach(() => {
cwdSpy.mockRestore();
rmSync(root, { recursive: true, force: true });
});

it('lit control: standing in the foreign directory, the working-directory default reads ITS manifest', () => {
cwdSpy.mockReturnValue(foreign);
expect(resolveSduiManifest()).toEqual({
status: 'resolved',
manifest: FOREIGN_MANIFEST,
path: join(foreign, PROJECT_SDUI_MANIFEST_FILE),
});
});

it('a foreign cwd carrying its own manifest does not win: the manifest beside the config is read', () => {
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
cwdSpy.mockReturnValue(foreign);
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
});

it('control: standing in the project directory itself, the same answer', () => {
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
cwdSpy.mockReturnValue(project);
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
});

it('a project with no manifest of its own does not borrow the foreign one', () => {
cwdSpy.mockReturnValue(foreign);
const r = resolveJsxGateManifest(HTML_STACK, project);
// Whatever the console leg answers in this checkout, it is never the
// foreign file, and a notice (where one is due) names the project's path.
expect(r.sduiManifest).not.toEqual(FOREIGN_MANIFEST);
for (const n of r.notices) {
expect(n.message).toContain(join(project, PROJECT_SDUI_MANIFEST_FILE));
expect(n.message).not.toContain(foreign);
}
});

it('a malformed manifest in the foreign cwd refuses nothing: it is not the project’s', () => {
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
writeFileSync(join(foreign, PROJECT_SDUI_MANIFEST_FILE), '{ "components": [ oops');
cwdSpy.mockReturnValue(foreign);
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
});
});

/**
* [#20166] The seam the pins above cannot reach: each of the three authoring
* commands hands the gate the directory of the config `loadConfig` resolved.
* The command-level behaviour — an explicit config path run from a foreign
* directory — is pinned by `test/jsx-gate-manifest-notice.e2e.test.ts`, which
* runs NIGHTLY; this is its per-PR half.
*/
describe('the three authoring commands hand the gate the config’s directory', () => {
const COMMANDS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'commands');
const CALL = /resolveJsxGateManifest\(/g;
// One call, bounded by its `;`: the stack, then `dirname(absolutePath)`.
const CONFIG_DIR_CALL = /resolveJsxGateManifest\([^;]*?,\s*(?:path\.)?dirname\(absolutePath\)\s*\);/g;

it.each(['validate.ts', 'compile.ts', 'lint.ts'])('%s', (file) => {
const source = readFileSync(join(COMMANDS_DIR, file), 'utf8');
// `absolutePath` is the path `loadConfig` resolved for this run.
expect(source).toMatch(/const \{[^}]*\babsolutePath\b[^}]*\} = loaded;/);
expect(source).toMatch(/const loaded = await loadConfig\(/);
const calls = source.match(CALL) ?? [];
expect(calls).toHaveLength(1);
expect(source.match(CONFIG_DIR_CALL) ?? []).toHaveLength(calls.length);
});
});

describe('printJsxGateNotices — the text face of `os validate` / `os build`', () => {
it('prints the rule tag and the hint, and nothing for an empty list', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
printJsxGateNotices([]);
expect(log).not.toHaveBeenCalled();
printJsxGateNotices(resolveJsxGateManifest(HTML_STACK, ABSENT).notices);
printJsxGateNotices(resolveJsxGateManifest(HTML_STACK, PROJECT_DIR, ABSENT).notices);
const out = log.mock.calls.map((c) => String(c[0])).join('\n');
expect(out).toContain(`[${JSX_PARSE_LEVEL_ONLY_RULE}]`);
expect(out).toContain('/proj/sdui.manifest.json');
Expand Down
Loading
Loading