@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,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime fixture discovery and the UNTESTED coverage tier (US-007).
|
|
3
|
+
*
|
|
4
|
+
* Fixtures are discovered from `core/hook-tests/*.yaml` in the *resolved HQ
|
|
5
|
+
* tree* at runtime, not bundled into the CLI — so a maintainer adds or changes a
|
|
6
|
+
* hook's expectation by committing a YAML file to hq-core, with no npm release.
|
|
7
|
+
* This module is the I/O half of that contract: it reads the directory, hands
|
|
8
|
+
* each document to the pure {@link parseFixture} validator, cross-references the
|
|
9
|
+
* results against the hooks the wiring tier found registered, and emits the
|
|
10
|
+
* coverage results.
|
|
11
|
+
*
|
|
12
|
+
* The coverage line — `tested/total` — is the load-bearing output of this story.
|
|
13
|
+
* It is not cosmetic: a doctor that quietly reports nothing for the hooks it has
|
|
14
|
+
* no fixture for reads as a clean bill of health, which is precisely the false
|
|
15
|
+
* confidence the whole tool exists to prevent. So every registered hook without
|
|
16
|
+
* a fixture is reported UNTESTED by name, and the ratio is surfaced in the
|
|
17
|
+
* summary, making thin coverage impossible to miss and giving maintainers a
|
|
18
|
+
* number that only grows as fixtures are added.
|
|
19
|
+
*
|
|
20
|
+
* Result mapping, in precedence order per fixture:
|
|
21
|
+
* - a malformed / non-YAML fixture -> WARN (skipped, named)
|
|
22
|
+
* - a fixture whose hook id is not registered -> WARN orphan (deleted hook?)
|
|
23
|
+
* - a registered hook, unrecognised version -> UNKNOWN (names both versions)
|
|
24
|
+
* - a registered hook, valid fixture -> counted as tested
|
|
25
|
+
* and per registered hook with no usable fixture -> UNTESTED.
|
|
26
|
+
*/
|
|
27
|
+
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
28
|
+
import { type ParsedFixture } from "./schema.js";
|
|
29
|
+
/** Directory, relative to the HQ root, fixtures are discovered from. */
|
|
30
|
+
export declare const HOOK_TESTS_RELDIR: string;
|
|
31
|
+
/** The id of the fixture-coverage family. */
|
|
32
|
+
export declare const FIXTURES_FAMILY_ID = "fixtures";
|
|
33
|
+
/** checkId of the single coverage-ratio result the report surfaces in its summary. */
|
|
34
|
+
export declare const FIXTURE_COVERAGE_CHECK_ID = "hooks.fixtures.coverage";
|
|
35
|
+
/**
|
|
36
|
+
* The hook ids the wiring tier found registered, deduplicated and sorted. A hook
|
|
37
|
+
* id is the identity a fixture is keyed by: the id passed to `hook-gate.sh` when
|
|
38
|
+
* a command routes through the gate (the common case), or the script's basename
|
|
39
|
+
* without `.sh` otherwise.
|
|
40
|
+
*
|
|
41
|
+
* Registrations are read from `.claude/settings.json` and
|
|
42
|
+
* `.claude/settings.local.json`. Claude and Grok both execute the canonical
|
|
43
|
+
* `.claude/hooks/` scripts and Codex only mirrors them, so the Claude settings
|
|
44
|
+
* carry the canonical hook-id set the fixtures are written against.
|
|
45
|
+
*/
|
|
46
|
+
export declare function registeredHookIds(hqRoot: string): string[];
|
|
47
|
+
/** A fixture file located on disk and validated (or a YAML-load failure). */
|
|
48
|
+
export interface DiscoveredFixture {
|
|
49
|
+
/** Path relative to the HQ root, e.g. `core/hook-tests/block-core-writes.yaml`. */
|
|
50
|
+
relpath: string;
|
|
51
|
+
/** Absolute path on disk. */
|
|
52
|
+
absPath: string;
|
|
53
|
+
/** Filename stem, used as the default hook id when the doc omits one. */
|
|
54
|
+
defaultHookId: string;
|
|
55
|
+
/** The validation outcome, or a YAML syntax error. */
|
|
56
|
+
parsed: ParsedFixture | {
|
|
57
|
+
status: "yaml-error";
|
|
58
|
+
message: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** The outcome of scanning `core/hook-tests/` for fixtures. */
|
|
62
|
+
export interface FixtureDiscovery {
|
|
63
|
+
/** Absolute fixtures directory (whether or not it exists). */
|
|
64
|
+
dir: string;
|
|
65
|
+
/** Whether `core/hook-tests/` exists on disk. */
|
|
66
|
+
present: boolean;
|
|
67
|
+
/** The fixture files found, in sorted filename order. Non-fixtures are skipped. */
|
|
68
|
+
fixtures: DiscoveredFixture[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Discover and validate every fixture under `core/hook-tests/`. A `*.yaml` file
|
|
72
|
+
* that carries none of the fixture keys (for example `allowed-divergence.yaml`)
|
|
73
|
+
* is not a fixture and is skipped silently — only files that {@link
|
|
74
|
+
* looksLikeFixture} are validated and reported.
|
|
75
|
+
*/
|
|
76
|
+
export declare function discoverFixtures(hqRoot: string): FixtureDiscovery;
|
|
77
|
+
/** The coverage tally: how many registered hooks have a usable fixture. */
|
|
78
|
+
export interface FixtureCoverage {
|
|
79
|
+
/** Registered hooks with a valid, recognised-version fixture. */
|
|
80
|
+
tested: number;
|
|
81
|
+
/** Registered hooks in total. */
|
|
82
|
+
total: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Run the fixture-coverage tier against the resolved tree: enumerate registered
|
|
86
|
+
* hooks, discover fixtures, and emit UNTESTED / WARN / UNKNOWN results plus the
|
|
87
|
+
* `tested/total` coverage line. Read-only and never throws.
|
|
88
|
+
*/
|
|
89
|
+
export declare function checkFixtureCoverage(context: CheckContext): CheckResult[];
|
|
90
|
+
/**
|
|
91
|
+
* The fixture-coverage check family. Registered alongside the hooks family so
|
|
92
|
+
* every run reports coverage; adding it needs no engine change (US-002's
|
|
93
|
+
* registry contract).
|
|
94
|
+
*/
|
|
95
|
+
export declare const fixtureCoverageFamily: CheckFamily;
|
|
96
|
+
//# sourceMappingURL=discover.d.ts.map
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime fixture discovery and the UNTESTED coverage tier (US-007).
|
|
3
|
+
*
|
|
4
|
+
* Fixtures are discovered from `core/hook-tests/*.yaml` in the *resolved HQ
|
|
5
|
+
* tree* at runtime, not bundled into the CLI — so a maintainer adds or changes a
|
|
6
|
+
* hook's expectation by committing a YAML file to hq-core, with no npm release.
|
|
7
|
+
* This module is the I/O half of that contract: it reads the directory, hands
|
|
8
|
+
* each document to the pure {@link parseFixture} validator, cross-references the
|
|
9
|
+
* results against the hooks the wiring tier found registered, and emits the
|
|
10
|
+
* coverage results.
|
|
11
|
+
*
|
|
12
|
+
* The coverage line — `tested/total` — is the load-bearing output of this story.
|
|
13
|
+
* It is not cosmetic: a doctor that quietly reports nothing for the hooks it has
|
|
14
|
+
* no fixture for reads as a clean bill of health, which is precisely the false
|
|
15
|
+
* confidence the whole tool exists to prevent. So every registered hook without
|
|
16
|
+
* a fixture is reported UNTESTED by name, and the ratio is surfaced in the
|
|
17
|
+
* summary, making thin coverage impossible to miss and giving maintainers a
|
|
18
|
+
* number that only grows as fixtures are added.
|
|
19
|
+
*
|
|
20
|
+
* Result mapping, in precedence order per fixture:
|
|
21
|
+
* - a malformed / non-YAML fixture -> WARN (skipped, named)
|
|
22
|
+
* - a fixture whose hook id is not registered -> WARN orphan (deleted hook?)
|
|
23
|
+
* - a registered hook, unrecognised version -> UNKNOWN (names both versions)
|
|
24
|
+
* - a registered hook, valid fixture -> counted as tested
|
|
25
|
+
* and per registered hook with no usable fixture -> UNTESTED.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import * as yaml from "js-yaml";
|
|
30
|
+
import { scanHookCommand } from "../checks/claude-wiring.js";
|
|
31
|
+
import { looksLikeFixture, parseFixture, SUPPORTED_FIXTURE_SCHEMA_VERSIONS, } from "./schema.js";
|
|
32
|
+
/** Directory, relative to the HQ root, fixtures are discovered from. */
|
|
33
|
+
export const HOOK_TESTS_RELDIR = path.join("core", "hook-tests");
|
|
34
|
+
/** The id of the fixture-coverage family. */
|
|
35
|
+
export const FIXTURES_FAMILY_ID = "fixtures";
|
|
36
|
+
/** checkId of the single coverage-ratio result the report surfaces in its summary. */
|
|
37
|
+
export const FIXTURE_COVERAGE_CHECK_ID = "hooks.fixtures.coverage";
|
|
38
|
+
/** A `*.yaml` file in `core/hook-tests/` that is not a fixture and must be skipped. */
|
|
39
|
+
const NON_FIXTURE_FILES = new Set(["allowed-divergence.yaml"]);
|
|
40
|
+
// --- registered-hook enumeration ----------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* The hook ids the wiring tier found registered, deduplicated and sorted. A hook
|
|
43
|
+
* id is the identity a fixture is keyed by: the id passed to `hook-gate.sh` when
|
|
44
|
+
* a command routes through the gate (the common case), or the script's basename
|
|
45
|
+
* without `.sh` otherwise.
|
|
46
|
+
*
|
|
47
|
+
* Registrations are read from `.claude/settings.json` and
|
|
48
|
+
* `.claude/settings.local.json`. Claude and Grok both execute the canonical
|
|
49
|
+
* `.claude/hooks/` scripts and Codex only mirrors them, so the Claude settings
|
|
50
|
+
* carry the canonical hook-id set the fixtures are written against.
|
|
51
|
+
*/
|
|
52
|
+
export function registeredHookIds(hqRoot) {
|
|
53
|
+
const claudeDir = path.join(hqRoot, ".claude");
|
|
54
|
+
const commands = [
|
|
55
|
+
...settingsCommands(path.join(claudeDir, "settings.json")),
|
|
56
|
+
...settingsCommands(path.join(claudeDir, "settings.local.json")),
|
|
57
|
+
];
|
|
58
|
+
const ids = new Set();
|
|
59
|
+
for (const command of commands) {
|
|
60
|
+
const id = hookIdFromCommand(command);
|
|
61
|
+
if (id)
|
|
62
|
+
ids.add(id);
|
|
63
|
+
}
|
|
64
|
+
return [...ids].sort();
|
|
65
|
+
}
|
|
66
|
+
/** Every `type: "command"` hook command across all events in one settings file. */
|
|
67
|
+
function settingsCommands(file) {
|
|
68
|
+
const settings = readJson(file);
|
|
69
|
+
if (!settings || typeof settings !== "object")
|
|
70
|
+
return [];
|
|
71
|
+
const hooks = settings.hooks;
|
|
72
|
+
if (!hooks || typeof hooks !== "object")
|
|
73
|
+
return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const entries of Object.values(hooks)) {
|
|
76
|
+
if (!Array.isArray(entries))
|
|
77
|
+
continue;
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const inner = entry?.hooks;
|
|
80
|
+
if (!Array.isArray(inner))
|
|
81
|
+
continue;
|
|
82
|
+
for (const item of inner) {
|
|
83
|
+
const hook = item;
|
|
84
|
+
if (hook?.type === "command" && typeof hook.command === "string") {
|
|
85
|
+
out.push(hook.command);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The hook id a command identifies: the gated id when it routes through
|
|
94
|
+
* `hook-gate.sh`, else the basename (sans `.sh`) of the first script it runs.
|
|
95
|
+
* `hook-gate.sh` itself is never a hook id.
|
|
96
|
+
*/
|
|
97
|
+
function hookIdFromCommand(command) {
|
|
98
|
+
const scan = scanHookCommand(command);
|
|
99
|
+
if (scan.gatedHookId)
|
|
100
|
+
return scan.gatedHookId;
|
|
101
|
+
for (const rp of scan.requiredRelpaths) {
|
|
102
|
+
const base = path.basename(rp);
|
|
103
|
+
if (base === "hook-gate.sh")
|
|
104
|
+
continue;
|
|
105
|
+
return base.replace(/\.sh$/, "");
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Discover and validate every fixture under `core/hook-tests/`. A `*.yaml` file
|
|
111
|
+
* that carries none of the fixture keys (for example `allowed-divergence.yaml`)
|
|
112
|
+
* is not a fixture and is skipped silently — only files that {@link
|
|
113
|
+
* looksLikeFixture} are validated and reported.
|
|
114
|
+
*/
|
|
115
|
+
export function discoverFixtures(hqRoot) {
|
|
116
|
+
const dir = path.join(hqRoot, HOOK_TESTS_RELDIR);
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = fs.readdirSync(dir);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { dir, present: false, fixtures: [] };
|
|
123
|
+
}
|
|
124
|
+
const fixtures = [];
|
|
125
|
+
for (const name of names.sort()) {
|
|
126
|
+
if (!/\.ya?ml$/i.test(name))
|
|
127
|
+
continue;
|
|
128
|
+
if (NON_FIXTURE_FILES.has(name))
|
|
129
|
+
continue;
|
|
130
|
+
const absPath = path.join(dir, name);
|
|
131
|
+
const relpath = path.join(HOOK_TESTS_RELDIR, name);
|
|
132
|
+
const defaultHookId = name.replace(/\.ya?ml$/i, "");
|
|
133
|
+
let doc;
|
|
134
|
+
try {
|
|
135
|
+
doc = yaml.load(fs.readFileSync(absPath, "utf8"));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
fixtures.push({
|
|
139
|
+
relpath,
|
|
140
|
+
absPath,
|
|
141
|
+
defaultHookId,
|
|
142
|
+
parsed: { status: "yaml-error", message: error.message },
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// A YAML file with none of the fixture keys is another kind of config file
|
|
147
|
+
// living in the same directory — skip it rather than flag it as malformed.
|
|
148
|
+
if (!looksLikeFixture(doc))
|
|
149
|
+
continue;
|
|
150
|
+
fixtures.push({
|
|
151
|
+
relpath,
|
|
152
|
+
absPath,
|
|
153
|
+
defaultHookId,
|
|
154
|
+
parsed: parseFixture(doc, { defaultHookId }),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { dir, present: true, fixtures };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Run the fixture-coverage tier against the resolved tree: enumerate registered
|
|
161
|
+
* hooks, discover fixtures, and emit UNTESTED / WARN / UNKNOWN results plus the
|
|
162
|
+
* `tested/total` coverage line. Read-only and never throws.
|
|
163
|
+
*/
|
|
164
|
+
export function checkFixtureCoverage(context) {
|
|
165
|
+
const hqRoot = context.hqRoot;
|
|
166
|
+
const registered = registeredHookIds(hqRoot);
|
|
167
|
+
const registeredSet = new Set(registered);
|
|
168
|
+
const discovery = discoverFixtures(hqRoot);
|
|
169
|
+
const results = [];
|
|
170
|
+
const testedIds = new Set();
|
|
171
|
+
const unknownVersion = new Map();
|
|
172
|
+
for (const fixture of discovery.fixtures) {
|
|
173
|
+
const parsed = fixture.parsed;
|
|
174
|
+
if (parsed.status === "yaml-error") {
|
|
175
|
+
results.push({
|
|
176
|
+
status: "WARN",
|
|
177
|
+
checkId: "hooks.fixtures.invalid",
|
|
178
|
+
target: fixture.relpath,
|
|
179
|
+
message: `Fixture ${fixture.relpath} is not valid YAML and was skipped: ${parsed.message}`,
|
|
180
|
+
remediation: `Fix the YAML syntax in ${fixture.relpath}.`,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (parsed.status === "invalid") {
|
|
185
|
+
results.push({
|
|
186
|
+
status: "WARN",
|
|
187
|
+
checkId: "hooks.fixtures.invalid",
|
|
188
|
+
target: fixture.relpath,
|
|
189
|
+
message: `Fixture ${fixture.relpath} is malformed and was skipped: ${parsed.message}.`,
|
|
190
|
+
remediation: `Correct ${fixture.relpath} against core/hook-tests/README.md.`,
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const hookId = parsed.status === "ok"
|
|
195
|
+
? parsed.fixture.hookId
|
|
196
|
+
: (parsed.hookId ?? fixture.defaultHookId);
|
|
197
|
+
// A fixture pointing at a hook that is registered nowhere is an orphan —
|
|
198
|
+
// most likely a leftover from a hook that was deleted. This takes precedence
|
|
199
|
+
// over the version check: there is no point telling the user a non-existent
|
|
200
|
+
// hook's fixture is on an unknown schema.
|
|
201
|
+
if (!registeredSet.has(hookId)) {
|
|
202
|
+
results.push({
|
|
203
|
+
status: "WARN",
|
|
204
|
+
checkId: "hooks.fixtures.orphan",
|
|
205
|
+
target: fixture.relpath,
|
|
206
|
+
message: `Fixture ${fixture.relpath} targets hook id "${hookId}", which is not registered in any settings file (orphan fixture).`,
|
|
207
|
+
remediation: `Remove ${fixture.relpath}, or restore the "${hookId}" hook registration.`,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (parsed.status === "unsupported-version") {
|
|
212
|
+
unknownVersion.set(hookId, {
|
|
213
|
+
relpath: fixture.relpath,
|
|
214
|
+
declared: parsed.declaredVersion,
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
testedIds.add(hookId);
|
|
219
|
+
}
|
|
220
|
+
// Per registered hook, in sorted order: covered hooks are silent (counted in
|
|
221
|
+
// the ratio); a hook whose only fixture is on an unrecognised schema is
|
|
222
|
+
// UNKNOWN (never PASS); everything else is UNTESTED.
|
|
223
|
+
for (const hookId of registered) {
|
|
224
|
+
if (testedIds.has(hookId))
|
|
225
|
+
continue;
|
|
226
|
+
const unknown = unknownVersion.get(hookId);
|
|
227
|
+
if (unknown) {
|
|
228
|
+
results.push({
|
|
229
|
+
status: "UNKNOWN",
|
|
230
|
+
checkId: "hooks.fixtures.unknown-schema",
|
|
231
|
+
target: hookId,
|
|
232
|
+
message: `Fixture ${unknown.relpath} declares schema version ${formatVersion(unknown.declared)}, ` +
|
|
233
|
+
`which this hq CLI does not recognise (supported: ${SUPPORTED_FIXTURE_SCHEMA_VERSIONS.join(", ")}); ` +
|
|
234
|
+
`hook "${hookId}" cannot be evaluated and is not counted as tested.`,
|
|
235
|
+
remediation: "Upgrade the hq CLI, or re-author the fixture on a supported schema version.",
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
results.push({
|
|
240
|
+
status: "UNTESTED",
|
|
241
|
+
checkId: "hooks.fixtures.untested",
|
|
242
|
+
target: hookId,
|
|
243
|
+
message: `Hook "${hookId}" is registered but has no fixture; its behaviour is never exercised.`,
|
|
244
|
+
remediation: `Add core/hook-tests/${hookId}.yaml (see core/hook-tests/README.md).`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const coverage = { tested: testedIds.size, total: registered.length };
|
|
248
|
+
results.push(coverageResult(coverage));
|
|
249
|
+
return results;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The single coverage-ratio result. Status NA: it is an informational meta-line,
|
|
253
|
+
* never a pass or a failure. The report surfaces its message in the run summary;
|
|
254
|
+
* its `target` carries the raw `tested/total` ratio for machine consumers.
|
|
255
|
+
*/
|
|
256
|
+
function coverageResult(coverage) {
|
|
257
|
+
const { tested, total } = coverage;
|
|
258
|
+
return {
|
|
259
|
+
status: "NA",
|
|
260
|
+
checkId: FIXTURE_COVERAGE_CHECK_ID,
|
|
261
|
+
target: `${tested}/${total}`,
|
|
262
|
+
message: `Fixture coverage: ${tested}/${total} registered hook${total === 1 ? "" : "s"} have a fixture.`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** Render a declared schema version for a message, quoting non-numbers. */
|
|
266
|
+
function formatVersion(value) {
|
|
267
|
+
return typeof value === "number" ? String(value) : JSON.stringify(value);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The fixture-coverage check family. Registered alongside the hooks family so
|
|
271
|
+
* every run reports coverage; adding it needs no engine change (US-002's
|
|
272
|
+
* registry contract).
|
|
273
|
+
*/
|
|
274
|
+
export const fixtureCoverageFamily = {
|
|
275
|
+
id: FIXTURES_FAMILY_ID,
|
|
276
|
+
title: "Fixture coverage",
|
|
277
|
+
run: (context) => Promise.resolve(checkFixtureCoverage(context)),
|
|
278
|
+
};
|
|
279
|
+
function readJson(file) {
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=discover.js.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook-fixture schema — the declarative format that pins a hook's expected
|
|
3
|
+
* behaviour in a file shipped next to the hook (US-007).
|
|
4
|
+
*
|
|
5
|
+
* The owner's chosen split is the whole point of this module: all executable
|
|
6
|
+
* logic lives in hq-cli, but the per-hook expectation *data* lives in the HQ
|
|
7
|
+
* tree at `core/hook-tests/*.yaml`, so a hook and its test ship in the same
|
|
8
|
+
* hq-core commit and adding a test needs no CLI release. This file owns the
|
|
9
|
+
* data contract that bridge rests on:
|
|
10
|
+
*
|
|
11
|
+
* - {@link parseFixture} validates one already-parsed YAML document into a
|
|
12
|
+
* typed {@link HookFixture}, or classifies it as *unsupported-version* or
|
|
13
|
+
* *invalid*. It never touches the filesystem — {@link discoverFixtures}
|
|
14
|
+
* (discover.ts) handles I/O and hands plain objects here — which keeps the
|
|
15
|
+
* schema a pure, exhaustively unit-testable function.
|
|
16
|
+
*
|
|
17
|
+
* - An unrecognised {@link HookFixture.schemaVersion} is a first-class outcome,
|
|
18
|
+
* not an error: a fixture written against a future schema must degrade to
|
|
19
|
+
* UNKNOWN (naming both versions) rather than FAIL or, worse, PASS. Baking a
|
|
20
|
+
* stale expectation into a green result is exactly the false-confidence
|
|
21
|
+
* failure mode `hq doctor` exists to prevent.
|
|
22
|
+
*
|
|
23
|
+
* - {@link classifyCaseStatus} encodes the `expectedFailure` rule (a case that
|
|
24
|
+
* pins a known, unfixed defect): a failing such case reports KNOWN-DEFECT
|
|
25
|
+
* rather than FAIL, and one that unexpectedly passes reports WARN that the
|
|
26
|
+
* marker is stale and should be removed. Case *execution* is US-008; this
|
|
27
|
+
* module owns only the classification semantics, so both the doctor and its
|
|
28
|
+
* tests agree on what a case outcome means.
|
|
29
|
+
*/
|
|
30
|
+
import type { DoctorStatus } from "../types.js";
|
|
31
|
+
/**
|
|
32
|
+
* Every fixture schema version this build of the CLI can interpret. A fixture
|
|
33
|
+
* declaring a version outside this set is reported UNKNOWN, never PASS — see
|
|
34
|
+
* {@link parseFixture}. Grows by one when the shape below changes.
|
|
35
|
+
*/
|
|
36
|
+
export declare const SUPPORTED_FIXTURE_SCHEMA_VERSIONS: readonly [1];
|
|
37
|
+
/** A schema version this CLI understands. */
|
|
38
|
+
export type FixtureSchemaVersion = (typeof SUPPORTED_FIXTURE_SCHEMA_VERSIONS)[number];
|
|
39
|
+
/** The schema version new fixtures should be authored against. */
|
|
40
|
+
export declare const CURRENT_FIXTURE_SCHEMA_VERSION: FixtureSchemaVersion;
|
|
41
|
+
/** Whether `value` is a schema version this build recognises. */
|
|
42
|
+
export declare function isSupportedSchemaVersion(value: unknown): value is FixtureSchemaVersion;
|
|
43
|
+
/**
|
|
44
|
+
* A case's expected outcome. Exactly one of three kinds:
|
|
45
|
+
* - `block` — the hook is expected to block the action.
|
|
46
|
+
* - `allow` — the hook is expected to allow the action.
|
|
47
|
+
* - `stderr` — the hook is expected to emit stderr matching `pattern`.
|
|
48
|
+
*/
|
|
49
|
+
export type ExpectedOutcome = {
|
|
50
|
+
kind: "block";
|
|
51
|
+
} | {
|
|
52
|
+
kind: "allow";
|
|
53
|
+
} | {
|
|
54
|
+
kind: "stderr";
|
|
55
|
+
pattern: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Marks a case as pinning a known, unfixed defect. A failing case carrying this
|
|
59
|
+
* reports KNOWN-DEFECT instead of FAIL; an unexpectedly passing one reports WARN
|
|
60
|
+
* (the marker is stale). The `reason` is required so a defect is never pinned
|
|
61
|
+
* silently.
|
|
62
|
+
*/
|
|
63
|
+
export interface ExpectedFailure {
|
|
64
|
+
/** Required, non-empty human reason the defect is tracked and unfixed. */
|
|
65
|
+
reason: string;
|
|
66
|
+
}
|
|
67
|
+
/** One named case within a fixture. */
|
|
68
|
+
export interface FixtureCase {
|
|
69
|
+
/** Case name, unique within the fixture (used in result messages). */
|
|
70
|
+
name: string;
|
|
71
|
+
/** Lifecycle event to replay the case under, e.g. `PreToolUse`. */
|
|
72
|
+
event: string;
|
|
73
|
+
/** Tool name the case exercises, e.g. `Edit`, `Bash`, `Read`. */
|
|
74
|
+
tool: string;
|
|
75
|
+
/** The tool input payload handed to the hook. Opaque to the schema. */
|
|
76
|
+
input: unknown;
|
|
77
|
+
/** The outcome the hook is expected to produce. */
|
|
78
|
+
expect: ExpectedOutcome;
|
|
79
|
+
/** Present only when the case pins a known, unfixed defect. */
|
|
80
|
+
expectedFailure?: ExpectedFailure;
|
|
81
|
+
}
|
|
82
|
+
/** A fully-validated fixture for a single hook id. */
|
|
83
|
+
export interface HookFixture {
|
|
84
|
+
/** The hook id this fixture guards, e.g. `block-core-writes`. */
|
|
85
|
+
hookId: string;
|
|
86
|
+
/** The schema version, guaranteed to be a supported one. */
|
|
87
|
+
schemaVersion: FixtureSchemaVersion;
|
|
88
|
+
/** The named cases, in document order. May be empty. */
|
|
89
|
+
cases: FixtureCase[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The result of validating one fixture document. A closed union so every caller
|
|
93
|
+
* handles all three outcomes; only `ok` yields a usable {@link HookFixture}.
|
|
94
|
+
*/
|
|
95
|
+
export type ParsedFixture = {
|
|
96
|
+
status: "ok";
|
|
97
|
+
fixture: HookFixture;
|
|
98
|
+
} | {
|
|
99
|
+
/** The declared version is not one this CLI understands. */
|
|
100
|
+
status: "unsupported-version";
|
|
101
|
+
/** The hook id (from the doc or the filename default), best-effort. */
|
|
102
|
+
hookId: string | null;
|
|
103
|
+
/** The version the fixture declared, verbatim, for the message. */
|
|
104
|
+
declaredVersion: unknown;
|
|
105
|
+
/** The versions this CLI does understand. */
|
|
106
|
+
supportedVersions: readonly number[];
|
|
107
|
+
} | {
|
|
108
|
+
/** The document is malformed in a way unrelated to the version. */
|
|
109
|
+
status: "invalid";
|
|
110
|
+
/** The hook id when one could be identified, else null. */
|
|
111
|
+
hookId: string | null;
|
|
112
|
+
/** Why the document was rejected. */
|
|
113
|
+
message: string;
|
|
114
|
+
};
|
|
115
|
+
/** Options for {@link parseFixture}. */
|
|
116
|
+
export interface ParseFixtureOptions {
|
|
117
|
+
/**
|
|
118
|
+
* The hook id to use when the document omits a `hookId` key — normally the
|
|
119
|
+
* fixture filename stem (`core/hook-tests/block-core-writes.yaml` ->
|
|
120
|
+
* `block-core-writes`). A `hookId` in the document takes precedence.
|
|
121
|
+
*/
|
|
122
|
+
defaultHookId?: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether an already-parsed YAML document looks like a fixture at all. A file in
|
|
126
|
+
* `core/hook-tests/` that carries none of the fixture keys (for example
|
|
127
|
+
* `allowed-divergence.yaml`) is not a fixture and is skipped silently rather
|
|
128
|
+
* than reported as malformed.
|
|
129
|
+
*/
|
|
130
|
+
export declare function looksLikeFixture(raw: unknown): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Validate one already-parsed fixture document. Pure: never reads the disk and
|
|
133
|
+
* never throws. The version is checked *before* the cases, because a future
|
|
134
|
+
* schema may shape its cases differently — so an unrecognised version short-
|
|
135
|
+
* circuits to `unsupported-version` without attempting to interpret the body.
|
|
136
|
+
*/
|
|
137
|
+
export declare function parseFixture(raw: unknown, options?: ParseFixtureOptions): ParsedFixture;
|
|
138
|
+
/**
|
|
139
|
+
* The observed result of running one case. The caller normalises each platform's
|
|
140
|
+
* block protocol (a non-zero Claude/Codex exit, or a Grok stdout `deny`) into the
|
|
141
|
+
* single `blocked` boolean, so this module stays platform-agnostic.
|
|
142
|
+
*/
|
|
143
|
+
export interface ObservedOutcome {
|
|
144
|
+
/** True when the hook blocked the action. */
|
|
145
|
+
blocked: boolean;
|
|
146
|
+
/** Captured stderr from the hook run (empty string when none). */
|
|
147
|
+
stderr: string;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether an observed outcome satisfies a case's expectation. `stderr` patterns
|
|
151
|
+
* are treated as regular expressions, falling back to a substring test when the
|
|
152
|
+
* pattern is not valid regex so a fixture author's literal string still works.
|
|
153
|
+
*/
|
|
154
|
+
export declare function outcomeMatches(expected: ExpectedOutcome, observed: ObservedOutcome): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Classify a case outcome into a {@link DoctorStatus}, applying the
|
|
157
|
+
* `expectedFailure` rule:
|
|
158
|
+
*
|
|
159
|
+
* | met | expectedFailure | status |
|
|
160
|
+
* |-----|-----------------|--------------|
|
|
161
|
+
* | yes | no | PASS |
|
|
162
|
+
* | no | no | FAIL |
|
|
163
|
+
* | no | yes | KNOWN-DEFECT | (the pinned defect is still present)
|
|
164
|
+
* | yes | yes | WARN | (marker is stale — remove it)
|
|
165
|
+
*
|
|
166
|
+
* `met` is whether the observed outcome matched the expectation
|
|
167
|
+
* ({@link outcomeMatches}). This is the single source of truth for what a case
|
|
168
|
+
* result means; US-008 executes the case and calls this to label it.
|
|
169
|
+
*/
|
|
170
|
+
export declare function classifyCaseStatus(met: boolean, expectedFailure?: ExpectedFailure): DoctorStatus;
|
|
171
|
+
//# sourceMappingURL=schema.d.ts.map
|