@indigoai-us/hq-cli 5.98.3 → 5.99.1
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 +43 -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-checkpoint.js +11 -3
- package/dist/commands/core.js +60 -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/core-utils/soft-timeout.d.ts +55 -0
- package/dist/lib/core-utils/soft-timeout.js +205 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
- package/dist/lib/core-utils/timeout-guard.js +207 -0
- 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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a hook — via the fixture that guards it — as a PURE GUARD or a
|
|
3
|
+
* SIDE-EFFECTING hook (US-008).
|
|
4
|
+
*
|
|
5
|
+
* `hq doctor --deep-test` proves that a blocking hook actually blocks by firing
|
|
6
|
+
* it with crafted inputs through the real `hook-gate.sh` path and reading the
|
|
7
|
+
* verdict. That is only sound for hooks whose entire observable behaviour is a
|
|
8
|
+
* decision — block, allow, or a stderr pattern — and which produce no filesystem
|
|
9
|
+
* or git effects when they run. Those are PURE GUARDS, and they are the only
|
|
10
|
+
* hooks US-008 executes.
|
|
11
|
+
*
|
|
12
|
+
* A hook that writes files or makes commits (autocommit, checkpoint, journal,
|
|
13
|
+
* reindex, …) cannot be verified by exit code and MUST NOT be fired against
|
|
14
|
+
* anything but a throwaway, effect-asserting sandbox. That is US-009's job, so
|
|
15
|
+
* this module marks such hooks SIDE-EFFECTING and the executor skips them; the
|
|
16
|
+
* classification is exported so US-009 can pick exactly those up.
|
|
17
|
+
*
|
|
18
|
+
* The current fixture schema (US-007) can only express decision outcomes
|
|
19
|
+
* (`block` / `allow` / `stderr`), so the operative signal here is the hook id's
|
|
20
|
+
* family: the four side-effecting families US-009 enumerates are matched by a
|
|
21
|
+
* marker substring, and everything else is a pure guard. When the schema later
|
|
22
|
+
* grows effect-based expectations, {@link classifyFixture} is where a fixture
|
|
23
|
+
* carrying one would additionally be classed side-effecting — the seam is kept
|
|
24
|
+
* here so the executor never has to know the difference.
|
|
25
|
+
*/
|
|
26
|
+
import type { HookFixture } from "../fixtures/schema.js";
|
|
27
|
+
/** A hook's deep-test class: verifiable by verdict, or effect-bearing. */
|
|
28
|
+
export type HookClass = "pure-guard" | "side-effecting";
|
|
29
|
+
/**
|
|
30
|
+
* Substrings that mark a hook id as belonging to a side-effecting family. This
|
|
31
|
+
* is the seed set US-009 names — autocommit, checkpoint, journal, reindex — and
|
|
32
|
+
* is deliberately matched as a case-insensitive substring so every member of a
|
|
33
|
+
* family is caught (`hq-autocommit`, `auto-checkpoint-precompact`,
|
|
34
|
+
* `journal-due`, …) without enumerating each id. US-009 extends this list as it
|
|
35
|
+
* brings more side-effecting hooks under sandboxed execution.
|
|
36
|
+
*/
|
|
37
|
+
export declare const SIDE_EFFECTING_ID_MARKERS: readonly string[];
|
|
38
|
+
/** A classification verdict together with the reason behind it. */
|
|
39
|
+
export interface Classification {
|
|
40
|
+
/** Whether the hook is a pure guard or side-effecting. */
|
|
41
|
+
hookClass: HookClass;
|
|
42
|
+
/** Human-readable reason, for messages and debugging. */
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Classify a bare hook id. Side-effecting when the id contains one of
|
|
47
|
+
* {@link SIDE_EFFECTING_ID_MARKERS} (case-insensitive), pure guard otherwise.
|
|
48
|
+
*/
|
|
49
|
+
export declare function classifyHookId(hookId: string): Classification;
|
|
50
|
+
/**
|
|
51
|
+
* Classify the hook a fixture guards. Currently a function of the hook id alone
|
|
52
|
+
* (see the module comment); the fixture is taken by value so a future
|
|
53
|
+
* effect-based expectation can be folded into the verdict here without changing
|
|
54
|
+
* any caller.
|
|
55
|
+
*/
|
|
56
|
+
export declare function classifyFixture(fixture: HookFixture): Classification;
|
|
57
|
+
/** Whether a fixture's hook is a pure guard — the ones `--deep-test` executes. */
|
|
58
|
+
export declare function isPureGuard(fixture: HookFixture): boolean;
|
|
59
|
+
/** Whether a hook id names a side-effecting hook (US-009's execution surface). */
|
|
60
|
+
export declare function isSideEffectingHookId(hookId: string): boolean;
|
|
61
|
+
//# sourceMappingURL=classify.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a hook — via the fixture that guards it — as a PURE GUARD or a
|
|
3
|
+
* SIDE-EFFECTING hook (US-008).
|
|
4
|
+
*
|
|
5
|
+
* `hq doctor --deep-test` proves that a blocking hook actually blocks by firing
|
|
6
|
+
* it with crafted inputs through the real `hook-gate.sh` path and reading the
|
|
7
|
+
* verdict. That is only sound for hooks whose entire observable behaviour is a
|
|
8
|
+
* decision — block, allow, or a stderr pattern — and which produce no filesystem
|
|
9
|
+
* or git effects when they run. Those are PURE GUARDS, and they are the only
|
|
10
|
+
* hooks US-008 executes.
|
|
11
|
+
*
|
|
12
|
+
* A hook that writes files or makes commits (autocommit, checkpoint, journal,
|
|
13
|
+
* reindex, …) cannot be verified by exit code and MUST NOT be fired against
|
|
14
|
+
* anything but a throwaway, effect-asserting sandbox. That is US-009's job, so
|
|
15
|
+
* this module marks such hooks SIDE-EFFECTING and the executor skips them; the
|
|
16
|
+
* classification is exported so US-009 can pick exactly those up.
|
|
17
|
+
*
|
|
18
|
+
* The current fixture schema (US-007) can only express decision outcomes
|
|
19
|
+
* (`block` / `allow` / `stderr`), so the operative signal here is the hook id's
|
|
20
|
+
* family: the four side-effecting families US-009 enumerates are matched by a
|
|
21
|
+
* marker substring, and everything else is a pure guard. When the schema later
|
|
22
|
+
* grows effect-based expectations, {@link classifyFixture} is where a fixture
|
|
23
|
+
* carrying one would additionally be classed side-effecting — the seam is kept
|
|
24
|
+
* here so the executor never has to know the difference.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Substrings that mark a hook id as belonging to a side-effecting family. This
|
|
28
|
+
* is the seed set US-009 names — autocommit, checkpoint, journal, reindex — and
|
|
29
|
+
* is deliberately matched as a case-insensitive substring so every member of a
|
|
30
|
+
* family is caught (`hq-autocommit`, `auto-checkpoint-precompact`,
|
|
31
|
+
* `journal-due`, …) without enumerating each id. US-009 extends this list as it
|
|
32
|
+
* brings more side-effecting hooks under sandboxed execution.
|
|
33
|
+
*/
|
|
34
|
+
export const SIDE_EFFECTING_ID_MARKERS = [
|
|
35
|
+
"autocommit",
|
|
36
|
+
"checkpoint",
|
|
37
|
+
"journal",
|
|
38
|
+
"reindex",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Classify a bare hook id. Side-effecting when the id contains one of
|
|
42
|
+
* {@link SIDE_EFFECTING_ID_MARKERS} (case-insensitive), pure guard otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyHookId(hookId) {
|
|
45
|
+
const lower = hookId.toLowerCase();
|
|
46
|
+
const marker = SIDE_EFFECTING_ID_MARKERS.find((m) => lower.includes(m));
|
|
47
|
+
if (marker) {
|
|
48
|
+
return {
|
|
49
|
+
hookClass: "side-effecting",
|
|
50
|
+
reason: `hook id "${hookId}" matches the side-effecting family marker "${marker}"; deep execution is deferred to the sandboxed effect tier`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
hookClass: "pure-guard",
|
|
55
|
+
reason: `hook id "${hookId}" is a decision-only guard with no known filesystem or git side effects`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Classify the hook a fixture guards. Currently a function of the hook id alone
|
|
60
|
+
* (see the module comment); the fixture is taken by value so a future
|
|
61
|
+
* effect-based expectation can be folded into the verdict here without changing
|
|
62
|
+
* any caller.
|
|
63
|
+
*/
|
|
64
|
+
export function classifyFixture(fixture) {
|
|
65
|
+
return classifyHookId(fixture.hookId);
|
|
66
|
+
}
|
|
67
|
+
/** Whether a fixture's hook is a pure guard — the ones `--deep-test` executes. */
|
|
68
|
+
export function isPureGuard(fixture) {
|
|
69
|
+
return classifyFixture(fixture).hookClass === "pure-guard";
|
|
70
|
+
}
|
|
71
|
+
/** Whether a hook id names a side-effecting hook (US-009's execution surface). */
|
|
72
|
+
export function isSideEffectingHookId(hookId) {
|
|
73
|
+
return classifyHookId(hookId).hookClass === "side-effecting";
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=classify.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observable-effect expectations for side-effecting hooks (US-009).
|
|
3
|
+
*
|
|
4
|
+
* A pure guard (US-008) is fully described by a decision — block, allow, or a
|
|
5
|
+
* stderr pattern — so its fixture cases assert exit-code outcomes. A hook that
|
|
6
|
+
* WRITES FILES or MAKES COMMITS (autocommit, checkpoint, journal, reindex, …)
|
|
7
|
+
* cannot be verified that way: a `git commit` hook that exits 0 tells you
|
|
8
|
+
* nothing about whether a commit was actually made. So its fixture cases assert
|
|
9
|
+
* OBSERVABLE EFFECTS instead — this module owns that data contract and the
|
|
10
|
+
* before/after evaluation that decides whether an effect happened.
|
|
11
|
+
*
|
|
12
|
+
* The three effect kinds the PRD names, and nothing more (the schema grows
|
|
13
|
+
* deliberately, one kind at a time):
|
|
14
|
+
*
|
|
15
|
+
* - `fileCreated: <path>` — a file appeared at <path> that was not there
|
|
16
|
+
* before the hook ran.
|
|
17
|
+
* - `commitCreated` — the sandbox git repo has more commits after the
|
|
18
|
+
* hook than before.
|
|
19
|
+
* - `fileUnmodified: <path>` — a named file's bytes are identical before and
|
|
20
|
+
* after (the negative assertion: the hook did NOT
|
|
21
|
+
* touch something it must leave alone).
|
|
22
|
+
*
|
|
23
|
+
* Evaluation is a strict before/after diff so it cannot be fooled by a file that
|
|
24
|
+
* merely already existed: {@link captureEffectBaseline} snapshots exactly what
|
|
25
|
+
* each expectation needs (git commit count, file existence, file hashes) BEFORE
|
|
26
|
+
* the hook runs, and {@link evaluateEffects} compares against the same paths
|
|
27
|
+
* afterwards. All git access goes through an injectable {@link GitProbe} so the
|
|
28
|
+
* evaluation is unit-testable and a non-git sandbox degrades to a null count
|
|
29
|
+
* rather than throwing.
|
|
30
|
+
*
|
|
31
|
+
* This module is pure data + comparison; the sandbox that seeds the tree and
|
|
32
|
+
* runs the hook is sandbox.ts. It reuses only type-level helpers from the
|
|
33
|
+
* fixture schema (US-007) and never mutates anything but is handed paths to read.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* A single observable effect a side-effecting hook's case expects. Exactly one
|
|
37
|
+
* of the three kinds the PRD enumerates.
|
|
38
|
+
*/
|
|
39
|
+
export type EffectExpectation = {
|
|
40
|
+
kind: "file-created";
|
|
41
|
+
path: string;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "commit-created";
|
|
44
|
+
} | {
|
|
45
|
+
kind: "file-unmodified";
|
|
46
|
+
path: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Parse a fixture case's `effects:` field into a list of {@link
|
|
50
|
+
* EffectExpectation}. Returns the list, or an error string naming what was
|
|
51
|
+
* wrong (never throws). A side-effecting case MUST declare at least one effect —
|
|
52
|
+
* an empty or absent list is an error, because a case that asserts no effect
|
|
53
|
+
* proves nothing.
|
|
54
|
+
*
|
|
55
|
+
* Accepted YAML shapes per entry (each entry names exactly one effect):
|
|
56
|
+
* - `{ fileCreated: "<path>" }`
|
|
57
|
+
* - `{ commitCreated: true }` (any truthy value, or the bare string
|
|
58
|
+
* `"commitCreated"`)
|
|
59
|
+
* - `{ fileUnmodified: "<path>" }`
|
|
60
|
+
*/
|
|
61
|
+
export declare function parseEffectExpectations(raw: unknown): EffectExpectation[] | string;
|
|
62
|
+
/** Human phrase for an effect, used in result messages. */
|
|
63
|
+
export declare function describeEffect(effect: EffectExpectation): string;
|
|
64
|
+
/**
|
|
65
|
+
* The minimal git surface the effect evaluation needs. Injectable so the
|
|
66
|
+
* comparison logic is unit-testable without a real repo, and so a non-git
|
|
67
|
+
* sandbox returns `null` (an honest "cannot tell") rather than throwing.
|
|
68
|
+
*/
|
|
69
|
+
export interface GitProbe {
|
|
70
|
+
/** Number of commits reachable from HEAD, or null when there is no repo/HEAD. */
|
|
71
|
+
commitCount(root: string): number | null;
|
|
72
|
+
}
|
|
73
|
+
/** The real probe: `git rev-list --count HEAD` in `root`. */
|
|
74
|
+
export declare const defaultGitProbe: GitProbe;
|
|
75
|
+
/**
|
|
76
|
+
* A snapshot of exactly the state the declared effects need to compare against,
|
|
77
|
+
* taken BEFORE the hook runs. Everything is captured up front so the evaluation
|
|
78
|
+
* is a pure diff and cannot be fooled by pre-existing state.
|
|
79
|
+
*/
|
|
80
|
+
export interface EffectBaseline {
|
|
81
|
+
/** Commits reachable from HEAD before the run, or null when not a git repo. */
|
|
82
|
+
commitCount: number | null;
|
|
83
|
+
/** Per `file-created` path: did it exist before the run. */
|
|
84
|
+
existedBefore: Map<string, boolean>;
|
|
85
|
+
/** Per `file-unmodified` path: its content hash before the run (null if absent). */
|
|
86
|
+
hashBefore: Map<string, string | null>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Snapshot the tree state the given expectations will be compared against. Reads
|
|
90
|
+
* only; touches nothing.
|
|
91
|
+
*/
|
|
92
|
+
export declare function captureEffectBaseline(root: string, expectations: readonly EffectExpectation[], git?: GitProbe): EffectBaseline;
|
|
93
|
+
/** The outcome of checking one expectation against the post-run tree. */
|
|
94
|
+
export interface EffectEvaluation {
|
|
95
|
+
/** The expectation that was checked. */
|
|
96
|
+
expectation: EffectExpectation;
|
|
97
|
+
/** Whether the observed after-state satisfied the expectation. */
|
|
98
|
+
met: boolean;
|
|
99
|
+
/** Human detail of what was observed, for result messages. */
|
|
100
|
+
detail: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Compare the post-run tree against the baseline for every expectation. Pure and
|
|
104
|
+
* read-only; the returned list is in the same order as `expectations`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function evaluateEffects(root: string, expectations: readonly EffectExpectation[], baseline: EffectBaseline, git?: GitProbe): EffectEvaluation[];
|
|
107
|
+
//# sourceMappingURL=effects.d.ts.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observable-effect expectations for side-effecting hooks (US-009).
|
|
3
|
+
*
|
|
4
|
+
* A pure guard (US-008) is fully described by a decision — block, allow, or a
|
|
5
|
+
* stderr pattern — so its fixture cases assert exit-code outcomes. A hook that
|
|
6
|
+
* WRITES FILES or MAKES COMMITS (autocommit, checkpoint, journal, reindex, …)
|
|
7
|
+
* cannot be verified that way: a `git commit` hook that exits 0 tells you
|
|
8
|
+
* nothing about whether a commit was actually made. So its fixture cases assert
|
|
9
|
+
* OBSERVABLE EFFECTS instead — this module owns that data contract and the
|
|
10
|
+
* before/after evaluation that decides whether an effect happened.
|
|
11
|
+
*
|
|
12
|
+
* The three effect kinds the PRD names, and nothing more (the schema grows
|
|
13
|
+
* deliberately, one kind at a time):
|
|
14
|
+
*
|
|
15
|
+
* - `fileCreated: <path>` — a file appeared at <path> that was not there
|
|
16
|
+
* before the hook ran.
|
|
17
|
+
* - `commitCreated` — the sandbox git repo has more commits after the
|
|
18
|
+
* hook than before.
|
|
19
|
+
* - `fileUnmodified: <path>` — a named file's bytes are identical before and
|
|
20
|
+
* after (the negative assertion: the hook did NOT
|
|
21
|
+
* touch something it must leave alone).
|
|
22
|
+
*
|
|
23
|
+
* Evaluation is a strict before/after diff so it cannot be fooled by a file that
|
|
24
|
+
* merely already existed: {@link captureEffectBaseline} snapshots exactly what
|
|
25
|
+
* each expectation needs (git commit count, file existence, file hashes) BEFORE
|
|
26
|
+
* the hook runs, and {@link evaluateEffects} compares against the same paths
|
|
27
|
+
* afterwards. All git access goes through an injectable {@link GitProbe} so the
|
|
28
|
+
* evaluation is unit-testable and a non-git sandbox degrades to a null count
|
|
29
|
+
* rather than throwing.
|
|
30
|
+
*
|
|
31
|
+
* This module is pure data + comparison; the sandbox that seeds the tree and
|
|
32
|
+
* runs the hook is sandbox.ts. It reuses only type-level helpers from the
|
|
33
|
+
* fixture schema (US-007) and never mutates anything but is handed paths to read.
|
|
34
|
+
*/
|
|
35
|
+
import { spawnSync } from "node:child_process";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import * as path from "node:path";
|
|
39
|
+
/**
|
|
40
|
+
* Parse a fixture case's `effects:` field into a list of {@link
|
|
41
|
+
* EffectExpectation}. Returns the list, or an error string naming what was
|
|
42
|
+
* wrong (never throws). A side-effecting case MUST declare at least one effect —
|
|
43
|
+
* an empty or absent list is an error, because a case that asserts no effect
|
|
44
|
+
* proves nothing.
|
|
45
|
+
*
|
|
46
|
+
* Accepted YAML shapes per entry (each entry names exactly one effect):
|
|
47
|
+
* - `{ fileCreated: "<path>" }`
|
|
48
|
+
* - `{ commitCreated: true }` (any truthy value, or the bare string
|
|
49
|
+
* `"commitCreated"`)
|
|
50
|
+
* - `{ fileUnmodified: "<path>" }`
|
|
51
|
+
*/
|
|
52
|
+
export function parseEffectExpectations(raw) {
|
|
53
|
+
if (!Array.isArray(raw)) {
|
|
54
|
+
return "`effects` must be a non-empty list of effect expectations";
|
|
55
|
+
}
|
|
56
|
+
if (raw.length === 0) {
|
|
57
|
+
return "`effects` is empty; a side-effecting case must assert at least one observable effect";
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let i = 0; i < raw.length; i++) {
|
|
61
|
+
const parsed = parseOneEffect(raw[i], i);
|
|
62
|
+
if (typeof parsed === "string")
|
|
63
|
+
return parsed;
|
|
64
|
+
out.push(parsed);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function parseOneEffect(raw, index) {
|
|
69
|
+
const where = `effect #${index + 1}`;
|
|
70
|
+
// Bare-string forms: only the no-argument `commitCreated` supports it.
|
|
71
|
+
if (raw === "commitCreated")
|
|
72
|
+
return { kind: "commit-created" };
|
|
73
|
+
if (!isRecord(raw)) {
|
|
74
|
+
return `${where} must be a mapping such as { fileCreated: <path> }, { commitCreated: true }, or { fileUnmodified: <path> }`;
|
|
75
|
+
}
|
|
76
|
+
if ("fileCreated" in raw) {
|
|
77
|
+
const p = raw.fileCreated;
|
|
78
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
79
|
+
return `${where}: fileCreated must be a non-empty path string`;
|
|
80
|
+
}
|
|
81
|
+
return { kind: "file-created", path: p };
|
|
82
|
+
}
|
|
83
|
+
if ("fileUnmodified" in raw) {
|
|
84
|
+
const p = raw.fileUnmodified;
|
|
85
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
86
|
+
return `${where}: fileUnmodified must be a non-empty path string`;
|
|
87
|
+
}
|
|
88
|
+
return { kind: "file-unmodified", path: p };
|
|
89
|
+
}
|
|
90
|
+
if ("commitCreated" in raw) {
|
|
91
|
+
// Any truthy value means "expect a commit"; a false value is a no-op that
|
|
92
|
+
// would assert nothing, so it is rejected rather than silently dropped.
|
|
93
|
+
if (raw.commitCreated)
|
|
94
|
+
return { kind: "commit-created" };
|
|
95
|
+
return `${where}: commitCreated must be true (drop the entry to assert nothing)`;
|
|
96
|
+
}
|
|
97
|
+
return `${where} names no known effect (expected fileCreated, commitCreated, or fileUnmodified)`;
|
|
98
|
+
}
|
|
99
|
+
/** Human phrase for an effect, used in result messages. */
|
|
100
|
+
export function describeEffect(effect) {
|
|
101
|
+
switch (effect.kind) {
|
|
102
|
+
case "file-created":
|
|
103
|
+
return `a file created at ${effect.path}`;
|
|
104
|
+
case "commit-created":
|
|
105
|
+
return "a git commit created";
|
|
106
|
+
case "file-unmodified":
|
|
107
|
+
return `${effect.path} left unmodified`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The real probe: `git rev-list --count HEAD` in `root`. */
|
|
111
|
+
export const defaultGitProbe = {
|
|
112
|
+
commitCount(root) {
|
|
113
|
+
const res = spawnSync("git", ["rev-list", "--count", "HEAD"], {
|
|
114
|
+
cwd: root,
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
});
|
|
117
|
+
if (res.status !== 0)
|
|
118
|
+
return null;
|
|
119
|
+
const n = Number.parseInt((res.stdout ?? "").trim(), 10);
|
|
120
|
+
return Number.isFinite(n) ? n : null;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Snapshot the tree state the given expectations will be compared against. Reads
|
|
125
|
+
* only; touches nothing.
|
|
126
|
+
*/
|
|
127
|
+
export function captureEffectBaseline(root, expectations, git = defaultGitProbe) {
|
|
128
|
+
const existedBefore = new Map();
|
|
129
|
+
const hashBefore = new Map();
|
|
130
|
+
let needsCommitCount = false;
|
|
131
|
+
for (const effect of expectations) {
|
|
132
|
+
switch (effect.kind) {
|
|
133
|
+
case "file-created":
|
|
134
|
+
existedBefore.set(effect.path, fs.existsSync(path.join(root, effect.path)));
|
|
135
|
+
break;
|
|
136
|
+
case "file-unmodified":
|
|
137
|
+
hashBefore.set(effect.path, hashFile(path.join(root, effect.path)));
|
|
138
|
+
break;
|
|
139
|
+
case "commit-created":
|
|
140
|
+
needsCommitCount = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
commitCount: needsCommitCount ? git.commitCount(root) : null,
|
|
146
|
+
existedBefore,
|
|
147
|
+
hashBefore,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Compare the post-run tree against the baseline for every expectation. Pure and
|
|
152
|
+
* read-only; the returned list is in the same order as `expectations`.
|
|
153
|
+
*/
|
|
154
|
+
export function evaluateEffects(root, expectations, baseline, git = defaultGitProbe) {
|
|
155
|
+
return expectations.map((expectation) => evaluateOne(root, expectation, baseline, git));
|
|
156
|
+
}
|
|
157
|
+
function evaluateOne(root, expectation, baseline, git) {
|
|
158
|
+
switch (expectation.kind) {
|
|
159
|
+
case "file-created": {
|
|
160
|
+
const abs = path.join(root, expectation.path);
|
|
161
|
+
const existedBefore = baseline.existedBefore.get(expectation.path) ?? false;
|
|
162
|
+
const existsAfter = fs.existsSync(abs);
|
|
163
|
+
if (existedBefore) {
|
|
164
|
+
return {
|
|
165
|
+
expectation,
|
|
166
|
+
met: false,
|
|
167
|
+
detail: `${expectation.path} already existed before the run, so its presence is not a created effect`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
expectation,
|
|
172
|
+
met: existsAfter,
|
|
173
|
+
detail: existsAfter
|
|
174
|
+
? `${expectation.path} was created`
|
|
175
|
+
: `${expectation.path} does not exist after the run`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
case "file-unmodified": {
|
|
179
|
+
const abs = path.join(root, expectation.path);
|
|
180
|
+
const before = baseline.hashBefore.get(expectation.path) ?? null;
|
|
181
|
+
const after = hashFile(abs);
|
|
182
|
+
const unchanged = before === after;
|
|
183
|
+
return {
|
|
184
|
+
expectation,
|
|
185
|
+
met: unchanged,
|
|
186
|
+
detail: unchanged
|
|
187
|
+
? `${expectation.path} was left unmodified`
|
|
188
|
+
: before === null
|
|
189
|
+
? `${expectation.path} was created by the run but was expected to be untouched`
|
|
190
|
+
: after === null
|
|
191
|
+
? `${expectation.path} was deleted by the run but was expected to be untouched`
|
|
192
|
+
: `${expectation.path} was modified by the run`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
case "commit-created": {
|
|
196
|
+
const after = git.commitCount(root);
|
|
197
|
+
const before = baseline.commitCount;
|
|
198
|
+
if (before === null || after === null) {
|
|
199
|
+
return {
|
|
200
|
+
expectation,
|
|
201
|
+
met: false,
|
|
202
|
+
detail: "commit count could not be read (the sandbox is not a git repository)",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const made = after - before;
|
|
206
|
+
return {
|
|
207
|
+
expectation,
|
|
208
|
+
met: made > 0,
|
|
209
|
+
detail: made > 0
|
|
210
|
+
? `${made} commit${made === 1 ? "" : "s"} were created`
|
|
211
|
+
: "no new commit was created",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// --- low-level helpers ---------------------------------------------------------
|
|
217
|
+
/** sha256 of a file's bytes, or null when the file is absent / unreadable. */
|
|
218
|
+
function hashFile(abs) {
|
|
219
|
+
try {
|
|
220
|
+
return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function isRecord(value) {
|
|
227
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=effects.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
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 type { CheckContext, CheckResult } from "../types.js";
|
|
40
|
+
import { type GateProfile } from "../hook-gate-profiles.js";
|
|
41
|
+
/** The id of the deep-test family the command appends under `--deep-test`. */
|
|
42
|
+
export declare const DEEP_FAMILY_ID = "deep";
|
|
43
|
+
/** Human title of the deep-test family. */
|
|
44
|
+
export declare const DEEP_FAMILY_TITLE = "Deep guard-hook tests";
|
|
45
|
+
/** Default per-case time bound. Deep test has no latency target, but no case
|
|
46
|
+
* may hang the command, so every execution is capped. */
|
|
47
|
+
export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 10000;
|
|
48
|
+
/**
|
|
49
|
+
* The tree entries copied into the sandbox. Deliberately a curated set, not the
|
|
50
|
+
* whole tree: the live `.claude/` carries worktrees and session state that can
|
|
51
|
+
* run to hundreds of megabytes, whereas the gate only needs the hook scripts,
|
|
52
|
+
* the settings a hook might read, the Codex/Grok mirrors, and `core/` (where the
|
|
53
|
+
* gate finds `hook-lib.sh` and hooks find their helpers). Missing entries are
|
|
54
|
+
* skipped, so a partial tree still sandboxes cleanly.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SANDBOX_COPY_RELPATHS: readonly string[];
|
|
57
|
+
/** One gate execution's inputs. */
|
|
58
|
+
export interface GateRunInput {
|
|
59
|
+
/** Absolute path to the sandboxed `hook-gate.sh`. */
|
|
60
|
+
gatePath: string;
|
|
61
|
+
/** Absolute path to the sandboxed hook script the gate delegates to. */
|
|
62
|
+
hookScriptPath: string;
|
|
63
|
+
/** The gated hook id (the gate's first argument). */
|
|
64
|
+
hookId: string;
|
|
65
|
+
/** The gate profile to run under, via `HQ_HOOK_PROFILE`. */
|
|
66
|
+
profile: GateProfile;
|
|
67
|
+
/** The JSON payload piped to the gate's stdin. */
|
|
68
|
+
payload: string;
|
|
69
|
+
/** Working directory (the sandbox root) the gate runs in. */
|
|
70
|
+
cwd: string;
|
|
71
|
+
/** The per-case time bound in milliseconds. */
|
|
72
|
+
timeoutMs: number;
|
|
73
|
+
}
|
|
74
|
+
/** One gate execution's observed outcome. */
|
|
75
|
+
export interface GateRunResult {
|
|
76
|
+
/** True when the execution exceeded {@link GateRunInput.timeoutMs}. */
|
|
77
|
+
timedOut: boolean;
|
|
78
|
+
/** Gate exit code, or null when it did not exit normally (timeout/signal). */
|
|
79
|
+
exitCode: number | null;
|
|
80
|
+
/** Captured stderr (empty string when none). */
|
|
81
|
+
stderr: string;
|
|
82
|
+
}
|
|
83
|
+
/** Executes one gate invocation. Injectable so the aggregation is unit-testable. */
|
|
84
|
+
export type GateRunner = (input: GateRunInput) => GateRunResult;
|
|
85
|
+
/**
|
|
86
|
+
* The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
|
|
87
|
+
* stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
|
|
88
|
+
* `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
|
|
89
|
+
* a hook cannot ignore the bound.
|
|
90
|
+
*/
|
|
91
|
+
export declare const defaultGateRunner: GateRunner;
|
|
92
|
+
/** Options for {@link runDeepGuardTests}. All are injectable for testing. */
|
|
93
|
+
export interface DeepTestOptions {
|
|
94
|
+
/** Per-case time bound. Default: {@link DEFAULT_PER_CASE_TIMEOUT_MS}. */
|
|
95
|
+
perCaseTimeoutMs?: number;
|
|
96
|
+
/** Tree entries copied into the sandbox. Default: {@link SANDBOX_COPY_RELPATHS}. */
|
|
97
|
+
sandboxRelpaths?: readonly string[];
|
|
98
|
+
/** Gate runner. Default: {@link defaultGateRunner}. */
|
|
99
|
+
runner?: GateRunner;
|
|
100
|
+
/** Leave the sandbox on disk (debugging). Default: false — it is removed. */
|
|
101
|
+
keepSandbox?: boolean;
|
|
102
|
+
}
|
|
103
|
+
/** Remove every sandbox still on disk. Safe to call repeatedly. */
|
|
104
|
+
export declare function cleanupAllDeepSandboxes(): void;
|
|
105
|
+
/**
|
|
106
|
+
* Execute every pure-guard fixture's cases through the real gate under all three
|
|
107
|
+
* profiles and return the per-case results. Read-only against the live tree;
|
|
108
|
+
* all execution happens in a sandbox that is removed before returning. Returns
|
|
109
|
+
* an empty list — creating no sandbox — when there is nothing to run.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runDeepGuardTests(context: CheckContext, options?: DeepTestOptions): Promise<CheckResult[]>;
|
|
112
|
+
//# sourceMappingURL=executor.d.ts.map
|