@indigoai-us/hq-cli 5.98.2 → 5.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core.js +25 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/hook-trust.d.ts +10 -13
- package/dist/utils/hook-trust.js +148 -27
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--deep-test` execution for pure guard hooks (US-008).
|
|
3
|
+
*
|
|
4
|
+
* Default `hq doctor` proves a hook is *wired*. Deep test proves it *works*: for
|
|
5
|
+
* every pure-guard fixture (US-007 schema, {@link isPureGuard} classification)
|
|
6
|
+
* it replays each case through the REAL `hook-gate.sh` and checks the verdict
|
|
7
|
+
* against the fixture's expectation. Four rules make this trustworthy rather
|
|
8
|
+
* than theatre:
|
|
9
|
+
*
|
|
10
|
+
* 1. Route through the gate, never the hook directly. A hook can pass direct
|
|
11
|
+
* invocation and still silently no-op under the active gate profile — the
|
|
12
|
+
* documented `block-hq-root-git-mutation` incident did exactly that. So
|
|
13
|
+
* each case runs `hook-gate.sh <id> <script>` with a payload on stdin, and
|
|
14
|
+
* under ALL THREE profiles (minimal, standard, strict). A profile whose
|
|
15
|
+
* outcome differs from the expectation is a FAIL naming that profile: an
|
|
16
|
+
* expected block that no-ops to exit 0 under standard is caught here and
|
|
17
|
+
* nowhere else.
|
|
18
|
+
*
|
|
19
|
+
* 2. Sandbox, never the live tree. The gate and hooks are copied into a
|
|
20
|
+
* throwaway temp tree and every case executes there, so a diagnosis can
|
|
21
|
+
* never mutate the user's real setup. A test asserts the live tree is
|
|
22
|
+
* byte-identical before and after a run.
|
|
23
|
+
*
|
|
24
|
+
* 3. Time-bound every case. A hook that hangs must fail its case, not the
|
|
25
|
+
* command — each execution is spawned with a per-case timeout and a
|
|
26
|
+
* timeout is reported as FAIL, never left to block the run.
|
|
27
|
+
*
|
|
28
|
+
* 4. Never fire a hook in default mode. This module runs only when the caller
|
|
29
|
+
* asks for it (the command wires it behind `--deep-test`); the default
|
|
30
|
+
* registry does not include it. A test asserts no hook process is spawned
|
|
31
|
+
* without the flag.
|
|
32
|
+
*
|
|
33
|
+
* Expectation matching and the `expectedFailure` rule are owned by
|
|
34
|
+
* {@link outcomeMatches} / {@link classifyCaseStatus} in the fixture schema, so
|
|
35
|
+
* a case outcome means the same thing here as in the coverage tier. The Claude
|
|
36
|
+
* block protocol (a non-zero gate exit is a block) is the only one this story
|
|
37
|
+
* handles; Grok's stdout-`deny` protocol is US-010's parity replay.
|
|
38
|
+
*/
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
40
|
+
import * as fs from "node:fs";
|
|
41
|
+
import * as os from "node:os";
|
|
42
|
+
import * as path from "node:path";
|
|
43
|
+
import { GATE_PROFILES } from "../hook-gate-profiles.js";
|
|
44
|
+
import { discoverFixtures, registeredHookIds } from "../fixtures/discover.js";
|
|
45
|
+
import { classifyCaseStatus, outcomeMatches, } from "../fixtures/schema.js";
|
|
46
|
+
import { isPureGuard } from "./classify.js";
|
|
47
|
+
/** The id of the deep-test family the command appends under `--deep-test`. */
|
|
48
|
+
export const DEEP_FAMILY_ID = "deep";
|
|
49
|
+
/** Human title of the deep-test family. */
|
|
50
|
+
export const DEEP_FAMILY_TITLE = "Deep guard-hook tests";
|
|
51
|
+
/** Default per-case time bound. Deep test has no latency target, but no case
|
|
52
|
+
* may hang the command, so every execution is capped. */
|
|
53
|
+
export const DEFAULT_PER_CASE_TIMEOUT_MS = 10_000;
|
|
54
|
+
/**
|
|
55
|
+
* The tree entries copied into the sandbox. Deliberately a curated set, not the
|
|
56
|
+
* whole tree: the live `.claude/` carries worktrees and session state that can
|
|
57
|
+
* run to hundreds of megabytes, whereas the gate only needs the hook scripts,
|
|
58
|
+
* the settings a hook might read, the Codex/Grok mirrors, and `core/` (where the
|
|
59
|
+
* gate finds `hook-lib.sh` and hooks find their helpers). Missing entries are
|
|
60
|
+
* skipped, so a partial tree still sandboxes cleanly.
|
|
61
|
+
*/
|
|
62
|
+
export const SANDBOX_COPY_RELPATHS = [
|
|
63
|
+
".claude/hooks",
|
|
64
|
+
".claude/settings.json",
|
|
65
|
+
".claude/settings.local.json",
|
|
66
|
+
".codex/hooks",
|
|
67
|
+
".codex/hooks.json",
|
|
68
|
+
".grok",
|
|
69
|
+
"core",
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
|
|
73
|
+
* stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
|
|
74
|
+
* `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
|
|
75
|
+
* a hook cannot ignore the bound.
|
|
76
|
+
*/
|
|
77
|
+
export const defaultGateRunner = (input) => {
|
|
78
|
+
const result = spawnSync("bash", [input.gatePath, input.hookId, input.hookScriptPath], {
|
|
79
|
+
input: input.payload,
|
|
80
|
+
cwd: input.cwd,
|
|
81
|
+
timeout: input.timeoutMs,
|
|
82
|
+
killSignal: "SIGKILL",
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
85
|
+
env: {
|
|
86
|
+
...process.env,
|
|
87
|
+
HQ_HOOK_PROFILE: input.profile,
|
|
88
|
+
CLAUDE_PROJECT_DIR: input.cwd,
|
|
89
|
+
HQ_ROOT: input.cwd,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const errno = result.error?.code;
|
|
93
|
+
return {
|
|
94
|
+
timedOut: errno === "ETIMEDOUT",
|
|
95
|
+
exitCode: result.status,
|
|
96
|
+
stderr: result.stderr ?? "",
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
// --- sandbox lifecycle --------------------------------------------------------
|
|
100
|
+
const trackedSandboxes = new Set();
|
|
101
|
+
let sandboxExitHandlerRegistered = false;
|
|
102
|
+
/** Remove every sandbox still on disk. Safe to call repeatedly. */
|
|
103
|
+
export function cleanupAllDeepSandboxes() {
|
|
104
|
+
for (const root of trackedSandboxes)
|
|
105
|
+
removeTree(root);
|
|
106
|
+
trackedSandboxes.clear();
|
|
107
|
+
}
|
|
108
|
+
function ensureSandboxExitHandler() {
|
|
109
|
+
if (sandboxExitHandlerRegistered)
|
|
110
|
+
return;
|
|
111
|
+
sandboxExitHandlerRegistered = true;
|
|
112
|
+
// Fires on normal exit and after a crash alike, guaranteeing no sandbox
|
|
113
|
+
// survives even if a case throws or the process is torn down mid-run.
|
|
114
|
+
process.on("exit", cleanupAllDeepSandboxes);
|
|
115
|
+
}
|
|
116
|
+
function removeTree(root) {
|
|
117
|
+
try {
|
|
118
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Best-effort: cleanup must never mask a test's real failure.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Copy the curated {@link SANDBOX_COPY_RELPATHS} from the live tree into a fresh
|
|
126
|
+
* temp root. Pure guards do not write, so one sandbox is reused for the whole
|
|
127
|
+
* run; the live tree is only ever read from.
|
|
128
|
+
*/
|
|
129
|
+
function createSandbox(hqRoot, relpaths) {
|
|
130
|
+
ensureSandboxExitHandler();
|
|
131
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-doctor-deep-")));
|
|
132
|
+
trackedSandboxes.add(root);
|
|
133
|
+
for (const rel of relpaths) {
|
|
134
|
+
const segments = rel.split("/");
|
|
135
|
+
const src = path.join(hqRoot, ...segments);
|
|
136
|
+
if (!fs.existsSync(src))
|
|
137
|
+
continue;
|
|
138
|
+
const dest = path.join(root, ...segments);
|
|
139
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
140
|
+
// cpSync preserves mode bits, so the gate and hooks stay executable.
|
|
141
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
return root;
|
|
144
|
+
}
|
|
145
|
+
// --- entry point --------------------------------------------------------------
|
|
146
|
+
/**
|
|
147
|
+
* Execute every pure-guard fixture's cases through the real gate under all three
|
|
148
|
+
* profiles and return the per-case results. Read-only against the live tree;
|
|
149
|
+
* all execution happens in a sandbox that is removed before returning. Returns
|
|
150
|
+
* an empty list — creating no sandbox — when there is nothing to run.
|
|
151
|
+
*/
|
|
152
|
+
export async function runDeepGuardTests(context, options = {}) {
|
|
153
|
+
const hqRoot = context.hqRoot;
|
|
154
|
+
const timeoutMs = options.perCaseTimeoutMs ?? DEFAULT_PER_CASE_TIMEOUT_MS;
|
|
155
|
+
const runner = options.runner ?? defaultGateRunner;
|
|
156
|
+
const copyRelpaths = options.sandboxRelpaths ?? SANDBOX_COPY_RELPATHS;
|
|
157
|
+
const runnable = selectRunnableFixtures(hqRoot);
|
|
158
|
+
if (runnable.length === 0)
|
|
159
|
+
return [];
|
|
160
|
+
const sandbox = createSandbox(hqRoot, copyRelpaths);
|
|
161
|
+
try {
|
|
162
|
+
return runAllFixtures(runnable, { sandbox, timeoutMs, runner });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
if (!options.keepSandbox) {
|
|
166
|
+
removeTree(sandbox);
|
|
167
|
+
trackedSandboxes.delete(sandbox);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The pure-guard fixtures worth executing: valid (US-007 already reports the
|
|
173
|
+
* invalid, unknown-version and orphan cases), registered, non-empty, and not a
|
|
174
|
+
* side-effecting hook (those are US-009's sandboxed effect tier).
|
|
175
|
+
*/
|
|
176
|
+
function selectRunnableFixtures(hqRoot) {
|
|
177
|
+
const registered = new Set(registeredHookIds(hqRoot));
|
|
178
|
+
const discovery = discoverFixtures(hqRoot);
|
|
179
|
+
const runnable = [];
|
|
180
|
+
for (const discovered of discovery.fixtures) {
|
|
181
|
+
if (discovered.parsed.status !== "ok")
|
|
182
|
+
continue;
|
|
183
|
+
const fixture = discovered.parsed.fixture;
|
|
184
|
+
if (!registered.has(fixture.hookId))
|
|
185
|
+
continue;
|
|
186
|
+
if (fixture.cases.length === 0)
|
|
187
|
+
continue;
|
|
188
|
+
if (!isPureGuard(fixture))
|
|
189
|
+
continue;
|
|
190
|
+
runnable.push(fixture);
|
|
191
|
+
}
|
|
192
|
+
return runnable;
|
|
193
|
+
}
|
|
194
|
+
function runAllFixtures(fixtures, env) {
|
|
195
|
+
const results = [];
|
|
196
|
+
const gatePath = path.join(env.sandbox, ".claude", "hooks", "hook-gate.sh");
|
|
197
|
+
// No gate means the real gate path cannot be exercised at all — honest UNKNOWN
|
|
198
|
+
// for every fixture (never PASS), mirroring the tool's central safeguard.
|
|
199
|
+
if (!fs.existsSync(gatePath)) {
|
|
200
|
+
for (const fixture of fixtures) {
|
|
201
|
+
results.push({
|
|
202
|
+
status: "UNKNOWN",
|
|
203
|
+
checkId: "hooks.deep.gate-missing",
|
|
204
|
+
target: fixture.hookId,
|
|
205
|
+
message: `Cannot deep-test "${fixture.hookId}": .claude/hooks/hook-gate.sh is absent, so its cases cannot be run through the real gate.`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
for (const fixture of fixtures) {
|
|
211
|
+
const scriptPath = path.join(env.sandbox, ".claude", "hooks", `${fixture.hookId}.sh`);
|
|
212
|
+
if (!fs.existsSync(scriptPath)) {
|
|
213
|
+
results.push({
|
|
214
|
+
status: "UNKNOWN",
|
|
215
|
+
checkId: "hooks.deep.script-missing",
|
|
216
|
+
target: fixture.hookId,
|
|
217
|
+
message: `Cannot deep-test "${fixture.hookId}": its script .claude/hooks/${fixture.hookId}.sh is absent, so no case can be executed.`,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const testCase of fixture.cases) {
|
|
222
|
+
results.push(evaluateCase(fixture, testCase, gatePath, scriptPath, env));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Run one case under all three profiles and classify the aggregate. A timeout
|
|
229
|
+
* under any profile is a FAIL for timeout; otherwise a profile whose verdict
|
|
230
|
+
* differs from the expectation makes the case FAIL (or KNOWN-DEFECT under an
|
|
231
|
+
* `expectedFailure` marker) naming those profiles, their exit codes, and their
|
|
232
|
+
* stderr.
|
|
233
|
+
*/
|
|
234
|
+
function evaluateCase(fixture, testCase, gatePath, scriptPath, env) {
|
|
235
|
+
const target = `${fixture.hookId}:${testCase.name}`;
|
|
236
|
+
const payload = buildPayload(testCase, env.sandbox);
|
|
237
|
+
const runs = [];
|
|
238
|
+
const timedOut = [];
|
|
239
|
+
for (const profile of GATE_PROFILES) {
|
|
240
|
+
const run = env.runner({
|
|
241
|
+
gatePath,
|
|
242
|
+
hookScriptPath: scriptPath,
|
|
243
|
+
hookId: fixture.hookId,
|
|
244
|
+
profile,
|
|
245
|
+
payload,
|
|
246
|
+
cwd: env.sandbox,
|
|
247
|
+
timeoutMs: env.timeoutMs,
|
|
248
|
+
});
|
|
249
|
+
if (run.timedOut) {
|
|
250
|
+
timedOut.push(profile);
|
|
251
|
+
runs.push({ profile, run, met: false });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const observed = {
|
|
255
|
+
blocked: run.exitCode !== 0,
|
|
256
|
+
stderr: run.stderr,
|
|
257
|
+
};
|
|
258
|
+
runs.push({ profile, run, met: outcomeMatches(testCase.expect, observed) });
|
|
259
|
+
}
|
|
260
|
+
// A hook that cannot answer within the bound fails its case rather than
|
|
261
|
+
// hanging the command — reported distinctly so a timeout is never confused
|
|
262
|
+
// with a wrong verdict.
|
|
263
|
+
if (timedOut.length > 0) {
|
|
264
|
+
return {
|
|
265
|
+
status: "FAIL",
|
|
266
|
+
checkId: "hooks.deep.timeout",
|
|
267
|
+
target,
|
|
268
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" exceeded the ${env.timeoutMs}ms per-case time bound under ${formatProfiles(timedOut)}; a hook that does not return within the bound is treated as a failure, not left to hang the command.`,
|
|
269
|
+
remediation: `Investigate why "${fixture.hookId}" does not return within ${env.timeoutMs}ms for case "${testCase.name}".`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const failed = runs.filter((r) => !r.met).map((r) => r.profile);
|
|
273
|
+
const status = classifyCaseStatus(failed.length === 0, testCase.expectedFailure);
|
|
274
|
+
if (status === "PASS") {
|
|
275
|
+
return {
|
|
276
|
+
status,
|
|
277
|
+
checkId: "hooks.deep.case",
|
|
278
|
+
target,
|
|
279
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" produced the expected ${describeExpect(testCase.expect)} through the gate under all three profiles.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (status === "WARN") {
|
|
283
|
+
// Marked as a pinned defect, yet it now passes everywhere — the marker is
|
|
284
|
+
// stale and should be removed (US-007 `expectedFailure` semantics).
|
|
285
|
+
return {
|
|
286
|
+
status,
|
|
287
|
+
checkId: "hooks.deep.stale-expected-failure",
|
|
288
|
+
target,
|
|
289
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" is marked expectedFailure (${testCase.expectedFailure?.reason ?? ""}) but now produces the expected ${describeExpect(testCase.expect)} under all three profiles; remove the stale marker.`,
|
|
290
|
+
remediation: `Delete the expectedFailure marker from case "${testCase.name}" in core/hook-tests/${fixture.hookId}.yaml.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// FAIL or KNOWN-DEFECT: name each failing profile with its exit code and stderr.
|
|
294
|
+
const detail = runs
|
|
295
|
+
.filter((r) => !r.met)
|
|
296
|
+
.map((r) => `${r.profile} (exit ${formatExit(r.run.exitCode)}${r.run.stderr.trim()
|
|
297
|
+
? `, stderr: ${oneLine(r.run.stderr)}`
|
|
298
|
+
: ", no stderr"})`)
|
|
299
|
+
.join("; ");
|
|
300
|
+
const base = `Case "${testCase.name}" for "${fixture.hookId}" expected ${describeExpect(testCase.expect)} but the outcome differed under ${formatProfiles(failed)}: ${detail}.`;
|
|
301
|
+
if (status === "KNOWN-DEFECT") {
|
|
302
|
+
return {
|
|
303
|
+
status,
|
|
304
|
+
checkId: "hooks.deep.case",
|
|
305
|
+
target,
|
|
306
|
+
message: `${base} Tracked as a known defect: ${testCase.expectedFailure?.reason ?? ""}.`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
status: "FAIL",
|
|
311
|
+
checkId: "hooks.deep.case",
|
|
312
|
+
target,
|
|
313
|
+
message: base,
|
|
314
|
+
remediation: remediationForExpect(testCase.expect, fixture.hookId, failed),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The Claude/Codex hook payload shape the gate delegates on stdin: the fixture's
|
|
319
|
+
* event, tool, and opaque input under the keys hooks read (`.hook_event_name`,
|
|
320
|
+
* `.tool_name`, `.tool_input`). `cwd`/`session_id` are set for hooks that key
|
|
321
|
+
* off them, pointing at the sandbox.
|
|
322
|
+
*/
|
|
323
|
+
function buildPayload(testCase, cwd) {
|
|
324
|
+
return JSON.stringify({
|
|
325
|
+
hook_event_name: testCase.event,
|
|
326
|
+
tool_name: testCase.tool,
|
|
327
|
+
tool_input: testCase.input,
|
|
328
|
+
cwd,
|
|
329
|
+
session_id: "hq-doctor-deep-test",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// --- message helpers ----------------------------------------------------------
|
|
333
|
+
function describeExpect(expect) {
|
|
334
|
+
switch (expect.kind) {
|
|
335
|
+
case "block":
|
|
336
|
+
return "a block";
|
|
337
|
+
case "allow":
|
|
338
|
+
return "an allow (pass-through)";
|
|
339
|
+
case "stderr":
|
|
340
|
+
return `stderr matching /${expect.pattern}/`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function remediationForExpect(expect, hookId, failed) {
|
|
344
|
+
if (expect.kind === "block") {
|
|
345
|
+
return `Ensure "${hookId}" blocks this input under ${formatProfiles(failed)} — most often it is missing from ${formatProfiles(failed)} in .claude/hooks/hook-gate.sh, so the gate no-ops it there.`;
|
|
346
|
+
}
|
|
347
|
+
if (expect.kind === "allow") {
|
|
348
|
+
return `"${hookId}" blocked an input the fixture expects to be allowed; verify the hook logic or correct the fixture case.`;
|
|
349
|
+
}
|
|
350
|
+
return `"${hookId}" did not emit the expected stderr; verify the hook or the fixture pattern.`;
|
|
351
|
+
}
|
|
352
|
+
/** Render a profile list as "minimal", "minimal and standard", or "a, b and c". */
|
|
353
|
+
function formatProfiles(profiles) {
|
|
354
|
+
const ordered = GATE_PROFILES.filter((p) => profiles.includes(p));
|
|
355
|
+
if (ordered.length <= 1)
|
|
356
|
+
return ordered.join("");
|
|
357
|
+
if (ordered.length === 2)
|
|
358
|
+
return `${ordered[0]} and ${ordered[1]}`;
|
|
359
|
+
return `${ordered.slice(0, -1).join(", ")} and ${ordered[ordered.length - 1]}`;
|
|
360
|
+
}
|
|
361
|
+
function formatExit(exitCode) {
|
|
362
|
+
return exitCode === null ? "none" : String(exitCode);
|
|
363
|
+
}
|
|
364
|
+
/** Collapse captured stderr to a single trimmed, length-capped line for a message. */
|
|
365
|
+
function oneLine(stderr) {
|
|
366
|
+
const collapsed = stderr.replace(/\s+/g, " ").trim();
|
|
367
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 197)}…` : collapsed;
|
|
368
|
+
}
|
|
369
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform parity replay (US-010).
|
|
3
|
+
*
|
|
4
|
+
* The payoff of the declarative fixture format: one case, three executions,
|
|
5
|
+
* automatic drift detection. Under `--deep-test`, every pure-guard fixture case
|
|
6
|
+
* is normalised into the Claude, Codex, and Grok payload shapes (payload-shapes.ts)
|
|
7
|
+
* and replayed through each platform's REAL adapter path, and their verdicts are
|
|
8
|
+
* compared. Platform drift — a Codex mirror whose logic diverged from its Claude
|
|
9
|
+
* original — becomes a test result instead of a manual audit.
|
|
10
|
+
*
|
|
11
|
+
* The three "real adapter paths", matching how each platform is actually wired:
|
|
12
|
+
*
|
|
13
|
+
* - Claude runs the canonical `.claude/hooks/<id>.sh` through
|
|
14
|
+
* `.claude/hooks/hook-gate.sh`, exactly as `.claude/settings.json` registers
|
|
15
|
+
* it. A block is a non-zero gate exit.
|
|
16
|
+
* - Codex runs its DUPLICATED `.codex/hooks/<id>.sh` mirror through
|
|
17
|
+
* `.codex/hooks/hook-gate.sh`, exactly as `.codex/hooks.json` registers it.
|
|
18
|
+
* This is the entire drift surface: only Codex runs copies, so a mirror whose
|
|
19
|
+
* logic differs is caught precisely here. A block is a non-zero gate exit.
|
|
20
|
+
* - Grok drives `.grok/hooks/hq-grok-hook-adapter.sh` with the camelCase
|
|
21
|
+
* payload; the adapter maps the Grok tool alias back to canonical and routes
|
|
22
|
+
* through the same `.claude/` gate. A block is a stdout `deny` decision
|
|
23
|
+
* and/or a non-zero exit (payload-shapes `interpretGrokBlock`).
|
|
24
|
+
*
|
|
25
|
+
* Three rules keep the replay honest:
|
|
26
|
+
*
|
|
27
|
+
* 1. Disagreement is a FAIL that names every platform's verdict side by side,
|
|
28
|
+
* so a reviewer sees `claude=block, codex=allow, grok=block` rather than a
|
|
29
|
+
* bare failure.
|
|
30
|
+
* 2. Grok's passive events (everything but PreToolUse) cannot inject
|
|
31
|
+
* model-facing context or emit a decision, so a case on such an event is
|
|
32
|
+
* NA — never PASS or FAIL — with the platform limitation named.
|
|
33
|
+
* 3. A platform whose script/adapter is absent, or whose case timed out, is an
|
|
34
|
+
* honest UNKNOWN (never PASS): an exec failure must not be misread as a
|
|
35
|
+
* block, and a platform we could not run cannot be declared in agreement.
|
|
36
|
+
*
|
|
37
|
+
* Parity replay runs ONLY under `--deep-test`; the command appends it there, not
|
|
38
|
+
* in the default registry, so a plain `hq doctor` never fires a hook. It reuses
|
|
39
|
+
* the same pure-guard selection as US-008's executor and its own sandbox, so the
|
|
40
|
+
* live tree is only ever read from.
|
|
41
|
+
*/
|
|
42
|
+
import type { CheckContext, CheckResult } from "../types.js";
|
|
43
|
+
import type { FixtureCase } from "../fixtures/schema.js";
|
|
44
|
+
import { type PlatformId } from "../payload-shapes.js";
|
|
45
|
+
/** The id of the parity family the command appends under `--deep-test`. */
|
|
46
|
+
export declare const PARITY_FAMILY_ID = "deep-parity";
|
|
47
|
+
/** Human title of the parity family. */
|
|
48
|
+
export declare const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
|
|
49
|
+
/** Default per-case, per-platform time bound. */
|
|
50
|
+
export declare const DEFAULT_PARITY_TIMEOUT_MS = 10000;
|
|
51
|
+
/** The gate profile the replay runs each hook under. Strict is the broadest
|
|
52
|
+
* allowlist, so profile gating never suppresses the hook whose LOGIC is being
|
|
53
|
+
* compared across platforms — profile-specific behaviour is US-008's concern. */
|
|
54
|
+
export declare const PARITY_GATE_PROFILE = "strict";
|
|
55
|
+
/** The session id stamped onto every replay payload. */
|
|
56
|
+
export declare const PARITY_SESSION_ID = "hq-doctor-parity-replay";
|
|
57
|
+
/** One platform replay's inputs. */
|
|
58
|
+
export interface PlatformRunInput {
|
|
59
|
+
/** Which platform's adapter path to exercise. */
|
|
60
|
+
platform: PlatformId;
|
|
61
|
+
/** The sandbox root (also the payload `cwd`). */
|
|
62
|
+
sandbox: string;
|
|
63
|
+
/** The hook id under test. */
|
|
64
|
+
hookId: string;
|
|
65
|
+
/** The case being replayed. */
|
|
66
|
+
testCase: FixtureCase;
|
|
67
|
+
/** Per-platform time bound in milliseconds. */
|
|
68
|
+
timeoutMs: number;
|
|
69
|
+
}
|
|
70
|
+
/** One platform replay's observed output, before protocol interpretation. */
|
|
71
|
+
export interface PlatformRunOutput {
|
|
72
|
+
/** True when the platform could not be run at all (missing script/adapter). */
|
|
73
|
+
unavailable?: {
|
|
74
|
+
reason: string;
|
|
75
|
+
};
|
|
76
|
+
/** True when the execution exceeded {@link PlatformRunInput.timeoutMs}. */
|
|
77
|
+
timedOut: boolean;
|
|
78
|
+
/** Process exit code, or null when it did not exit normally. */
|
|
79
|
+
exitCode: number | null;
|
|
80
|
+
/** Captured stdout (empty when none). */
|
|
81
|
+
stdout: string;
|
|
82
|
+
/** Captured stderr (empty when none). */
|
|
83
|
+
stderr: string;
|
|
84
|
+
}
|
|
85
|
+
/** Executes one platform replay. Injectable so the comparison is unit-testable. */
|
|
86
|
+
export type PlatformRunner = (input: PlatformRunInput) => PlatformRunOutput;
|
|
87
|
+
/** The real runner set, keyed by platform. */
|
|
88
|
+
export type PlatformRunners = Record<PlatformId, PlatformRunner>;
|
|
89
|
+
/** The real Claude runner: the canonical script through the Claude gate. */
|
|
90
|
+
export declare const defaultClaudeRunner: PlatformRunner;
|
|
91
|
+
/** The real Codex runner: the duplicated mirror through the Codex gate. */
|
|
92
|
+
export declare const defaultCodexRunner: PlatformRunner;
|
|
93
|
+
/**
|
|
94
|
+
* The real Grok runner. Grok shares the canonical `.claude/hooks/` scripts with
|
|
95
|
+
* Claude — it has no mirror of its own — and its adapter's whole job is to read
|
|
96
|
+
* the camelCase payload, map the tool alias back to canonical, build the Claude
|
|
97
|
+
* JSON, and pipe it to the same `hook-gate.sh`. We reproduce that routing here
|
|
98
|
+
* (via {@link toGrokPayload} + {@link grokToSnakePayload}) rather than spawn the
|
|
99
|
+
* adapter directly, for two reasons: the live adapter runs a whole tool
|
|
100
|
+
* *sequence* where the replay needs one isolated hook, and its user-global
|
|
101
|
+
* bridge cannot be simulated from a project-local run (US-005 keeps bridge
|
|
102
|
+
* presence a wiring check). The Grok block protocol still applies — the gate's
|
|
103
|
+
* non-zero exit is what the adapter would translate into a stdout `deny`
|
|
104
|
+
* ({@link interpretGrokBlock} in verdictFor recognises both).
|
|
105
|
+
*/
|
|
106
|
+
export declare const defaultGrokRunner: PlatformRunner;
|
|
107
|
+
/** The default real runner set wired to each platform's real adapter path. */
|
|
108
|
+
export declare const defaultPlatformRunners: PlatformRunners;
|
|
109
|
+
/** Options for {@link runParityReplay}. All injectable for testing. */
|
|
110
|
+
export interface ParityOptions {
|
|
111
|
+
/** Per-case, per-platform time bound. Default: {@link DEFAULT_PARITY_TIMEOUT_MS}. */
|
|
112
|
+
perCaseTimeoutMs?: number;
|
|
113
|
+
/** Tree entries copied into the sandbox. Default: {@link SANDBOX_COPY_RELPATHS}. */
|
|
114
|
+
sandboxRelpaths?: readonly string[];
|
|
115
|
+
/** Platform runners. Defaults to the real {@link defaultPlatformRunners}. */
|
|
116
|
+
runners?: Partial<PlatformRunners>;
|
|
117
|
+
/** Leave the sandbox on disk (debugging). Default: false. */
|
|
118
|
+
keepSandbox?: boolean;
|
|
119
|
+
}
|
|
120
|
+
/** Remove every parity sandbox still on disk. Safe to call repeatedly. */
|
|
121
|
+
export declare function cleanupAllParitySandboxes(): void;
|
|
122
|
+
/**
|
|
123
|
+
* Replay every pure-guard fixture case through all three platform adapters and
|
|
124
|
+
* report the per-case parity verdict. Read-only against the live tree; all
|
|
125
|
+
* execution happens in a sandbox that is removed before returning. Returns an
|
|
126
|
+
* empty list — creating no sandbox — when there is nothing to replay.
|
|
127
|
+
*/
|
|
128
|
+
export declare function runParityReplay(context: CheckContext, options?: ParityOptions): Promise<CheckResult[]>;
|
|
129
|
+
//# sourceMappingURL=parity.d.ts.map
|