Skip to content

Commit 8a85dbb

Browse files
hotlongclaude
andcommitted
fix(cli): the JSX page gate reads the project's sdui.manifest.json beside the config, not in the invoker's cwd
`os validate`, `os build` and `os lint` resolved the project leg of the SDUI component manifest from `process.cwd()`, while every other project-relative lookup of the same run (the capability preflight, the access-matrix snapshot) reads the config's own directory. Run with an explicit config path from anywhere else, the project's own manifest was never read, and a manifest sitting in the invoker's directory judged a project it does not belong to. `resolveJsxGateManifest` now takes the project directory as a required argument, and the three commands hand it `dirname()` of the config path `loadConfig` resolved. `resolveSduiManifest(dir, consoleOrigin?)` keeps its signature and its working-directory default, which `init`'s scaffold check reads by its own decision. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bff748 commit 8a85dbb

7 files changed

Lines changed: 272 additions & 29 deletions

File tree

‎packages/cli/src/commands/compile.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,9 @@ export default class Compile extends Command {
481481
// too; it never changes this command's exit status. A project
482482
// manifest that exists but cannot be used is refused instead
483483
// (already reported on stderr; the catch-all exits 1).
484-
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
484+
// [#20166] Read beside the config this run was given, never in the
485+
// invoker's working directory.
486+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>, path.dirname(absolutePath));
485487
jsxGateNotices = [...jsxGate.notices];
486488
if (!flags.json) printJsxGateNotices(jsxGateNotices);
487489
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);

‎packages/cli/src/commands/lint.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

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

‎packages/cli/src/commands/validate.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,10 @@ export default class Validate extends Command {
446446
// this to a failure under `--strict` would break every such project.
447447
// A project manifest that exists but cannot be used is REFUSED
448448
// instead (thrown, already reported on stderr; the catch-all exits 1).
449-
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
449+
// [#20166] The project's manifest is the one beside the config this
450+
// run was given — the directory the capability preflight below reads
451+
// too — never the invoker's working directory.
452+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>, dirname(absolutePath));
450453
jsxGateNotices = [...jsxGate.notices];
451454
if (!flags.json) printJsxGateNotices(jsxGateNotices);
452455
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);

