Closes#2015, folded into #1928 rather than left as a follow-up.
The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.
Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.
- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
`clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
`resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
encoding uses.
Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review:
- `<x.y.z>` / `<major|minor|patch>` inside a bash block is shell redirection,
so both documented commands were syntax errors if copied. Verified: `bash -n`
rejects `git tag <x.y.z> && ...` and accepts `git tag 2.3.0 && ...`. Use a
concrete 2.2.0 -> 2.3.0 example and say what to substitute.
- "agree on the version at every point" / "never disagree" overstated it. The
procedure deliberately bumps v2/main first, so between the bump and the
milestone merge the branches differ — v2/main reads the version being built,
main the one released. Describe the real invariant: they converge when the
release merge lands, and v2/main is never left behind main.
Signed-off-by: cliffhall <cliff@futurescale.com>
v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest`
are at 2.2.0. The bump was created on the milestone-merge branch, which is cut
from main, so it only ever existed downstream of v2/main and nothing carried it
back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on
v2/main.
That is not cosmetic. A branch cut from a milestone-merge branch silently
carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff
on a container bugfix — and anything reading the version in development
(readInspectorVersion, --version, GET /api/config) reported a version two
releases old.
Move the bump to v2/main, before the milestone merge, so it flows into main with
the rest of the milestone's work and the branches never disagree. The release
tag is applied to the resulting main commit instead. The publish workflow needs
no change: its tag-vs-package.json assertion runs at the release commit on main,
which still carries the bump.
Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2
v1 history (~230 commits v2/main lacks, retained through `chore: replace main's
tree with v2`), so merging would graft all of it into the develop branch's log
permanently to deliver a two-file change.
Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a
specific version value, so this is a safe two-file change.
Closes#2010
Signed-off-by: cliffhall <cliff@futurescale.com>
Two defaults that each break the Inspector inside a container.
#1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted
to the NAME `localhost`, and `server.listen(port, host)` resolves a name
through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17
the default result order is `verbatim`, so on a glibc Linux host — whose
/etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The
server bound IPv6 loopback only and refused every IPv4 client, including the
dev container port forwarder. It presents as an endless spinner rather than a
connection error because the forwarder accepts on the host side before failing
to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit
HOST=localhost is still honored as typed.
#2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox
listened on an OS-assigned port, so it changed every run and could never be
named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default
it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an
OS-assigned port (loudly) if it is taken — the collision a dynamic default
never had.
Verified in the reporter's exact image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners
come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox.
Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both
defaults in the web README, the migration guide, and the root README's Docker
section.
Signed-off-by: cliffhall <cliff@futurescale.com>
Review caught a second-order effect of the fix: making the leaked-timer net
global invalidated the rationale of a neighbouring rule.
"Do not hand-roll a bare MantineProvider — that reintroduces the leak class"
stopped being true the moment the net moved into setup.ts, which covers every
unit test regardless of how it renders. The rule itself is still right, so keep
it and replace the justification: a hand-rolled provider skips the project theme
and the helper's options and drifts from every other test. Both copies say
explicitly that the old reason no longer holds, so nobody re-derives it.
Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md
requires for review-relevant guidance — it carried the identical stale claim.
That makes four places this one belief had propagated to. A fix can create stale
docs by making a previously-true statement false, which is easy to miss when
only the changed lines get read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Review follow-up on #1983. Copilot was right, and about a hazard this repo
had already proven and then documented away: the claim that ink's ">=19"
peer "keeps npm from hoisting it next to a React the Inspector couldn't
also use" is false. A consumer pinning React 19.0 satisfies ">=19" while
`^19.2.4` nests underneath, so ink renders through their React and the
bundle through ours — the #1952 crash, one level up, breaking TUI startup
rather than just its forms.
Bundling ink closes it (verified: with a `createRequire` banner for the
inlined CJS, since react-reconciler calls `require("react")` and
signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler
and yoga-layout come along. The root `react` range closes it for free
instead: `^19.0.0` is open to the whole major, so npm can satisfy our
React and a consumer's pinned React 19 with one copy, and an external ink
resolves the same copy the bundle does.
Verified against both consumer trees — the reporter's (React 18 + ink 5)
and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`.
That makes the range load-bearing while looking like an ordinary version
bump, so the tests pin it: the root range must equal ink's own declared
peer floor. They also assert each exempt package is both external and a
root dependency — external means consumers install it, the mirror image
of the inlined ones, which must not be root-declared.
The docs in all four places now say ink is exempt on cost and never on a
peer range, and name that claim as one we got wrong.
Closes#1952
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
A window.setTimeout that outlives its test file fires after happy-dom disposes
that file's window; React's dispatchSetState then throws an uncaught
ReferenceError: window is not defined. Vitest attributes it to whichever file
was running, so a single leak fails the ENTIRE run with every test passing —
landing on an innocent file under a step named "Enforce per-file coverage gate."
Seen twice in CI, both on docs-only branches.
Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount
(useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF
race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules
rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the
unmount lands, cancelAnimationFrame is a no-op and that callback then schedules
a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is
why it only ever appeared in CI.
Track every timer and clear whatever is still outstanding after cleanup(). The
ordering is load-bearing: legitimate unmount cleanups get their turn first, so
only true leaks are dropped. Fake timers pass through untracked, which is
correct — a fake timer cannot outlive the environment.
Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that
env="test" prevents this. It does not: env is read only by Transition.mjs at its
render branch, while useTransition runs before that check (hooks cannot be
conditional) and still schedules real timers. Measured — opening a <Modal>
through renderWithMantine schedules three 200ms timers. Believing that guarantee
is why this went unexamined.
The regression test is mutation-verified: with the net disabled it fails, with
it restored it passes. It deliberately does not assert that Mantine schedules no
timers — it does, and that is normal; the contract is that none survive.
Closes#1984
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time
rather than the README, which is where the earlier errors came from:
const authors = [commit.author.name, commit.committer.name]
const emails = [commit.author.email, commit.committer.email]
if (isMerge || ...) continue // parents.length > 1
else if (author && author.type === 'Bot') continue
So, corrected:
- Scope. Merge commits and bot-authored commits are skipped, not gated. "Every
commit" overstated it and could prompt a needless history rewrite.
- Matching. The trailer may match the author OR the committer, not the author
alone. A cherry-picker's own signoff is accepted.
- Certification. The DCO expressly covers submitting work created by others
that you have the right to submit, so signing off a cherry-pick is legitimate.
My "never sign off someone else's commit" would have told a maintainer not to
do a correct thing. The real prohibition is narrower: never fabricate a
trailer in another person's name.
This also retracts the mechanical argument in bd6c1072 for dropping the hook —
it does satisfy the check, since you are the committer. The recommendation
against it stands on honest grounds now: the trailer is a certification, a hook
makes it for every commit automatically including work merely applied on
someone else's behalf, and it provably cannot tell the difference. A judgment
about deliberateness, not a claim that it breaks.
Mirror corrected to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 4 was right that the hook signs with the configured identity while the
DCO check requires a trailer matching the commit *author*, and that the two
diverge on cherry-pick and `git commit -c`.
I tried the suggested author-aware guard and it does not work: inside
prepare-commit-msg, `git var GIT_AUTHOR_IDENT` returns the config identity, not
the preserved author. Verified end to end — cherry-picking Bob's commit as
Alice yields author=Bob, committer=Alice, `Signed-off-by: Alice`. That commit
both still fails the gate and certifies work Alice did not write, which is the
one thing the last bullet of this section forbids.
Three review rounds have now found three holes in this hook, each fix opening
the next. That is a wrong mechanism rather than an unfinished one, so the hook
is out. A subtly-broken hook in the file that governs how this repo is worked
on is worse than no hook.
The bullet now recommends `git commit -s` and names BOTH false automations —
`format.signOff` and the hook — recording the cherry-pick finding so the next
person doesn't rediscover it, and pointing anyone who wants automation at a
layer that actually knows the author. Mirror updated to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
`ink-form` and `ink-scroll-view` were externalized by the TUI bundler and
both declare a loose `react` peer (">=18"). Installed under a project that
has its own React 18, npm hoists them to that project's root — where the
peer is satisfied — while the Inspector's React 19 nests underneath. The
bundle then renders through React 19 while those two call hooks on React
18, whose dispatcher is null, so opening a tool test form (or any scroll
view) dies with "Cannot read properties of null (reading 'useState')".
Inline them instead: their `import "react"` is emitted into the bundle and
so resolves from the build directory exactly like the bundle's own, with
npm's placement no longer part of the decision. This also pins their
transitive deps to what this repo resolved — notably `ink-select-input@6`
via `overrides`, which npm ignores for a package installed as a dependency,
so consumers were getting the React-18-era v5.
`ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails
ESM interop with `Dynamic require of "assert"`) and doesn't need to be —
its ">=19" peer keeps npm from hoisting it next to an unusable React.
Both packages are dropped from the root `dependencies`, since the tarball
now ships their code rather than having consumers install them.
`clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the
failure is invisible in this repo and in every smoke (a dev install has
one React) and only appears once the package is installed under another
project, so the invariant is asserted against the config instead — every
dependency declaring a `react` peer must be bundled unless exempted.
Closes#1952
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks
Two claims were wrong, both verified against the app's own documentation
(dcoapp/app): "The DCO also creates an override button for users with write
access to the repository to create a successful check."
So "there is no per-PR override" and "there is no non-rewrite escape hatch"
were both false, and a maintainer following them would conclude that rewriting
history was the only way to unblock a PR.
Documented, but deliberately not as a repair. The override only silences the
check; it does not make the author certify anything, and the certification is
the whole point of a DCO signoff. Using it on your own unsigned commits asserts
nothing while looking like compliance — so the text says to treat it as
unavailable and sign the commits instead.
The same source confirms two things the doc already claimed: remediation
commits are disabled by default, and the trailer must match the commit author's
email (which is what the identity-aware hook in 6899e0ae exists for).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
The hook skipped insertion on any `Signed-off-by:` line, which is wrong for a
message reused from another commit — cherry-pick, `git commit -c` — since that
message already carries someone else's trailer. The DCO check requires one
matching the *author*, so the guard would be satisfied while the commit still
failed the gate: the exact silent-no-op failure mode this bullet warns about.
Match this identity's exact trailer instead. Verified: a plain commit signs
once; a message already carrying another author's signoff now gets the real
author's added alongside it; `git commit -s` is not double-signed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Both review points were factually right and my original text was wrong.
1. `git config format.signOff true` — which I had recommended as the *better*
option — only defaults the -s flag for `git format-patch`. `git commit` never
reads it, and there is no `commit.signoff` equivalent. Verified all three in a
throwaway repo: format.signOff → no trailer; commit.signoff → no effect; a
prepare-commit-msg hook → works. Shipping that advice would have been worse
than saying nothing, since it looks like a fix and silently changes nothing.
Now recommends `git commit -s`, gives the verified hook for automation, and
names format.signOff explicitly as a trap — the name invites exactly the
mistake I made, so it is worth warning about rather than omitting.
2. The DCO app's empty remediation-commit flow needs
`allowRemediationCommits.individual`. This repo ships no DCO config, so it
runs with that disabled and the original unsigned commits keep failing. The
escape hatch I offered does not exist here; rewriting is the only repair, so
the text now says to coordinate before rewriting shared history instead.
Mirror updated with both corrections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
The DCO check fails any PR with an unsigned commit, and the rule appeared
nowhere in the repo — not AGENTS.md, CONTRIBUTING.md, README.md, or the Copilot
mirror. A hard merge gate that is invisible until it fails, whose remedy is a
history rewrite, is exactly what AGENTS.md exists to capture. It blocked #1978.
Added to the "When work is complete" list beside the other PR-hygiene gates,
weighted toward prevention over recitation: `git config format.signOff true`
once per clone beats remembering `-s`, because the failure is only discovered
after pushing. The repair path (`git rebase --signoff` + `--force-with-lease`)
carries the rebase caveat and the empty-remediation-commit alternative for when
you are not the sole author, plus a note that the signoff is a DCO assertion in
the author's own name and is never applied on someone else's behalf.
Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md
requires for PR-hygiene rules — two lines next to `Closes #N` and the version
label, since the mirror is read on every review and is a distillation, not a
copy.
Not added to CONTRIBUTING.md: external contributors file issues rather than
PRs, so DCO never reaches them, and documenting it there would muddy that.
Closes#1979
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot was right that the previous test proved only that `removeSafe` can
delete a directory — it never drove `stop()`, so deleting the cleanup call
would have left the unit suite AND all three smokes green. That is exactly the
regression these tests exist to catch, and they did not catch it.
Extract the teardown into an exported `teardownWebServer({ child, catalogDir,
label })`, which `stop()` now delegates to, and drive it against a stand-in
child process (a real `node -e setInterval` that stays alive until signalled)
rather than a mock. Three cases:
- it removes the catalog dir it was given (asserted on the dir itself, not a
spy, so the assertion cannot drift from the behavior);
- it awaits the child's exit before removing — the #1801 race in one
assertion, since losing the await is otherwise invisible;
- an already-dead child is not an error, which is the failure path a smoke
takes when it calls fail() after the launcher has crashed.
Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail
(previously: 0). AGENTS.md updated to match.
Signed-off-by is now on every commit (#1978 DCO); the requirement itself is
undocumented and tracked in #1979.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Addresses the two review comments that still applied to HEAD (the third, the
stop() race, was already fixed in a8022138).
- AGENTS.md still described the prod-web-server helper as shared by only
smoke:web and smoke:web:browser, and said nothing about catalog isolation.
It is the source of truth for these smoke mechanics, so record the invariant
there: the temp catalog per run and why, that only the catalog (not HOME) is
redirected, that teardown needs both halves of child-cleanup.mjs and is
therefore async for every caller, and where the contract is unit-tested.
- The `label` JSDoc claimed the option prefixes the temp dir. It does not —
that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's
dir is greppable); `label` only prefixes teardown warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
Took v2/main's project-layout line and quality-gate table (adding
verify:dep-lockstep), keeping this branch's migration-guide mention and
prettier table alignment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
README: kept v2/main's showcase table including the new structured-output
row. AGENTS: kept v2/main's newer type-label and triage rules, rendered in
this branch's markdown formatting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Copilot review round 2, four findings, all factual errors in the docs added by
the previous commit:
- `yaml` is in the root `dependencies`, not `devDependencies`, and its only
importer is `test-servers/src/load-config.ts` — not `core/` or the root
tooling, as the `vitest.shared.mts` comment claimed.
- The rule as written told a reader to put anything reached through root-owned
code — `core/` included — in `devDependencies`. That is wrong and would break
the published package silently: the client builds externalize npm packages, so
a published install resolves them from the root manifest, where
devDependencies are absent. Placement (which manifest) and classification
(which section) are now stated as separate questions, the second following
from who consumes the package at runtime.
`yaml`'s runtime classification is left alone and called out instead: moving it
changes what ships, which is not a docs change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Per the maintenance rules, a change to the dependency layout has to land in the
docs with it. Adds the placement rule in three places, at three altitudes:
- README (Setup): the rule plus the concrete drift it prevents.
- AGENTS.md: a "Dependency placement" section — the MCP SDK packages are root
only, the v1 SDK is not ours and must not become a dependency, and anything
reached solely through root-owned code with no manifest (`test-servers/src`,
`core/`) is a root devDependency aliased to the repo root in
`vitest.shared.mts`.
- .github/copilot-instructions.md: the distilled, reviewer-citable form, so a
diff re-adding one of these to a client manifest is flagged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
From Copilot's ninth pass of #1962. The one-line descriptions in AGENTS.md and
the README claimed "no install-crossing dependency resolves to two different
versions", which is stronger than the gate: candidates cover only packages the
shared sources name directly, and #1965 records a live counterexample
(`@modelcontextprotocol/sdk`, skewed across two install roots while the guard
stays green).
The boundary was already documented at length on the guard and in the AGENTS.md
rule; the summaries just hadn't been narrowed to match, which is how a doc ends
up asserting a guarantee nobody implemented. Both now say "directly imported"
and point at #1965.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
All four suppressed comments from Copilot's third pass of #1962. Each was
correct.
The candidate boundary omitted `vitest.shared.mts` — root-owned, imported by
every client's vitest config, and already treated as shared non-client source
by `verify:typecheck-coverage`. It imports only Node built-ins today, which
is exactly why the omission would have gone unnoticed until a third-party
import appeared there, resolved from the root, and skewed. Shared *files* are
now a first-class list beside the shared dirs.
The allowlist was a blanket exemption by name. Each entry's rationale
establishes that a patch/minor difference is benign, which is no evidence
that a React 18-vs-19 or Hono 4-vs-5 split would be — that is a different
type surface. Being listed now tolerates skew only *within a major version*;
a cross-major skew fails like anything else. Deriving the major from the
lockfile version keeps this automatic, with no per-package range to maintain
and bump. A version that can't be parsed is treated as "cannot prove same
major" and fails rather than passes.
`verify-format-coverage.mjs`'s comment overstated the vouching graph: the
three guards do not each check the others. This one checks both siblings;
each sibling checks only this one. Described as it actually works.
The AGENTS.md rule said to bump a shared dependency in "root + all four
clients", but launcher declares no zod and `findSkew` ignores installs where
a package is absent — so the rule as written asked for spurious dependencies.
Reworded to every install that *declares* it, and mirrored in
`.github/copilot-instructions.md`.
The failure output follows: it no longer advises adding a package that is
already allowlisted, saying "MAJOR skew" instead, and it names all three
shared sources rather than the two dirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Closes#1896
`clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The
cause is not a zod regression nor a pathological inference site of ours: it
is two copies of zod at *mismatched versions* inside one tsc program.
`clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files
resolve zod (and the SDK's zod) from the **root** install, alongside web's
own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs
4.4.3 TypeScript must relate two structurally-distinct declarations of every
`@modelcontextprotocol/*` schema type, which is exponential over a recursive
generic surface — `TS2589 Type instantiation is excessively deep`, then OOM.
Bumping root zod to 4.4.3 and changing nothing else returns the build to
baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at
4.3.6. Two copies at the same version are harmless; skew is what explodes.
So drop the `~4.3.6` hold on `clients/web` and move all four manifests and
lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate`
to keep them there. The guard derives its candidate set from what `core/`
and `test-servers/src` import, compares the committed lockfiles' top-level
entries, and fails deny-by-default on any skew not in the annotated
`TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so
dropping it from `validate` is caught by `verify:format-coverage`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Closes#1822
Adds docs/v1-to-v2-migration.md — the map for users arriving from v1:
- CLI flag mapping (every v1 flag survives; lists what v2 adds)
- `--config` vs `--catalog` semantics with before/after examples
- Node engine bump (>=22.7.5 → >=22.19.0)
- what no longer ships (the three sub-packages; v2 is one tarball)
- the three behavior changes most likely to bite: exit code 5 on a
failing tool call, target-must-come-first under --cli, and the
reversed `--` separator
- env-var mapping (MCP_PROXY_AUTH_TOKEN → MCP_INSPECTOR_API_TOKEN,
MCP_PROXY_FULL_ADDRESS removed, SERVER_PORT repurposed), the v1 UI
config settings, web query params, and Docker
Linked from the root README (banner + docs list), the CLI README, and
docs/mcp-server-configuration.md; AGENTS.md's docs/ tree entry updated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
`smoke:web:browser` stops at first paint and never connects to a server, so
everything downstream of the connect — the Apps tab, the sandbox controller,
the UI-protocol bridge — was unexercised by any smoke. That is the code #1859
broke, and nothing would have caught a regression in it.
smoke:web:app boots the same prod --web server, spawns the mcp-app-http.json
composable server, and drives connect → open app → widget ready through one
deep-link navigate. The assertion is the documented data-app-status="ready"
contract, which the renderer only reports once the widget has loaded inside the
sandbox iframe AND completed its bridge handshake — so a single attribute
covers the proxy being served, the UI resource loading, and the handshake.
Two mechanics found by running it, both silent-failure shaped:
- server-composable.ts announces readiness on stderr, not stdout, so watching
stdout alone times out with an empty diagnostic. Both streams are scanned.
- the bound port is not the configured one — createTestServerHttp resolves via
findAvailablePort(), which walks upward when the port is taken. The smoke
parses the announced URL instead of assuming 3130.
Scope: this runs against the repo build tree like every other smoke, so it would
NOT have caught #1859 itself (a packaging failure; the file is always present
in-repo). pack:verify owns that dimension — the two are complements, and the
header comment says so. A cheap structural pre-check does assert the proxy page
sits where sandbox-controller.ts resolves it, so a move/rename fails fast.
Both failure modes verified by hand: with clients/web/static removed, the
pre-check fires; with the pre-check bypassed, the data-app-status assertion
catches it independently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
The version label says which line work belongs to; nothing said what KIND of
work it is. In practice that meant reaching for bug or enhancement and
pressing everything else into one of them — a migration guide and a
tsup->tsdown swap both landing as "enhancement", which degrades the label to
"not a bug" and makes filtering by it meaningless.
Every issue created or triaged now carries exactly one of bug, enhancement,
documentation, chore, or question, independent of its v1/v2 label. Adds a
table of what each is and is not for, and states the anti-pattern explicitly
so the two familiar labels don't absorb the other three. A PR needs no type
label — it is classified through the issue it closes, the same way it is
tracked through that issue's board card.
Wires the requirement into the three places that would otherwise drift: the
"not created until" callout, triage pass 1 (an outside reporter can set
neither label, so both are applied in the same step), and the board audit,
which gains an "open, no type label" check and a matching row in the
invariant table.
Note `chore` did not exist as a repo label and was created for this; the
existing `dependencies` label is Dependabot's and scoped to PRs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
A board card in Done asserts that the work shipped, and Done is read as the
record of what a milestone actually delivered. Nothing said so, so an issue
closed as a duplicate or not-planned was being moved to Done like any other
close — which makes that record wrong in a way no later reader can detect:
counting Done cards can no longer tell a shipped fix from a report closed as
a duplicate of one.
New rule: exactly two things earn a card a place in Done — its PR merged, or
it is a parent whose last sub-issue closed. Every other close (duplicate,
won't fix, not planned, obsolete, superseded) means nothing shipped, so the
card is deleted instead. Deleting a card touches only the board; the issue
keeps its labels and comments and stays searchable, so nothing is lost.
Also notes that the close reason is the machine-readable form of the same
distinction, and that `gh issue close --reason` cannot express `duplicate` —
it accepts only completed/not planned, so duplicate must be set through the
API or the web UI's "Mark as duplicate" (which also records a duplicate-of
link).
Adds the invariant to the board audit as a tenth check. That required the
audit to read closed issues too, so its fetch moves to --state all with
stateReason, `open(n)` becomes a state test rather than "present in the map",
and --limit must now clear the repo's total issue count, not just the open
ones. The two wrong-board label checks are scoped to open issues in the same
pass — over all history they flag four long-closed cards whose labels nobody
intends to rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Follow-up on the same issue: the triage rules were clear on the happy path
but three gaps meant a bare "triage new issues" would diverge from what a
maintainer actually does.
Define triage by STATE, not authorship. An issue needs triage when it
arrives with no board card and no milestone — whoever filed it. Framing it
as "externally-filed" was wrong: a maintainer opening an issue by hand in
the GitHub UI lands in exactly the same state, because write access makes
the board reachable, not automatic. What puts an issue in Todo is somebody
performing the approval, not who owns the account. Renamed the section and
reworded every dependent passage accordingly.
Fix the discovery query, which diffed open issues against board #28 only.
Verified against the live boards: it reports #1929 as unboarded when that
issue is correctly carded on #11, so following it literally double-boards
the issue — recreating a defect a past sweep introduced. Now diffs against
the union of both boards, filtered by .content.repository (an org project
can hold other repos' issues; #11 carries one from modelcontextprotocol/
servers), and prints each hit's destination, since an unboarded issue that
already has a milestone is approved and belongs in Todo, not Incoming.
Add "The board audit" — the other drift classes a single-issue rule can't
catch (double-boarded, non-Issue items, statusless cards, the Incoming ⇔
milestone invariant in both directions, wrong board for label, missing
version label, missing Priority), as a table of invariant + fix plus one
runnable jq check that should print 0 across the board. Both snippets were
executed verbatim against the live boards.
Add "Recording the score". The rubric claimed its reasoning "survives in a
form someone can argue with later", but the board stores only the result
and is private, so nothing kept that promise. Triage now posts the axes,
the bonuses claimed, and the total as a comment.
Also scope pass 2 explicitly to a human — deciding what ships in which
release is not inferable from a rubric — and correct the stale note about
three statusless cards on #11.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
AGENTS.md had no rule linking a milestone to a board Status, and its two
existing rules contradicted each other: every new issue was to be milestoned
at create time, yet new items were to start in Incoming, with Todo reserved
for work a maintainer had approved. If every issue is milestoned at birth,
"has a milestone" carries no approval information and nothing records the
moment of sign-off.
Make assigning a milestone the approval act, giving one invariant:
Incoming <=> no milestone; everything past it <=> milestoned. External
reports and maintainer-filed issues then differ only in whether approval has
already happened -- an outside reporter cannot set a label, milestone, or
board field, so their issue arriving with none of them is normal rather than
a defect to fix on arrival.
- scope the "not created until five things" callout to maintainer-filed issues
- split the Status bullets into the two cases
- scope the milestone mandate to issues you create, stating the external
exception explicitly
- add a "Triaging externally-filed issues" section: the two passes, plus a
gh/jq snippet that diffs open issues against the board to find unboarded ones
- reword both prose descriptions of the Incoming/Todo line
- reword the rubric's "Linked to a milestone" bonus as "already approved" and
note it is the re-scoring case only; under the old rules it was a constant
offset on every issue, shifting every band boundary down by one
- update both gh recipes (#28 and #11) to default to Todo, with an Incoming
variant for triage
No mirror to .github/copilot-instructions.md: board and milestone mechanics
are on that file's deliberately-absent list.
Closes#1930
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1838)
eslint 9 pulled `minimatch@3` → `brace-expansion@1.1.16`, which carries
GHSA-mh99-v99m-4gvg (high, DoS via unbounded expansion length). It appeared in
all five lockfiles — the root plus each of the four clients, none of which is an
npm workspace, so all five had to move together.
Deferred from the pre-2.0.0 sweep because it is dev-scope, not attacker-
controlled, and a major bump across five packages was the wrong thing to attempt
immediately before an irreversible publish. This is that work.
## What moved
- `eslint` `^9.39.4/5` → `^10.8.0` and `@eslint/js` → `^10.0.1`, in all five
manifests.
- `eslint-plugin-react-hooks` `^7.0.1` → `^7.1.1` in web and tui. Required, not
cosmetic: 7.0.1's peer range stops at `^9.0.0`, so installing it alongside
eslint 10 fails `ERESOLVE`.
- Everything else already declared eslint 10 support and stayed put:
`typescript-eslint@^8.56.1` (`^8.57 || ^9 || ^10`),
`eslint-plugin-react-refresh@^0.5.2` (`^9 || ^10`),
`eslint-plugin-storybook@^10.2.19` (`>=8`).
## Config migration
None was needed. All five configs were already flat (`eslint.config.js` with
`defineConfig`/`globalIgnores` from `eslint/config`), which is the format v10
keeps and the only one it still supports. The root config's `core/` and shared-
surface blocks — including the `_`-prefix `argsIgnorePattern` /
`varsIgnorePattern` / `caughtErrorsIgnorePattern` — are unchanged and still gate
`lint:core` and `lint:shared`.
No rule was disabled, downgraded, or scoped away to make the new eslint pass.
v10 surfaced 21 real violations across three rules; all 21 are fixed in the code.
### `preserve-caught-error` (new in `eslint:recommended`) — 15 sites
A `throw new Error(msg)` inside a `catch` silently drops the original error.
Each now passes `{ cause }`, so the underlying failure survives into the chain.
Messages are unchanged, so nothing that asserts on them moves. 12 in `core/`,
2 in `clients/cli`, 1 in `test-servers/src`.
### `no-useless-assignment` (new in `eslint:recommended`) — 2 sites
`clients/cli/src/cli.ts`'s `optionArgs` and `test-servers/src/test-server-oauth`
's `valid` were initialized and then unconditionally overwritten on every path.
Both are now declared without the dead initializer; TypeScript's definite-
assignment analysis covers the remaining paths.
### `react-hooks/set-state-in-effect` — 8 sites (web)
These were false negatives the plugin fixed in 7.1.0, not a rule we newly opted
into: `set-state-in-effect` has been `error` in `flat/recommended` since 7.0.1,
and the web config has always extended it. Every one was the same anti-pattern —
resetting or re-syncing local state from a prop inside a `useEffect`, which
paints the stale value for one frame and then renders again to correct it.
All eight now use a new `useValueChange(value, onChange)` hook
(`clients/web/src/hooks/useValueChange.ts`), which is React's documented
"adjusting state during render": compare against the previous render's value
with `Object.is` and call back *during* render, so React discards the
in-progress output before it reaches the DOM.
- `MrtrConversation`, `ProtocolEntry`, `TaskCard` — mirror `isListExpanded`.
- `NetworkEntry` — same mirror, plus the "Reveal in Network" force-open. The
reveal's rAF scroll stays an effect (real external-system work); only the
`setIsExpanded(true)` moved. `isExpanded` is now seeded from
`isListExpanded || revealed`, since a render-time sync fires on change and so
cannot cover an entry that mounts already revealed.
- `PromptArgumentsForm`, `ResourceTemplatePanel` — reset on template/prompt
switch.
- `ServerConfigModal` — reset on open. Keyed on `opened ? initial : undefined`,
which collapses "opened flipped" and "initial changed while open" into one
value; `initial` is already memoized, so it cannot thrash.
`useValueChange` has its own test file, and `ServerConfigModal` gains an
open→edit→close→reopen test (the close leg was previously untested, which is
what left the new guard uncovered).
## Advisory delta
`npm audit`, per package, before → after:
| Package | before | after |
| ------------------- | -------------------------- | -------------------------- |
| root | 9 (1 low, 6 mod, 2 high) | 8 (1 low, 6 mod, 1 high) |
| `clients/web` | 19 (3 low, 6 mod, 6 high, 4 crit) | 17 (3 low, 6 mod, 4 high, 4 crit) |
| `clients/cli` | 6 (2 low, 4 high) | 4 (2 low, 2 high) |
| `clients/tui` | 4 (1 low, 3 high) | 2 (1 low, 1 high) |
| `clients/launcher` | 3 (3 high) | 1 (1 high) |
`brace-expansion` is gone from all five — `eslint@10` resolves `minimatch@10` →
`brace-expansion@5.0.9`. Dropping `@eslint/eslintrc` (removed in v10) also took
`js-yaml` with it, clearing GHSA-52cp-r559-cp3m (high) from all four clients as
a bonus. The remainder are unrelated and tracked separately.
`npm run ci` passes end to end — validate, the per-file ≥90 coverage gate, the
build gate, all five smokes (including `smoke:tui`, run locally on a real TTY),
and 462 Storybook tests.
Also documents the effect rule in AGENTS.md and mirrors it into
`.github/copilot-instructions.md`, since it is now something a reviewer cites.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: state useValueChange's referential-stability requirement (#1838)
Copilot review on #1900: the hook compares with `Object.is`, so an object or
array literal rebuilt in the component body compares unequal every render.
Because the callback is what updates state, that is not merely a wasted render —
it is an infinite loop.
Every current caller passes a primitive (`boolean`, `string`) or an already-
memoized value, so nothing changes behaviorally. But the hazard was implicit,
and the next caller is the one it would bite.
Documented on the hook itself, and one clause added to the AGENTS.md rule with
its mirror in `.github/copilot-instructions.md`. No runtime guard: the
requirement is the same one a `useEffect` dependency array carries, and here the
failure is loud and immediate rather than silent.
`npm run ci` passes.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cli): stop asserting the --tool-args-json catch binding is an Error (#1838)
Copilot review round 2 on #1900: the `--tool-args-json` parse handler formatted
its message with `(e as Error).message`. A `catch` binding is `unknown`, so that
cast is an assertion rather than a check — a thrown non-Error would render
`--tool-args-json is not valid JSON: undefined`, which points the user at
nothing.
`JSON.parse` does only throw `SyntaxError` today, so this is not a live bug. But
the cast is the kind AGENTS.md asks us not to leave unjustified, the same
handler was already being touched here to add `{ cause: e }`, and every other
site in this PR uses the guarded form. Now it does too.
`npm run ci` passes.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: require useValueChange's onChange to be render-pure (#1838)
Copilot review round 3 on #1900: `onChange` is called during render, so it is
bound by render-phase purity — and the web client mounts under `StrictMode`,
which deliberately double-renders in development. A callback doing anything
external would run an unpredictable number of times, and concurrent React can
abandon an in-progress render entirely.
Every current caller already passes a callback that only calls `setState`, so
nothing changes. But "runs during render" is the whole point of the hook and the
constraint that follows from it was left implicit.
Stated on the hook, with the same clause in the AGENTS.md rule and its
`.github/copilot-instructions.md` mirror. All three now point at `NetworkEntry`
as the worked example of the split: the reveal's force-open is a state update
and lives in `useValueChange`, while its `requestAnimationFrame` scroll stays a
`useEffect`.
`npm run ci` passes.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: replace the markdown bug template with issue forms (#1844)
Issues are the only intake channel now that external pull requests are off
(#1820), so the chooser is where the contribution policy has to be stated —
GitHub gives a would-be contributor no explanation when the "New pull request"
button isn't there.
The one existing template was a legacy **markdown** template, whose fields are
prose headings a reporter can delete or ignore. Most reports still arrive
missing the client and the transport, which are the two facts that decide where
to look. Issue forms enforce them: this replaces `bug_report.md` with
`1-bug_report.yml`, where client (web/cli/tui/core), version line (v1/v2),
Inspector version, Node version, transport, MCP server, repro, expected, and
actual are all `validations.required`. It auto-labels `bug`, and its intro says
maintainers implement the fix, so a prompt plus screenshots beats a diff —
with a field for exactly that.
Adds `2-feature_request.yml` (auto-labels `enhancement`), which puts the
problem statement first and marks the solution optional, since the problem is
what survives when a specific solution turns out not to fit.
Neither form applies a `v1`/`v2` label: GitHub can't map a form answer to a
label, so the version line is a required dropdown and a maintainer still labels
at triage. v1 takes security fixes only, so routing that answer early is the
point.
`config.yml` disables blank issues and carries the contact links. **There is
deliberately no security *template*** — a template still opens a public issue,
which is precisely what a vulnerability report must not do. The redirect is a
contact link straight to the private advisory form (`/security/advisories/new`,
verified enabled on this repo) plus one to `SECURITY.md`, which covers the v1
line too. The remaining links deflect the recurring misfiled classes: the
specification repo and the TypeScript SDK for reports that aren't about this
tool, the docs site, the contribution policy, and `#inspector-dev`.
Also fixes three links the #1884 rename left dangling — `SECURITY.md` and the
PR template still pointed at `CONTRIBUTORS.md`, which no longer exists on this
branch. A policy nobody can open is the same as no policy.
Validated against GitHub's issue-forms schema (unique ids, allowed `type`s,
`markdown` blocks carrying no `id`/`validations`, `checkboxes` marking
`required` per option rather than under `validations`).
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: address Copilot review on the issue forms (#1844)
- PR template said `v2` targets `main`; the v2 base branch is `v2/main`.
Correct on its face, and the one line in that template a reader acts on.
- Scoped the Node floor on the bug form to v2, since the form serves both
lines and v1's floor is older — reading `>= 22.19.0` as universal would
make a legitimate v1 report look out of support.
- Both the CONTRIBUTING.md paragraph and the AGENTS.md section now say the
chooser is served from the **default branch**, so neither claims a form
added on `v2/main` is live before the milestone merge.
* docs: re-wrap the CONTRIBUTING chooser paragraph (#1844)
Round-2 Copilot nit: the previous wrap left a dangling "The" at end of
line, splitting a phrase mid-sentence in the raw markdown.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: clarify the protocol-era prompt on the bug form (#1844)
Round-3 Copilot nit. "the protocol era (legacy / modern) if you selected
one" reads as referring to a field on this form, which has none — the era is
selected on the *connection*, in the Inspector's server settings. Reworded to
"the protocol era you connected with (auto / legacy / modern)", which also
picks up `auto`, the default the previous list omitted.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: stop describing the feature form as sharing the bug form's fields (#1844)
Round-4 Copilot nits, and it was right three times over — I had described
"client, version line, transport" as the required set for *both* forms in
CONTRIBUTING.md, AGENTS.md, and the config.yml header. The feature form has
neither a version-line dropdown nor a transport field, and cannot have the
first: v1 takes security fixes only, so a feature request is v2 by
construction.
Each of the three now distinguishes the two forms rather than generalizing
from the bug form.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: declare v2 statically on the feature form (#1844)
Round-5 Copilot nit caught a real contradiction: the AGENTS.md paragraph said
no form can apply a version label, then asserted the feature form "is always
labeled v2". Both halves can't be true.
Resolved in the direction that makes the claim true rather than softer. A
form's `labels:` is static, which is only an obstacle when the value depends
on a reporter's answer — and for the feature form it doesn't: v1 takes security
fixes only, so a feature request is v2 by construction. So it now declares
`["enhancement", "v2"]` outright, which also satisfies AGENTS.md's
label-at-create-time rule without a triage step.
The bug form still can't, and shouldn't: its version line is genuinely the
reporter's to answer.
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: add Incoming board status, adopt Priority field, document triage rubric (#1891)
Board #28 had no state for "filed but not yet triaged" — Todo means
maintainer-approved, so parking unreviewed issues there falsely asserted
sign-off. Adds an Incoming status ahead of Todo.
The board's Priority single-select existed but was unused (0 of 264 cards)
and undocumented, with P0/P1/P2 options matching no vocabulary in the repo.
Re-optioned to Urgent/High/Medium/Low and documented alongside Status.
Completes the priority rubric: fills in the score bands (12+/9-11/6-8/<=5),
expands both axes into scored tables, and fixes the truncated signal-indicator
line. Severity alone tops out at High by design.
Also drops the "V2 Go Live" status reference — that option is not in the
field's option list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: correct the Done option id and harden the deleted-column guidance (#1891)
The Done Status option was deleted from board #28, blanking the Status of
all 247 cards that held it. Restored from a pre-change item-list snapshot;
verified all 247 are Done again and no other card was disturbed.
A recreated option never regains its old id, so Done is now 259d6aab rather
than 248a3910 — updated in the option table and the merge step.
The hazard callout recommended the web UI as the safe path, which is what
made this look safe. It is not: deleting an option blanks its cards in the
UI exactly as it does via the API, with no undo. Corrects that, adds a
snapshot-first rule, and documents the recovery recipe used here — including
the grouping check that proves the orphaned set is exactly the deleted
option's cards before re-applying.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: add Incoming to the v1 board and document its recipes (#1891)
Board #11 had the same gap as #28 — no state for "filed but not yet
triaged" — so a v1 security-fix issue had nowhere to sit that didn't
assert maintainer approval. Adds Incoming there too.
AGENTS.md carried no v1 board recipes at all, only a link, so anyone
boarding a v1 issue had to discover the project and field ids by hand
and was liable to reach for #28's (which are rejected, but only after
the attempt). Adds a V1 board section with its ids, states that Incoming
is the default status for new items on both boards, and notes that
Priority is v2-only — #11 has no such field.
The Incoming option was added with all four existing option ids echoed
back, and a before/after item-list diff confirms all 74 cards kept their
Status. Three cards on #11 carry no Status; that predates this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: distinguish the board Priority from the org-level issue field (#1891)
An issue page shows two fields named "Priority" and nothing syncs them.
Ours is the project field on board #28. The other is a GitHub issue field
(IFSS_kgDOAdAWeg) defined at the modelcontextprotocol org and shared by
every repo in it, alongside Effort/Start date/Target date. Same name, same
four option names, unrelated storage — #1891 sat at Urgent in Fields and
High on the board simultaneously.
Adds a table naming both, states that no pass-through exists in either
direction, and says to leave the org-level one alone: it is not repo-scoped,
so deleting it would strip Priority from every other org repo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: mark the org Priority field untrusted and cap it at a +1 signal (#1891)
Both boards are private (public: false, verified), so maintainer-assigned
Status and Priority are invisible to reporters — board priority is a
working queue, not a published commitment.
The org-level Fields -> Priority is the opposite: public on the issue page
and not part of triage. A value there is a reporter's preference, not an
assessment, so it is untrusted input. It now feeds the rubric as a sixth
signal bonus — flat +1 for Urgent or High, identical for both — and never
maps to a band. Urgent needs 12, so nothing a reporter types reaches it
alone; the issue must already sit at 11 on maintainer-assessed axes.
Supersedes the earlier "it is noise" framing: the signal is worth keeping,
it just cannot be allowed to decide the outcome. Also corrects the band
arithmetic (2-15 -> 2-16) now that a sixth bonus exists, and notes that
scores recorded on 2026-08-01 cite the old /15 denominator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: drop the /15 denominator caveat now the recorded scores are restated (#1891)
The 51 triage comments were rewritten from /15 to /16, so the note saying
they cite the old denominator no longer describes anything. No total moved:
the reporter-set signal only scores for Urgent or High, and the one scored
issue carrying an org-field value (#1826) had Low, which earns nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: describe the board-option hazard per field, not Status-only (#1891)
Copilot review on #1892 flagged three places that still read as
Status-only now that the section covers Priority too:
- the version-label bullet said a v2 issue needs "a board card with a
Status"; it needs a Status and a Priority (v1 needs only a Status,
since board #11 has no Priority field)
- the updateProjectV2Field hazard said a bad edit "orphans the Status
of every card"; it orphans whichever field was edited
- the recovery recipe is genuinely Status-specific (reads .status,
writes the Status field id), so say so and give the two
substitutions for a Priority deletion
Verified `gh project item-list --format json` exposes both .status and
.priority before documenting the .priority swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct post-swap branch model, PR policy, and stale gate/tree entries
Part of the go-live phase-8 docs hygiene: everything that silently inverted
when `main` became v2.
Branch/release model — documented canonically in AGENTS.md and corrected
everywhere it was restated:
- AGENTS.md: replace the one-line "Base Branches" bullet with a table giving
each branch its role (v2/main = develop, main = release, v1/main =
maintenance), whether PRs target it, and which npm dist-tag it publishes.
- README.md: the "Repo status" callout had it backwards, claiming `main` was
the legacy v1 implementation. Also note that a v2 release is cut from `main`
after the milestone merge, not from v2/main.
- CONTRIBUTORS.md: the version table listed v2's base branch as `main`.
PR policy:
- Every PR must reference an issue, from anyone, no exceptions.
- PRs are opened by repo maintainers only — org write access is not
authorization. Everyone else files a detailed issue.
- Branch names start with the target version segment (v2/…, v1/…).
- Issues and PRs carry exactly one of `v1`/`v2` at creation; default `v2`.
- Screenshots proving UI/TUI changes go in the gitignored pr-screenshots/.
Corrections to stale content found while in there:
- The TUI coverage bullet still described the Ink/App.tsx/hooks exclusion as
interim pending #1501, which closed 2026-06-29; the gate now covers all of
src/** with only tui-servers.ts excluded.
- The completion checklist said `npm run validate` was the gate and covered
"e2e tests", contradicting the mandatory-pre-push-gate section (npm run ci).
- v1 PR base was given as `main`; it is `v1/main`.
- Project tree omitted core/client, docs/, and scripts/ (README: core/client,
docs/). core/client is under the coverage gate and imported by both App.tsx
trees.
Also adds the launcher and `npm run test:scripts` to the "run the tests" list
and a tiers overview, since neither was mentioned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: require a milestone on every new issue, defaulting to the current one
Adds the rule alongside the version-label rule, since they fail the same way:
an unmilestoned issue drops out of release planning as silently as an
unlabeled one drops out of version filtering.
- Set it at create time (`gh issue create --milestone <title> ...`).
- If the user didn't specify one, default to the current milestone — the open
milestone with the nearest due date — rather than leaving it blank pending a
decision. Includes the `gh api .../milestones` one-liner that identifies it.
- Milestones are release buckets, so pick by when the work ships, not by size;
sub-issues normally inherit their parent's milestone.
Also widens the section's opening callout: an issue is not "created" until it
is labeled, milestoned, AND boarded with a Status — four distinct steps, not
three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: add .github/copilot-instructions.md and require mirroring into it
GitHub Copilot reads .github/copilot-instructions.md when reviewing a PR. The
repo had no such file, so Copilot reviewed without any of the conventions that
actually govern this codebase — the ban on `any` and on unjustified double
casts, the Mantine-first styling rules, the `.withProps()` extraction rule, the
lib/utils split, test placement, and the per-file >=90% four-dimension gate.
The new file is a review-focused distillation of AGENTS.md: the rules a
reviewer would cite against a diff, plus a short "what to prioritize" list that
puts correctness and security first (this backend spawns processes and proxies
outbound requests). Deliberately omitted: board recipes and IDs, milestone and
branch mechanics, release procedure, and the project tree — no reviewer cites
those, and copying them would double the maintenance surface.
AGENTS.md's "Keep documentation files up to date" section now requires
mirroring review-relevant changes into it in the same PR, states that AGENTS.md
stays the source of truth, and spells out what counts as review-relevant. There
is no generation step and nothing detects drift, so the rule is the only thing
keeping the two in sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: address Copilot review — v1 policy wording, stale V2 Go Live, typo
- Align the v1 maintenance policy on "security and bug fixes only" in
AGENTS.md and CONTRIBUTORS.md; both said "security fixes only" while the
README callout, the branch table, and the board description said
"security and bug fixes".
- Drop the stale "or V2 Go Live" instruction from the work-begins step —
that column no longer exists on board #28 (verified via
`gh project field-list 28`), so the option id it implied would be
rejected. Restore the removed-column id list the same edit dropped, now
including V2 Go Live, and re-date the table to today's verification.
- Fix "contains the the new version" typo.
- Strip a stray trailing blank line at the end of AGENTS.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: restore `inspector/` as the Project Structure tree root
The tree is a filesystem layout — every child is a real directory
(`clients/`, `core/`, `test-servers/`). Labeling its root `v2/main/` named
a branch instead, which reads as a path that does not exist in a checkout
and diverged from the identical tree in README.md, which still says
`inspector/`. The branch model is documented in its own table under
Repository & Project Boards; the tree does not need to restate it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: settle the v1 policy wording as "security fixes only"
Reverses the direction the Copilot round took in 33cc0366. That commit
normalized the six v1-policy sites onto "security and bug fixes" because that
was the majority wording — but majority is the wrong tiebreak here. The policy
is set by what we told contributors: the #1819 backlog close note, posted to
120 closed PRs, says "v1 will receive security fixes only", and the five PRs
retained for 1.0.5 are all security fixes.
So the two sites that already said "security fixes only" were the correct ones,
and the four that said otherwise are now aligned to them:
- AGENTS.md: the `v1` label description, the branch-role table, and the v1
board line.
- README.md: the "Repo status" callout.
CONTRIBUTORS.md and AGENTS.md's Project Status bullet already read correctly
and are unchanged.
This is the substance of #1813, which is why that issue's PR is now reduced to
its PR-template-link half — the wording work landed here to avoid two PRs
editing the same lines in opposite directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
* docs: point the AGENTS.md policy link at CONTRIBUTING.md
This PR rewrites the paragraph holding the only in-repo link to the
contributing policy, so it owns that line. #1884 renames the file
CONTRIBUTORS.md -> CONTRIBUTING.md; having #1884 also edit this line would
guarantee a one-line conflict between the two.
Writing the new filename here instead makes #1884 a pure `git mv` with no
overlapping line, so neither PR conflicts with the other.
Between this merging and #1884 merging, the link points at a filename that
doesn't exist yet. That window is deliberate and bounded — this PR is first in
the merge order (#1821) and #1884 is third.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846)
A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server)
rejects a tools/call whose argument carries an `x-mcp-header` annotation
when the matching `Mcp-Param-*` request header is missing (-32020
"header mismatch"). The Inspector never sent that header.
The SDK only mirrors inside `client.callTool()` (and skips it entirely in
a browser environment), but the Inspector routes tools/call through
`client.request()` for manual MRTR driving (#1704), so the SDK never
mirrors for us — on any transport. On top of that, the remote (web)
transport dropped per-request headers before they reached the backend's
upstream fetch.
- core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders`
+ value encoding (Base64 sentinel for non-ASCII/whitespace/empty values)
and add `mcpParamHeadersForTool`.
- InspectorClient.attemptToolCall: on a modern connection, build the
headers from the tool's declarations + call arguments and attach them to
the tools/call request options. `Protocol.request` forwards `headers`
to the transport and preserves them across MRTR retry legs, so the
direct StreamableHTTP transport applies them. Fixes CLI/TUI.
- RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest:
forward per-send headers and apply them to the backend's upstream
transport.send. The browser can't set cross-origin headers, but the
Node backend can — where the Inspector's real request is issued. Fixes web.
Verified end-to-end against the live GitHub MCP Server (modern HTTP).
Closes#1846
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
* review: harden backend header allowlist, fix breadcrumbs, trim comments
Address Copilot review on #1847:
- server.ts: restrict client-supplied per-send headers to the `Mcp-Param-`
prefix (string values only) via `mcpParamHeadersOnly` before applying them
upstream, so the per-send channel can't inject arbitrary headers (e.g.
Authorization). `settings.headers` remains the channel for configured
upstream headers. Covered by new unit tests.
- Fix stale `#1810` breadcrumbs → `#1846` in the two new tests.
- Trim the verbose mirroring comments in inspectorClient/types/remote transport.
- Drop the redundant base64-on-the-wire integration test (encoding is unit-
tested; wire delivery is proven by the verbatim test), remove two unneeded
test casts, and cover the boolean `true` branch.
Not changed (declined with reason on the PR): buildMcpParamHeaders remains a
faithful port of the SDK's helper and does not re-validate values against the
declared JSON Schema type — arg types are enforced by schema validation on
both sides, and matching the SDK's conforming wire output is the module's goal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
* review: mirror Mcp-Param-* on the task-augmented tools/call too
Address the maintainer review on #1847:
- `callToolStream` (the "Run as task" path) built its own request options and
never mirrored, so a task-augmented `tools/call` on an annotated tool was
still rejected by a strict modern server with -32020. It now mirrors like the
plain path.
- Extract the two blocks both `tools/call` entry points had duplicated —
`convertStringToolArgs` (the string-arg coercion) and
`applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't
drift again. That duplication is why the task path was missed.
- Regression test: `callToolStream` against the modern `get_weather` server
asserts the POST carries `Mcp-Param-City` (verified failing without the fix).
- README: the modern-network showcase claimed mirroring is skipped in the
browser so the web client gets -32020, and that CLI/TUI mirror. Both are now
wrong — the Inspector mirrors itself, on every client. AGENTS: note that
xMcpHeader.ts now also builds the wire headers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t
* docs: correct the stale "browser omits Mcp-Param-*" copy
Both statements predate this PR and are now the opposite of true, per the
wire capture on #1847 (proxy-observed upstream headers, web leg included).
- ToolDetailPanel: the mirrored-headers note claimed the browser omits
`Mcp-Param-*`. It now says the web client's headers are applied by the Node
backend that issues the upstream request.
- createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated
tool is "uncallable from the web client against a strict modern server" —
the exact wrong conclusion to reach while debugging a -32020. It now records
that the Inspector builds the headers itself, and that they do NOT travel
this `/api/fetch` proxy path: they ride the transport's per-send `headers`
through `/api/mcp/send`.
Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx
matches the section title only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
* docs: refresh V2 board Status option IDs in AGENTS.md (#1823)
`V2 Go Live` (`b3a6966e`) was added to the Status field via the web UI on
2026-07-27 for the go-live phases (#1804) and was not in the table; the
`SDK V2 + New Spec` row it replaced in position (`1bbb6f57`) had already
been removed, so following the table failed with "option Id does not
belong to the field". Record the current five options, note that adding
the column via the web UI left the other four IDs untouched (evidence the
UI path is safe, unlike the GraphQL full-replace hazard documented below
it), and list every removed column's dead ID in one place.
Also add the missing recipe for moving a card that is already on the board:
the existing recipes only cover `item-add` for a new item, leaving no
copy-paste path for looking an existing card's item id up by issue number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
* docs: close the ID-rotation pointer over the new recipe (review #1825)
The hazard note's maintenance pointer lists the hardcoded option ids that
must be updated when they rotate, but the recipe added in the previous
commit hardcodes a third one (159c8a02) that the pointer did not name —
reproducing the staleness class this PR exists to fix. Add it.
Also fold in two adjacent fixes: the "In review" casing left behind two
lines from the "In progress" → "In Progress" fix (labels are matched
exactly when re-fetching ids by name), and a note that --limit must stay
above the board's item count, since exceeding it truncates silently and
surfaces as an opaque node-resolution error from item-edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
* docs: move the --limit caveat inside the recipe fence (review #1825)
The section tells the reader to copy the recipes verbatim, so guidance that
lives only in the prose above the fence is lost by anyone who grabs the block
— which is the whole point of the block. The two recipes above it already
carry their context as in-block comments; match that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: add CONTRIBUTORS.md issues-only policy (#1517)
Document the contribution policy: we accept issues, not pull requests.
Maintainers handle design and implementation through a prompt-driven
workflow. Contributors who have already built a change locally should
share the prompt they used rather than a diff.
Cross-reference the policy from AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add PR template redirecting external authors to issues-only policy (#1517)
GitHub auto-populates pull_request_template.md into every new PR body,
so it steers would-be external PR authors to CONTRIBUTORS.md before they
submit a diff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: drop version-board details from CONTRIBUTORS.md (#1517)
The policy takes effect after v2/main merges into main, leaving a single
line of development. Tell contributors to open a well-formed issue rather
than routing them to per-version project boards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add version-board/label table to CONTRIBUTORS.md (closes#1595)
Completes requirement #2 of #1517: a table mapping each active version to
its base branch, project board, and version label, plus a "Label by
version" note mirroring AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
* docs: drop v1.5 from CONTRIBUTORS.md, invite deeper contribution (#1517)
Review feedback on PR #1604:
- Remove the v1.5 row/bullet from the version table — v1.5 was an internal
intermediate branch that external contributors never saw, so surfacing it
in an outward-facing doc is noise. The table is now v1 / v2 only.
- Add a "Want to work on the Inspector with us?" section pointing at the MCP
Contributor Discord (#inspector-dev) and the community calls, framing the
issues-only rule as being about unsolicited patches rather than a closed
door, with supervised work as the path toward maintainership.
- Link that section from "Why this policy exists", and add the same Discord
pointer to the PR template.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD
* docs: drop project-board links from CONTRIBUTORS.md (#1517)
The org project boards aren't visible to anyone outside the organization, so
linking them in an outward-facing doc points contributors at a 404. The
version table is now base branch + label only, and the PR template names the
version labels instead of "the appropriate version board".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD
* docs: v1 takes security fixes only (#1517)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD
* docs: use post-runbook branch names (v1/main, main) (#1517)
This doc will be read after the branch runbook completes, when v1 lives on
v1/main and v2 is the default main. Update the version table, the label-by-
version line, and the PR template's label hint + CONTRIBUTORS.md links
accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): offer explicit re-authorize when callback state is lost (#1808)
A `/oauth/callback` that arrives with no recorded SEP-2352 discovery state
made the SDK throw `AuthorizationServerMismatchError` and the Inspector
dead-ended, surfacing the raw SDK text with no path forward.
The SDK folds two very different failures into that one error class, so
`core/auth/issuerBinding.ts` classifies them structurally: a recoverable
"lost authorization state" (the SDK puts prose, not an issuer URL, in the
`recordedIssuer` slot) versus a genuine cross-AS `issuer_mismatch`. Only
the first gets a recovery affordance.
Web: the callback failure now raises a dedicated banner ("Authorization
state was lost" / "Authorize again") whose action clears the stale OAuth
state for that server and starts a fresh authorization. A genuine
mismatch stays a red, non-dismissible security notification with both
issuers named and no one-click retry.
Node runners (CLI/TUI): the same classification rewrites the callback
rejection into the shared actionable copy instead of the SDK text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
* fix(auth): detect the SDK mismatch error via isInstance (#1808)
The callback-leg classifier gated on `err.mcpBrand`, but the SDK declares
that brand in a `static {}` block, so it lives on the constructor and is
never reachable from an instance. The predicate always returned false, so
`findIssuerBindingFailure()` never matched and the #1808 recovery banner
was inert in production — the callback dead-ended exactly as before.
Use the SDK's own `AuthorizationServerMismatchError.isInstance()`, which is
cross-copy safe by construction (it consults a `Symbol.for()`-keyed brand
set), with a `name` fallback for a serialization boundary. Correct the
module doc, which cited cross-bundle safety as the reason to avoid
`instanceof` — the very thing the SDK's branded `hasInstance` provides.
Every fixture now constructs a real `AuthorizationServerMismatchError`
instead of a hand-rolled object with an own `mcpBrand`, which is why a
green `npm run ci` hid this. Adds a test pinning the brand's placement so
the feature cannot silently go inert again.
Review follow-ups: drop the unused `isLostAuthorizationStateError`, remove
the duplicated banner title default, resolve the banner title/action label
where the banner is raised, keep the clear-failure notification open, and
bound remote-supplied issuers in user-facing copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(cli,tui): typecheck the __tests__ dirs (#1791)
The cli/tui `typecheck` scripts only checked `src` (the src-only tsconfig
excludes `**/*.test.*`), leaving the `__tests__` dirs — which do the most
`as` casting — with no tsc pass. Add a `tsconfig.test.json` per client that
includes `__tests__` with the test-exclusions dropped and the test-only path
aliases (`@modelcontextprotocol/inspector-test-server`, the `@inspector/core/*`
deep paths, express/vitest) resolving what vitest resolves, then fold
`tsc -p tsconfig.test.json` into each `typecheck`.
Fixes the 76 errors it surfaced:
- cli: a wrong `@modelcontextprotocol/inspector-core/...` module specifier
(should be `@inspector/core/...`; only a type import so vitest never caught
it), `r.headers` optional-access guards, and partial OAuth mocks replaced
with typed `makeFakeCliOAuthClient()` / `makeFakeServerSettings()` factories
instead of `as` casts. Adds `@types/express` (devDep) so the transitively
aliased test-server source typechecks, mirroring clients/web.
- tui: rest-param signatures on the App OAuth spies (fixes the spread-arg and
narrow-return errors), a widened callback param (`iss?`), a `makeFakeServer-
Settings()` factory for the tuiOAuth tests, required OAuth-metadata/client
fields in AuthTab, and a loosened `entry()` message param for the
deliberately-malformed wire messages HistoryTab's defensive branches render.
Gate coverage only; no runtime behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(cli,tui): address review — real spy signatures, config typecheck, nits
Responds to the @claude review of #1799:
- App.test.tsx: type the OAuth spies against the real InspectorClient method
signatures (`vi.fn<InspectorClient["…"]>()`) and forward `Parameters<…>`
tuples from the FakeClient wrappers, so `toHaveBeenCalledWith(...)` on the
six OAuth-lifecycle spies stays argument-checked under the new gate rather
than accepting anything via `unknown[]`. `getOAuthState` regains its wide
`OAuthConnectionState | undefined` return.
- Typecheck the client config files too: add `vitest.config.ts` / `tsup.config.ts`
(and tui `dev.ts`) to each src `tsconfig.json` include — they were the one
first-party surface still getting no tsc pass.
- Trim the redundant `src/**/*` from each `tsconfig.test.json` include (tsc
pulls in the src the tests import; the src-only config already checks it).
- Rename the tui settings helper to `oauth-test-fakes.ts` to match cli's, and
soften both factories' docstrings to stop naming a loader they don't mirror
field-for-field.
- Fix a stale `@modelcontextprotocol/inspector-core/...` specifier in a
createRemoteTransport JSDoc example (the same wrong package name the gate
caught in the cli tests, surviving in a comment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: address round-2 review — correct claims, tighten types, typecheck launcher
Responds to the round-2 @claude review of #1799:
- Correct the toHaveBeenCalledWith claim: vitest doesn't type-check
`toHaveBeenCalledWith(...)` arguments against the mock's signature, so the
App.test.tsx comment no longer claims it does — the typed spies buy checked
implementations/return payloads (the stale-literal fix), not arg-checking.
- authenticate spy now uses the real `InspectorClient["authenticate"]`
signature (returns a `URL`), so it no longer diverges from production; the
one mockResolvedValue is updated to a URL.
- makeFakeCliOAuthClient members are typed against the real CliOAuthClient
signatures (not bare vi.fn()), so a wrong-signature override is rejected.
- HistoryTab: revert the `entry()` `message: unknown` widening (it un-checked
the ~15 well-formed fixtures) and instead scope a named `MALFORMED` cast to
the two intentionally-malformed messages.
- Typecheck the launcher `__tests__` too (the same gate hole in the one client
the PR hadn't touched): new clients/launcher/tsconfig.test.json + a typecheck
script folded into validate. Completes the "every client's __tests__ is
typechecked" invariant.
- Doc the new test-typecheck + config-file typecheck in AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: address round-3 nits — launcher config typecheck, callTool typed, docs
Responds to the round-3 @claude review of #1799 (all non-blocking):
- launcher: add `vitest.config.ts` to tsconfig.test.json's include (it was in
neither project — the build config's rootDir: ./src rejects it). Now
typechecked.
- App.test.tsx: type `callTool` against `InspectorClient["callTool"]` (+ the
Parameters<…> wrapper) so its `mockResolvedValue` payloads are checked, and
give `clearOAuthTokens` an `async () => {}` impl so the mock actually returns
the `Promise<void>` its type promises.
- AGENTS.md: correct the per-client alias-set description (cli's is widest,
tui's is core+react only, launcher's has no paths) and note launcher's config
file lives in its test project, not the src project.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: address round-4 — durable typecheck-coverage guard + launcher/doc nits
Responds to the round-4 @claude review of #1799 (all non-blocking):
- Add `verify:typecheck-coverage` (scripts/verify-typecheck-coverage.mjs), the
typecheck-coverage analog of verify:format-coverage and the durable guard for
the invariant this PR establishes: for each Node client it runs the tsconfig
projects its `typecheck` names with `tsc --listFiles`, unions them, and fails
on any tracked .ts/.tsx that lands in no project. This closes the class the
review kept surfacing by hand (launcher/__tests__ in round 3,
launcher/vitest.config.ts in round 4) — a new top-level file can no longer go
untypechecked silently. Wired into `npm run ci` and the GitHub workflow.
- launcher: reorder `validate` to run `typecheck` before `build` (matches tui
and the documented order), and make `typecheck` self-contained — it now runs
both `tsconfig.json` and `tsconfig.test.json`, so running it standalone means
the same thing it does in cli/tui.
- makeFakeCliOAuthClient: note on the comment that the typing covers the
defaults, not `overrides` (a bare vi.fn() override is Mock<(...args)=>any>).
- Docs: README table + AGENTS.md updated for the new guard and the corrected
launcher typecheck/validate ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: address round-5 — wire-check the guard, --listFilesOnly, into validate
Responds to the round-5 @claude review of #1799:
- Finding 1 (the pre-merge one): the guard now ports verify:format-coverage's
reachability checks — it asserts each client's `typecheck` is reachable from
its own `validate` and that the root chain runs each client's `validate`, so
it can't stay green while measuring a typecheck nothing invokes. Verified:
stripping `npm run typecheck` from launcher's validate now fails the guard.
- Nit 3: switch `tsc --listFiles` → `tsc --listFilesOnly` (identical file list,
~4× faster: whole guard 23s → ~4s). Now cheap enough for the inner loop, so
it moves into `validate` (right after verify:format-coverage) instead of a
standalone ci step — matching its sibling. Removed the separate ci-chain entry
and GitHub workflow step (validate covers it).
- Nit 4: `npx --no-install tsc` so a missing local tsc fails fast instead of
reaching for the registry (matches verify-build-gate.mjs).
- Nit 5: corrected projectFiles' JSDoc to describe the filter that's actually
there (drops repo-external + node_modules paths; core/ + test-servers/ stay
but are harmless).
- Finding 2: fixed the 2-space misindent on the new root package.json line
(prettier --write). Docs (README/AGENTS) updated for the validate placement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): address round-6 guard nits — dedupe wiring, robust diagnostics
Responds to the round-6 @claude review of #1799 (all guard-ergonomics nits):
- Nit 2: extract the shared reachability logic (`reachableScripts`,
`rootRunsClientValidate`) into scripts/lib/npm-scripts.mjs and consume it from
BOTH verify-format-coverage.mjs and verify-typecheck-coverage.mjs, so the
"is this gate actually run?" wiring can't drift between them (same rationale
as scripts/lib/prod-web-server.mjs).
- Nit 1: typecheckProjects now harvests `-p`/`--project` from every script
reachable from `typecheck` (not the single string), so a delegating
`typecheck` (`npm run typecheck:src && …`) is handled instead of failing with
a false "names no -p project" diagnosis. Also accepts `--project`.
- Nit 3: on a tsc config error, echo the `error TS…` diagnostic (scanning both
streams — tsc prints config errors to stdout) so a broken tsconfig is
self-explaining rather than surfacing only as a misleading "file in no
project" report.
- Nit 4: refreshed the CI workflow's stale "Validate" step name/comment (it now
runs the coverage guards + validate:core + per-client typecheck).
- Nit 5: reworded the `.d.ts` carve-out comment to read as pre-emptive (there
are none under cli/tui/launcher today).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-7 — guard .mts/.cts too; drop dead export
Responds to the round-7 @claude review of #1799:
- Finding 1: verify-typecheck-coverage now requires a tsc pass for tracked
`.mts`/`.cts` (not just `.ts`/`.tsx`), matching verify-format-coverage's
extension set — `.mts` is already the shared-config idiom here, so a client
`vitest.config.ts` → `.mts` rename can no longer drop out of both the tsconfig
include and the guard's required set. No-op on today's tree (no client
`.mts`/`.cts`); confirmed a tracked `.mts` with a type error is now caught.
Ambient `.d.{ts,mts,cts}` stay excluded.
- Nit 2: drop the unused `export` on `rootReachedCommands` (internal to the lib;
nothing imports it, and scripts/ isn't eslint-gated to catch dead exports).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: round-8 — single casts in HistoryTab, uniform wrappers, doc scope
Responds to the round-8 @claude review of #1799:
- Finding 1: reduce the `MALFORMED` helper and `entry()`'s return cast from
`as unknown as` to a single `as` (both compile clean — the malformed shapes
are comparable to the JSONRPCMessage union), and delete the false "only
option" sentence. Fixes the AGENTS.md avoid-double-casts violation.
- Nit 3: write all seven FakeClient OAuth wrappers as
`(...a: Parameters<InspectorClient["…"]>)` (the three zero-arg ones were
hand-written `()` forwarders), so the block comment's "forward the same
Parameters<…> tuple" is literally true and a future added parameter can't be
silently dropped.
- Nit 2: update the five places that still described the guard as `.ts`/`.tsx`
only to `.ts`/`.tsx`/`.mts`/`.cts` (script header + trackedSourceFiles JSDoc,
AGENTS.md ×2, README.md).
Nits 4 (js config files out of the typecheck guard by construction) and the
footnoted callTool/ancestor-tsc items left as agreed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: round-9 — drop entry()'s harmful cast; guards vouch for each other
Responds to the round-9 @claude review of #1799:
- Finding 1: remove `entry()`'s `as MessageEntry` return cast in
HistoryTab.test.tsx. It was unnecessary (the spread of Partial<MessageEntry>
over complete defaults is directly assignable) and harmful — it silenced type
errors in the helper's OWN default fields (a `direction`/`method` typo now
surfaces TS2820/TS2353). MALFORMED's single cast stays (load-bearing). This
leaves MALFORMED as the file's only cast, matching what the comments describe.
- Nit 2: add `rootReachesScript` to scripts/lib/npm-scripts.mjs and have each
coverage guard assert its SIBLING is still wired into the root `validate`. A
guard can't detect being unrun itself, but the two now vouch for each other,
so dropping either from `validate` is caught by the other (only deleting both
slips through). Verified: removing verify:typecheck-coverage from validate
now fails verify:format-coverage, and vice-versa.
Nits 3 (git-index visibility) and 4 (pre-existing as-never/as-unknown casts in
tests — out of scope, none added by this PR) left as agreed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: round-10 — reject non-checking typecheck passes; pre/post hooks; docs
Responds to the round-10 @claude review of #1799:
- Finding 1: verify-typecheck-coverage now rejects a `-p <project>` whose own
command carries `--noCheck` or `--listFilesOnly` — such a pass lists files
without type-checking them, which previously satisfied both the wiring and
file-coverage halves while checking nothing. (Splits each script on
`&&`/`||`/`;` so a flag on one command can't taint another.) Verified a
`typecheck` using `tsc … --noCheck` now fails with a clear message; `--noCheck`
is a live idiom here (cli's test-servers:build), so this is a real copy-paste
hazard.
- Nit 2: reachableScripts (shared lib) now also follows npm's implicit
`pre<name>`/`post<name>` lifecycle hooks, so moving a gate into e.g.
`prevalidate` is recognized instead of producing a false "not reachable"
failure (cli already uses a `pretest` hook). Verified.
- Nit 3: AGENTS.md "Test placement" now states the Node clients (cli/tui/
launcher) keep all tests in `__tests__/`, enforced by verify:typecheck-coverage
(a co-located src test lands in no tsconfig project).
- Nit 4: noted on the factory comments that `Partial<…>` overrides admit an
explicit `undefined` for a required field (exactOptionalPropertyTypes off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* fix: round-11 — launcher build excludes tests; guard catches all noCheck vectors
Responds to the round-11 @claude review of #1799:
- Finding 1 (shipping bug): clients/launcher/tsconfig.json now excludes
`**/*.test.{ts,tsx}` (matching cli/tui). launcher's `build` is an EMITTING
tsc over src/**, and clients/launcher/build is in the root `files` allowlist,
so a co-located `src/**/*.test.ts` would have been compiled into the published
tarball. Excluding it also makes such a test land in no tsconfig project, so
verify:typecheck-coverage catches it — making the AGENTS.md placement rule
literally true for all three clients.
- Finding 2: verify-typecheck-coverage now catches both remaining ways a
typecheck could check nothing: (a) case-insensitive `--noCheck`/`--nocheck`/
`--listFilesOnly` on the command, and (b) `noCheck` set in the tsconfig itself
(detected via `tsc --showConfig`, which surfaces it from the config or its
extends chain). Verified both fail the guard.
- Nits 3 & 4: restructured into an explicit gate-integrity phase (wiring +
neutered + config-noCheck + no-projects) that reports under its own accurate
header and exits BEFORE the file-coverage pass — so an inert gate no longer
prints file-remediation advice, nor buries the real cause under an
"in no tsconfig project" line for every file that gate covered.
The one CIMD OAuth E2E integration test that flaked in the coverage run passes
32/32 in isolation and on re-run; unrelated to these script/config changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* fix: round-12 — exclude .mts/.cts tests from build; drop false integrity line
Responds to the round-12 @claude review of #1799:
- Finding 1 (tarball, again): the round-11 test exclude only covered
`.ts`/`.tsx`, so a co-located `src/*.test.mts` still compiled into launcher's
shipped `build/`. All three clients now exclude `**/*.test.*` (one glob,
covering .ts/.tsx/.mts/.cts) — which is also exactly what AGENTS.md already
claims. Verified a `src/probe.test.mts` is no longer emitted and now fails the
guard.
- Finding 2: the gate-integrity phase no longer prints a false "names no
`-p <project>`" line when config-`noCheck` disables projects — the condition
now keys off `projects.length` (harvested), not `checking.length` (post-
filter), so a client whose projects were all config-disabled shows only the
accurate per-project lines.
- Nit 3: recorded the boundary on the phase comment — it does not detect
shell-level failure suppression (`… || true`, `; exit 0`).
- Nit 5: the file-coverage remediation now leads with "for a co-located test,
move it to `__tests__/`" (adding it to the src `include` would reintroduce the
build emit) before the general add-to-include advice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-13 — implicit-tsconfig + derived clients; diagnostics
Responds to the round-13 @claude review of #1799:
- Finding 1: verify-typecheck-coverage now understands a `tsc` command with no
`-p`/`-b` project flag — it resolves the implicit `./tsconfig.json` (tsc's own
default), so the idiomatic `tsc --noEmit` form counts as coverage instead of
producing false "in no tsconfig project" failures. Also harvests `-b`/`--build`
project paths. Verified `tsc --noEmit && tsc --noEmit -p tsconfig.test.json`
now reports OK.
- Finding 2: CLIENTS is no longer hardcoded — it's derived from the `cd
clients/<x> && npm run validate` entries in the root `validate` chain (minus an
explicit web exclusion), so a new Node client is auto-required, matching the
repo-wide reach of its sibling verify-format-coverage. Added a
guard-would-check-nothing bail if the derivation yields an empty set. Verified
a new `clients/newclient` is picked up and its uncovered file flagged.
- Nit 3: the phase-2 remediation now leads with the general add-to-`include`
advice and only appends the "move it to __tests__/" note when a failing path
is actually a test file (was misdirecting plain files).
- Nit 4: AGENTS.md no longer says the test config includes "only" __tests__
(launcher's also includes its vitest.config.ts).
(Two different web integration tests each flaked once under parallel load during
the coverage runs — both pass in isolation and on a clean re-run; timing-
sensitive and unrelated to these script/doc-only changes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-14 — enumerate clients from disk (fail-closed)
Responds to the round-14 @claude review of #1799:
- Finding 1: replace the root-chain `CLIENTS` derivation (round 13) with a
disk enumeration — every `clients/<name>` whose package.json has a `typecheck`
script. The chain derivation was fail-open: a root-chain edit that kept
running a client but didn't match the strict regex silently dropped it from
the required set (112→53 files, still green). Disk is fail-closed — a client
can't be silently dropped, a new client with a typecheck is auto-required, and
web (no typecheck script; builds via `tsc -b`) is excluded on its own, so the
explicit exclusion is gone. The existing `rootRunsClientValidate` wiring check
still verifies the root chain runs each — now LOUD when it doesn't. Verified:
`cd ./clients/tui && …` now hard-fails the integrity phase instead of silently
shrinking coverage; `cd clients/tui && npm run build && npm run validate` stays
gated. Removed the now-unused `clientsRunByRootValidate` from the shared lib.
- Nits 2 & 3: documented the two unreachable-today limitations on
typecheckProjects — a solution-style `tsc -b` (empty `files` + `references`)
would under-report, and the implicit-tsconfig fallback assumes no file operands.
- Nit 4: refreshed the file header to mention `-b`/`--build` + the implicit
`./tsconfig.json` (only the JSDoc had been updated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-15 — enroll-or-exempt enumeration; normalize `cd ./`
Responds to the round-15 @claude review of #1799:
- Finding 1: the disk enumeration keyed on the presence of a script named
`typecheck`, so renaming it silently dropped the client (112→53, still green)
— contradicting the docstring. Now every `clients/*` with a readable
package.json must EITHER declare a `typecheck` (→ enrolled) OR be in an
explicit EXEMPT map (`clients/web`, reason: `tsc -b`); anything else is a hard
gate-integrity failure. This is fail-closed on the rename axis too, and web's
skip is a stated decision rather than an accident of which scripts it declares
— so web gaining a `typecheck` no longer auto-enrolls it into the solution-`-b`
false failures. Verified: renaming tui's `typecheck` now hard-fails; a `tsc -b`
on web stays exempt.
- Nit 3: `rootRunsClientValidate` normalizes a leading `./`, so
`cd ./clients/tui && npm run validate` (which genuinely runs it) is no longer a
false "no longer runs" alarm.
- Nit 2: README/AGENTS/script-header prose no longer names a fixed
cli/tui/launcher scope — they describe the disk-discovered "every clients/*
with a typecheck script (web exempt)" rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-16 — anchor wiring match; close enumeration axes
Responds to the round-16 @claude review of #1799:
- Finding 1 (only false-PASS): rootRunsClientValidate matched the client dir as
a bare substring, so a prefix-sibling (`clients/tui` vs `clients/tui-next`)
satisfied the wiring check for the shorter name — dropping a client from the
root chain went green if a sibling was present. Now an anchored regex (escaped
dir + optional leading `./` + a trailing boundary). Verified the sibling case
now hard-fails.
- Nit 2: a `clients/*` dir holding tracked TS but no readable package.json is
now a gate-integrity failure (was treated as "not a client"), so the
"fail-closed on every axis" claim holds. Verified.
- Nit 3: readdirSync is wrapped, so a missing `clients/` dir yields the intended
"found no clients/* dir" message rather than a raw ENOENT stack.
- Nit 4: EXEMPT's reason is now surfaced in the success line ("clients/web
exempt: typechecks via tsc -b …"), so the exemption is visible at runtime, not
only in source.
(Also moved `isRequiredSource` above nodeClients — nit 2's new tracked-TS check
calls trackedSourceFiles at module-init, which referenced it before init.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-17 — share quote-aware tokenizer between guards
Responds to the round-17 @claude review of #1799:
- Finding 1: the two coverage guards parsed shell strings differently —
verify-format-coverage had a quote-aware tokenize(); verify-typecheck-coverage
used a raw regex. So a quoted `-p "tsconfig.test.json"` (a form tsc accepts)
made the typecheck guard report every file that project covers as uncovered,
and a quoted `cd "clients/tui"` in the root chain tripped a false wiring
failure. Moved tokenize() (now single+double quote) into the shared lib,
imported it into both guards, rewrote typecheckProjects to walk tokens
(`-p`/`--project`/`-b`/`--build` → next token), and strip quotes before the
`cd` match in rootRunsClientValidate. Verified both quoted forms now pass.
- Nit 2: a stale EXEMPT key (a dir that no longer exists) was asserted in the
success line unchecked. nodeClients now fails if an EXEMPT key isn't a real
`clients/*` directory. Verified.
- Nit 3 (build/dist filter belt-and-braces) left as a recorded boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-18 — match tsc by basename; strip quotes on both halves
Responds to the round-18 @claude review of #1799:
- Finding 1 (regression from round 17): `tokens.includes("tsc")` didn't match a
path-invoked binary (`node_modules/.bin/tsc`), which the previous `\btsc\b`
regex did — so such a `typecheck` was falsely reported as naming no project.
Now matches on the token basename (`isTsc`), keeping round-17's quoting fix
while restoring the tolerance. Verified a `node_modules/.bin/tsc` typecheck
reports OK.
- Nit 2: `rootRunsClientValidate` stripped quotes for the `cd` match but tested
`npm run validate` against the unstripped string, so `npm run "validate"`
false-failed. Both halves now test the stripped string.
- Nits 3 & 4: documented on typecheckProjects — the contrived `--noCheck false`
(checking on) is still treated as disabling, and the `&&`/`||`/`;` split runs
before tokenizing (a quoted operator inside an arg would split mid-token,
unreachable for project paths).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test: round-19 — typecheck test-servers' unimported bin; --prefix wiring form
Responds to the round-19 @claude review of #1799:
- Finding 1: `test-servers/src/server-composable.ts` (a `#!/usr/bin/env node`
bin entry nothing imports) got no tsc pass — the barrel alias covers the other
13 test-servers/src files transitively, but not this one, and its own build
runs `tsc --noCheck`, so its errors were emitted into `test-servers/build`
unchecked. Added it explicitly to `clients/cli/tsconfig.test.json`'s include
(zero source changes; verified a bad line in it now fails cli's typecheck).
Softened the AGENTS.md clause that implied the whole test-server source is
transitively covered.
- Nit 2: `rootRunsClientValidate` now also recognizes the
`npm --prefix <dir> run validate` invocation form (was `cd`-only).
- Nit 3 (stderr spy drops the completion-callback overload): left as-is —
latent, not live (no exercised path awaits a stderr drain), and adding typed
overload params would reintroduce the write-overload complexity the round-1
vi.spyOn refactor removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-20 — anchor validate tail; guard shared-source class
Responds to the round-20 @claude review of #1799:
- Finding 1 (false PASS): the wiring matcher's tail `/\brun validate\b/` also
matched `run validate:fast` (`:` is a word boundary) — a DIFFERENT script that
may skip typecheck. So the root chain could run a client's `validate:fast`
while the guard kept measuring its `validate` (unrun), green. Anchored the
tail to a boundary (`/\brun validate(?=$|[\s&;])/`). Verified `validate:fast`
now hard-fails.
- Finding 2: durably close the test-servers/src class instead of point-fixing.
Added a SHARED_ROOTS check — every tracked TS under `test-servers/src` + the
root `vitest.shared.mts` (the `format:check:shared`/`lint:shared` surface) must
land in the GLOBAL union of client projects. So a NEW unimported bin entry is
caught (not just server-composable.ts, named in round 19). Verified a fresh
`server-orphan-bin.ts` now fails. Zero source changes — the union already
covers 15/15.
- Nit 4: the integrity message and rootRunsClientValidate docstring now mention
the `npm --prefix` form too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-21 — deny-by-default non-client coverage
Responds to the round-21 @claude review of #1799 (findings 1 & 2, one fix):
- Replaced the `SHARED_ROOTS` allowlist with a deny-by-default non-client set:
all tracked TS minus `clients/*` (per-client pass) minus a reasoned `core/`
exemption (covered by web's `tsc -b`). This closes BOTH round-21 findings at
once:
- Finding 1 (stale allowlist fail-open): a rename of `test-servers/src` no
longer silently drops it — its files reappear in the required "other" set
and hard-fail (verified: `git mv test-servers/src test-servers/source` now
fails instead of going 127→113 green). The subtractive `core/` exemption is
fail-closed on staleness for the same reason.
- Finding 2 (allowlist vs deny-by-default): a new top-level TS location is now
auto-required (verified `tools/probe.ts` is flagged), matching the repo-wide
reach of the sibling verify-format-coverage.
- Nit 3: added the shared-source clause to the README table row (AGENTS got it
last round).
Header comment updated to describe the non-client coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-22 — narrow core/ exemption to what web's tsc -b covers
Responds to the round-22 @claude review of #1799:
- Finding 1: the `core/` exemption was whole-tree, but web's `tsc -b` only
covers `core/**/*.ts` MINUS co-located `*.test.ts(x)` (its app project's
include). So a `core/*.tsx`/`*.mts`/`*.cts` or a co-located `core` test got no
tsc pass from anything. Narrowed the exemption to exactly that shape
(`isExemptCoreFile`): a core `.ts` that isn't a co-located test. So a core
`.tsx`/`.mts`/co-located test is now required in some project. Zero-fix today
(0 such files); verified `core/mcp/probe-widget.tsx` and a co-located core
`.test.ts` are now flagged.
- Nit 2: `EXEMPT_ROOTS` is now a Map (dir → reason) and its reason is surfaced
in the success line alongside the client exemption.
- Nit 3: the non-client miss message is now the general "in no client's tsconfig
project"; the `test-servers` remediation clause is conditional on an actual
`test-servers/` path (tracked via `nonClientMisses`, not string-sniffing the
composed message).
- Nit 4: success line says "(N clients + non-client)".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-23 — core exemption mirrors both web projects
Responds to the round-23 @claude review of #1799:
- Finding 1: round 22's `isExemptCoreFile` mirrored only web's app project, but
web's `tsc -b` also builds `tsconfig.test.json`, which includes
`core/**/__tests__/**/*.{ts,tsx}`. So a `core/**/__tests__/*.test.ts` (a live
layout — `core/mcp/__tests__/fakeInspectorClient.ts` already sits there) was
flagged though web checks it. Now mirrors the UNION: under `__tests__/` →
exempt `.ts`/`.tsx`; otherwise `.ts` minus co-located `*.test.ts(x)`. Verified
a core `__tests__` test now passes while a core `.tsx` outside `__tests__`
stays required.
- Finding 2: `EXEMPT_ROOTS` was a Map whose keys nothing read (round 22 replaced
its filter with hardcoded `isExemptCoreFile`) — a phantom registry. Collapsed
to a single `CORE_EXEMPT_REASON` const used in the OK line.
- Nit 3: README/AGENTS scope descriptions updated (core is no longer wholesale
excluded; the non-client set is deny-by-default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-24 — enroll clients/web via tsconfig references
Responds to the round-24 @claude review of #1799:
- Finding 1: `clients/web` was whole-tree EXEMPT, but web's `tsc -b` only reaches
what its four referenced projects include — a file at web's package root
(e.g. `playwright.config.ts`) got no tsc pass and passed EVERY gate, fully
silently. This was the last whole-tree exemption wider than the gate it
defers to. Now web is ENROLLED: a client with no `typecheck` script but a
`tsconfig.json` `references` array is measured through those reference
projects (`tsc -p <ref> --listFilesOnly`), and is wired iff its `validate`
runs `tsc -b` (`validateRunsTscBuild`). EXEMPT is now the empty escape hatch.
Verified: a web-root `playwright.config.ts` with a type error is now flagged,
and dropping `tsc -b` from web's build hard-fails the integrity phase.
Zero-fix on the tree — the union of web's 4 projects covers all 625 tracked
web files.
- Nit 2: assert `core/` exists (the OK line unconditionally states its
exemption) — parity with the EXEMPT stale-key check.
- Nit 3: a `core/` miss now gets core-specific remediation (widen web's
`tsconfig.app.json` include, not a cli/tui project).
- Nit 4: README/AGENTS scope descriptions corrected — web is enrolled (not
exempt), and the exact core-exempt shape (app `*.ts` minus co-located tests +
test `__tests__/**/*.{ts,tsx}`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-25 — drop redundant core exemption; guard tsc -b --noCheck
Responds to the round-25 @claude review of #1799:
- Finding 1: enrolling web (round 24) made the hand-maintained `core/` exemption
both redundant and fail-open — web's measured `tsc -b` projects already cover
core, and the mirror could go stale (narrowing web's include let an unimported
core error pass). Deleted `isExemptCoreFile`, `CORE_EXEMPT_REASON`, the core
presence assertion, and the exemptNote core clause. `core/` is now required
like everything else and covered by measurement (127→894 files; a `core/*.tsx`
web doesn't reach is now caught by measurement, not a hand-model). Kept the
core-specific remediation clause.
- Finding 2: the reference (`tsc -b`) path skipped the non-inertness check — a
`tsc -b --noCheck` build gated nothing while the guard stayed green. Turned
`validateRunsTscBuild` into `tscBuildStatus` (ok/neutered/none); a neutered
`tsc -b` now hard-fails, matching the typecheck-script path. Factored the
disabling-flag predicate (`isDisablingFlag`) so both paths share it.
- Nit 3: `clientTsconfigReferences` now strips block comments too (every other
tsconfig in the repo uses `/* */`).
- Nit 5: README/AGENTS wiring clause covers web's `tsc -b` (not just the
`typecheck` script), and core is described as measured, not exempt.
Nit 4 resolves itself: EXEMPT is empty and the exemptNote/ternary now collapse
cleanly. Nit 6 (tsc -b bound to a specific tsconfig) left as recorded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-26 — expand solution configs wherever harvested
Responds to the round-26 @claude review of #1799:
- Finding 1: the reference (`tsc -b`) path was selected only by "no typecheck
script", so adding `typecheck: "tsc -b"` to web (the natural uniformity move)
flipped it to the typecheck-script path, where the harvested solution config
lists nothing → 665 false failures. Fixed at the measurement layer:
projectFiles now expands a solution config (`{"files":[], "references":[…]}`
— lists nothing under --listFilesOnly) to its references and measures those
recursively, so a `tsc -b` project is measured no matter which path harvests
it. Generalized the references reader (tsconfigReferences(path)). Verified web
with `typecheck: "tsc -b"` now passes. Dropped the stale "web … is out of
scope" JSDoc clause.
- Nit 2: a `core/**/__tests__/**` miss now points at `tsconfig.test.json` (its
actual owner), not `tsconfig.app.json`.
- Nit 4: AGENTS.md's one-line summary now mentions the non-client (`core/`,
`test-servers/src`) coverage, matching the detailed bullet.
Nit 3 (EXEMPT empty, dormant stale-key check) left as the intended escape hatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-27 — non-inertness follows references on both paths
Responds to the round-27 @claude review of #1799:
- Finding 1: round 26 made COVERAGE follow a solution config's references, but
the non-inertness (`noCheck`) check on the typecheck-script path still only
inspected the harvested solution (empty compilerOptions) — so a `noCheck` in
a REFERENCED project was invisible there (caught only on the reference path).
Factored a shared `resolveLeafProjects` (a solution → its real leaf projects,
cached rawProjectFiles) used by BOTH coverage (`projectFiles`) and a shared
`checkingLeaves` disable-check, so a `noCheck` in a referenced project is now
caught on either path. Verified: `typecheck: "tsc -b"` + `noCheck` in
tsconfig.app.json now hard-fails (was green). Caching also cut the guard from
~20s to ~11s.
- Nit 2: directory-form references (`{ "path": "./packages/a" }`) now resolve to
`<dir>/tsconfig.json`, so a nested-solution dir reference is expanded.
- Nit 3: reworded the empty-listFiles comment (it's "solution config OR errored
config", both of which the reference expansion handles).
- Nit 4: script header notes a `tsc -b` solution is reduced to its references on
either enrollment path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-28 — dedup shared leaf in checkingLeaves
Responds to the round-28 @claude review of #1799 (which found no new gate hole):
- Finding 1: `checkingLeaves` dedup'd the `checking` array but ran
projectDisablesChecking + integrity.push on every visit, so a leaf reachable
from two harvested projects produced a duplicate `noCheck` line (and a
redundant `tsc --showConfig`). Now tracks visited leaves in a Set and skips an
already-seen leaf entirely. Verified a duplicate harvested project yields one
line, not two.
- Nit 3 (record-completeness): noted in AGENTS that the one tier the guard
structurally can't see — a per-file `// @ts-nocheck` — is owned by
`@typescript-eslint/ban-ts-comment` across lint:core/lint:shared/each client.
Nit 2 (the ~11s perf claim isn't reproducible on a CI-class runner) and nit 4
(hybrid-solution / composite boundaries, both tsc-rejected → unreachable) left
as recorded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(scripts): unit-test the typecheck-coverage guard's parsers
Responds to the round-29 @claude review of #1799 (no new gate hole; the finding
is that the guard itself has no tests):
- Made the guard's pure helpers importable: exported `typecheckProjects`,
`tscBuildStatus`, `tsconfigReferences`, `clientTsconfigReferences`,
`resolveLeafProjects`, `isTsc`, `isDisablingFlag`, `isRequiredSource`, and
moved the module-init + phase execution behind an exported `main()` run only
when the file is executed directly (importing it for tests no longer runs the
guard). Behavior when run directly is unchanged (894 files).
- Added table-driven tests (node's built-in `node --test`; the root has no
vitest harness by design) for the shared lib (`tokenize`, `reachableScripts`
incl. pre/post hooks, `rootRunsClientValidate` across cd/--prefix/quoted/
prefix-sibling/`validate:fast`, `rootReachesScript`) and the guard's parsers
(`isRequiredSource`, `isTsc`, `isDisablingFlag`, `typecheckProjects`,
`tscBuildStatus`) — one case per rule a review round found (r7/r10/r13/r15/
r16/r17/r18/r19/r20/r25). 12 tests. Wired `test:scripts` into `validate`.
- Nit 2: a `scripts/*.ts` miss now gets a scripts-specific remediation (keep it
`.mjs`, or give `scripts/` its own tsconfig project).
- Documented the guard's test suite in AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(scripts): make test:scripts self-guarding; fill parser test gaps
Responds to the round-30 @claude review of #1799 (mutation testing killed 5/5;
findings are about the new test gate + suite completeness):
- Finding 1: `test:scripts` lacked the two properties the guard enforces for
every tsc pass. verify-typecheck-coverage now asserts, in phase 1, that
`test:scripts` is reachable from the root `validate` (via the already-imported
rootReachesScript) AND that its tracked `scripts/**/*.{test,spec}.*` file set
is non-empty and every file is matched by the command's glob (a new exported
`globToRegExp`). So dropping it from validate, or renaming a test to
`*.spec.mjs`/`*.test.mts` (which node --test silently skips), now hard-fails.
Verified both.
- Nit 2: extracted a pure `parseTsconfigReferences(raw)` from tsconfigReferences
and table-tested the JSONC tolerance (block/line comments, trailing comma, no
references, malformed, no path) — the r17/r26 rules had no cover.
- Nit 3: added `-b`/`--build`/bare-`tsc -b` and explicit `--project` cases to
typecheckProjects's test so its title matches its table.
- Nit 4: rewrote the rootRunsClientValidate test to put `cmd` in a script
reached from validate, and added the reachability cases (an orphan cd-validate
must NOT count; validate:tui indirection must) — the r5 property.
- Nit 5: added test:scripts to AGENTS's validate ordering + the README table.
15 guard tests now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore(scripts): round-31 — glob harvest follows delegation; brace/? globs
Round-31 review findings, both false failures in the new `test:scripts`
self-guard:
1. The glob half read `rootPkg.scripts["test:scripts"]` literally while the
wiring half two lines above used `reachableScripts`, so a delegating
`test:scripts` (`npm run test:scripts:lib && npm run test:scripts:guard`)
— which genuinely runs all 15 tests — reported every tracked test file as
"not matched by the glob", advising a rename the files already satisfy.
Now harvested across every script reachable FROM `test:scripts`, the same
fix round 6 applied to the `typecheck` harvest; `pretest:scripts` counts
for free.
2. `globToRegExp` escaped `{`/`}` as literals and left `?` as a regex
quantifier, both divergent from `node --test`'s glob. `*.{test,spec}.mjs`
is the natural widening here (the guard's own file scan already treats a
`.spec.` file as a test) and false-failed; `?` silently meant "optional
previous char" and threw outright when segment-leading. Adds `?` → `[^/]`
and a brace-alternation pass, with an unbalanced brace falling back to a
literal.
Nit 3: `resolveLeafProjects`'s two path-resolution rules (the r26
directory-form reference, and refs resolving against the referring config's
dir) extracted as `projectConfigFile`/`refToProject` so they are one-row
tables like the other parsers, rather than untestable behind a `tsc` spawn.
Nit 4: AGENTS.md + README document that the guard now enforces `test:scripts`
on three axes (wired, non-empty, glob-covered).
Verified: probes C and D exit 0; probe B (rename to `*.spec.mjs`) still
caught; `test:scripts` 18/18; `verify:typecheck-coverage` OK 894 files;
`verify:format-coverage` OK 919; `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-32 — measure globs with node's own matcher
Round-32 review; both findings point at deleting code.
1. `globToRegExp` hand-modeled `node --test`'s glob and was still one form
short — character classes (`*[.]test.mjs`, `*.test.[mc]js`) hit the escape
set and became literals, so the guard false-failed on files node genuinely
runs. Replaced the whole translator (and its brace balance pre-scan) with
node's `path.matchesGlob` — 22.5.0+, and the repo floor is >=22.19.0. Same
*measure, don't model* move round 25 made when it deleted `isExemptCoreFile`
in favor of measuring web's projects; being exact for every form node
accepts is the point, since node's matcher is the thing being modeled.
2. The round-31 glob harvest was inline in `main()`, so mutation-reverting it
to the literal single-script read left the suite green — the commit's
headline fix was its one untested change. Extracted as `testScriptGlobs`,
with rows for the direct form, the delegating form (both child globs, not
`npm`/`run`/`<name>`), a `pretest:scripts` hook, and an unreachable script
contributing nothing.
Nit 3: `resolveLeafProjects`' JSDoc moved back onto it (the extraction left it
stranded above `projectConfigFile`, which had two stacked doc blocks).
Nit 4a: a `test:scripts` naming no path/glob harvested nothing, and `.some()`
over an empty list blamed every test file with unfollowable advice. Now one
explicit message naming the real problem.
Verified: probes for the direct, delegating, brace, `[.]` and `[mc]` forms all
exit 0 (the last two were false failures before); probe B (rename to
`*.spec.mjs`) still caught; bare `node --test` reports the new single message;
`test:scripts` 18/18; `verify:typecheck-coverage` OK 894; `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-33 — scope the glob harvest to `node --test` segments
1. False pass. `testScriptGlobs` kept every non-flag token from every reachable
script, so a glob belonging to a NEIGHBOURING command was attributed to the
test runner — and since `.some()` needs only one glob to match, a broader
one suppressed a real miss rather than adding an inert pattern. A
`pretest:scripts` of `prettier --check "scripts/**/*.mjs"` (reachable via
the pre-hook round 31 added) was enough: the probe-B rename to `*.spec.mjs`
then passed while `node --test` silently ran 12 of 18 tests. Now scoped per
command segment to the ones invoking `--test`, exactly as
`typecheckProjects` gates on `tokens.some(isTsc)`. The JSDoc paragraph
claiming a surplus token can't suppress a miss is corrected — it was false.
2. The gate-integrity block is extracted as `testScriptProblems(scripts,
testFiles)`, pure and outside `main()`. The nit-4a branch added last round
was the third consecutive commit whose newest branch sat outside the test
seam and mutation-survived; extracting the block ends the pattern rather
than pinning one more condition from the outside.
Verified. The finding-1 probe (prettier pre-hook + `*.spec.mjs` rename) now
exits 1 where it exited 0; the hook alone still exits 0. Mutation testing over
the whole helper — empty-harvest branch to `if (false)`, segment gate removed,
wiring axis removed, non-empty axis removed, segment split removed,
`reachableScripts` to `Object.keys`, `matchesTestGlob` to equality — kills
7/7, including the branch that survived the last three rounds.
`test:scripts` 21/21 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-34 — harvest only positional args; normalize `./`
Both findings are the axis round 33 fixed, one level in.
1. False pass. The segment gate stopped a neighbouring COMMAND's glob, but
within a kept segment the filter was still "any non-flag token", so a
glob-valued flag's VALUE was harvested as a positional arg.
`--test-coverage-include "scripts/**/*.mjs"` is broader than the test glob
and matches a renamed `*.spec.mjs`, so it vouched for the very file the
runner skips — and it isn't hypothetical: `scripts/` sits outside the ≥90
coverage gate, so that flag is the obvious next thing added here. Tokens
preceded by a value-taking `--test-*` flag are now dropped.
2. False failure. `./scripts/**/*.test.mjs` — which `node --test` accepts, and
the common npm-script idiom — matched nothing, because `git ls-files` emits
no `./`. Both files were then blamed for a rename that never happened, the
unfollowable-remediation shape fixed for the bare-runner case last round. A
leading `./` is normalized off, as `rootRunsClientValidate` already does.
Nit 4: the gate-integrity footer's typecheck-wiring advice is now conditioned
on at least one issue being about the typecheck wiring — a `test:scripts`
failure carries its own per-line remediation, and appending `--noCheck`/`tsc
-b` advice under a renamed-test-file message sends the reader after the wrong
thing.
Verified. The coverage-include probe with the rename now exits 1 where it
exited 0, and exits 0 without the rename; the `./` probe exits 0 where it
exited 1; a `test:scripts`-only failure no longer prints the typecheck footer.
Mutation sweep 9/9 killed, including both new rules. `test:scripts` 22/22 ·
`verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 ·
`npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-35 — fourth axis: flags that narrow the run
1. False pass on a new axis. The three axes ask whether the tests are run,
whether there are any, and whether the runner is told about each — none asks
what it then DOES with them. `--test-shard 1/2` keeps every glob intact and
executes 6 of 22 tests, exiting 0, with the guard green; `--test-only` runs
2, as does a non-matching `--test-name-pattern`. This is `--noCheck` for the
test runner, one domain over. Adds `NARROWING_TEST_FLAGS` and a fourth axis
in `testScriptProblems`. Three of those flags were already in
`VALUE_TAKING_TEST_FLAGS` — the guard knew them well enough to drop their
values but not that they shrink the run. Both `--flag value` and
`--flag=value` report as the bare flag name.
2. The footer conditioning added last round was itself inside `main()` — the
pattern the same commit's comment documented, recurring in the commit that
documented it. Extracted as `integrityAdvice(problems, nonTypecheck)`.
Doing that fixes nit 3 in the same move: the old condition keyed off
`testScriptIssues` specifically, so it covered one of three non-typecheck
categories. A sibling-vouch failure ("the root validate no longer runs
verify:format-coverage") and client-enrollment problems still got
`--noCheck`/`tsc -b` advice about a typecheck pass they have nothing to do
with. All three sets are now excluded.
Verified. `--test-shard 1/2`, `--test-only` and `--test-name-pattern zzz` each
now exit 1 naming the flag, while node runs 6/2/2 of 22. A sibling-vouch
failure no longer prints the typecheck footer. Mutation sweep 12/12 killed,
including all three new rules and the branch this commit replaces.
`test:scripts` 24/24 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-36 — discover test files by content, not name
1. False pass on the axis underneath the other four. The required test-file set
was built with the same name pattern the `test:scripts` glob uses, so a
rename escaping BOTH was invisible: the file left `testFiles` entirely, so
nothing asked whether the runner was told about it, and the survivor
satisfied the non-empty axis. A dot→hyphen slip
(`npm-scripts.test.mjs` → `npm-scripts-test.mjs`) dropped 6 of 24 tests with
every gate green — and the file that stops running is the one pinning the
r16 prefix-sibling and r20 tail anchors, the series' two other false passes.
Round 30 fixed "renamed to a form the glob misses" by broadening the name
pattern; a fifth name pattern only moves the boundary again. `node --test`
decides what a test is by whether it registers tests, so `isScriptsTestFile`
reads that instead — the same measure-don't-model move as r25 and r32. It
also covers what no name pattern can: a NEW test file added under a name the
glob misses, never run and never noticed.
Nit 2: `enrollmentProblems` no longer suppresses the footer. "declares no
`typecheck` script …" IS about the typecheck pass and the footer's first clause
is its canonical fix; excluding it lost the advice for the r15 rename scenario.
The criterion is "is this about the typecheck pass at all", not "does it carry
per-line advice" — several typecheck problems carry their own and still want
the footer. JSDoc corrected accordingly.
Nit 3: the complementary-shard boundary (`1/2` + `2/2` across two reachable
scripts genuinely runs everything, and is flagged anyway) recorded on the
helper, alongside the guard's other documented boundaries.
Nit 4: the three `--test-coverage-{lines,branches,functions}` thresholds added
to VALUE_TAKING_TEST_FLAGS. Their values are numeric and so inert rather than
suppressing, but model completeness is cheaper than re-deriving that later.
Verified. The hyphen rename now exits 1 naming the file; the `.spec.mjs` rename
is still caught; an enrollment failure prints the footer again; the guard is
unchanged at 894 files OK (exactly the 2 tracked test files reference
`node:test`, none of the other 12 `scripts/` files do). Mutation sweep 14/14.
`test:scripts` 25/25 · `verify:format-coverage` OK 919 · `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-37 — register-not-mention; guard the discovery read
Both findings were introduced by round 36's content predicate.
1. False failure. The predicate matched a bare `node:test` mention, so a shared
helper importing the runner's `mock` API — registering no tests — was
enrolled as a required test file and told to rename itself to `*.test.mjs`,
which would only make node run an empty file. `scripts/lib/` is already the
repo's shared-helper location, so this is the plausible next file there.
Tightened to node's actual criterion: an import/require OF `node:test` AND a
test registration.
2. Regression. `readFileSync` was passed as an argument, so it ran for every
tracked `scripts/` file before the predicate's extension gate could reject
it — a file in the index but not the worktree (`rm` without `git rm`) took
the guard down with a raw ENOENT stack, aborting phase 1 before any real
finding printed. Discovery did no reads at all before round 36. Extension is
now gated first and the read wrapped: an unreadable tracked file warns and
is skipped, the round-6 precedent where a failed `tsc` echoes its diagnostic
instead of dying.
Verified: the mock-helper probe exits 0; the tracked-but-deleted probe exits 0
with a one-line skip warning instead of a stack; the dot→hyphen rename is still
caught. Mutation sweep over the predicate 3/3 (import form, registration,
extension). `test:scripts` 25/25 · `verify:typecheck-coverage` OK 894 ·
`verify:format-coverage` OK 919 · `npm run ci` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
* chore(scripts): round-37 nit 3 — one definition of the JS-extension check
Finding 2's fix hoisted the extension gate into `main()` (it must run before
the read), leaving a second copy of the regex inside the predicate that reads
as protection but can never fire there. Extracted as `isJsFile` and used in
both positions, so the two can't drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#1795.
Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms.
Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* test(web): settle real-timer Transitions so the coverage gate can't leak 'window is not defined' (#1786)
The web coverage gate was intermittently failing with an unhandled
`ReferenceError: window is not defined` thrown from a Mantine `Transition`
timer that fired after happy-dom tore down `window` (the #1760 class). The
three `ViewHeader.test.tsx` tests that use `renderWithMantineTransitions`
(real, env="default" timers) each drove their *primary* transition to
completion, but the concurrent *enter* on a sibling cell leaves no unmount to
`waitFor`, so its rAF→setTimeout chain could still be pending at teardown and
fire post-teardown, failing an unrelated later file's run.
Fix: after each such test, flush pending transition timers against the
still-mounted tree via `settleTransitions()` (a wait longer than the 300ms
header duration + rAF slack), so the settling setState targets a live tree and
nothing is left to fire after cleanup unmounts.
Also close the related nit: `renderWithMantine`/`renderWithMantineTransitions`
now accept a `colorScheme` option, so `ViewHeader.test.tsx` no longer
hand-rolls a bare `defaultColorScheme="dark"` MantineProvider (the #1760
anti-pattern). AGENTS.md documents both the option and the settle requirement.
Closes#1786
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): address review — export settleTransitions, derive settle from HEADER_ANIM_MS, guard fake timers, correct comments
Review feedback on PR #1793:
1. Derive the settle window from the real animation duration: export
`HEADER_ANIM_MS` from ViewHeader.tsx and compute
`TRANSITION_SETTLE_MS = HEADER_ANIM_MS + 200` in the test, so bumping the
header duration can't silently make the settle insufficient (the invariant
is now structural, not a comment).
2. Move `settleTransitions` into the shared `renderWithMantine.tsx` helper
(exported, optional `ms` arg) next to `renderWithMantineTransitions`, since
AGENTS.md points every future caller at it — shared test scaffolding belongs
in src/test/, not a single test file. AGENTS.md now references the helper.
3. Guard `settleTransitions` against `vi.useFakeTimers()`: it awaits a real
setTimeout, so under fake timers it would hang to the test timeout. Throw a
clear message instead.
4. Correct the diagnosis comments: the hazard isn't a Mantine timer surviving
unmount (its effect cleanup cancels timers) — it's the React state update
Mantine's inner rAF schedules outside `act`, resolving post-teardown. Also
fix the stale setup.ts matchMedia comment: the reduce-motion stub does not
disable transitions (Mantine defaults respectReducedMotion=false); env="test"
is the actual #1760 protection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): tighten the review-trail comments (round 2)
Comment-accuracy follow-ups from PR #1793 round 2 (test infra only, no
behavior change):
- DEFAULT_SETTLE_MS: drop the false "Mantine's default header transitions run
~300ms" attribution (300ms is ViewHeader's HEADER_ANIM_MS; Mantine's Transition
default is 250ms). Justify 500ms generically — a typical few-hundred-ms
transition plus rAF slack.
- settleTransitions doc: stop asserting a single escape mechanism as fact. State
what's observed (a post-teardown dispatchSetState throwing from
resolveUpdatePriority, which runs synchronously inside dispatchSetState), what's
known (useTransition has an unmount cleanup), and that the exact escape route
isn't pinned down — the act-wrapped drain fixes it regardless.
- setup.ts: happy-dom v20 does implement matchMedia, so reframe the block as a
deliberate override (pinning prefers-reduced-motion: reduce) rather than a
polyfill for a missing API.
- Export MantineRenderOptions so callers can name the options type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): resolve the last comment-contradiction + tighten the matchMedia override (round 3)
Round-3 review of PR #1793 (test infra only):
- setup.ts finding 1: the inner `value:` comment still said "happy-dom doesn't
implement it", contradicting the block comment two lines up (which now
correctly states happy-dom v20 DOES implement matchMedia and this is an
override). Dropped the false clause; kept the part that actually justifies the
`as unknown as MediaQueryList` double cast (the stub omits most of the
interface).
- setup.ts finding 5 (folded in rather than deferred): anchored the `matches`
regex to `/prefers-reduced-motion:\s*reduce/` so the override no longer also
answers `true` to `(prefers-reduced-motion: no-preference)`.
- renderWithMantine.tsx nit 4: the `env="test"` rationale sat on `makeWrapper`,
which also builds the `env="default"` wrappers (opposite purpose). Moved it
onto `renderWithMantine`, mirroring the `env="default"` rationale on
`renderWithMantineTransitions`; `makeWrapper` keeps a one-liner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): make renderWithMantineTransitions own the settle (round 4)
Round-4 review of PR #1793 — implements the note-2 hardening in-PR rather than
deferring it, plus note 1 and the nit:
- Note 2 (settle was a convention AGENTS.md had to police): the real-transitions
helper now OWNS the settle. `renderWithMantineTransitions` arms an automatic
post-test drain via a helper-registered `afterEach`, so a real-transitions
test can no longer leak the #1760 class by forgetting to settle a concurrent
enter. Verified empirically that the helper's `afterEach` runs BEFORE the
global cleanup() in setup.ts (afterEach is LIFO; setup.ts's cleanup is a
setupFile registered first) — the ordering the drain depends on. The three
explicit `settleTransitions()` calls in ViewHeader.test.tsx are gone; each
render passes `settleMs: HEADER_ANIM_MS + 200` instead, keeping the window
duration-derived. The auto-settle skips under fake timers (and no
transitions test uses them). `settleTransitions` stays exported for the rare
manual mid-body case.
- Note 1 (settle formula was duration-only): the helper doc + the new
`settleMs` option doc now say the window must exceed the longest JS chain —
duration PLUS any enterDelay/exitDelay — plus rAF slack.
- Nit: ViewHeader.test.tsx now states why HEADER_ANIM_MS is exported (the
settle window must track the animation it settles).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): pin + self-check the auto-settle hook ordering; fix fake-timer doc; keep longest settle (round 5)
Round-5 review of PR #1793:
- Note 2 (load-bearing hook ordering was unpinned/undefended): the
auto-settle's afterEach must run before setup.ts's cleanup(). Added BOTH
guards the review suggested. (1) Runtime self-check: renderWithMantineTransitions
captures the render container; the afterEach throws a clear error if it's
already detached (cleanup ran first). (2) Pinned `sequence.hooks: "stack"` on
the unit project. While verifying, found the ordering actually holds in EVERY
sequence.hooks mode (stack/list/parallel) because setupFile hooks are outer
relative to the helper's import-registered hook — so the comments now state
that true mechanism (the pin is defense-in-depth; the container.isConnected
check is the real enforcement), rather than claiming it "depends on stack".
Full 3533-test unit suite passes under "stack".
- Note 1 (fake-timer claim was backwards): AGENTS.md no longer says the leak
"would return" under fake timers (fake clocks park Mantine's setTimeout/rAF
and are discarded, not drained). The real hazard — the auto-settle silently
no-ops, so real-clock work is unprotected — is now stated. The afterEach also
console.warns on the fake-timer skip (was silent; the manual settleTransitions
throws — this closes that asymmetry).
- Note 3 (arming was last-write-wins): armedSettleMs now keeps the longest
window via Math.max, so a later short-animation render can't under-settle an
earlier long one.
- Nit: documented `settleMs: 0` as a working opt-out for a test that drove every
transition to completion itself (act flush, no wait).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): post-await self-check + correct the hook-ordering mechanism (round 6)
Round-6 review of PR #1793:
- Finding 1 (self-check blind in parallel): added the post-await
container.isConnected re-check the review suggested (factored both checks into
assertConnectedForSettle(container, "before"|"after")). While verifying it, I
established the actual mechanism, which differs from what both prior comments
and the review assumed: setup.ts's cleanup() is a *setupFile (outer)* hook, and
Vitest runs inner afterEach hooks to completion before outer ones in EVERY
sequence.hooks mode — so cleanup never races this settle. Verified: ViewHeader's
real-transitions tests pass under stack/list/parallel with neither check
tripping, and a same-suite `afterEach(cleanup)` probe (where the ordering CAN
break) does trip them (pre-check). So sequence.hooks isn't load-bearing here;
the comments in renderWithMantine.tsx, vite.config.ts, and AGENTS.md now say
that. The "stack" pin stays as documentation of intent / defense-in-depth, and
the two checks are the real guard against a future same-level-cleanup
regression.
- Nit: documented why last-write-wins on armedContainer is fine (cleanup detaches
every container at once, so the last is a faithful "cleanup ran" proxy).
- Nit: the fake-timer skip warning now interpolates the current test name so it's
actionable at teardown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): disarm the auto-settle on self-unmount; concurrency + message nits (round 7)
Round-7 review of PR #1793:
- Finding 1 (guard misdiagnoses a self-unmount): the detached-tree check fired
on ANY detachment and blamed a broken cleanup ordering, but a test calling the
unmount() returned by renderWithMantineTransitions also detaches the container
(a common pattern here). Fixed at the source: the helper now wraps unmount() to
disarm the auto-settle (clear armedSettleMs/armedContainer) — once the test has
unmounted there's nothing to settle against a live tree. The guard message also
now names the self-unmount cause. Verified: a probe that unmounts a
real-transitions render no longer trips the guard; ViewHeader unaffected.
- Nit: documented that the module-level arming assumes sequential tests (not safe
under test.concurrent/describe.concurrent).
- Nit: the fake-timer skip warning falls back to "(unknown test)" when
currentTestName is undefined.
- Finding 3 (doc weight): trimmed the AGENTS.md bullet to the *rule* (use the
transitions variant only for mid-flight assertions; pass a duration-derived
settleMs; no fake timers; use the returned unmount()) and pointed at the
helper's own comments for the full mechanism, which now carries it at more
depth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): downgrade (not disarm) the auto-settle on self-unmount; per-container liveness (round 8)
Round-8 review of PR #1793:
- Finding 1 (round-7 disarm was too blunt + self-contradictory): disarming on
self-unmount removed the drain entirely for the mid-flight-unmount test that
most needs #1760 protection, and its "useTransition cleanup applies" rationale
contradicted this file's own settleTransitions doc (which says that cleanup
can't be relied on to stop the rAF). Now the wrapped unmount() DOWNGRADES: the
drain still runs (mechanism-independent — flushes queued rAF/setTimeout while
window is alive; a setState on the unmounted tree is a React no-op), only the
liveness assertion for that container is dropped.
- Finding 2 (disarm undid round-5 max-wins arming): arming is now per-container
(a Set of live containers) instead of all-or-nothing. Unmounting one tree drops
only its own container; a second still-mounted tree keeps its longer window and
its liveness check. The isConnected assertions run over the still-live set.
Verified with probes: returned unmount() drains without tripping the guard; a
second still-mounted tree is unaffected when the first self-unmounts; a bare
mid-body cleanup() STILL trips the guard (it's live, not decoration); ViewHeader
unchanged. Comments/AGENTS.md updated so the self-unmount path and the
settleTransitions doc no longer contradict each other.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): make the auto-settle drain best-effort so a guard trip doesn't also leak (round 9)
Round-9 review of PR #1793 (merge-verdict; optional robustness note):
- Finding 1 (pre-settle throw skipped the drain): on the ordering regression the
guard exists to catch, throwing before the drain meant the maintainer got BOTH
the explanatory error AND an uncaught post-teardown `window is not defined` in
an unrelated file. The afterEach now captures a liveness failure, always runs
the act-wrapped drain (flushing queued rAF/setTimeout while window is alive),
and throws afterward — so the explanatory error stands alone. Verified: a bare
mid-body cleanup() still throws the guard error (now after draining); ViewHeader
unchanged.
- Nits: AGENTS.md now names the reachable bare-detach path (a mid-body cleanup()
on a still-armed tree) rather than "a bare RTL unmount"; the settleMs doc notes
arming is per-test, so in a multi-render test the longest window wins (a `0` is
superseded by a longer sibling render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): fix the regressed fake-timer doc + guarantee the liveness error survives a drain throw (round 10)
Round-10 review of PR #1793 (merge-verdict):
- Finding 1 (AGENTS.md fake-timer clause regressed in the round-7 doc trim): it
had reverted to "silently dropping the protection", which round 5 had corrected
— "silently" is now false (the auto-settle console.warns on the fake-timer
skip) and "dropping the protection" reinstates a walked-back framing (fake
clocks park Mantine's timers on a discarded clock, so the no-op doesn't
obviously reopen#1760). Reworded to match the helper: it warns, and the hazard
is that real-clock work is then unprotected, so the test depends on which clock
was installed at teardown.
- Finding 3 (nit): matched the console.warn string to that same corrected
framing, so AGENTS.md and the warning agree.
- Finding 2 (nit): a drain-time throw could discard the captured liveness error
(the substitution round 9 set out to prevent, one step further). Now the drain
error is captured too and the liveness error is preferred, so the explanatory
error always wins. Verified a bare mid-body cleanup() still throws the liveness
error after the best-effort drain; lint clean (no throw-in-finally); ViewHeader
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): preserve the drain error as cause + route the guard message to the reachable cause (round 11)
Round-11 review of PR #1793 (merge-verdict; two optional polish items):
- Finding 1: when both a liveness error and a drain-time throw occur, the drain
error was dropped entirely. It's now attached as the liveness error's `cause`
(when the liveness error is an Error), so an act-surfaced component error from
the settling render is preserved without changing which error fails the test.
- Finding 2: the guard's error message pointed the reader first at the
`sequence.hooks: "stack"` pin, which the file itself documents as
defense-in-depth and NOT the ordering guarantee. Reworded to name the actually-
reachable cause first — cleanup moved to a same-level afterEach instead of the
setup.ts setupFile (outer) hook — and demote the pin to a parenthetical, so a
maintainer who trips the check isn't sent to investigate a non-causal setting.
Verified: a bare mid-body cleanup() throws the reworded message; eslint clean;
ViewHeader unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): guard message states observation before candidate causes; log the preserved drain error (round 12)
Round-12 review of PR #1793 (merge-verdict):
- Finding 1: the round-11 rewording had dropped the "Expected cause:" hedge and
asserted setup.ts's cleanup() as the fact, but the check only observes that a
still-live armed tree was detached — it can't identify what detached it.
Reworded to state that observation first, then list candidate causes: (a) a
bare mid-body cleanup() in the test (the reachable case today), or (b) cleanup
moved off the setup.ts setupFile hook to a same-level afterEach. No single
cause asserted as fact.
- Nits 2a/2b: when both a liveness error and a drain error fire, the drain error
is now also console.error'd (in addition to being attached as `cause`), so it's
readable regardless of whether the reporter renders `error.cause` and isn't
silently dropped even if the liveness error somehow isn't an Error.
Verified: a bare mid-body cleanup() throws the reworded message; eslint clean;
ViewHeader unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): final comment/string polish on the auto-settle guard (round 13)
Round-13 review of PR #1793 (merge-verdict; string/comment polish only):
- Finding 1: the console.error text claimed the drain error was "reported as the
liveness error's cause" unconditionally, but the cause is only attached when
the liveness error is an Error — misleading on the very non-Error path the
hoisted log exists to serve. Dropped that parenthetical so the log is true on
both paths.
- Nit: trimmed the now-duplicated outer comment to just the priority rationale
(the inner comment, which mentions the log + instanceof conditional, is the
accurate one).
- Nit: guard message says "drained against a dead tree" (exact for both the
before- and during-settle detach) instead of "drained nothing".
- Nit: the message now names renderWithMantine.tsx rather than "the ordering note
here", so it's self-contained out of context.
eslint clean; ViewHeader unchanged; npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): correct the sequence.hooks default claim (it's "stack" in vitest 4, not "parallel") (round 14)
Round-14 review of PR #1793 (merge-verdict):
- Finding 1: verified against installed vitest 4.1.0 — `resolved.sequence.hooks
??= "stack"` (coverage chunk), so the runtime default is "stack"; the CLI
help-text's "parallel" is stale in vitest itself. The vite.config.ts comment
claimed the pin overrides a "parallel" default; corrected to say the pin is
Vitest 4's own default made explicit (documents intent + guards a future
default change), which is a truer reason to keep it than an override.
- Nit: tied the two additions together — the post-settle check's same-level race
can only arise if someone changes sequence.hooks to "parallel" (same-level
hooks run sequentially under "stack"/"list"), so the pin and the post-check now
reference each other rather than sitting in opposed positions.
ViewHeader unchanged; npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* test(web): document the isolate:true dependency (empirically confirmed) + fix pre-check-blindness wording (round 15)
Round-15 review of PR #1793 (merge-verdict; comment-only):
- Finding 1: the auto-settle's module-level afterEach depends on isolate:true
(the default) to re-register per test file; under isolate:false the module
evaluates once per worker and the hook binds to only the first importing file,
so other files arm with no consuming hook — the drain silently never runs and
the state leaks across files. Reproduced it (vitest --no-isolate --pool=threads
--no-file-parallelism over two real-transitions files leaves one file's arming
unconsumed) and documented the assumption + the onTestFinished escape hatch on
the arming-state comment.
- Nit: the "sequence.hooks is why the pre-check can't see the concurrent detach"
clause was wrong — the pre-check is blind because it runs synchronously before
the await (a property of where it sits, not the hook mode). Reworded to state
that, and that the concurrent case *additionally* needs sequence.hooks:
"parallel".
ViewHeader unchanged; npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: close a batch of quality-gate & tidy gaps (#1785#1787#1789#1790#1767)
Bundles five small, related tidy/gate items into one PR:
- #1785: delete the dead src/types/navigation.ts InspectorTab (0 importers;
duplicated/drifted from utils/inspectorTabs InspectorTabId). src/types/
now holds only ambient .d.ts stubs.
- #1787: add src/hooks/** to the web coverage `include` (it was silently
outside the ≥90 gate) and add tests for the two untested hooks,
useServerJsonImport and useImportClientConfig, bringing every hook to
≥90 on lines/statements/functions/branches.
- #1789: widen clients/{cli,tui,launcher} format/format:check to their
top-level configs; add root format:shared / format:check:shared covering
test-servers/src/**, vitest.shared.mts, and the repo-root eslint.config.js.
- #1790: pin prettier to an exact version (3.8.4, dropping the ^3.8.1
caret) in all five package.json files so the format gate's verdict can't
shift with an in-range patch bump; lockfiles synced.
- #1767: its root-file format+lint gap is closed here (format:shared +
a new lint:shared via a second files block in the root eslint.config.js;
web/server/ was already gated in #1788). Its larger cli/tui __tests__
typecheck piece (76 type errors) is split out to #1791.
Prettier-normalized the newly-covered files (pure formatting). No runtime
behavior change. `npm run ci` green.
Closes#1785Closes#1787Closes#1789Closes#1790Closes#1767
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore: address PR #1792 review findings
- #1789 gap: widen clients/web format to include .storybook and clients/
launcher format to include scripts/; reformat the 3 .storybook files
(were non-conforming under the pinned prettier 3.8.4). Audited all 885
tracked source files — now fully prettier-clean.
- Export VALIDATE_DEBOUNCE_MS / COLLAPSE_DELAY_MS / HIGHLIGHT_DURATION_MS
from useServerJsonImport and use them in the test instead of hardcoded
300/1000/250 (so a tuned constant can't silently desync the test).
- Assert the symmetric renameErrors["alpha"] alongside ["beta"] so a
one-directional collision-check regression fails.
- Comment the blank-rename test as exercising a UI-unreachable defensive
branch (canImport gates submit).
- Un-stale the two specification/v2_ux_interfaces_plan.md references to the
now-removed navigation.ts InspectorTab.
npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: sync AGENTS.md format-surface description with the widened globs (#1792 review r2)
1ce3b64 added .storybook to clients/web's format glob and scripts/ to
clients/launcher's, but AGENTS.md:222 still described the pre-fix scope.
Bring it in line and note the non-recursive top-level glob is why nested
dirs are named explicitly. Docs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore: handle PR #1792 round-3 follow-ups in-place (format-coverage guard, uniform globs, theme gate)
Rather than defer these to tickets, fold them into this PR:
- Obs 2: widen every client + root format glob to a uniform
*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} so a future .mts/.cjs/.tsx top-level
file can't silently slip the gate.
- Obs 1: add scripts/verify-format-coverage.mjs (wired into `validate`) —
it parses the format:check globs out of every package.json, enumerates
all tracked source files, and fails listing any not covered by a glob.
The durable, standing version of #1789's one-shot audit; negative-tested.
- Obs 3: bring src/theme/** under the coverage gate (21/23 files were
already ≥90 transitively; add Paper.test.tsx for the one laggard,
Paper.ts). src/App.tsx (~4.5k lines, ~42% branches) is converted from a
silent omission into an explicit, documented exclusion (comment on the
coverage `include` + AGENTS.md) — gating it is a dedicated effort, not a
whitelist tweak.
npm run ci green (the new guard reports 885/885 tracked source files gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore: address PR #1792 round-4 findings (guard hardening, docs, exclusion list)
- Harden verify-format-coverage: only harvest format:check globs from
scripts transitively reachable from `validate` (via npm run refs), so an
unwired/renamed format:check no longer counts as coverage. Negative-tested
— renaming clients/web format:check to an unreferenced name now fails the
guard (627 files flagged) instead of passing.
- Name src/main.tsx and src/index.ts (browser + bin bootstraps) alongside
src/App.tsx in the coverage-include comment and AGENTS.md, so the
documented exclusion list is complete rather than claiming App.tsx is the
only one.
- Doc sync: document verify:format-coverage (AGENTS.md + README script table
+ scripts/ layout line), fix the `validate` ordering (verify:format-
coverage now runs before validate:core) in AGENTS.md and README, and
update the quoted globs to the uniform *.{ts,tsx,mts,cts,js,jsx,mjs,cjs} /
test-servers *.{ts,tsx,mts,cts}.
npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore: address PR #1792 round-5 findings (airtight guard + doc/config sync)
- verify-format-coverage: assert the root `validate` chain invokes each
client's `validate` (cd <dir> && npm run validate), closing the last
reachability edge — a client dropped from the root chain now fails the
guard even though its own globs still exist. Negative-tested.
- eslint.config.js: widen the shared `files` block to
test-servers/src/**/*.{ts,tsx,mts,cts} to match the widened lint:shared
glob, so a future .mts/.cts there is lint-gated, not just prettier-gated.
- AGENTS.md: scope the "uniform extension set" claim to client globs
(core stays {ts,tsx}, shared {ts,tsx,mts,cts}), with verify:format-coverage
as the backstop — removing the self-contradiction with the line below.
npm run ci green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(web): format-gate the ungated first-party TS/JS (#1778)
Prettier only covered core/ and each client's src/, leaving the web Node
backend (clients/web/server/**), web top-level configs (vite.config.ts,
tsup.runner.config.ts, eslint.config.js, ...), and the root build/verify
tooling (scripts/**/*.mjs) ungated — so their formatting was never checked
by validate/CI. eslint already reached these via `eslint .`, so this is a
prettier-only gap.
- clients/web format/format:check now cover src, server, and top-level
*.{ts,js}, not just src.
- New root format:scripts / format:check:scripts for scripts/**/*.mjs,
folded into the root format and into validate:core.
- Normalized the newly-covered files with prettier --write (pure
formatting; no runtime change).
Closes#1778
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* chore: future-proof the scripts format glob (#1778 review)
Widen format:scripts / format:check:scripts from "scripts/**/*.mjs" to
"scripts/**/*.{mjs,js,cjs,ts}" so a future non-.mjs file under scripts/
is auto-gated, matching the future-proof glob style clients/web now uses.
A brace glob is a single pattern, so prettier does not error on the
unmatched branches. Addresses observation #2 from the PR #1788 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: sync AGENTS.md scripts glob after the widening (#1788 review r2)
d86d8f7 widened the scripts glob to {mjs,js,cjs,ts} and updated the
validate:core bullet, but the format bullet at AGENTS.md:217 still said
scripts/**/*.mjs. Bring it in line so the two mentions agree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): sharpen src/lib vs src/utils rule and move misfiled modules (#1776)
Codify the empirical rule the web client already followed loosely — utils =
pure functions that compute; lib = things that instantiate, adapt, or touch
the environment — in AGENTS.md and clients/web/README.md, and move the modules
that sat on the wrong side of it.
Moves (source + co-located test):
- lib/sandbox-csp -> utils (pure CSP-string construction; type-only import)
- utils/browserTabVisibility -> lib (registers visibilitychange listeners)
- utils/oauthResume -> lib (reads/writes sessionStorage)
- utils/clearServerOAuthState -> lib (drives live InspectorClient / OAuthStorage)
Borderline audits (kept in place): pendingReauth (pure type declarations),
deepLink / correlateTransportErrors / mcpNetworkHeaders / oauthUx / oauthFlow /
oauthNetworkPhase (pure transforms / type-only core imports) stay in utils;
downloadFile stays whole in lib (dominant side is DOM side effects).
Also relocate the lone src/test/lib/downloadFile.test.ts straggler beside its
source at src/lib/downloadFile.test.ts, and document the test-placement rule
(side-by-side by default; src/test/ only for core/ tests, the integration
project, and shared scaffolding).
Pure move — no behavior change; no build/coverage config keys off the boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* refactor(web): address review — fix stale paths, move customHeaders, one-way arrows (#1776)
Responds to the @claude review on PR #1784:
1. Stale path references (fixed): update the four in-repo pointers left at
pre-move paths — specification/v2_auth_mid_session.md (oauthResume,
browserTabVisibility → lib/), and the two load-bearing sandbox security
comments (sandbox_proxy.html, sandbox-controller.ts → src/utils/sandbox-csp).
2. lib/types/customHeaders (moved): it's pure (types + pure transforms), the
rule's clearest counterexample. Moved lib/types/customHeaders.{ts,test.ts} →
utils/ (no external importers; only its own test) and removed the
undocumented lib/types/ subdir. Documented that utils/ is the home for pure
shared domain types + their constructors — no separate types/ bucket.
3. One-way arrows (item 3): declare OAuthResumeAuthKind in utils/pendingReauth
and re-export it from lib/oauthResume, so the only cross-directory arrow now
points lib → utils. Codified that invariant in AGENTS.md.
Wording nit: AGENTS.md + README now state diagnostic logging
(console.warn/error) doesn't count as a side effect for the utils rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 2 — stale customHeaders refs, rule wording (#1776)
Doc-only follow-up to the round-2 @claude review on PR #1784 (no code changed):
1. Stale paths (same class as round-1 item 1): the customHeaders move left
three references to the deleted lib/types/customHeaders.ts path in
specification/ (v2_ux_interfaces_phase_0_4_inventory.md,
v2_ux_interfaces_plan.md ×2) — updated to utils/customHeaders.ts.
2. customHeaders anchor: note in AGENTS.md that its shape is staged for
ServerSettingsForm (per v2_ux_interfaces_plan.md), so its zero-importer
state reads as intentional rather than rot; kept as the no-types/-bucket
illustration and led the anchor list with load-bearing modules.
3. Type-only-import clause: split the conflated clause — a type-only import
(pendingReauth) and re-exporting core's pure functions/constants
(oauthUx/oauthFlow) are distinct reasons to stay in utils; what makes a
module lib is wrapping core's stateful runtime. Fixed in both AGENTS.md
and the web README.
Cosmetic: re-padded the v2_auth_mid_session.md table row to column width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 3 — scope the "no types/ bucket" claim (#1776)
Doc-only follow-up to the round-3 @claude review on PR #1784 (no code changed):
1. "no separate types/ bucket" overreached: clients/web/src/types/ does exist
(navigation.ts + react-syntax-highlighter .d.ts ambient stubs wired via
tsconfig.app.json paths). Scoped the claim in both AGENTS.md and the web
README to "no types/ sub-bucket INSIDE lib/utils", and noted src/types/ is
for ambient .d.ts stubs, not domain types.
2. README example list drifted from AGENTS.md's round-2 load-bearing anchors —
realigned it (inspectorTabs/mcpNetworkHeaders, dropped the zero-importer
customHeaders from the headline list).
3. Clarified v2_ux_interfaces_plan.md:185 so the v2 path reads as the copy's
destination, not as the v1.5 source location.
Follow-up filed as #1785 (board Todo): src/types/navigation.ts InspectorTab is
dead and duplicates utils/inspectorTabs.ts — kept out of this pure-move PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 4 — reconcile src/types/ claim with #1785 (#1776)
Doc-only follow-up to the round-4 @claude review on PR #1784 (no code changed):
The round-3 scope fix overshot: it asserted src/types/ holds "not domain types
at all", but the dead navigation.ts InspectorTab is a plain-.ts domain type
still living there, and specification/v2_ux_interfaces_plan.md:181-182 actively
prescribes placing InspectorTab there — so AGENTS.md and that spec doc gave
opposite guidance about the same directory.
- AGENTS.md + web README: softened to "not the place for new domain types"
and added a parenthetical that the one lingering domain type (dead
navigation.ts) is tracked for removal in #1785; until then a pure domain
type belongs in utils/, not src/types/.
- v2_ux_interfaces_plan.md:181: marked the tab-enum placement guidance as
superseded by #1785 (live type is InspectorTabId in utils/inspectorTabs.ts).
Self-heals on merge of #1785.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs: address review round 5 — fix garbled #1785 clause, restructure utils rule (#1776)
Doc-only follow-up to the round-5 @claude review on PR #1784 (no code changed):
1. Grammar (item 1): the round-4 #1785 clause read "superseding [#1785] will
remove it" — a participle with no subject. Reworded to "the dead
navigation.ts InspectorTab, which #1785 will remove".
2. Readability (item 2): the src/utils/ bullet had accreted into a single
~1,400-char blob across five review rounds. Promoted the carve-outs to
scannable sub-bullets (Domain types / Diagnostic logging / Importing from
core) and lifted the src/types/ note out to its own line, since it's a
statement about a sibling directory, not about utils.
AGENTS.md only; the README's parallel sentence was already clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* docs+config: address review round 6 — correct the coverage-glob claim (#1776)
Responds to the round-6 @claude review on PR #1784:
1. "no build/coverage glob" was factually wrong (should-fix): the web coverage
include in clients/web/vite.config.ts DOES enumerate src/lib/** and
src/utils/**. The conclusion (a move between them is coverage-neutral)
survives, but the reason was wrong. Reworded both AGENTS.md and the web
README to say nothing *enforces* the boundary — no path alias, and the
include lists both dirs so a move is coverage-neutral — plus the important
whitelist caveat: a module outside components/lib/utils/server falls out of
the ≥90 gate entirely.
2. Removed a pre-existing duplicate glob in vite.config.ts (src/lib/**/*.{ts,tsx}
appeared twice, lines 161 and 163). Inert (vitest de-dupes) but it sat three
lines from the config the doc now describes.
Nits: gave the README bullet the same round-5 sub-bullet restructure as
AGENTS.md, and made its #1785 reference a real markdown link (bare #N doesn't
autolink in rendered repo markdown).
npm run ci green (config change re-verified through coverage + smokes + Storybook).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* build(web): fail vite build on a browser-externalized Node built-in (#1769)
Vite 8 (rolldown) externalizes a node:* / Node built-in that reaches the
browser graph with a warning and ships a `module.exports = {}` stub — the
build still succeeds, shipping a broken bundle. The runtime browser smoke
(smoke:web:browser, #1615) only catches the subset where that stub is *called*
at module init; a stub imported but never called ships silently and nothing
gates it.
Add a Vite build plugin that turns that specific browser-externalization
warning into a hard `vite build` error, moving detection upstream into
`npm run build` / `validate` and additionally catching the never-called case
the runtime smoke can't see.
- clients/web/server/browser-externalized-builtin-gate.ts: Vite-agnostic
detection + error + per-build state machine (unit-tested to the ≥90 gate).
- vite.config.ts: thin Vite wiring. A throw inside rolldown's onLog is
swallowed (the one hook where it doesn't abort — verified against vite@8.0.0),
so the gate records the warning in onLog and re-throws in buildEnd. Scoped to
`vite build` (apply: 'build'); the Node runner build (tsup) is unaffected.
- scripts/verify-build-gate.mjs + verify:build-gate (in npm run ci and the
GitHub workflow): runs a real build with a node:fs probe and asserts it fails
via the gate — the only check that catches the warning phrasing drifting in a
future Vite bump and silently disabling the message-keyed gate.
There is no stable log `code` on the rolldown warning (only message + plugin),
so the gate keys off the documented message phrasing; the drift guard above is
what keeps that safe across Vite versions. smoke:web:browser stays as the
runtime backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): harden the browser-externalized-builtin gate per review (#1769)
Review follow-ups on PR #1777:
- Per-build state: add gate.reset() called from the plugin's buildStart, so a
`vite build --watch` rebuild doesn't inherit a prior build's recorded warning
(the plugin instance is reused across rebuilds). Makes the docblock true.
- buildEnd(error) now only asserts on the success path — rolldown also calls
buildEnd(error) when the build already failed for another reason, and throwing
then would mask that real error with the #1769 message.
- verify-build-gate.mjs: drop the shell `rm -rf` cache-clear entirely (verified
the gate fires reliably without it — the earlier "caching" symptom was the
onLog-swallow behavior, not a stale cache); add a result.error spawn-failure
check so a missing `npx` isn't misdiagnosed as "not via the gate"; add
SIGINT/SIGTERM handlers so an interrupt during the multi-minute build still
restores the mutated entry; add a progress log before the spawn.
- Match the double-quote style of the sibling server/ files.
- Docs: list browser-externalized-builtin-gate.ts in the AGENTS.md server/ block
and the clients/web/README server/ list (with why a build helper lives under
server/), and add verify-build-gate to the root README scripts/ line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-2 review hardening for the #1769 build gate
- Scope the gate to the browser (`client`) environment via applyToEnvironment,
so a future SSR/node environment built from this config isn't failed for a
legitimate node:* import — browser-only intent is now structural.
- Report ALL externalized built-ins in one build, deduped, instead of only the
first offender (N leaks no longer means N fix-and-rebuild cycles).
browserExternalizedBuiltinError now takes string[]; gate uses a Set.
- verify-build-gate.mjs: pin Vite with `npx --no-install` so the drift check
can never silently run a registry-fetched Vite instead of the repo-pinned one
(the whole point is version fidelity); append the probe instead of prepending
(ES imports hoist, and appending won't demote a future leading directive);
clarify the signal-handler comment (handlers are a backstop — a blocked
spawnSync tears down via the process group first).
- AGENTS.md: update both `ci` pipeline enumerations to include verify:build-gate
and drop the now-false "Storybook is the only CI step left out" claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-3 review polish for the #1769 build gate
- Error message: name the transitive-dependency remedy (a resolve.alias to a
browser shim) — "remove the import / gate behind the backend" doesn't apply
when the node:* import lives in node_modules.
- isBrowserExternalizedBuiltinLog is now a `message is string` type guard, so
recordLog drops its redundant `!== undefined` clause while still typing the
Set.add() — a real one-liner, not a coverage-only removal.
- verify-build-gate.mjs: docblock now states WHY it builds the real config +
entry (catches config-level regressions — plugin removed, onwarn suppression
added — that a temp config would miss), so the minute-long build isn't later
"optimized" into a temp entry and silently lose that coverage. Guard the
entry read so a renamed main.tsx fails through fail() with an actionable
message. On a botched restore, save the captured original to a sidecar .bak
and point there instead of recommending `git checkout --`, which would
discard uncommitted edits.
- AGENTS.md: fix the muddled CI-steps sentence (GitHub CI runs the whole chain
as separate steps, Storybook last) and note the applyToEnvironment client-only
scoping in the gate prose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-4 review polish — sharper verify-build-gate diagnostics (#1769)
All in scripts/verify-build-gate.mjs's failure paths:
- Split the "build SUCCEEDED" failure into three diagnoses keyed off the
captured output, so it names the right file: warning present but build passed
→ plugin not applying (removed from `plugins`, `applyToEnvironment` env-name
mismatch, or an `onwarn` suppression); node:fs present but phrase absent →
phrasing drift, update the constant; neither → probe never reached the graph.
The old message hard-coded "phrasing drifted", which misdirects for the
config-regression class this script exists to catch. Added applyToEnvironment
to the docblock's config-regression list too.
- Bound the build with a 10-min spawnSync timeout (+ SIGKILL); a timeout sets
result.error (ETIMEDOUT) and reports via the existing branch, instead of
burning to the GitHub job's 360-min default with no output.
- Guard the probe write so a read-only/EACCES checkout fails through fail()
with an actionable message instead of escaping as a raw stack.
- SIGTERM now exits 143 (was 130, which is SIGINT's code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-5 review polish — self-consistent verify-build-gate (#1769)
All in scripts/verify-build-gate.mjs:
- Drift guard: assert the gate source file still contains the mirrored
KNOWN_PHRASE literal, failing fast if the two drift. Without it, a stale
KNOWN_PHRASE after a phrasing update would make the three-way diagnosis
misreport a plugin-not-applying regression as a phrasing drift — the exact
misdirection the diagnosis exists to avoid.
- Make the .bak safety net reachable on the path that needs it most: restoreEntry
now catches its own write failure and routes to a shared saveBackupAndFail
helper, instead of letting the write escape the finally as a raw stack (which
skipped the .bak fallback and left the probe injected in the entry).
- Add the fourth "plugin isn't applying" cause to the diagnosis: a future Vite
emitting the warning before the client env's buildStart reset clears it (same
symptom as the other three).
- Flag once, on the failing-check path, that clients/web/dist holds a probe
build so a local debugger doesn't serve it unaware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-6 review polish — verify-build-gate literal contracts (#1769)
All in scripts/verify-build-gate.mjs; one theme — the literals the script
asserts on can each go stale independently of what they mirror:
- Success key: assert on the gate's distinctive thrown-error prefix
"Build failed (#1769)", not a bare "#1769". The issue number also appears in
vite.config.ts comments, so a code frame from an unrelated build error there
could otherwise make the check report OK with the gate dead — the one
direction it must never get wrong (a false pass hiding a dead gate).
- Drift guard now anchors on the BROWSER_EXTERNALIZED_BUILTIN_PHRASE assignment
(regex) instead of a whole-file substring match, so the old wording lingering
in a comment can't mask a changed constant; and its readFileSync of the gate
module is guarded so a moved file fails through fail() actionably.
- Extract PROBE_MODULE ("node:fs") used to build both the PROBE and the
diagnosis check + messages, so changing the probe module can't silently
misroute a phrasing-drift failure into "probe never reached the graph".
- Comment the load-bearing diagnosis order (phrase before module-name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-7 review polish — drift-guard both mirrored literals (#1769)
- Extract ERROR_PREFIX ("Build failed (#1769)") and drift-guard it against the
gate module too, and use it as the success key. Previously only KNOWN_PHRASE
was guarded; the prefix is mirrored across the same TS/.mjs boundary for the
same reason, so rewording the gate's thrown error would make a correctly-firing
gate report "the build broke for another reason" (benign direction, but the
same misdirection the other guards close).
- Write the restore-failure .bak under the OS temp dir, not next to the entry:
a sidecar inside src/ is untracked and a `git add -A` (likely while recovering)
would sweep it into a commit.
- Branch-(b) remediation now says update BOTH the gate constant AND KNOWN_PHRASE,
so a phrasing-drift fix lands in one pass instead of tripping the drift guard
on the next run.
- Note next to BROWSER_EXTERNALIZED_BUILTIN_PHRASE that it must stay a single
string literal (the script's drift guard anchors a regex on the assignment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-8 review polish — safe recovery path + structural onLog order (#1769)
- saveBackupAndFail no longer claims a backup it may not have written: it tracks
whether the write succeeded and only points the user at the .bak in that case
(otherwise it says the probe is still injected and to restore from VCS). Uses
mkdtempSync for a unique dir instead of a fixed tmp filename, so a
pre-existing/foreign-owned /tmp file can't make the recovery message point at
someone else's content.
- Guard the restore-verification readFileSync too, routing a failure through
saveBackupAndFail so the whole restore path is uniformly safe (a raw throw
there would skip the backup net).
- vite.config.ts: enforce: 'pre' on the gate plugin so it runs ahead of the
normal-plugin group in the onLog chain — a plugin whose onLog returns false
filters the log for later plugins, so trailing the array would let a future
log-filtering plugin silently blind the gate. Makes "the gate sees every log"
structural rather than array-position-dependent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-9 review polish — prove the gate fired on the probe (#1769)
The success key confirmed the #1769 gate fired, but not that OUR probe tripped
it. If the repo already leaked a Node built-in, the gate would fire on that and
the script would report OK even if the probe was tree-shaken or entryPath went
stale — branch (c)'s failure silently inverted into a pass. Add an assertion
that PROBE_MODULE appears among the gate's embedded offender warnings. Unreachable
inside `npm run ci` (validate's build:web fails first on a pre-existing leak),
but this script is a documented standalone command — run exactly when debugging
a leak, the case where this mattered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-10 review polish — roll back a partial probe write (#1769)
The probe writeFileSync uses the default 'w' flag (truncates at open), so a
mid-write ENOSPC/EIO could leave clients/web/src/main.tsx empty or half-written
— and its catch called fail() directly, the one path on this script that could
destroy the entry rather than merely dirty it (every other read/write is already
routed into the recovery net). Call restoreEntry() in that catch to roll the
partial write back (falling through to the .bak net if the restore itself can't
write), and correct the comment that claimed a write failure happens "before any
mutation". Also widen the enforce:'pre' comment to note it orders ahead of Vite's
core plugins too (harmless — the gate defines no resolve/load/transform).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-11 review polish — don't let the rollback swallow the write error (#1769)
Round 10's restoreEntry() in the probe-write catch swallowed the primary error:
restoreEntry() on its own write failure calls saveBackupAndFail → fail → exit,
so the "could not write the probe" diagnosis never ran when the rollback also
failed — which is exactly the read-only-checkout / ENOSPC case (both writes fail
at open). On a read-only checkout nothing was ever written, yet the developer
was told the restore failed and pointed at a /tmp backup of an untouched file.
Roll back inline instead: attempt the restore without exiting, track whether it
succeeded, then always report the PRIMARY write error, appending "restore from
version control" only when the rollback didn't take. Also pin the
"Build failed (#1769)" error prefix as contiguous in a single string fragment
(a comment symmetric with the phrase pin), since verify-build-gate.mjs
drift-guards on it as ERROR_PREFIX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
* build(web): round-12 review polish — don't let the pin comment defeat its own guard (#1769)
The round-11 pin comment quoted "Build failed (#1769)" verbatim, so the prefix
appeared twice in the gate module and verify-build-gate.mjs's whole-file
includes(ERROR_PREFIX) drift guard passed on the comment alone — rewording the
thrown error would have kept the guard green while a correctly-firing gate
reported "the build broke for another reason." Reworded the comment to not repeat
the full lead (matching how the phrase pin avoids repeating its phrase); the
literal now occurs exactly once, restoring the guard's meaning.
Also make the probe-write recovery message factual instead of inferred: read the
entry back and only warn "restore from version control" when it actually differs
from the original. On a read-only checkout both writes fail at open, so the file
is untouched and the old hedge needlessly implied damage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codebase-wide sweep bringing the web client into strict compliance with the AGENTS.md TypeScript and Mantine/React conventions: ~170 inline 2+-static-prop elements extracted to named .withProps() constants across ~77 files, the AGENTS.md rule wording tightened to match (static-prop boundary + Accordion/Transition/data-*/Box exemptions), a real ScreenStage Box->Flex layout regression fixed (direction: column) with a Storybook regression guard, and an App.tsx double-cast tightened. Closes#1762.