@indigoai-us/hq-cli 5.98.3 → 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 +41 -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/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 +1 -1
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed execution for side-effecting hooks (US-009).
|
|
3
|
+
*
|
|
4
|
+
* US-008 proves a PURE GUARD works by firing it through the gate and reading the
|
|
5
|
+
* verdict. That is unsound for a hook that WRITES FILES or MAKES COMMITS: you
|
|
6
|
+
* cannot fire `hq-autocommit` at the user's real tree to see if it commits. So
|
|
7
|
+
* every side-effecting hook (classified in classify.ts: autocommit, checkpoint,
|
|
8
|
+
* journal, reindex, …) runs here instead, inside a THROWAWAY HQ tree, and its
|
|
9
|
+
* fixture cases assert OBSERVABLE EFFECTS (effects.ts) rather than exit codes.
|
|
10
|
+
*
|
|
11
|
+
* Four rules make this trustworthy rather than dangerous:
|
|
12
|
+
*
|
|
13
|
+
* 1. Never the live tree. Each case runs in a fresh temp tree seeded with the
|
|
14
|
+
* hook scripts, a real git repository, session metadata, and a minimum
|
|
15
|
+
* company scaffold. A commit or a file the hook makes lands in the sandbox
|
|
16
|
+
* and nowhere the user can see. A fresh sandbox PER CASE keeps one case's
|
|
17
|
+
* writes from being mistaken for the next case's effect.
|
|
18
|
+
*
|
|
19
|
+
* 2. Always torn down. Every sandbox is tracked and removed in a `finally`, and
|
|
20
|
+
* a process-exit handler sweeps any that a crash or interrupt skipped — so
|
|
21
|
+
* no temp tree survives, on success, failure, or SIGINT alike.
|
|
22
|
+
*
|
|
23
|
+
* 3. A thin sandbox reports UNKNOWN, never FAIL. If a case needs a precondition
|
|
24
|
+
* the sandbox did not seed (session metadata, a company, …), it is reported
|
|
25
|
+
* UNKNOWN naming that precondition — because a sandbox that manufactures a
|
|
26
|
+
* false FAIL for its own gap erodes trust in the doctor faster than no tool.
|
|
27
|
+
* A `commit-created` effect implicitly needs a git repo, so a sandbox with
|
|
28
|
+
* no git yields UNKNOWN for it, not a false failure.
|
|
29
|
+
*
|
|
30
|
+
* 4. One bad setup does not abort the run. If seeding one hook's sandbox
|
|
31
|
+
* throws, that hook is reported (UNKNOWN) and the remaining hooks still run.
|
|
32
|
+
*
|
|
33
|
+
* The effect data contract and before/after comparison live in effects.ts; this
|
|
34
|
+
* module owns the filesystem: seeding, discovery of side-effecting fixtures,
|
|
35
|
+
* running the hook, and turning the evaluation into {@link CheckResult}s.
|
|
36
|
+
*/
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import * as fs from "node:fs";
|
|
39
|
+
import * as os from "node:os";
|
|
40
|
+
import * as path from "node:path";
|
|
41
|
+
import * as yaml from "js-yaml";
|
|
42
|
+
import { classifyCaseStatus, looksLikeFixture, } from "../fixtures/schema.js";
|
|
43
|
+
import { HOOK_TESTS_RELDIR, registeredHookIds } from "../fixtures/discover.js";
|
|
44
|
+
import { isSideEffectingHookId } from "./classify.js";
|
|
45
|
+
import { captureEffectBaseline, describeEffect, defaultGitProbe, evaluateEffects, parseEffectExpectations, } from "./effects.js";
|
|
46
|
+
/** Default per-case time bound. No latency target, but no case may hang. */
|
|
47
|
+
export const DEFAULT_PER_CASE_TIMEOUT_MS = 15_000;
|
|
48
|
+
/** Every precondition the default seeder knows how to provide. */
|
|
49
|
+
export const KNOWN_PRECONDITIONS = [
|
|
50
|
+
"git-repo",
|
|
51
|
+
"session-metadata",
|
|
52
|
+
"company-scaffold",
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Tree entries copied from the live tree into every sandbox — the same curated
|
|
56
|
+
* set US-008 uses: the hook scripts and gate, the settings a hook may read, the
|
|
57
|
+
* Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
|
|
58
|
+
* skipped so a partial tree still sandboxes cleanly.
|
|
59
|
+
*/
|
|
60
|
+
export const SANDBOX_SEED_RELPATHS = [
|
|
61
|
+
".claude/hooks",
|
|
62
|
+
".claude/settings.json",
|
|
63
|
+
".claude/settings.local.json",
|
|
64
|
+
".codex/hooks",
|
|
65
|
+
".codex/hooks.json",
|
|
66
|
+
".grok",
|
|
67
|
+
"core",
|
|
68
|
+
];
|
|
69
|
+
/** A slug for the throwaway company the `company-scaffold` precondition seeds. */
|
|
70
|
+
const SANDBOX_COMPANY_SLUG = "sandbox-co";
|
|
71
|
+
/** `*.yaml` files under core/hook-tests that are configuration, not fixtures. */
|
|
72
|
+
const NON_FIXTURE_FILES = new Set(["allowed-divergence.yaml"]);
|
|
73
|
+
// --- sandbox lifecycle --------------------------------------------------------
|
|
74
|
+
const trackedSandboxes = new Set();
|
|
75
|
+
let sandboxExitHandlerRegistered = false;
|
|
76
|
+
/** Remove every side-effect sandbox still on disk. Safe to call repeatedly. */
|
|
77
|
+
export function cleanupAllSandboxes() {
|
|
78
|
+
for (const root of trackedSandboxes)
|
|
79
|
+
removeTree(root);
|
|
80
|
+
trackedSandboxes.clear();
|
|
81
|
+
}
|
|
82
|
+
/** The sandbox roots this process is still tracking (for tests). */
|
|
83
|
+
export function trackedSandboxRoots() {
|
|
84
|
+
return [...trackedSandboxes];
|
|
85
|
+
}
|
|
86
|
+
function ensureSandboxExitHandler() {
|
|
87
|
+
if (sandboxExitHandlerRegistered)
|
|
88
|
+
return;
|
|
89
|
+
sandboxExitHandlerRegistered = true;
|
|
90
|
+
// Fires on normal exit and after a crash/interrupt alike — the guarantee that
|
|
91
|
+
// no sandbox survives even when a case throws or the process is torn down.
|
|
92
|
+
process.on("exit", cleanupAllSandboxes);
|
|
93
|
+
}
|
|
94
|
+
function removeTree(root) {
|
|
95
|
+
try {
|
|
96
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best-effort: cleanup must never mask a test's real failure.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Remove one sandbox and stop tracking it. Idempotent. */
|
|
103
|
+
export function destroySandbox(root) {
|
|
104
|
+
removeTree(root);
|
|
105
|
+
trackedSandboxes.delete(root);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Seed a fresh throwaway HQ tree: copy the curated hook scripts, then provide the
|
|
109
|
+
* requested preconditions. Returns the root and the set of preconditions that
|
|
110
|
+
* were actually seeded (a precondition that could not be provided — e.g. git is
|
|
111
|
+
* unavailable — is simply absent from the set, which surfaces as UNKNOWN for any
|
|
112
|
+
* case needing it, never a false failure).
|
|
113
|
+
*
|
|
114
|
+
* On any failure AFTER the temp dir is created, the partial tree is removed and
|
|
115
|
+
* the error rethrown so a setup failure never leaks a directory.
|
|
116
|
+
*/
|
|
117
|
+
export function seedSandbox(hqRoot, opts = {}) {
|
|
118
|
+
ensureSandboxExitHandler();
|
|
119
|
+
const relpaths = opts.seedRelpaths ?? SANDBOX_SEED_RELPATHS;
|
|
120
|
+
const intended = new Set(opts.preconditions ?? KNOWN_PRECONDITIONS);
|
|
121
|
+
const prefix = opts.prefix ?? "hq-doctor-sandbox-";
|
|
122
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
123
|
+
trackedSandboxes.add(root);
|
|
124
|
+
try {
|
|
125
|
+
copyTreeEntries(hqRoot, root, relpaths);
|
|
126
|
+
const seeded = new Set();
|
|
127
|
+
const sessionId = `hq-doctor-sandbox-${path.basename(root)}`;
|
|
128
|
+
if (intended.has("session-metadata")) {
|
|
129
|
+
seedSessionMetadata(root, sessionId);
|
|
130
|
+
seeded.add("session-metadata");
|
|
131
|
+
}
|
|
132
|
+
if (intended.has("company-scaffold")) {
|
|
133
|
+
seedCompanyScaffold(root);
|
|
134
|
+
seeded.add("company-scaffold");
|
|
135
|
+
}
|
|
136
|
+
// git LAST, so the initial commit captures everything else that was seeded.
|
|
137
|
+
if (intended.has("git-repo") && seedGitRepo(root)) {
|
|
138
|
+
seeded.add("git-repo");
|
|
139
|
+
}
|
|
140
|
+
return { root, seeded, sessionId };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
destroySandbox(root);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function copyTreeEntries(hqRoot, root, relpaths) {
|
|
148
|
+
for (const rel of relpaths) {
|
|
149
|
+
const segments = rel.split("/");
|
|
150
|
+
const src = path.join(hqRoot, ...segments);
|
|
151
|
+
if (!fs.existsSync(src))
|
|
152
|
+
continue;
|
|
153
|
+
const dest = path.join(root, ...segments);
|
|
154
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
155
|
+
// cpSync preserves mode bits, so hooks and the gate stay executable.
|
|
156
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function seedSessionMetadata(root, sessionId) {
|
|
160
|
+
// The on-disk shape a session-aware hook expects: a state dir plus a workspace
|
|
161
|
+
// scaffold. The session id itself is carried in the payload and the run env.
|
|
162
|
+
fs.mkdirSync(path.join(root, ".claude", "state"), { recursive: true });
|
|
163
|
+
fs.mkdirSync(path.join(root, "workspace", "threads"), { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(root, ".claude", "state", `session-${sessionId}`), `${sessionId}\n`);
|
|
165
|
+
}
|
|
166
|
+
function seedCompanyScaffold(root) {
|
|
167
|
+
const companiesDir = path.join(root, "companies");
|
|
168
|
+
fs.mkdirSync(path.join(companiesDir, SANDBOX_COMPANY_SLUG), { recursive: true });
|
|
169
|
+
fs.writeFileSync(path.join(companiesDir, "manifest.yaml"), `companies:\n - slug: ${SANDBOX_COMPANY_SLUG}\n name: Sandbox Co\n`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Initialise a git repo with a single baseline commit, so a hook that commits
|
|
173
|
+
* advances the count from a known 1. Returns false (git-repo not seeded) when git
|
|
174
|
+
* is unavailable or a step fails, rather than throwing — an absent repo surfaces
|
|
175
|
+
* as UNKNOWN for a commit case, not a false failure.
|
|
176
|
+
*/
|
|
177
|
+
function seedGitRepo(root) {
|
|
178
|
+
if (git(root, ["init", "-q", "-b", "main"]) !== 0)
|
|
179
|
+
return false;
|
|
180
|
+
// Identity straight into .git/config, matching the reference harness — no
|
|
181
|
+
// dependency on the runner's global git config.
|
|
182
|
+
fs.appendFileSync(path.join(root, ".git", "config"), "[user]\n\temail = hq-doctor-sandbox@example.com\n\tname = hq-doctor-sandbox\n" +
|
|
183
|
+
"[commit]\n\tgpgsign = false\n");
|
|
184
|
+
// Guarantee there is always something to commit, even with an empty seed set.
|
|
185
|
+
fs.writeFileSync(path.join(root, ".hq-sandbox-baseline"), "seeded\n");
|
|
186
|
+
if (git(root, ["add", "-A"]) !== 0)
|
|
187
|
+
return false;
|
|
188
|
+
return git(root, ["commit", "-q", "-m", "sandbox baseline"]) === 0;
|
|
189
|
+
}
|
|
190
|
+
function git(root, args) {
|
|
191
|
+
const res = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
192
|
+
return res.status;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The real runner: fire the hook script directly with the payload on stdin, its
|
|
196
|
+
* cwd and session env pointed at the sandbox, and a hard timeout. Unlike a pure
|
|
197
|
+
* guard (US-008), a side-effecting hook is run directly rather than through the
|
|
198
|
+
* gate — its exit code is irrelevant here; what matters is the effect it leaves
|
|
199
|
+
* on the sandbox, which effects.ts reads afterwards.
|
|
200
|
+
*/
|
|
201
|
+
export const defaultHookRunner = (input) => {
|
|
202
|
+
const result = spawnSync("bash", [input.hookScriptPath], {
|
|
203
|
+
input: input.payload,
|
|
204
|
+
cwd: input.cwd,
|
|
205
|
+
timeout: input.timeoutMs,
|
|
206
|
+
killSignal: "SIGKILL",
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
209
|
+
env: {
|
|
210
|
+
...process.env,
|
|
211
|
+
CLAUDE_PROJECT_DIR: input.cwd,
|
|
212
|
+
HQ_ROOT: input.cwd,
|
|
213
|
+
CLAUDE_SESSION_ID: input.sessionId,
|
|
214
|
+
HQ_SESSION_ID: input.sessionId,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const errno = result.error?.code;
|
|
218
|
+
return {
|
|
219
|
+
timedOut: errno === "ETIMEDOUT",
|
|
220
|
+
exitCode: result.status,
|
|
221
|
+
stderr: result.stderr ?? "",
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Discover and parse every side-effecting fixture under `core/hook-tests/`. Only
|
|
226
|
+
* fixtures whose hook id classifies as side-effecting are returned — pure guards
|
|
227
|
+
* are US-008's and are ignored here. Malformed side-effecting fixtures are
|
|
228
|
+
* collected separately so the tier can surface them without aborting.
|
|
229
|
+
*/
|
|
230
|
+
export function discoverSideEffectFixtures(hqRoot) {
|
|
231
|
+
const dir = path.join(hqRoot, HOOK_TESTS_RELDIR);
|
|
232
|
+
const fixtures = [];
|
|
233
|
+
const invalid = [];
|
|
234
|
+
let names;
|
|
235
|
+
try {
|
|
236
|
+
names = fs.readdirSync(dir);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { fixtures, invalid };
|
|
240
|
+
}
|
|
241
|
+
for (const name of names.sort()) {
|
|
242
|
+
if (!/\.ya?ml$/i.test(name))
|
|
243
|
+
continue;
|
|
244
|
+
if (NON_FIXTURE_FILES.has(name))
|
|
245
|
+
continue;
|
|
246
|
+
const relpath = path.join(HOOK_TESTS_RELDIR, name);
|
|
247
|
+
const defaultHookId = name.replace(/\.ya?ml$/i, "");
|
|
248
|
+
let doc;
|
|
249
|
+
try {
|
|
250
|
+
doc = yaml.load(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
// A YAML syntax error is only ours to report if the file is meant to be a
|
|
254
|
+
// side-effecting fixture — which we cannot tell without parsing it. The
|
|
255
|
+
// stem is the best hint; if it names a side-effecting hook, flag it.
|
|
256
|
+
if (isSideEffectingHookId(defaultHookId)) {
|
|
257
|
+
invalid.push({
|
|
258
|
+
hookId: defaultHookId,
|
|
259
|
+
relpath,
|
|
260
|
+
message: `not valid YAML: ${error.message}`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!looksLikeFixture(doc))
|
|
266
|
+
continue;
|
|
267
|
+
const hookId = resolveHookId(doc, defaultHookId);
|
|
268
|
+
if (!isSideEffectingHookId(hookId))
|
|
269
|
+
continue;
|
|
270
|
+
const parsed = parseSideEffectCases(doc);
|
|
271
|
+
if (typeof parsed === "string") {
|
|
272
|
+
invalid.push({ hookId, relpath, message: parsed });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
fixtures.push({ hookId, relpath, cases: parsed });
|
|
276
|
+
}
|
|
277
|
+
return { fixtures, invalid };
|
|
278
|
+
}
|
|
279
|
+
function resolveHookId(doc, defaultHookId) {
|
|
280
|
+
if (isRecord(doc)) {
|
|
281
|
+
const declared = doc.hookId;
|
|
282
|
+
if (typeof declared === "string" && declared.trim() !== "")
|
|
283
|
+
return declared;
|
|
284
|
+
}
|
|
285
|
+
return defaultHookId;
|
|
286
|
+
}
|
|
287
|
+
/** Parse a fixture doc's `cases` into side-effect cases, or an error string. */
|
|
288
|
+
function parseSideEffectCases(doc) {
|
|
289
|
+
if (!isRecord(doc))
|
|
290
|
+
return "fixture is not a mapping";
|
|
291
|
+
if (!("cases" in doc) || !Array.isArray(doc.cases)) {
|
|
292
|
+
return "fixture `cases` must be a list";
|
|
293
|
+
}
|
|
294
|
+
const cases = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (let i = 0; i < doc.cases.length; i++) {
|
|
297
|
+
const parsed = parseSideEffectCase(doc.cases[i], i);
|
|
298
|
+
if (typeof parsed === "string")
|
|
299
|
+
return parsed;
|
|
300
|
+
if (seen.has(parsed.name))
|
|
301
|
+
return `duplicate case name "${parsed.name}"`;
|
|
302
|
+
seen.add(parsed.name);
|
|
303
|
+
cases.push(parsed);
|
|
304
|
+
}
|
|
305
|
+
return cases;
|
|
306
|
+
}
|
|
307
|
+
function parseSideEffectCase(raw, index) {
|
|
308
|
+
const where = `case #${index + 1}`;
|
|
309
|
+
if (!isRecord(raw))
|
|
310
|
+
return `${where} is not a mapping`;
|
|
311
|
+
const name = raw.name;
|
|
312
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
313
|
+
return `${where} is missing a non-empty \`name\``;
|
|
314
|
+
}
|
|
315
|
+
const label = `case "${name}"`;
|
|
316
|
+
const event = raw.event;
|
|
317
|
+
if (typeof event !== "string" || event.trim() === "") {
|
|
318
|
+
return `${label} is missing a non-empty \`event\``;
|
|
319
|
+
}
|
|
320
|
+
const tool = raw.tool;
|
|
321
|
+
if (typeof tool !== "string" || tool.trim() === "") {
|
|
322
|
+
return `${label} is missing a non-empty \`tool\``;
|
|
323
|
+
}
|
|
324
|
+
if (!("input" in raw))
|
|
325
|
+
return `${label} is missing a tool \`input\` payload`;
|
|
326
|
+
if (!("effects" in raw)) {
|
|
327
|
+
return `${label} is missing an \`effects\` list (a side-effecting case asserts effects, not exit codes)`;
|
|
328
|
+
}
|
|
329
|
+
const effects = parseEffectExpectations(raw.effects);
|
|
330
|
+
if (typeof effects === "string")
|
|
331
|
+
return `${label}: ${effects}`;
|
|
332
|
+
const requires = parseRequires(raw.requires);
|
|
333
|
+
if (typeof requires === "string")
|
|
334
|
+
return `${label}: ${requires}`;
|
|
335
|
+
const expectedFailure = parseExpectedFailure(raw.expectedFailure);
|
|
336
|
+
if (typeof expectedFailure === "string")
|
|
337
|
+
return `${label}: ${expectedFailure}`;
|
|
338
|
+
const parsedCase = {
|
|
339
|
+
name,
|
|
340
|
+
event,
|
|
341
|
+
tool,
|
|
342
|
+
input: raw.input,
|
|
343
|
+
effects,
|
|
344
|
+
requires,
|
|
345
|
+
};
|
|
346
|
+
if (expectedFailure)
|
|
347
|
+
parsedCase.expectedFailure = expectedFailure;
|
|
348
|
+
return parsedCase;
|
|
349
|
+
}
|
|
350
|
+
function parseRequires(raw) {
|
|
351
|
+
if (raw === undefined || raw === null)
|
|
352
|
+
return [];
|
|
353
|
+
if (!Array.isArray(raw))
|
|
354
|
+
return "`requires` must be a list of precondition names";
|
|
355
|
+
const out = [];
|
|
356
|
+
for (const item of raw) {
|
|
357
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
358
|
+
return "`requires` entries must be non-empty strings";
|
|
359
|
+
}
|
|
360
|
+
out.push(item);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
function parseExpectedFailure(raw) {
|
|
365
|
+
if (raw === undefined || raw === null)
|
|
366
|
+
return undefined;
|
|
367
|
+
if (!isRecord(raw))
|
|
368
|
+
return "`expectedFailure` must be a mapping with a `reason`";
|
|
369
|
+
const reason = raw.reason;
|
|
370
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
371
|
+
return "`expectedFailure` requires a non-empty `reason`";
|
|
372
|
+
}
|
|
373
|
+
return { reason };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Execute every registered side-effecting hook's fixture cases in per-case
|
|
377
|
+
* sandboxes and return the effect verdicts. Read-only against the live tree; all
|
|
378
|
+
* writes and commits happen in throwaway trees that are always torn down.
|
|
379
|
+
* Returns an empty list when there is nothing side-effecting to run.
|
|
380
|
+
*/
|
|
381
|
+
export async function runSideEffectTests(context, options = {}) {
|
|
382
|
+
const hqRoot = context.hqRoot;
|
|
383
|
+
const timeoutMs = options.perCaseTimeoutMs ?? DEFAULT_PER_CASE_TIMEOUT_MS;
|
|
384
|
+
const runner = options.runner ?? defaultHookRunner;
|
|
385
|
+
const git = options.gitProbe ?? defaultGitProbe;
|
|
386
|
+
const seeder = options.seeder ??
|
|
387
|
+
((root) => seedSandbox(root, {
|
|
388
|
+
seedRelpaths: options.seedRelpaths,
|
|
389
|
+
preconditions: options.preconditions,
|
|
390
|
+
}));
|
|
391
|
+
const registered = new Set(registeredHookIds(hqRoot));
|
|
392
|
+
const discovery = discoverSideEffectFixtures(hqRoot);
|
|
393
|
+
const results = [];
|
|
394
|
+
// A malformed side-effecting fixture is surfaced (WARN) but never executed.
|
|
395
|
+
for (const bad of discovery.invalid) {
|
|
396
|
+
results.push({
|
|
397
|
+
status: "WARN",
|
|
398
|
+
checkId: "hooks.deep.effects.invalid",
|
|
399
|
+
target: bad.relpath,
|
|
400
|
+
message: `Side-effecting fixture ${bad.relpath} is malformed and was skipped: ${bad.message}.`,
|
|
401
|
+
remediation: `Correct ${bad.relpath} (each case needs an \`effects\` list; see core/hook-tests/README.md).`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const env = { hqRoot, timeoutMs, runner, git, seeder, keepSandbox: options.keepSandbox === true };
|
|
405
|
+
for (const fixture of discovery.fixtures) {
|
|
406
|
+
// A fixture whose hook is registered nowhere is not executed here; the
|
|
407
|
+
// coverage tier (US-007) owns orphan reporting.
|
|
408
|
+
if (!registered.has(fixture.hookId))
|
|
409
|
+
continue;
|
|
410
|
+
for (const testCase of fixture.cases) {
|
|
411
|
+
results.push(runCase(fixture, testCase, env));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return results;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Run one side-effecting case in its own sandbox and classify the observed
|
|
418
|
+
* effects. Guarantees the sandbox is torn down whatever happens.
|
|
419
|
+
*/
|
|
420
|
+
function runCase(fixture, testCase, env) {
|
|
421
|
+
const target = `${fixture.hookId}:${testCase.name}`;
|
|
422
|
+
// A `commit-created` effect cannot be observed without a git repo, so it
|
|
423
|
+
// implicitly requires one — a sandbox with no git yields UNKNOWN, not FAIL.
|
|
424
|
+
const required = new Set(testCase.requires);
|
|
425
|
+
if (testCase.effects.some((e) => e.kind === "commit-created")) {
|
|
426
|
+
required.add("git-repo");
|
|
427
|
+
}
|
|
428
|
+
let seed;
|
|
429
|
+
try {
|
|
430
|
+
seed = env.seeder(env.hqRoot, fixture.hookId);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
// Rule 4: one hook's setup failure is reported, but the run continues.
|
|
434
|
+
return {
|
|
435
|
+
status: "UNKNOWN",
|
|
436
|
+
checkId: "hooks.deep.effects.sandbox-setup-failed",
|
|
437
|
+
target,
|
|
438
|
+
message: `Sandbox setup for "${fixture.hookId}" failed, so case "${testCase.name}" could not be run: ${oneLine(error.message)}. Remaining hooks are unaffected.`,
|
|
439
|
+
remediation: `Investigate the sandbox seeding failure for "${fixture.hookId}".`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
// Rule 3: a precondition the sandbox did not seed is UNKNOWN, not FAIL.
|
|
444
|
+
const missing = [...required].filter((r) => !seed.seeded.has(r));
|
|
445
|
+
if (missing.length > 0) {
|
|
446
|
+
return {
|
|
447
|
+
status: "UNKNOWN",
|
|
448
|
+
checkId: "hooks.deep.effects.precondition-missing",
|
|
449
|
+
target,
|
|
450
|
+
message: `Cannot verify "${fixture.hookId}" case "${testCase.name}": the sandbox did not seed the required precondition${missing.length === 1 ? "" : "s"} ${formatList(missing)}. Reported UNKNOWN rather than FAIL so a thin sandbox does not manufacture a false failure.`,
|
|
451
|
+
remediation: `Seed ${formatList(missing)} in the sandbox, or drop it from the fixture case's \`requires\`.`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const scriptPath = path.join(seed.root, ".claude", "hooks", `${fixture.hookId}.sh`);
|
|
455
|
+
if (!fs.existsSync(scriptPath)) {
|
|
456
|
+
return {
|
|
457
|
+
status: "UNKNOWN",
|
|
458
|
+
checkId: "hooks.deep.effects.script-missing",
|
|
459
|
+
target,
|
|
460
|
+
message: `Cannot verify "${fixture.hookId}": its script .claude/hooks/${fixture.hookId}.sh is absent from the sandbox, so no effect can be observed.`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const baseline = captureEffectBaseline(seed.root, testCase.effects, env.git);
|
|
464
|
+
const run = env.runner({
|
|
465
|
+
hookScriptPath: scriptPath,
|
|
466
|
+
payload: buildPayload(testCase, seed.root, seed.sessionId),
|
|
467
|
+
cwd: seed.root,
|
|
468
|
+
sessionId: seed.sessionId,
|
|
469
|
+
timeoutMs: env.timeoutMs,
|
|
470
|
+
});
|
|
471
|
+
if (run.timedOut) {
|
|
472
|
+
return {
|
|
473
|
+
status: "FAIL",
|
|
474
|
+
checkId: "hooks.deep.effects.timeout",
|
|
475
|
+
target,
|
|
476
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" exceeded the ${env.timeoutMs}ms per-case time bound; a hook that does not return within the bound is treated as a failure, not left to hang the command.`,
|
|
477
|
+
remediation: `Investigate why "${fixture.hookId}" does not return within ${env.timeoutMs}ms.`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const evaluations = evaluateEffects(seed.root, testCase.effects, baseline, env.git);
|
|
481
|
+
return classifyEffects(fixture, testCase, evaluations, run);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
if (!env.keepSandbox)
|
|
485
|
+
destroySandbox(seed.root);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Turn the per-effect evaluations into a single case result, applying the
|
|
490
|
+
* `expectedFailure` rule (shared with US-008 via {@link classifyCaseStatus}).
|
|
491
|
+
*/
|
|
492
|
+
function classifyEffects(fixture, testCase, evaluations, run) {
|
|
493
|
+
const target = `${fixture.hookId}:${testCase.name}`;
|
|
494
|
+
const allMet = evaluations.every((e) => e.met);
|
|
495
|
+
const status = classifyCaseStatus(allMet, testCase.expectedFailure);
|
|
496
|
+
const effectList = formatList(testCase.effects.map(describeEffect));
|
|
497
|
+
if (status === "PASS") {
|
|
498
|
+
return {
|
|
499
|
+
status,
|
|
500
|
+
checkId: "hooks.deep.effects.case",
|
|
501
|
+
target,
|
|
502
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" produced the expected observable effect${testCase.effects.length === 1 ? "" : "s"} in the sandbox: ${effectList}.`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (status === "WARN") {
|
|
506
|
+
// Pinned as a defect, yet every effect now happens — the marker is stale.
|
|
507
|
+
return {
|
|
508
|
+
status,
|
|
509
|
+
checkId: "hooks.deep.effects.stale-expected-failure",
|
|
510
|
+
target,
|
|
511
|
+
message: `Case "${testCase.name}" for "${fixture.hookId}" is marked expectedFailure (${testCase.expectedFailure?.reason ?? ""}) but now produces every expected effect (${effectList}); remove the stale marker.`,
|
|
512
|
+
remediation: `Delete the expectedFailure marker from case "${testCase.name}" in core/hook-tests/${fixture.hookId}.yaml.`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
// FAIL or KNOWN-DEFECT: name each effect that did not happen, plus the hook's
|
|
516
|
+
// exit code and stderr for diagnosis.
|
|
517
|
+
const unmet = evaluations
|
|
518
|
+
.filter((e) => !e.met)
|
|
519
|
+
.map((e) => `${describeEffect(e.expectation)} — ${e.detail}`)
|
|
520
|
+
.join("; ");
|
|
521
|
+
const stderr = run.stderr.trim();
|
|
522
|
+
const base = `Case "${testCase.name}" for "${fixture.hookId}" did not produce the expected effect${evaluations.filter((e) => !e.met).length === 1 ? "" : "s"}: ${unmet} (hook exit ${formatExit(run.exitCode)}${stderr ? `, stderr: ${oneLine(stderr)}` : ", no stderr"}).`;
|
|
523
|
+
if (status === "KNOWN-DEFECT") {
|
|
524
|
+
return {
|
|
525
|
+
status,
|
|
526
|
+
checkId: "hooks.deep.effects.case",
|
|
527
|
+
target,
|
|
528
|
+
message: `${base} Tracked as a known defect: ${testCase.expectedFailure?.reason ?? ""}.`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
status: "FAIL",
|
|
533
|
+
checkId: "hooks.deep.effects.case",
|
|
534
|
+
target,
|
|
535
|
+
message: base,
|
|
536
|
+
remediation: `Verify "${fixture.hookId}" performs ${effectList} for case "${testCase.name}", or correct the fixture.`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The Claude/Codex payload the hook reads on stdin: the fixture's event, tool,
|
|
541
|
+
* and opaque input, plus the sandbox cwd and the seeded session id.
|
|
542
|
+
*/
|
|
543
|
+
function buildPayload(testCase, cwd, sessionId) {
|
|
544
|
+
return JSON.stringify({
|
|
545
|
+
hook_event_name: testCase.event,
|
|
546
|
+
tool_name: testCase.tool,
|
|
547
|
+
tool_input: testCase.input,
|
|
548
|
+
cwd,
|
|
549
|
+
session_id: sessionId,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// --- message helpers ----------------------------------------------------------
|
|
553
|
+
/** Render a list as "a", "a and b", or "a, b and c". */
|
|
554
|
+
function formatList(items) {
|
|
555
|
+
if (items.length <= 1)
|
|
556
|
+
return items.join("");
|
|
557
|
+
if (items.length === 2)
|
|
558
|
+
return `${items[0]} and ${items[1]}`;
|
|
559
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
560
|
+
}
|
|
561
|
+
function formatExit(exitCode) {
|
|
562
|
+
return exitCode === null ? "none" : String(exitCode);
|
|
563
|
+
}
|
|
564
|
+
/** Collapse a string to a single trimmed, length-capped line for a message. */
|
|
565
|
+
function oneLine(text) {
|
|
566
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
567
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 197)}…` : collapsed;
|
|
568
|
+
}
|
|
569
|
+
function isRecord(value) {
|
|
570
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
571
|
+
}
|
|
572
|
+
//# sourceMappingURL=sandbox.js.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq doctor --fix` — apply the allowlisted safe repairs (US-011).
|
|
3
|
+
*
|
|
4
|
+
* Read-by-default is the doctor's whole safety posture, so the write path is
|
|
5
|
+
* wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
|
|
6
|
+
*
|
|
7
|
+
* 1. Dirty-tree refusal. If the working tree has uncommitted changes under
|
|
8
|
+
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
|
|
9
|
+
* unless `--force`, so a repair can never be tangled up with unrelated
|
|
10
|
+
* in-flight edits to the security layer.
|
|
11
|
+
* 2. Allowlist only. It repairs exactly three classes — restore an execute
|
|
12
|
+
* bit, add a hook id to the gate profiles it is missing from, re-register a
|
|
13
|
+
* script present on disk — and NEVER rewrites a hook body or deletes a file.
|
|
14
|
+
* Classification is owned by {@link deriveRemediation}; a content-drift
|
|
15
|
+
* finding is manual-only and simply never appears in the fixable set.
|
|
16
|
+
* 3. Preview + confirmation. Every change is shown diff-style and requires
|
|
17
|
+
* confirmation, with `--yes` for non-interactive use.
|
|
18
|
+
* 4. Backup first. Before any write, the affected files are copied under
|
|
19
|
+
* `workspace/doctor/backups/<timestamp>/` and the restore command is
|
|
20
|
+
* printed on completion.
|
|
21
|
+
* 5. Re-verify. After applying, the affected checks are re-run and their
|
|
22
|
+
* post-fix status reported, so the user sees the real result rather than an
|
|
23
|
+
* assumed success.
|
|
24
|
+
*
|
|
25
|
+
* The applier resolves nothing about the host: the caller passes the HQ root
|
|
26
|
+
* (the command resolves it once), keeping this module decoupled from the CLI and
|
|
27
|
+
* trivially testable against a fake tree.
|
|
28
|
+
*/
|
|
29
|
+
import type { DoctorStatus } from "../types.js";
|
|
30
|
+
import { type GateProfile } from "../hook-gate-profiles.js";
|
|
31
|
+
import { type FixClass } from "./remediation.js";
|
|
32
|
+
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
33
|
+
export declare const HOOK_CONFIG_DIRS: readonly [".claude", ".codex", ".grok"];
|
|
34
|
+
/** Options for {@link applyFixes}. Everything is injectable for hermetic tests. */
|
|
35
|
+
export interface ApplyFixesOptions {
|
|
36
|
+
/** The resolved HQ root the repairs run against. Required. */
|
|
37
|
+
hqRoot: string;
|
|
38
|
+
/** Skip the interactive confirmation (the `--yes` flag). Default: false. */
|
|
39
|
+
yes?: boolean;
|
|
40
|
+
/** Override the dirty-tree refusal (the `--force` flag). Default: false. */
|
|
41
|
+
force?: boolean;
|
|
42
|
+
/** stdout sink. Default: `process.stdout.write`. */
|
|
43
|
+
stdout?: (chunk: string) => void;
|
|
44
|
+
/** stderr sink. Default: `process.stderr.write`. */
|
|
45
|
+
stderr?: (chunk: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The interactive confirmation. Called only when `yes` is false. Default reads
|
|
48
|
+
* a y/N answer from the TTY; a non-interactive default declines. Injected in
|
|
49
|
+
* tests to accept or reject without a terminal.
|
|
50
|
+
*/
|
|
51
|
+
confirm?: () => boolean | Promise<boolean>;
|
|
52
|
+
/** Clock for the backup directory name. Default: `new Date()`. */
|
|
53
|
+
now?: () => Date;
|
|
54
|
+
/**
|
|
55
|
+
* The dirty-tree probe: the changed paths under {@link HOOK_CONFIG_DIRS}, or
|
|
56
|
+
* [] when clean or not a git repo. Default shells out to `git status`.
|
|
57
|
+
*/
|
|
58
|
+
dirtyCheck?: (hqRoot: string) => string[];
|
|
59
|
+
}
|
|
60
|
+
/** One applied (or attempted) repair, with its post-fix re-check status. */
|
|
61
|
+
export interface AppliedFix {
|
|
62
|
+
/** The check id of the finding that was repaired. */
|
|
63
|
+
checkId: string;
|
|
64
|
+
/** The safe class applied. */
|
|
65
|
+
fixClass: FixClass;
|
|
66
|
+
/** The file path or hook id acted on. */
|
|
67
|
+
target: string;
|
|
68
|
+
/** One-line human summary of the change. */
|
|
69
|
+
summary: string;
|
|
70
|
+
/** The status of the re-run check for this target, or null if not found. */
|
|
71
|
+
postStatus: DoctorStatus | null;
|
|
72
|
+
}
|
|
73
|
+
/** The outcome of an {@link applyFixes} run. */
|
|
74
|
+
export interface ApplyFixesResult {
|
|
75
|
+
/** 0 on success or a clean no-op; 1 when the run was refused. */
|
|
76
|
+
exitCode: number;
|
|
77
|
+
/** Whether any file was written. */
|
|
78
|
+
wrote: boolean;
|
|
79
|
+
/** Why the run wrote nothing, when it did not: refusal reason or null. */
|
|
80
|
+
refused: "dirty-tree" | "declined" | null;
|
|
81
|
+
/** How many findings were auto-fixable. */
|
|
82
|
+
fixableCount: number;
|
|
83
|
+
/** The backup directory, or null when nothing was written. */
|
|
84
|
+
backupDir: string | null;
|
|
85
|
+
/** The restore command, or null when nothing was written. */
|
|
86
|
+
restoreCommand: string | null;
|
|
87
|
+
/** The applied repairs with their post-fix status. */
|
|
88
|
+
applied: AppliedFix[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Apply every auto-fixable finding, honouring the dirty-tree refusal, the
|
|
92
|
+
* preview/confirmation gate, the pre-write backup, and the post-fix re-check.
|
|
93
|
+
* Returns a structured result rather than throwing so both the CLI and tests can
|
|
94
|
+
* assert on it.
|
|
95
|
+
*/
|
|
96
|
+
export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
|
|
97
|
+
/**
|
|
98
|
+
* The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
|
|
99
|
+
* each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
|
|
100
|
+
* (git exits non-zero): with no VCS there is no in-flight change to protect, and
|
|
101
|
+
* the backup still covers the write.
|
|
102
|
+
*/
|
|
103
|
+
export declare function uncommittedHookConfigChanges(hqRoot: string): string[];
|
|
104
|
+
/**
|
|
105
|
+
* Insert a new `case` arm allowlisting `hookId` (leading to `return 0`) at the
|
|
106
|
+
* top of each named profile function's `case "$1" in` block. Additive: it never
|
|
107
|
+
* rewrites an existing arm, so an id already present in another profile is
|
|
108
|
+
* untouched and the parser recognises the new arm exactly as it would a
|
|
109
|
+
* hand-written one. A profile whose function or case block cannot be located is
|
|
110
|
+
* left unchanged rather than corrupted.
|
|
111
|
+
*/
|
|
112
|
+
export declare function addHookIdToGateProfiles(source: string, hookId: string, profiles: readonly GateProfile[]): string;
|
|
113
|
+
/**
|
|
114
|
+
* Append a `type: command` hook registration to `settings.hooks[event]`,
|
|
115
|
+
* creating the `hooks` map and the event array as needed. Tolerant of a
|
|
116
|
+
* non-object `hooks` value — it is replaced rather than trusted.
|
|
117
|
+
*/
|
|
118
|
+
export declare function registerHookInSettings(settings: unknown, event: string, command: string): Record<string, unknown>;
|
|
119
|
+
//# sourceMappingURL=apply.d.ts.map
|