runDoctor (src/commands/doctor.ts) is a hand-written array of thirteen ad-hoc const checkX functions. A throw inside any of them kills the entire command — the user who most needs a diagnosis gets a stack trace instead of a report. No check is timed, so the performance budget in PRD §10 is an assertion with no instrument. The one dependency between checks is a function parameter (checkHook(opts, hookRuntime)), and there is no data structure a future --only/--category could filter.
Why this exists
- ADR-0032 §4: "Doctor must never die of its own check; today a throw in any
checkX kills the whole command."
- PRD §1.5: "The current doctor's per-check wall time is unmeasured; this field is how the budget in §10 becomes a measurement instead of an assertion."
- What the registry buys, per ADR-0032 §4: stable ordering tests can assert, filters over data instead of new code paths, each
run testable in isolation with an injected context, and a declared place for the dependencies that exist implicitly today.
Scope
Still one file (src/commands/doctor.ts); the split is a later ticket.
export interface DoctorContext {
cwd: string;
fix: boolean;
// Injected effects, defaulting to the real ones (PRD §2.3 names the set:
// cwd, git runner, spawn, clock, index opener):
git: (args: string[], opts?: { cwd?: string }) => { code: number; stdout: string; stderr: string };
spawn: typeof spawnSync;
env: NodeJS.ProcessEnv;
now: () => bigint; // process.hrtime.bigint
openIndex: typeof openIndex;
}
export interface CheckDefinition {
id: string;
title: string;
category: Category;
dependencies: string[]; // ids of earlier registry entries only (PRD §2 req 2)
optional: boolean;
run: (ctx: DoctorContext) => DoctorCheck;
}
- The registry order is pinned by the byte-identical text constraint to the current output order:
cli-runtime, notes-refspec, notes-push, commit-msg-hook, hook-runtime, inject-runtime, inject-version, mcp-lifecycle, pending-backlog, git-trailers, history-depth, index-health, squash-conservation.
- Declared dependency data in this ticket:
inject-version → inject-runtime only (backward in that order, so it satisfies §2 req 2). The commit-msg-hook → hook-runtime edge is not declared yet — see the open question below; the existing result-threading is kept, internal to the module, with the runner still computing hook-runtime exactly once.
- The runner walks the registry, emits rows in registry order, runs each check exactly once, and stamps
durationMs on every row from process.hrtime.bigint(), rounded to whole milliseconds, never negative (the monotonic-clock choice ADR-0032 §4 fixes). durationMs: number joins DoctorCheck here, since this is the ticket that sets it.
- Crash containment (PRD §2 req 4): every
run is wrapped; a throw becomes a fail row for that id — title and category from the definition, evidence: { error: <first line of the message> }, a one-sentence detail that does not collide with any shipping detail string, fix: null. The remaining checks still run.
runDoctor(opts) keeps its exact signature and builds the default context; register, formatReport, evaluateInjectRun exports unchanged.
Acceptance criteria
Tests that must exist
| case |
what it catches |
a throwing check yields a fail row and the run continues (a test-only registry entry, or one check stubbed to throw, then assert all other ids present and the process exit is a normal 1) |
the exact defect PRD §2.4 names — and specifically a runner that catches the throw but aborts the remaining checks, which "contains" the crash while still losing the report |
registry shape holds |
a forward or dangling dependency reference, a duplicate id, an empty category — each a build-time error per PRD §2 req 2 |
durationMs is stamped, integral, non-negative on every row |
a wall-clock (Date.now) implementation that can go negative across a clock step — the failure mode the monotonic choice exists to prevent; drive ctx.now with a fake monotonic clock and assert the stamped values |
hook-runtime runs once |
a naive runner that executes it once as a dependency and again as a row — observable as a doubled probe spawn via the injected ctx.spawn |
text snapshot unchanged |
any ordering drift from the array-to-registry move |
Open question (PRD conflict) — flagged, not decided here
PRD §2 req 2 (dependencies may name only earlier entries), §2.1 (declares commit-msg-hook → hook-runtime), and §9.1 (text output byte-identical, which pins commit-msg-hook before hook-runtime in registry order) cannot all hold at once. Options: reorder the two rows (violates §9.1 until the text ticket), allow declared-anywhere edges with a cycle check (amends §2 req 2), or decouple execution order from display order (amends §2 req 1). This needs a PRD/ADR-0032 amendment; the collapse ticket blocks on the answer. This ticket sidesteps it by declaring only the backward edge.
Depends on
Out of scope
blockedBy semantics and replacing the hookRuntime parameter (the collapse ticket), typed skip mapping, evidence population beyond the crash row, --only/--category (the mechanism lands with PRD §2.5 in the filters ticket), the envelope, the file split.
Traceability: PRD §1.5, §2, §2.1, §9.1; ADR-0032 §4.
runDoctor(src/commands/doctor.ts) is a hand-written array of thirteen ad-hocconst checkXfunctions. A throw inside any of them kills the entire command — the user who most needs a diagnosis gets a stack trace instead of a report. No check is timed, so the performance budget in PRD §10 is an assertion with no instrument. The one dependency between checks is a function parameter (checkHook(opts, hookRuntime)), and there is no data structure a future--only/--categorycould filter.Why this exists
checkXkills the whole command."runtestable in isolation with an injected context, and a declared place for the dependencies that exist implicitly today.Scope
Still one file (
src/commands/doctor.ts); the split is a later ticket.cli-runtime, notes-refspec, notes-push, commit-msg-hook, hook-runtime, inject-runtime, inject-version, mcp-lifecycle, pending-backlog, git-trailers, history-depth, index-health, squash-conservation.inject-version → inject-runtimeonly (backward in that order, so it satisfies §2 req 2). Thecommit-msg-hook → hook-runtimeedge is not declared yet — see the open question below; the existing result-threading is kept, internal to the module, with the runner still computinghook-runtimeexactly once.durationMson every row fromprocess.hrtime.bigint(), rounded to whole milliseconds, never negative (the monotonic-clock choice ADR-0032 §4 fixes).durationMs: numberjoinsDoctorCheckhere, since this is the ticket that sets it.runis wrapped; a throw becomes afailrow for that id — title and category from the definition,evidence: { error: <first line of the message> }, a one-sentence detail that does not collide with any shipping detail string,fix: null. The remaining checks still run.runDoctor(opts)keeps its exact signature and builds the default context;register,formatReport,evaluateInjectRunexports unchanged.Acceptance criteria
runDoctorwalks it (PRD §2 req 1).runyields afailrow withevidence.errorset to the first line of the message, and every other registered check still appears in the report (PRD §2 req 4, §11 "crash containment").durationMs >= 0.hook-runtimein particular, which is both a row and an input tocommit-msg-hook.--jsonv1 keys unchanged.Tests that must exist
a throwing check yields a fail row and the run continues(a test-only registry entry, or one check stubbed to throw, then assert all other ids present and the process exit is a normal 1)registry shape holdsdurationMs is stamped, integral, non-negative on every rowDate.now) implementation that can go negative across a clock step — the failure mode the monotonic choice exists to prevent; drivectx.nowwith a fake monotonic clock and assert the stamped valueshook-runtime runs oncectx.spawntext snapshot unchangedOpen question (PRD conflict) — flagged, not decided here
PRD §2 req 2 (dependencies may name only earlier entries), §2.1 (declares
commit-msg-hook → hook-runtime), and §9.1 (text output byte-identical, which pinscommit-msg-hookbeforehook-runtimein registry order) cannot all hold at once. Options: reorder the two rows (violates §9.1 until the text ticket), allow declared-anywhere edges with a cycle check (amends §2 req 2), or decouple execution order from display order (amends §2 req 1). This needs a PRD/ADR-0032 amendment; the collapse ticket blocks on the answer. This ticket sidesteps it by declaring only the backward edge.Depends on
Out of scope
blockedBysemantics and replacing thehookRuntimeparameter (the collapse ticket), typed skip mapping, evidence population beyond the crash row,--only/--category(the mechanism lands with PRD §2.5 in the filters ticket), the envelope, the file split.Traceability: PRD §1.5, §2, §2.1, §9.1; ADR-0032 §4.