Skip to content

fix(cli)!: the JSX page gate reads the project's sdui.manifest.json beside the config, not in the invoker's cwd - #20675

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20166-manifest-beside-config
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20166-manifest-beside-config

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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. cli/console: the deployment's SDUI component manifest reaches the metadata save door (page.requires enforcement, stage ①) #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 5c311e8171d7 to a95825d1ee2c, compile.ts 0dd6cd86d223 to 74f020b8dd81, lint.ts 56422eeeaeb8 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 7c21afd63f and at ff4d8e7c37)

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

hotlong and others added 3 commits September 29, 2026 23:45
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 2 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: os lint (command, 30 pages), os validate (command, 53 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f705a98d3ca108a0af38c18d49e82b82629fb842 — the merge of head ff4d8e7c3757959c5756f69266864857e77b5bf9 into base 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9a4b2bb38f9fb82c8e970d4f8d66d72379faa345 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ff4d8e7c3757959c5756f69266864857e77b5bf9
Local-runs: none

Inputs read: card #20166 (body and all 5 comments: triage 5853342743, unlock 5891947978, claim 5893320052, os-dev-report 5894294363, seat answer 5894329055); PR #20675 body, its 8-file list and the net diff (603 lines, +328/-29); the check-runs on the head; reference text at origin/main 9a4b2bb38f (which contains the PR's merge point 6c11ef9ecb; the two commits after it touch only two unrelated changesets). Nothing built, run or re-run.

Check-runs on the head at 2026-09-29T16:35:57Z (newest run per name, 33 names): 27 success (Build Core, Check Changeset, Check PR Size, Dogfood Regression Gate 1/3 2/3 3/3 and the roll-up, Dogfood Verify CLI, Governed Surface Queue Guard, Temporal Conformance, Test Core 1/6 2/6 5/6 6/6, Type Check consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, the four claim/closing-target guards, Auto Label, filter, Flag docs), 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 3 in_progress (Lint & Repo Gates, Test Core 3/6, Test Core 4/6), 0 failure. An earlier read at 16:29Z had 31 names with 17 in progress. Lint & Repo Gates is where check-adr-0087-registration and check-changeset-no-major answer; it had not answered at either read, so the ADR-0087 marker and the Clause-② arm are judged below on their text; the dev's local pass of those gates is the dev's report, not a gate verdict.

① Derived judgments

D1 — resolveJsxGateManifest(stack, projectDir, resolution?): a new REQUIRED second parameter, and it is internal. Right. packages/cli/package.json exports is closed to ., ./console, ./hook-body, ./package.json; src/index.ts exports command classes only; src/console.ts re-exports from utils/console.js and src/hook-body.ts from utils/extract-hook-body.js. Nothing published reaches utils/sdui-manifest.ts, so the signature change is not a public-surface change. Its default is now resolveSduiManifest(projectDir); the only way back to the working directory is a caller passing undefined, which tsc refuses (typecheck lanes green on the head) and the three seam pins refuse at source.

D2 — resolveSduiManifest(cwd = process.cwd(), consoleOrigin = import.meta.url): signature and default unchanged. Right. The hunk at that function touches its docblock only; the signature lines are context. utils/scaffold-validate.ts (bare resolveSduiManifest(), init's own recorded decision) and commands/serve.ts (no call yet; #20542's) are untouched, so the freeze the seat promised #20542 holds at this head.

D3 — all three commands now read beside the config, with no working-directory default left on those paths. Right. validate.ts passes dirname(absolutePath) (dirname was already imported; it is the same absolutePath the capability preflight's projectDir uses), compile.ts passes path.dirname(absolutePath), lint.ts passes dirname(absolutePath) with the import added. absolutePath comes from loadConfig → resolveConfigPath, which is path.resolve(process.cwd(), source) for an explicit path and the auto-detected file in the cwd otherwise, so it is absolute in both spellings (the relative spelling has its own e2e pin). No other resolveSduiManifest or resolveJsxGateManifest call exists in the three files at origin/main. This answers the brief's (1): yes on both counts.

D4 — the accept-set moves the diff implies, each named. Right, and complete.

  • A run with no config path, or from the project's own directory: dirname(absolutePath) equals the cwd, unchanged (the table's first row; the same-directory controls).
  • An explicit config path from another directory: newly refused where the project's own manifest (or, absent one, the console copy) does not declare the page or is unusable (N1, N2, N3); newly admitted where the cwd's manifest used to refuse or was malformed (W1, W2, H1). The changeset's four bullets name exactly these.
  • os dev and os start spawn compile --output PATH with NO config argument, so the child auto-detects in its cwd: unchanged, and the changeset rightly does not list them. os init's scaffold check: unchanged, and listed.
  • Repo-root runs against an example's config: the root sdui.manifest.json is no longer the project leg for them; they read the console copy when built (same bytes, per build-console.sh's cp) or run at parse level with the notice when not. The one repo script that makes such a run, scripts/check-i18n-coverage.mjs (lint examples/X/objectstack.config.ts --json with cwd: REPO_ROOT), counts i18n/ rules only and the exit stays 0, so its reading does not move. No workflow under .github invokes an example's config from the repo root. The three showcase html pages use only box, flex, grid and a, all declared by the root manifest, and packages/lint/src/validate-jsx-pages.production-witness.test.ts keeps holding them clean against that file from disk, not through the CLI. Consequence worth the seat's eye, not a defect: in a console-absent checkout (CI included) a repo-root os validate examples/app-showcase/... now checks those pages at parse level, where before it was fully armed by the root manifest; that is the contract's own outcome for a project without a manifest.

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, dirname(absolutePath), absolutePath destructured from loadConfig's result) = 8. E2E adds 1 lit control (the foreign manifest refuses the page when it IS the project's), 3 same-directory controls, 3 foreign-cwd, 3 bare-cwd, 1 relative-path = 11, over the existing runCli(args, cwd) and withManifest fixture. "A foreign cwd with its own manifest must not win" has a pin at both levels, each with its lit control and its same-directory control. The H3 ablation (callers reverted to main's call text, so projectDir is undefined and the default parameter falls to process.cwd()) reds exactly the 3 seam pins and the 7 foreign/bare/relative e2e cases while both control sets stay green, which is the expected partition: the 5 hermetic pins call the function directly and are untouched by a caller ablation; their liveness rests on the lit control showing the process.cwd() spy is the path the default reads, which is adequate. The e2e file is nightly-tier, so per-PR CI on this head exercises the seam and hermetic pins only; that is the file's recorded division of labour.

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 origin/main declares 107 components including the html tier (box, flex, grid, a, p, h1-h6, span, ...) and not div; the showcase pages use only declared tags; so the "before" run (root manifest via cwd) and the "after, console built" run (same bytes) both give 0 jsx-*, and "after, console absent" adds exactly the one notice (83→84 warnings; 511→512 lint issues). The H2 row "N1 built: the console copy refuses a div too" is consistent with div being absent from that file. No exit code moves in either console state.

② Semver level

Clause-②: no (narrowing)

The changeset .changeset/20166-jsx-gate-manifest-beside-config.md grades @objectstack/cli minor, opens fix(cli)!:, carries **BREAKING for runs given a config path in another directory.**, the line Clause-②: no (narrowing), and one marker adr-0087: not-required (no-migration-prescription). This matches what the diff publishes.

Brief's (2) — the direction. no (narrowing) is right; A is upheld. AGENTS.md L1074-1075 defines the declaration as Clause-②: yes|no plus at most one arm, where yes answers "does this card widen an accept set or enlarge the public surface" and (narrowing) is BREAKING. scripts/pm/clause2-line.mjs glosses the four readings: yes (narrowing) is "a diff that widens ONE surface and narrows ANOTHER"; no (narrowing) is "NOT a widening, but breaking". This diff adds no export, no key, no accepted value; nothing an author can write becomes newly valid. The contract triage bound the card to is "a project's html pages are judged by that project's own manifest, else the console copy", and the accept set THAT contract names does not grow: W1, W2 and H1 were refusals by a manifest that belonged to another directory, a bug-class defect the card was filed for, and repairing a wrong refusal is a fix, not a widening. Declaring yes (narrowing) would assert a widened surface the contract never had and would need a second surface to point at; there is only one (the lookup root), whose relocation moves verdicts both ways. The newly refused runs are what an upgrader must be told, and the BREAKING changeset carries them. The bump is minor either way (the launch-window rule in check-changeset-no-major.mjs ships breaking as minor), so the level axis is unaffected by the reading.

Brief's (3) — the disposition and the table. Honest, and the table tells an upgrader what to do. no-migration-prescription is checked by the gate statement-against-statement: refused when the body carries a rewrite prescription. The body's table | the run | the project manifest it read | the project manifest it reads now | frames a file-lookup mapping, not a rewrite: its header cells match neither OLD_COLUMN_RE (wrote/removed/retired) nor the OLD-then-NEW pairing headerFramesRewrite requires, and no FROM → TO label appears. Nothing authorable is renamed or removed and objectstack migrate meta has nothing to rewrite, so not-required (no-migration-prescription) is the honest disposition, and the dev was right to decline the dispatch order's FROM → TO label: that convention is for authorable rewrites, and using it here would have forced a registered disposition with no ledger entry to register. The upgrader's action is stated in one line ("the manifest that belongs to the project is the one to keep beside its config"), and the two rows plus four direction bullets say which runs move and which way.

PR body line 2 is the claim's bare Clause-②: no; the arm lives in the changeset, which is the line check-adr-0087-registration reads (breakingDeclaration signal 4). The body's bare no is readable and gate-neutral for check-changeset-no-major's level axis. The two carriers differ by the arm only; equalising them is the seat's cosmetic call, not a defect.

③ Boundary flags

Every flag in the os-dev-report and the seat answer, answered:

  1. open_questions A/B (direction): A, upheld in ② above.
  2. Deviation: ## Which manifest each run reads instead of FROM → TO: right, per ② (3).
  3. Deviation: PR body bare Clause-②: no vs changeset no (narrowing): gate-neutral, consistent with the claim and with PR fix(cli)!: the JSX page gate's console manifest fallback resolves through @objectstack/console/package.json, so a project without its own manifest gets full component checking #20589's precedent; seat's to equalise if wanted.
  4. Deviation: verification ran UNLOCKED (no flock on macOS): disclosed verbatim in the PR body; a dev-side process fact with no bearing on the diff.
  5. Deviations: turbo build filter, label-write under with-fleet.sh --read, model-free Co-Authored-By pair: process only; nothing in the diff depends on them.
  6. Out-of-scope (a), the pending .changeset/19922-console-manifest-fallback.md sentence — brief's (4): the correction paragraph is accurate. The 19922 entry at origin/main says "They look first for the sdui.manifest.json in the directory the command runs in", the quote is exact, and "which is the same directory whenever the command runs in the project" is true. Leaving that file untouched is right: it is outside the claim's file surface (one .changeset/20166-*.md), and the seat ruled the correction stays in this entry. Residual, not a defect: the paragraph says "this release's console-fallback entry", which holds while both entries are pending together (both are, at 9a4b2bb38f); if a version PR ships 19922's first, the phrase misnames the release and the seat's stated follow-up (one-line amendment, carrier the domain:cli seat) is the channel.
  7. Out-of-scope (b), resolveSduiManifest's parameter still named cwd: the docblock now states the parameter is the project directory for every command caller and reserves the default for init; a rename is outside the surface and would move cli/console: the deployment's SDUI component manifest reaches the metadata save door (page.requires enforcement, stage ①) #20542's frozen signature. Noted, not required.
  8. Out-of-scope (c), stale comments in packages/lint/src/validate-jsx-pages.production-witness.test.ts (L46-48) and scripts/cross-package-test-inputs.mjs (the [finding] nothing wires an sdui.manifest.json, so validateJsxPages runs parse-only and validateTree — including both ported lockstep diagnostics — is dead code in the production gate #12924 entry): verified stale in wording only; the witness reads the root file from disk and its behaviour is unchanged. Carrier none, as the dev said.
  9. Out-of-scope (d), the two published-subpath-*.pin.test.ts files failing on a symlinked macOS TMPDIR: neither file is in the diff; host-only.
  10. Seat note, resolveSduiManifest(dir, consoleOrigin?) frozen for cli/console: the deployment's SDUI component manifest reaches the metadata save door (page.requires enforcement, stage ①) #20542: verified unchanged (D2).
  11. Card premise (200-error arming): no longer holds on main, consistent with the measurements (D6); [Decision] may a kind:'html' page author intrinsic HTML tags (div, a)? The console manifest says no, 3 of 3 shipped html pages say yes, and #19922's fallback cannot go live until one side changes #20112's reading no longer depends on the run's starting directory.

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: claude/issue-20166-manifest-beside-config
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 16:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 6981abf Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20166-manifest-beside-config branch September 29, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant