fix(cli)!: the JSX page gate reads the project's sdui.manifest.json beside the config, not in the invoker's cwd - #20675
Conversation
…side 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>
… the config Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…nifest-beside-config Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f705a98d3ca108a0af38c18d49e82b82629fb842 && git checkout f705a98d3ca108a0af38c18d49e82b82629fb842
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345 ff4d8e7c3757959c5756f69266864857e77b5bf9 && git checkout -B drift-repro 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345 && git merge --no-ff ff4d8e7c3757959c5756f69266864857e77b5bf9
node scripts/docs-audit/affected-docs.mjs --json 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345
|
Contract reviewServed-tier: Inputs read: card #20166 (body and all 5 comments: triage Check-runs on the head at 2026-09-29T16:35:57Z (newest run per name, 33 names): 27 ① Derived judgmentsD1 — D2 — D3 — all three commands now read beside the config, with no working-directory default left on those paths. Right. D4 — the accept-set moves the diff implies, each named. Right, and complete.
D5 — the tests (brief's (5)). They prove the rule and its controls. Right. Counted from the diff: unit adds 5 hermetic resolver pins (lit control: the cwd default reads the foreign manifest; foreign cwd does not win; same-directory control; a manifest-less project does not borrow the foreign one; a malformed foreign manifest refuses nothing) plus 3 seam pins, one per command (one call, D6 — the card's 200-error premise (brief's (6)). The dev's reading is consistent with the PR's measurements. Right. The root manifest at ② Semver levelClause-②: no (narrowing) The changeset Brief's (2) — the direction. Brief's (3) — the disposition and the table. Honest, and the table tells an upgrader what to do. PR body line 2 is the claim's bare ③ Boundary flagsEvery flag in the os-dev-report and the seat answer, answered:
Nothing to escalate. One observation for the seat, outside this PR's acceptance: with the root manifest no longer serving as the project leg for repo-root runs, the showcase's html gate through the CLI is armed in CI only where the console copy is built (D4); the vocabulary stays pinned by the lint witness, so no CI signal is lost today. Implemented-by: VERDICT: PASS |
Fixes #20166
Clause-②: no
What this changes
os validate,os build(compile) andos lintarm the JSX page gate with an SDUI component manifest: the project's ownsdui.manifest.jsonfirst, then the copy@objectstack/consoleships. The project leg was read fromprocess.cwd(), while every other project-relative lookup of the same run reads the directory of the config the command was given (the capability preflight'sprojectDir: dirname(absolutePath), the access-matrix snapshot beside the config). Soos validate path/to/app/objectstack.config.ts, run from anywhere else, never readpath/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 isresolveSduiManifest(projectDir). There is no working-directory default left for a caller to fall into.validate.ts,compile.ts,lint.ts) hand itdirname()of the config pathloadConfigresolved: 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. cli/console: the deployment's SDUI component manifest reaches the metadata save door (page.requires enforcement, stage ①) #20542 relies on it, andos 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.@objectstack/climinor,Clause-②: no (narrowing), BREAKING, ADR-0087not-required (no-migration-prescription). See Direction below and the deviation note.Pins
src/utils/sdui-manifest.test.ts. The invoker's directory is played by aprocess.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 callsresolveJsxGateManifestonce, withdirname(absolutePath)taken fromloadConfig. 8 new cases, and the existing calls now pass a project directory..e2e),test/jsx-gate-manifest-notice.e2e.test.ts. This is triage note 3. Each ofvalidate/build/lintis 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 forvalidate. 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), notdist/index.js. The fixture's project manifest declaresdiv. The foreign directory's manifest declaresspanonly, and the page is a div tag.H0: confirmed on
main6bff748bbd. From the project directory (default config and explicit path), all three commands exit 0. From a foreign directory carrying its own manifest,validate,buildandlinteach exit 1 withjsx-forbidden-tag+jsx-unknown-component: the foreign manifest judged the project. From a bare directory, all three exit 0 with the parse-level notice namingBARE/sdui.manifest.jsonthenpackages/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 nojsx-*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.--ranreconciliation: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-loadsansweredPREREQUISITE NOT MET(8 packages outside the CLI closure had nodist/). After those 8 were built (turbo, 41/41 cached), it exits 0.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-mapsandcheck:console-injectionall exit 0.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsneed this PR's context. NOT MEASURED locally, reason: they exit 2NOT WIREDwithout a PR number or body. Their workflows run them on this PR.pnpm lint(eslint . --no-inline-config, the whole repository) exits 0 atff4d8e7c37.node scripts/check-issue-citations.mjs --base origin/main, after mergingorigin/main(6c11ef9ecb): 5 citations judged, 5 resolve.pnpm --filter @objectstack/cli typecheckexits 0.tsc --noEmit --listFilesincludessrc/utils/sdui-manifest.test.ts, andcheck:test-typecheckovertsconfig.test.jsonlists both touchedtest/files.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 232 files passed, 3350 tests passed, 5 failed. All 5 are intest/published-subpath-console.pin.test.tsandtest/published-subpath-hook-body.pin.test.ts, and they are a host artefact. This Mac'sTMPDIRis a symlink (/varto/private/var). Rerun withTMPDIRset 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.main's exact call text; withprojectDirundefined, the default parameter readsprocess.cwd(). The mutation went throughscripts/ablation-replace.mjs: each anchor went x1 to x0, and the blobs changed (validate.ts5c311e8171d7 to a95825d1ee2c,compile.ts0dd6cd86d223 to 74f020b8dd81,lint.ts56422eeeaeb8 to 3769869f163b). On disk, per file, thedirname(absolutePath)call count is 0 and the bare-call count is 1. Results:3 failed | 45 passed. The 3 failures are exactly the seam pins.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.git checkout HEAD --on absolute paths. Proof: each file's blob equals its HEAD blob, andgit diff HEADis empty.dist/: the unit file imports./sdui-manifest.jsfromsrc/, and the e2e spawnsbin/run-dev.jsthrough tsx oversrc/.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot 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.
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 =main6bff748bbd, after =8a85dbb583. The repository tracks a rootsdui.manifest.json(sha256d0666ac5…), and the showcase carries none. The console copy was measured in both states. When built, the copy is placed byscripts/build-console.sh's owncpof the root file, byte-identical (same sha256). When not built, the copy is absent, as in CI and in this worktree.sdui.manifest.json(project leg, from cwd)jsx-*· no noticejsx-*jsx-*jsx-*· no noticesdui/jsx-parse-level-onlynotice: 3 html pages) · 0jsx-*In each state, the "after" reading equals what a same-directory run (from
examples/app-showcase) already gives onmain. No exit code moves. The 200-error arming measured on #19922 is gone frommain: 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
validatewith an explicit absolute config path. N1 also ranbuildandlint, which gave the same answers. "Before" is the H3 ablation, i.e.main's resolution path. "After" is8a85dbb583.jsx-forbidden-tagA 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 bareClause-②: noline, as ordered. The arm is in the changeset, the way PR #20589 carried it.Deviation: no
## FROM → TOheading in the changeset. The order asked for a FROM/TO.check-adr-0087-registrationreads aFROM → TOlabel as a rewrite prescription (from-to-label), and that refuses the honest disposition,not-required (no-migration-prescription): no metadata changes shape, andobjectstack migrate metahas nothing to rewrite. The only other disposition open isregistered, which needs apackages/specledger 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:
.changeset/19922-console-manifest-fallback.mdsays these commands "look first for thesdui.manifest.jsonin 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 namedcwd, 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.resolveSduiManifest()picks up "from the repo root":packages/lint/src/validate-jsx-pages.production-witness.test.tsandscripts/cross-package-test-inputs.mjs(the#12924entry). For a repo-root run against an example's config, the artefact now arrives through the console copy (build-console.shcopies the same bytes), so the witness still witnesses production bytes, by the console leg. Stale comments; carrier: none.published-subpath-*.pin.test.tsfailures on a macOS host with a symlinkedTMPDIR(above) are a portability observation; carrier: none.Generated by Claude Code