Skip to content

Commit 6981abf

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(cli)!: the JSX page gate reads the project's sdui.manifest.json beside the config, not in the invoker's cwd (#20675)
Fixes #20166 Clause-②: no ## What this changes `os validate`, `os build` (`compile`) and `os lint` arm the JSX page gate with an SDUI component manifest: the project's own `sdui.manifest.json` first, then the copy `@objectstack/console` ships. The project leg was read from `process.cwd()`, while every other project-relative lookup of the same run reads the directory of the config the command was given (the capability preflight's `projectDir: dirname(absolutePath)`, the access-matrix snapshot beside the config). So `os validate path/to/app/objectstack.config.ts`, run from anywhere else, never read `path/to/app/sdui.manifest.json`, and a manifest in the invoker's directory judged a project it does not belong to. - `resolveJsxGateManifest(stack, projectDir, resolution?)` (`packages/cli/src/utils/sdui-manifest.ts`) now takes the project directory as a **required** argument, and its default resolution is `resolveSduiManifest(projectDir)`. There is no working-directory default left for a caller to fall into. - The three callers (`validate.ts`, `compile.ts`, `lint.ts`) hand it `dirname()` of the config path `loadConfig` resolved: triage's execution note 1, for all three commands. - `resolveSduiManifest(cwd = process.cwd(), consoleOrigin?)` keeps its signature and its working-directory default, as the order requires. #20542 relies on it, and `os init`'s scaffold check (`scaffold-validate.ts`, unchanged) reads the invoker's directory by its own recorded decision. Its docblock now says that the parameter is the project directory whenever a command judges a project. - Changeset: `@objectstack/cli` **minor**, `Clause-②: no (narrowing)`, BREAKING, ADR-0087 `not-required (no-migration-prescription)`. See *Direction* below and the deviation note. ## Pins - **Per-PR (unit), `src/utils/sdui-manifest.test.ts`.** The invoker's directory is played by a `process.cwd()` spy on a foreign directory that carries its own manifest. Lit control: the working-directory default reads that manifest. `resolveJsxGateManifest(stack, projectDir)` reads the manifest beside the config instead. The control is a spy on the project directory itself, which gives the same answer. A manifest-less project does not borrow the foreign manifest, and a malformed foreign manifest refuses nothing. A seam pin checks that each of the three command files calls `resolveJsxGateManifest` once, with `dirname(absolutePath)` taken from `loadConfig`. 8 new cases, and the existing calls now pass a project directory. - **Command level (nightly `.e2e`), `test/jsx-gate-manifest-notice.e2e.test.ts`.** This is triage note 3. Each of `validate` / `build` / `lint` is given an explicit config path from three places: the config's own directory (the control), a foreign directory carrying its own manifest that refuses the page, and a bare directory. A relative-path spelling is added for `validate`. A lit control shows the foreign manifest really refuses the page when it is the project's own. 11 new cases. ## Measurements (PM hypotheses) All runs go through `bin/run-dev.js` (the source entry), not `dist/index.js`. The fixture's project manifest declares `div`. The foreign directory's manifest declares `span` only, and the page is a div tag. **H0: confirmed on `main` `6bff748bbd`.** From the project directory (default config and explicit path), all three commands exit 0. From a foreign directory carrying its own manifest, `validate`, `build` and `lint` each exit 1 with `jsx-forbidden-tag` + `jsx-unknown-component`: the foreign manifest judged the project. From a bare directory, all three exit 0 with the parse-level notice naming `BARE/sdui.manifest.json` then `packages/console/dist/sdui.manifest.json`. The project's own manifest is never named. **H1: confirmed on `8a85dbb583`.** The same 12 runs all exit 0, with no `jsx-*` finding and no notice. The same-directory controls are unchanged, and the foreign directory's manifest does not win. **H3: confirmed.** The ablation is reported under *Tests* below. **H2: both directions, measured.** See the table in *Acceptance notes*: runs are newly refused **and** newly admitted, but only runs whose config path names a directory other than the one they run in. ## Tests The union of gates was run after the final commit, at `ff4d8e7c37`: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run with no paths, derived **63** commands. All 63 exit 0. `--ran` reconciliation: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. On the first run, `pnpm check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (8 packages outside the CLI closure had no `dist/`). After those 8 were built (turbo, 41/41 cached), it exits 0. - Artifact-roster rows that could apply were run too: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `release-pending-publish --self-test`, `check-sdui-manifest`, `check:cli-examples-parity`, `check:scaffold-emission-policy`, `check-published-list-mirrors`, `check:stack-collection-maps` and `check:console-injection` all exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` need this PR's context. NOT MEASURED locally, reason: they exit 2 `NOT WIRED` without a PR number or body. Their workflows run them on this PR. - `pnpm lint` (`eslint . --no-inline-config`, the whole repository) exits 0 at `ff4d8e7c37`. - `node scripts/check-issue-citations.mjs --base origin/main`, after merging `origin/main` (`6c11ef9ecb`): 5 citations judged, 5 resolve. - `pnpm --filter @objectstack/cli typecheck` exits 0. `tsc --noEmit --listFiles` includes `src/utils/sdui-manifest.test.ts`, and `check:test-typecheck` over `tsconfig.test.json` lists both touched `test/` files. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 232 files passed, 3350 tests passed, 5 failed. All 5 are in `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`, and they are a host artefact. This Mac's `TMPDIR` is a symlink (`/var` to `/private/var`). Rerun with `TMPDIR` set to its real path, both files pass (29/29). Neither file, nor anything it reads, is in this diff. - `--project integration`, `OS_TEST_TIERS=nightly`, `test/jsx-gate-manifest-notice.e2e.test.ts`: 43/43 passed, nothing skipped, because the console copy is absent in this checkout. The diff touches that file, so it ran locally. - **H3 ablation.** The three callers were reverted to the cwd default, which is `main`'s exact call text; with `projectDir` undefined, the default parameter reads `process.cwd()`. The mutation went through `scripts/ablation-replace.mjs`: each anchor went x1 to x0, and the blobs changed (`validate.ts` 5c311e8 to a95825d1ee2c, `compile.ts` 0dd6cd8 to 74f020b8dd81, `lint.ts` 56422ee to 3769869f163b). On disk, per file, the `dirname(absolutePath)` call count is 0 and the bare-call count is 1. Results: - Unit: `3 failed | 45 passed`. The 3 failures are exactly the seam pins. - E2E: `7 failed | 36 passed`. The 7 failures are exactly the foreign-cwd, bare-cwd and relative-path pins. The lit control and the three same-directory controls stay green. - Restore: a trap on EXIT/INT/TERM ran `git checkout HEAD --` on absolute paths. Proof: each file's blob equals its HEAD blob, and `git diff HEAD` is empty. - Nothing here resolves through `dist/`: the unit file imports `./sdui-manifest.js` from `src/`, and the e2e spawns `bin/run-dev.js` through tsx over `src/`. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm turbo run build --filter='@objectstack/cli^...' --concurrency=2 pnpm turbo run build --filter='@objectstack/example-showcase^...' --concurrency=2 pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/utils/sdui-manifest.test.ts test/validate-build-gate-parity.test.ts OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 --reporter=verbose test/jsx-gate-manifest-notice.e2e.test.ts bash ablate.sh (the H3 ablation above: ablation-replace x3, both test files, the direction matrix, restore) pnpm turbo run build --filter=(8 packages named by check:dual-build-cjs-loads) --concurrency=2 pnpm --filter @objectstack/cli typecheck pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 pnpm lint (twice: at 7c21afd and at ff4d8e7) ## Acceptance notes **Triage note 2: the repo-root showcase invocation, both ways.** The command is `os CMD examples/app-showcase/objectstack.config.ts --json`, run from the repository root. Before = `main` `6bff748bbd`, after = `8a85dbb583`. The repository tracks a root `sdui.manifest.json` (sha256 `d0666ac5…`), and the showcase carries none. The console copy was measured in both states. When built, the copy is placed by `scripts/build-console.sh`'s own `cp` of the root file, byte-identical (same sha256). When not built, the copy is absent, as in CI and in this worktree. | run | manifest read | validate | build | lint | |:--|:--|:--|:--|:--| | before, console copy absent | repo-root `sdui.manifest.json` (project leg, from cwd) | exit 0 · 0 errors · 83 warnings · 0 `jsx-*` · no notice | same as validate | exit 0 · 511 issues (0 errors, 484 warnings, 27 suggestions) · 0 `jsx-*` | | before, console copy built | repo-root file (project leg) | exit 0 · 83 warnings · 0 `jsx-*` | same | exit 0 · 511 issues (0 / 484 / 27) | | **after, console copy built** | console copy (same bytes) | exit 0 · 83 warnings · 0 `jsx-*` · no notice | same | exit 0 · 511 issues (0 / 484 / 27) | | **after, console copy absent** | none: parse level | exit 0 · 84 warnings (the +1 is the `sdui/jsx-parse-level-only` notice: 3 html pages) · 0 `jsx-*` | same | exit 0 · 512 issues (0 / 484 / 28, the +1 is the notice) | In each state, the "after" reading equals what a same-directory run (from `examples/app-showcase`) already gives on `main`. No exit code moves. The 200-error arming measured on #19922 is gone from `main`: the regenerated root manifest now declares the html-tier vocabulary. So #20112's reading no longer depends on which directory this run starts in. **H2: which runs move, and which way.** These runs use `validate` with an explicit absolute config path. N1 also ran `build` and `lint`, which gave the same answers. "Before" is the H3 ablation, i.e. `main`'s resolution path. "After" is `8a85dbb583`. | class (config path names another directory) | console copy | before | after | direction | |:--|:--|:--|:--|:--| | N1: the project's manifest refuses the page, run from a bare directory | absent | exit 0 + notice | exit 1, `jsx-forbidden-tag` | **newly refused** | | N1 | built | exit 1 (the console copy refuses a div too) | exit 1 | unchanged in this fixture; a tag the console declares but the project's manifest does not is newly refused | | N2: the project's manifest is malformed, run from a bare directory | absent | exit 0 + notice | exit 1, the unusable-manifest refusal naming the project's file | **newly refused** | | N3: no project manifest, run from a directory whose manifest admits the page | built | exit 0 | exit 1 (judged by the console copy) | **newly refused** | | W1: no project manifest, run from a directory whose manifest refuses the page | absent | exit 1 | exit 0 + notice | **newly admitted** | | W2: no project manifest, run from a directory whose manifest is malformed | absent | exit 1 (refusal naming the other directory's file) | exit 0 + notice | **newly admitted** | | H1: the project's manifest admits the page, run from a directory whose manifest refuses it | either (the project leg is read first) | exit 1 | exit 0 | **newly admitted** | | U: neither directory carries a manifest | either | unchanged | unchanged | only the notice's first path moves, from cwd to the project directory | | any run from the project's own directory | either | unchanged | unchanged | control | A run is newly refused, so the changeset is `minor`, `Clause-②: no (narrowing)`, BREAKING. Runs are also newly admitted: in per-run terms that is a widening, and it is reported for the seat to judge. This body carries the claim's bare `Clause-②: no` line, as ordered. The arm is in the changeset, the way PR #20589 carried it. **Deviation: no `## FROM → TO` heading in the changeset.** The order asked for a FROM/TO. `check-adr-0087-registration` reads a `FROM → TO` label as a rewrite prescription (`from-to-label`), and that refuses the honest disposition, `not-required (no-migration-prescription)`: no metadata changes shape, and `objectstack migrate meta` has nothing to rewrite. The only other disposition open is `registered`, which needs a `packages/spec` ledger entry, the spec seat's surface, and no ledger entry can move a file. So the before/now mapping ships as a table under `## Which manifest each run reads`. With that heading the gate reads no prescription and passes. **Notes, not filed:** - The pending `.changeset/19922-console-manifest-fallback.md` says these commands "look first for the `sdui.manifest.json` in the directory the command runs in". This PR makes that sentence false before it ships. That file is outside this claim's file surface, so this PR's changeset carries a correction paragraph. A one-line edit to the 19922 entry would make the release notes read cleanly. - `resolveSduiManifest`'s first parameter is still named `cwd`, although every command caller now passes the project directory. The rename was outside the claim's surface (docblocks and pass-through only). The docblock now says so. - Comments describe the repo-root artefact as what `resolveSduiManifest()` picks up "from the repo root": `packages/lint/src/validate-jsx-pages.production-witness.test.ts` and `scripts/cross-package-test-inputs.mjs` (the `#12924` entry). For a repo-root run against an example's config, the artefact now arrives through the console copy (`build-console.sh` copies the same bytes), so the witness still witnesses production bytes, by the console leg. Stale comments; carrier: none. - The `published-subpath-*.pin.test.ts` failures on a macOS host with a symlinked `TMPDIR` (above) are a portability observation; carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00f045d commit 6981abf

8 files changed

Lines changed: 328 additions & 29 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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. -->
10+
11+
**BREAKING for runs given a config path in another directory.**
12+
13+
**What changed.** These commands check the `source` of each `kind: 'html'` page
14+
against an SDUI component manifest: the project's own `sdui.manifest.json` first,
15+
then the copy `@objectstack/console` ships. They located everything else about a
16+
project from the directory of its config, but looked for the project's own
17+
manifest in the directory the command was run from. So
18+
`objectstack validate path/to/app/objectstack.config.ts`, run from anywhere else,
19+
never read `path/to/app/sdui.manifest.json`, and a manifest that happened to sit in
20+
the directory it was run from judged a project it does not belong to. They now read
21+
the manifest beside the config.
22+
23+
## Which manifest each run reads
24+
25+
| the run | the project manifest it read | the project manifest it reads now |
26+
|:--|:--|:--|
27+
| `objectstack validate` / `build` / `lint` with no config path, in the project's directory | `./sdui.manifest.json` | `./sdui.manifest.json` (unchanged) |
28+
| 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` |
29+
30+
When the project carries no manifest of its own, both rows then fall back to the
31+
copy `@objectstack/console` ships, as before.
32+
33+
**Which runs change, and which way.** Only runs whose config path names a directory
34+
other than the one they run in. For those, the verdict can move in both directions:
35+
36+
- A page the project's own manifest does not declare is now refused
37+
(`jsx-forbidden-tag`, `jsx-unknown-component`, `jsx-unknown-prop`, exit 1), where
38+
the other directory's manifest, or the console's copy, used to admit it.
39+
- A project manifest that is present but not usable is now refused (exit 1), naming
40+
that file, where the run used to read some other file.
41+
- A project with no manifest of its own is now checked against the console's copy,
42+
where the other directory's manifest used to decide.
43+
- In the other direction, a page the other directory's manifest refused, and that
44+
the project's own manifest (or the console's copy) declares, is now admitted.
45+
46+
If such a run now fails, the manifest that belongs to the project is the one to
47+
keep beside its config.
48+
49+
**What is not affected.** A run in the project's own directory, with or without a
50+
config path, reads the same file as before. `objectstack init`'s check of a freshly
51+
generated scaffold keeps reading the directory it was run from.
52+
53+
**A correction to this release's console-fallback entry.** That entry says these
54+
commands "look first for the `sdui.manifest.json` in the directory the command runs
55+
in". From this release they look first beside the config the command was given,
56+
which is the same directory whenever the command runs in the project.

‎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');

0 commit comments

Comments
 (0)