‎packages/cli/src/utils/sdui-manifest.test.ts‎

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* validate` / `os build` / `os lint`, and their exit statuses — are pinned by
77
* `test/jsx-gate-manifest-notice.e2e.test.ts`, which runs NIGHTLY (it spawns
88
* the CLI). This file is the per-PR guard, so every rule those faces read is
9-
* pinned HERE too — the package-carried layout included.
9+
* pinned HERE too — the package-carried layout included, and (#20166) the
10+
* project directory the manifest is read in: the config's, not the invoker's.
1011
*
1112
* ⛔ Anchors, not prose: the notice is found by its `rule` id and asserted on
1213
* the DATA it must carry (the page count and every place looked), never on the
@@ -18,7 +19,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
1819
import { createRequire } from 'node:module';
1920
import { tmpdir } from 'node:os';
2021
import { dirname, join } from 'node:path';
21-
import { pathToFileURL } from 'node:url';
22+
import { fileURLToPath, pathToFileURL } from 'node:url';
2223
import { validateJsxPages } from '@objectstack/lint';
2324
import {
2425
CONSOLE_SDUI_MANIFEST,
@@ -60,6 +61,9 @@ const PACKAGE_CARRIED: Record<string, Record<string, unknown>> = {
6061
},
6162
};
6263

64+
/** The project directory the injected answers below were made for. */
65+
const PROJECT_DIR = '/proj';
66+
6367
const ABSENT: SduiManifestResolution = {
6468
status: 'absent',
6569
lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST],
@@ -214,7 +218,7 @@ describe('the console leg — the copy @objectstack/console ships is reached, th
214218
try {
215219
let thrown: unknown;
216220
try {
217-
resolveJsxGateManifest(HTML_STACK, r);
221+
resolveJsxGateManifest(HTML_STACK, project, r);
218222
} catch (e) {
219223
thrown = e;
220224
}
@@ -338,13 +342,14 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
338342
});
339343

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

345349
it('absent with a page to check: parse level, and ONE notice carrying the count and every place looked', () => {
346350
const r = resolveJsxGateManifest(
347351
{ pages: [...HTML_STACK.pages, { name: 'b', kind: 'jsx', source: '<div />' }] },
352+
PROJECT_DIR,
348353
ABSENT,
349354
);
350355
expect(r.sduiManifest).toBeUndefined();
@@ -362,7 +367,7 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
362367
it.each(Object.entries(PACKAGE_CARRIED))(
363368
'absent, %s beside package-carried html pages: the notice, counting the package page',
364369
(_label, stack) => {
365-
const r = resolveJsxGateManifest(stack, ABSENT);
370+
const r = resolveJsxGateManifest(stack, PROJECT_DIR, ABSENT);
366371
expect(r.sduiManifest).toBeUndefined();
367372
expect(r.notices).toHaveLength(1);
368373
expect(r.notices[0]).toMatchObject({ severity: 'info', rule: JSX_PARSE_LEVEL_ONLY_RULE });
@@ -373,26 +378,30 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
373378
it.each(Object.entries(PACKAGE_CARRIED))(
374379
'unusable, %s beside package-carried html pages: refused, not waved through',
375380
(_label, stack) => {
376-
expect(() => resolveJsxGateManifest(stack, UNUSABLE)).toThrow(SduiManifestRefusalError);
381+
expect(() => resolveJsxGateManifest(stack, PROJECT_DIR, UNUSABLE)).toThrow(SduiManifestRefusalError);
377382
},
378383
);
379384

380385
it('absent with nothing to check: silence is the true answer', () => {
381-
expect(resolveJsxGateManifest(NO_PAGES_STACK, ABSENT)).toEqual({ sduiManifest: undefined, notices: [] });
386+
expect(resolveJsxGateManifest(NO_PAGES_STACK, PROJECT_DIR, ABSENT)).toEqual({ sduiManifest: undefined, notices: [] });
382387
expect(
383-
resolveJsxGateManifest({ pages: [{ name: 'r', kind: 'react', source: 'export default () => null' }] }, ABSENT),
388+
resolveJsxGateManifest(
389+
{ pages: [{ name: 'r', kind: 'react', source: 'export default () => null' }] },
390+
PROJECT_DIR,
391+
ABSENT,
392+
),
384393
).toEqual({ sduiManifest: undefined, notices: [] });
385394
});
386395

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

392401
it('unusable with a page to check: refused, reported once on stderr, no minted code', () => {
393402
let thrown: unknown;
394403
try {
395-
resolveJsxGateManifest(HTML_STACK, UNUSABLE);
404+
resolveJsxGateManifest(HTML_STACK, PROJECT_DIR, UNUSABLE);
396405
} catch (e) {
397406
thrown = e;
398407
}
@@ -409,13 +418,105 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
409418
});
410419
});
411420

421+
/**
422+
* [#20166] The project leg is read in the project directory the command hands
423+
* over — the config's own — and never in the invoker's working directory. The
424+
* invoker's directory is played by a `process.cwd()` spy, so the pin is
425+
* hermetic: a foreign directory that carries its OWN manifest is where a
426+
* working-directory reading would land, and it must not win.
427+
*/
428+
describe('resolveJsxGateManifest — the project directory is the config’s, never the invoker’s cwd', () => {
429+
const FOREIGN_MANIFEST = { components: { span: { type: 'span', inputs: [{ name: 'children', type: 'slot' }] } } };
430+
let root = '';
431+
let project = '';
432+
let foreign = '';
433+
let cwdSpy: ReturnType<typeof vi.spyOn>;
434+
beforeEach(() => {
435+
root = realpathSync(mkdtempSync(join(tmpdir(), 'os-sdui-project-dir-')));
436+
project = join(root, 'project');
437+
foreign = join(root, 'foreign');
438+
mkdirSync(project);
439+
mkdirSync(foreign);
440+
writeFileSync(join(foreign, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(FOREIGN_MANIFEST));
441+
cwdSpy = vi.spyOn(process, 'cwd');
442+
});
443+
afterEach(() => {
444+
cwdSpy.mockRestore();
445+
rmSync(root, { recursive: true, force: true });
446+
});
447+
448+
it('lit control: standing in the foreign directory, the working-directory default reads ITS manifest', () => {
449+
cwdSpy.mockReturnValue(foreign);
450+
expect(resolveSduiManifest()).toEqual({
451+
status: 'resolved',
452+
manifest: FOREIGN_MANIFEST,
453+
path: join(foreign, PROJECT_SDUI_MANIFEST_FILE),
454+
});
455+
});
456+
457+
it('a foreign cwd carrying its own manifest does not win: the manifest beside the config is read', () => {
458+
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
459+
cwdSpy.mockReturnValue(foreign);
460+
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
461+
});
462+
463+
it('control: standing in the project directory itself, the same answer', () => {
464+
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
465+
cwdSpy.mockReturnValue(project);
466+
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
467+
});
468+
469+
it('a project with no manifest of its own does not borrow the foreign one', () => {
470+
cwdSpy.mockReturnValue(foreign);
471+
const r = resolveJsxGateManifest(HTML_STACK, project);
472+
// Whatever the console leg answers in this checkout, it is never the
473+
// foreign file, and a notice (where one is due) names the project's path.
474+
expect(r.sduiManifest).not.toEqual(FOREIGN_MANIFEST);
475+
for (const n of r.notices) {
476+
expect(n.message).toContain(join(project, PROJECT_SDUI_MANIFEST_FILE));
477+
expect(n.message).not.toContain(foreign);
478+
}
479+
});
480+
481+
it('a malformed manifest in the foreign cwd refuses nothing: it is not the project’s', () => {
482+
writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST));
483+
writeFileSync(join(foreign, PROJECT_SDUI_MANIFEST_FILE), '{ "components": [ oops');
484+
cwdSpy.mockReturnValue(foreign);
485+
expect(resolveJsxGateManifest(HTML_STACK, project)).toEqual({ sduiManifest: MANIFEST, notices: [] });
486+
});
487+
});
488+
489+
/**
490+
* [#20166] The seam the pins above cannot reach: each of the three authoring
491+
* commands hands the gate the directory of the config `loadConfig` resolved.
492+
* The command-level behaviour — an explicit config path run from a foreign
493+
* directory — is pinned by `test/jsx-gate-manifest-notice.e2e.test.ts`, which
494+
* runs NIGHTLY; this is its per-PR half.
495+
*/
496+
describe('the three authoring commands hand the gate the config’s directory', () => {
497+
const COMMANDS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'commands');
498+
const CALL = /resolveJsxGateManifest\(/g;
499+
// One call, bounded by its `;`: the stack, then `dirname(absolutePath)`.
500+
const CONFIG_DIR_CALL = /resolveJsxGateManifest\([^;]*?,\s*(?:path\.)?dirname\(absolutePath\)\s*\);/g;
501+
502+
it.each(['validate.ts', 'compile.ts', 'lint.ts'])('%s', (file) => {
503+
const source = readFileSync(join(COMMANDS_DIR, file), 'utf8');
504+
// `absolutePath` is the path `loadConfig` resolved for this run.
505+
expect(source).toMatch(/const \{[^}]*\babsolutePath\b[^}]*\} = loaded;/);
506+
expect(source).toMatch(/const loaded = await loadConfig\(/);
507+
const calls = source.match(CALL) ?? [];
508+
expect(calls).toHaveLength(1);
509+
expect(source.match(CONFIG_DIR_CALL) ?? []).toHaveLength(calls.length);
510+
});
511+
});
512+
412513
describe('printJsxGateNotices — the text face of `os validate` / `os build`', () => {
413514
it('prints the rule tag and the hint, and nothing for an empty list', () => {
414515
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
415516
try {
416517
printJsxGateNotices([]);
417518
expect(log).not.toHaveBeenCalled();
418-
printJsxGateNotices(resolveJsxGateManifest(HTML_STACK, ABSENT).notices);
519+
printJsxGateNotices(resolveJsxGateManifest(HTML_STACK, PROJECT_DIR, ABSENT).notices);
419520
const out = log.mock.calls.map((c) => String(c[0])).join('\n');
420521
expect(out).toContain(`[${JSX_PARSE_LEVEL_ONLY_RULE}]`);
421522
expect(out).toContain('/proj/sdui.manifest.json');

‎packages/cli/src/utils/sdui-manifest.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@
6262
* at parse level even where the console shipped the file. It now resolves the
6363
* console's `package.json` from the CLI's OWN location and joins the file's
6464
* path to it ({@link consoleSduiManifestPath}), which keeps `exports` closed.
65+
*
66+
* ## The project leg is read beside the config, not in the invoker's cwd (#20166)
67+
*
68+
* The first place looked is the project's own `sdui.manifest.json`, and the
69+
* project is the directory of the config the command was given. `os validate
70+
* path/to/objectstack.config.ts` locates everything else about that project
71+
* from there — the capability preflight's `projectDir`, the access-matrix
72+
* snapshot beside the config — so the manifest follows the same root. It used
73+
* to follow the invoker's working directory instead: run from anywhere else,
74+
* the command never read the project's own manifest, and a manifest that
75+
* happened to sit in the invoker's directory judged a project it does not
76+
* belong to. {@link resolveJsxGateManifest} therefore takes the project
77+
* directory as a REQUIRED argument, with no working-directory default for a
78+
* caller to fall into; `os validate`, `os build` and `os lint` hand it
79+
* `dirname()` of the config path `loadConfig` resolved. A run started in the
80+
* project's own directory is unchanged, because that directory is both.
6581
*/
6682

6783
import { existsSync, readFileSync } from 'node:fs';
@@ -73,7 +89,7 @@ import { artifactPackages, packageBodyAsStack } from './artifact-packages.js';
7389
import { printErrorToStderr, printInfo } from './format.js';
7490
import { authoringRuleUnionStack } from './stack-collections.js';
7591

76-
/** The file the project provides, looked for in the working directory. */
92+
/** The file the project provides, looked for in the project directory: the config's own directory. */
7793
export const PROJECT_SDUI_MANIFEST_FILE = 'sdui.manifest.json';
7894

7995
/**
@@ -186,12 +202,20 @@ export function consoleSduiManifestPath(origin: string | URL = import.meta.url):
186202
}
187203

188204
/**
189-
* The manifest for the project in `cwd`, or the reason there is none: the
190-
* project's own file first, then the copy `@objectstack/console` ships
191-
* (located from `consoleOrigin`, see {@link consoleSduiManifestPath}). Never
192-
* throws: what an `unusable` answer costs is the caller's decision
205+
* The manifest for the project whose directory is `cwd`, or the reason there
206+
* is none: the project's own file first, then the copy `@objectstack/console`
207+
* ships (located from `consoleOrigin`, see {@link consoleSduiManifestPath}).
208+
* Never throws: what an `unusable` answer costs is the caller's decision
193209
* ({@link resolveJsxGateManifest} refuses it; `init`'s scaffold check, which
194210
* reads the INVOKER's directory rather than the project's, does not).
211+
*
212+
* ⚠️ Despite its name, `cwd` is the PROJECT directory — the directory of the
213+
* config the command was given — whenever a command judges a project: the
214+
* three authoring commands pass it through {@link resolveJsxGateManifest}.
215+
* The working-directory default serves `init`'s scaffold check alone, whose
216+
* module header records that reading as its own decision. ⛔ A new caller that
217+
* has a config path passes that path's directory: never `process.cwd()`, and
218+
* never the default.
195219
*/
196220
export function resolveSduiManifest(
197221
cwd: string = process.cwd(),
@@ -312,10 +336,17 @@ export interface JsxGateManifest {
312336
* holds. Throws {@link SduiManifestRefusalError} for an `unusable` project
313337
* manifest when there is a page to check; see the header for the three
314338
* outcomes.
339+
*
340+
* `projectDir` is the directory of the config the command was given, and it
341+
* is required: see the header for why the project leg is read there and not
342+
* in the invoker's working directory (#20166). `resolution` is the pins'
343+
* seam — an answer already made, standing in for the resolver's over
344+
* `projectDir`.
315345
*/
316346
export function resolveJsxGateManifest(
317347
stack: AnyRec,
318-
resolution: SduiManifestResolution = resolveSduiManifest(),
348+
projectDir: string,
349+
resolution: SduiManifestResolution = resolveSduiManifest(projectDir),
319350
): JsxGateManifest {
320351
if (resolution.status === 'resolved') return { sduiManifest: resolution.manifest, notices: [] };
321352
const pages = countJsxGatePages(stack);

0 commit comments

Comments
 (0)