diff --git a/.changeset/20166-jsx-gate-manifest-beside-config.md b/.changeset/20166-jsx-gate-manifest-beside-config.md new file mode 100644 index 00000000000..bf0d990aa45 --- /dev/null +++ b/.changeset/20166-jsx-gate-manifest-beside-config.md @@ -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) + + + +**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. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 11817da9648..0dd6cd86d22 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -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); + // [#20166] Read beside the config this run was given, never in the + // invoker's working directory. + const jsxGate = resolveJsxGateManifest(result.data as Record, path.dirname(absolutePath)); jsxGateNotices = [...jsxGate.notices]; if (!flags.json) printJsxGateNotices(jsxGateNotices); const parsedUnion = authoringRuleUnionStack(result.data as Record); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 9a2477dbac6..56422eeeaeb 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -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'; @@ -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); + // [#20166] Read beside the config this run was given, never in the + // invoker's working directory. + const jsxGate = resolveJsxGateManifest(normalized as Record, dirname(absolutePath)); const issues = lintConfig(normalized, { sduiManifest: jsxGate.sduiManifest }); issues.push(...jsxGate.notices.map(authoringFindingToLintIssue)); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 325956c31b8..5c311e8171d 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -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); + // [#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, dirname(absolutePath)); jsxGateNotices = [...jsxGate.notices]; if (!flags.json) printJsxGateNotices(jsxGateNotices); const parsedUnion = authoringRuleUnionStack(result.data as Record); diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index 255996b1346..f5665f1d4d1 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -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 @@ -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, @@ -60,6 +61,9 @@ const PACKAGE_CARRIED: Record> = { }, }; +/** 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], @@ -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; } @@ -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: '
' }] }, + PROJECT_DIR, ABSENT, ); expect(r.sduiManifest).toBeUndefined(); @@ -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 }); @@ -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; } @@ -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; + 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'); diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index be1f8d4ad33..fb0908a2335 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -62,6 +62,22 @@ * 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. + * + * ## The project leg is read beside the config, not in the invoker's cwd (#20166) + * + * The first place looked is the project's own `sdui.manifest.json`, and the + * project is the directory of the config the command was given. `os validate + * path/to/objectstack.config.ts` locates everything else about that project + * from there — the capability preflight's `projectDir`, the access-matrix + * snapshot beside the config — so the manifest follows the same root. It used + * to follow the invoker's working directory instead: run from anywhere else, + * the command never read the project's own manifest, and a manifest that + * happened to sit in the invoker's directory judged a project it does not + * belong to. {@link resolveJsxGateManifest} therefore takes the project + * directory as a REQUIRED argument, with no working-directory default for a + * caller to fall into; `os validate`, `os build` and `os lint` hand it + * `dirname()` of the config path `loadConfig` resolved. A run started in the + * project's own directory is unchanged, because that directory is both. */ import { existsSync, readFileSync } from 'node:fs'; @@ -73,7 +89,7 @@ import { artifactPackages, packageBodyAsStack } from './artifact-packages.js'; import { printErrorToStderr, printInfo } from './format.js'; import { authoringRuleUnionStack } from './stack-collections.js'; -/** The file the project provides, looked for in the working directory. */ +/** The file the project provides, looked for in the project directory: the config's own directory. */ export const PROJECT_SDUI_MANIFEST_FILE = 'sdui.manifest.json'; /** @@ -186,12 +202,20 @@ export function consoleSduiManifestPath(origin: string | URL = import.meta.url): } /** - * 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 + * The manifest for the project whose directory is `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). + * + * ⚠️ Despite its name, `cwd` is the PROJECT directory — the directory of the + * config the command was given — whenever a command judges a project: the + * three authoring commands pass it through {@link resolveJsxGateManifest}. + * The working-directory default serves `init`'s scaffold check alone, whose + * module header records that reading as its own decision. ⛔ A new caller that + * has a config path passes that path's directory: never `process.cwd()`, and + * never the default. */ export function resolveSduiManifest( cwd: string = process.cwd(), @@ -312,10 +336,17 @@ export interface JsxGateManifest { * holds. Throws {@link SduiManifestRefusalError} for an `unusable` project * manifest when there is a page to check; see the header for the three * outcomes. + * + * `projectDir` is the directory of the config the command was given, and it + * is required: see the header for why the project leg is read there and not + * in the invoker's working directory (#20166). `resolution` is the pins' + * seam — an answer already made, standing in for the resolver's over + * `projectDir`. */ export function resolveJsxGateManifest( stack: AnyRec, - resolution: SduiManifestResolution = resolveSduiManifest(), + projectDir: string, + resolution: SduiManifestResolution = resolveSduiManifest(projectDir), ): JsxGateManifest { if (resolution.status === 'resolved') return { sduiManifest: resolution.manifest, notices: [] }; const pages = countJsxGatePages(stack); 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 bbb81cd327e..537bf79733a 100644 --- a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts +++ b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts @@ -24,7 +24,13 @@ * a `kind: 'full'` page) beside html pages that live only in `packages[]`: * the union fold keeps the top-level key, but the per-package pass still * hands those pages to the gate, so the notice (counting them) and the - * refusal both fire there too, on all three commands. + * refusal both fire there too, on all three commands; + * - [#20166] an EXPLICIT config path run from somewhere else reads the + * manifest beside that config: a foreign working directory carrying its + * own `sdui.manifest.json` does not win, and a bare one is not where the + * project's manifest is looked for. The control is a run from the config's + * own directory, and a lit control shows the foreign manifest really + * refuses the page when it IS the project's. * * The notice is found by its rule id — an anchor — never by its prose. * @@ -44,7 +50,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFile } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -178,6 +184,8 @@ const TOP_FULL = "[{ name: 'jxg_home', label: 'Home', kind: 'full', regions: [] /** Declares exactly the one component the fixture page uses. */ const MANIFEST = JSON.stringify({ components: { div: { type: 'div', inputs: [{ name: 'children', type: 'slot' }] } } }); const MALFORMED = '{ "components": [ oops'; +/** [#20166] Declares `span` only, so the fixture page's `div` is refused wherever this file judges it. */ +const FOREIGN_MANIFEST = JSON.stringify({ components: { span: { type: 'span', inputs: [{ name: 'children', type: 'slot' }] } } }); type Fixture = | 'noManifest' @@ -189,7 +197,8 @@ type Fixture = | 'pkgTopEmpty' | 'pkgTopFull' | 'pkgTopEmptyMalformed' - | 'pkgTopFullMalformed'; + | 'pkgTopFullMalformed' + | 'foreign'; /** The package-carried fixtures, by layout, as the `it.each` rows below read them. */ const PACKAGE_LAYOUTS = [ @@ -210,6 +219,9 @@ const FIXTURES: Record = { pkgTopFull: { config: packageCarried(TOP_FULL) }, pkgTopEmptyMalformed: { config: packageCarried(TOP_EMPTY), manifest: MALFORMED }, pkgTopFullMalformed: { config: packageCarried(TOP_FULL), manifest: MALFORMED }, + // [#20166] A directory carrying its OWN project and manifest — the foreign + // working directory the runs below stand in. + foreign: { config: stack('
hi
'), manifest: FOREIGN_MANIFEST }, }; /** Every spawn this file reads, keyed `fixture|args`. */ @@ -232,6 +244,7 @@ const PLAN: ReadonlyArray = [ ['malformed', ['build', '--json']], ['malformed', ['lint', '--json']], ['noPagesMalformed', ['validate', '--json']], + ['foreign', ['validate', '--json']], ...PACKAGE_LAYOUTS.flatMap(([, notice, malformed]) => COMMANDS.flatMap((command) => [ [notice, [command, '--json']] as const, @@ -245,6 +258,48 @@ const runs = new Map(); const dirs = {} as Record; let root = ''; +/** + * [#20166] Runs of the `withManifest` project — its manifest declares the + * page's `div` — handed its config path EXPLICITLY, from where the run stands: + * + * besideConfig the config's own directory (the control); + * foreign a directory carrying its own project and a manifest that + * refuses `div` — where a working-directory reading lands; + * bare a directory with no manifest at all. + * + * `relative` spells the config path relative to where the run stands, the + * way an author types it; `absolute` is the resolved path. + */ +type Stand = 'besideConfig' | 'foreign' | 'bare'; +type Spelling = 'absolute' | 'relative'; +const ELSEWHERE_PLAN: ReadonlyArray = [ + ...COMMANDS.flatMap((command) => + (['besideConfig', 'foreign', 'bare'] as const).map((stand) => [stand, 'absolute', command] as const), + ), + ['foreign', 'relative', 'validate'], +]; +const elsewhereKey = (stand: Stand, spelling: Spelling, command: string) => `${stand}|${spelling}|${command}`; +const elsewhereRuns = new Map(); + +function elsewhere(stand: Stand, spelling: Spelling, command: string): Run { + const r = elsewhereRuns.get(elsewhereKey(stand, spelling, command)); + if (!r) throw new Error(`not in ELSEWHERE_PLAN: ${elsewhereKey(stand, spelling, command)}`); + return r; +} + +/** Every `jsx-*` finding a payload carries, in any of its lists. */ +function jsxFindingsIn(payload: Record): Array> { + const all = [ + ...(Array.isArray(payload.errors) ? payload.errors : []), + ...(Array.isArray(payload.warnings) ? payload.warnings : []), + ...(Array.isArray(payload.issues) ? payload.issues : []), + ] as unknown[]; + return all.filter( + (x): x is Record => + typeof x === 'object' && x !== null && String((x as { rule?: unknown }).rule ?? '').startsWith('jsx-'), + ); +} + function run(fixture: Fixture, ...args: string[]): Run { const r = runs.get(key(fixture, args)); if (!r) throw new Error(`not in PLAN: ${key(fixture, args)}`); @@ -261,13 +316,22 @@ beforeAll(async () => { if (f.manifest !== undefined) writeFileSync(join(dir, 'sdui.manifest.json'), f.manifest); dirs[name] = dir; } + const bare = join(root, 'bare'); + mkdirSync(bare); + const stands: Record = { besideConfig: dirs.withManifest, foreign: dirs.foreign, bare }; + const config = join(dirs.withManifest, 'objectstack.config.ts'); // A few at a time: each spawn is a full CLI start from source. - const queue = [...PLAN]; - const worker = async () => { - for (let next = queue.shift(); next; next = queue.shift()) { - const [fixture, args] = next; + const queue: Array<() => Promise> = [ + ...PLAN.map(([fixture, args]) => async () => { runs.set(key(fixture, args), await runCli(args, dirs[fixture])); - } + }), + ...ELSEWHERE_PLAN.map(([stand, spelling, command]) => async () => { + const configArg = spelling === 'absolute' ? config : relative(stands[stand], config); + elsewhereRuns.set(elsewhereKey(stand, spelling, command), await runCli([command, configArg, '--json'], stands[stand])); + }), + ]; + const worker = async () => { + for (let next = queue.shift(); next; next = queue.shift()) await next(); }; await Promise.all([worker(), worker(), worker()]); }, 600_000); @@ -398,3 +462,42 @@ describe('[round 1] html pages carried only in packages[], beside a top-level pa expect(r.stderr).toContain(manifestPath); }); }); + +describe('[#20166] an explicit config path reads the manifest beside that config, wherever the run stands', () => { + it('lit control: the foreign manifest refuses this page when it IS the project’s', () => { + const r = run('foreign', 'validate', '--json'); + expect(r.code, r.stdout + r.stderr).toBe(1); + expect(jsxFindingsIn(payloadOf(r, 'foreign')).some((f) => f.rule === 'jsx-forbidden-tag')).toBe(true); + }); + + it.each(COMMANDS)('control — os %s CONFIG from the config’s own directory: exit 0, no jsx finding, no notice', (command) => { + const r = elsewhere('besideConfig', 'absolute', command); + const payload = payloadOf(r, `besideConfig ${command}`); + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(jsxFindingsIn(payload)).toEqual([]); + expect(noticesIn(payload)).toEqual([]); + }); + + it.each(COMMANDS)('os %s CONFIG from a foreign directory carrying its own manifest: that manifest does not win', (command) => { + const r = elsewhere('foreign', 'absolute', command); + const payload = payloadOf(r, `foreign ${command}`); + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(jsxFindingsIn(payload)).toEqual([]); + expect(noticesIn(payload)).toEqual([]); + }); + + it.each(COMMANDS)('os %s CONFIG from a directory with no manifest: the project’s own is read, and no notice', (command) => { + const r = elsewhere('bare', 'absolute', command); + const payload = payloadOf(r, `bare ${command}`); + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(jsxFindingsIn(payload)).toEqual([]); + expect(noticesIn(payload)).toEqual([]); + }); + + it('os validate with the config path spelled relative to the foreign directory: the same answer', () => { + const r = elsewhere('foreign', 'relative', 'validate'); + const payload = payloadOf(r, 'foreign relative validate'); + expect(r.code, r.stdout + r.stderr).toBe(0); + expect(jsxFindingsIn(payload)).toEqual([]); + }); +}); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index e9be241a81e..db279d1e6b8 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -121,7 +121,7 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // project `sdui.manifest.json` that exists but cannot be read, parsed or // carries no `components` map is refused (exit 1) when there is a // `kind:'html'` page to check. Not a registry rule: the manifest is a file - // in the working directory, not part of the stack a rule is handed. + // beside the config (#20166), not part of the stack a rule is handed. 'resolveJsxGateManifest', // [#20331, #20393] The boot registrar's divergent view-container `name` // refusal (`viewContainerNameRefusal`, @objectstack/objectql), judged at