@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,248 @@
|
|
|
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
|
+
/**
|
|
31
|
+
* Every fixture schema version this build of the CLI can interpret. A fixture
|
|
32
|
+
* declaring a version outside this set is reported UNKNOWN, never PASS — see
|
|
33
|
+
* {@link parseFixture}. Grows by one when the shape below changes.
|
|
34
|
+
*/
|
|
35
|
+
export const SUPPORTED_FIXTURE_SCHEMA_VERSIONS = [1];
|
|
36
|
+
/** The schema version new fixtures should be authored against. */
|
|
37
|
+
export const CURRENT_FIXTURE_SCHEMA_VERSION = 1;
|
|
38
|
+
/** Whether `value` is a schema version this build recognises. */
|
|
39
|
+
export function isSupportedSchemaVersion(value) {
|
|
40
|
+
return (typeof value === "number" &&
|
|
41
|
+
SUPPORTED_FIXTURE_SCHEMA_VERSIONS.includes(value));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether an already-parsed YAML document looks like a fixture at all. A file in
|
|
45
|
+
* `core/hook-tests/` that carries none of the fixture keys (for example
|
|
46
|
+
* `allowed-divergence.yaml`) is not a fixture and is skipped silently rather
|
|
47
|
+
* than reported as malformed.
|
|
48
|
+
*/
|
|
49
|
+
export function looksLikeFixture(raw) {
|
|
50
|
+
if (!isRecord(raw))
|
|
51
|
+
return false;
|
|
52
|
+
return "schemaVersion" in raw || "cases" in raw || "hookId" in raw;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate one already-parsed fixture document. Pure: never reads the disk and
|
|
56
|
+
* never throws. The version is checked *before* the cases, because a future
|
|
57
|
+
* schema may shape its cases differently — so an unrecognised version short-
|
|
58
|
+
* circuits to `unsupported-version` without attempting to interpret the body.
|
|
59
|
+
*/
|
|
60
|
+
export function parseFixture(raw, options = {}) {
|
|
61
|
+
if (!isRecord(raw)) {
|
|
62
|
+
return {
|
|
63
|
+
status: "invalid",
|
|
64
|
+
hookId: options.defaultHookId ?? null,
|
|
65
|
+
message: "fixture is not a mapping",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const hookId = resolveHookId(raw, options.defaultHookId);
|
|
69
|
+
if (hookId === null) {
|
|
70
|
+
return {
|
|
71
|
+
status: "invalid",
|
|
72
|
+
hookId: null,
|
|
73
|
+
message: "fixture is missing a `hookId` and no filename default was given",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!("schemaVersion" in raw)) {
|
|
77
|
+
return {
|
|
78
|
+
status: "invalid",
|
|
79
|
+
hookId,
|
|
80
|
+
message: "fixture is missing the required `schemaVersion` field",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const declaredVersion = raw.schemaVersion;
|
|
84
|
+
if (!isSupportedSchemaVersion(declaredVersion)) {
|
|
85
|
+
return {
|
|
86
|
+
status: "unsupported-version",
|
|
87
|
+
hookId,
|
|
88
|
+
declaredVersion,
|
|
89
|
+
supportedVersions: SUPPORTED_FIXTURE_SCHEMA_VERSIONS,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!("cases" in raw) || !Array.isArray(raw.cases)) {
|
|
93
|
+
return {
|
|
94
|
+
status: "invalid",
|
|
95
|
+
hookId,
|
|
96
|
+
message: "fixture `cases` must be a list",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const cases = [];
|
|
100
|
+
const seenNames = new Set();
|
|
101
|
+
for (let i = 0; i < raw.cases.length; i++) {
|
|
102
|
+
const parsed = parseCase(raw.cases[i], i);
|
|
103
|
+
if (typeof parsed === "string") {
|
|
104
|
+
return { status: "invalid", hookId, message: parsed };
|
|
105
|
+
}
|
|
106
|
+
if (seenNames.has(parsed.name)) {
|
|
107
|
+
return {
|
|
108
|
+
status: "invalid",
|
|
109
|
+
hookId,
|
|
110
|
+
message: `duplicate case name "${parsed.name}"`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
seenNames.add(parsed.name);
|
|
114
|
+
cases.push(parsed);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: "ok",
|
|
118
|
+
fixture: { hookId, schemaVersion: declaredVersion, cases },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Validate one case object; returns the case or an error message string. */
|
|
122
|
+
function parseCase(raw, index) {
|
|
123
|
+
const where = `case #${index + 1}`;
|
|
124
|
+
if (!isRecord(raw))
|
|
125
|
+
return `${where} is not a mapping`;
|
|
126
|
+
const name = raw.name;
|
|
127
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
128
|
+
return `${where} is missing a non-empty \`name\``;
|
|
129
|
+
}
|
|
130
|
+
const label = `case "${name}"`;
|
|
131
|
+
const event = raw.event;
|
|
132
|
+
if (typeof event !== "string" || event.trim() === "") {
|
|
133
|
+
return `${label} is missing a non-empty \`event\``;
|
|
134
|
+
}
|
|
135
|
+
const tool = raw.tool;
|
|
136
|
+
if (typeof tool !== "string" || tool.trim() === "") {
|
|
137
|
+
return `${label} is missing a non-empty \`tool\``;
|
|
138
|
+
}
|
|
139
|
+
if (!("input" in raw)) {
|
|
140
|
+
return `${label} is missing a tool \`input\` payload`;
|
|
141
|
+
}
|
|
142
|
+
if (!("expect" in raw)) {
|
|
143
|
+
return `${label} is missing an \`expect\` outcome`;
|
|
144
|
+
}
|
|
145
|
+
const expect = parseExpectedOutcome(raw.expect);
|
|
146
|
+
if (typeof expect === "string")
|
|
147
|
+
return `${label}: ${expect}`;
|
|
148
|
+
const expectedFailure = parseExpectedFailure(raw.expectedFailure);
|
|
149
|
+
if (typeof expectedFailure === "string")
|
|
150
|
+
return `${label}: ${expectedFailure}`;
|
|
151
|
+
const parsedCase = {
|
|
152
|
+
name,
|
|
153
|
+
event,
|
|
154
|
+
tool,
|
|
155
|
+
input: raw.input,
|
|
156
|
+
expect,
|
|
157
|
+
};
|
|
158
|
+
if (expectedFailure)
|
|
159
|
+
parsedCase.expectedFailure = expectedFailure;
|
|
160
|
+
return parsedCase;
|
|
161
|
+
}
|
|
162
|
+
/** Parse the `expect` field into an {@link ExpectedOutcome} or an error string. */
|
|
163
|
+
function parseExpectedOutcome(raw) {
|
|
164
|
+
if (raw === "block")
|
|
165
|
+
return { kind: "block" };
|
|
166
|
+
if (raw === "allow")
|
|
167
|
+
return { kind: "allow" };
|
|
168
|
+
if (isRecord(raw) && "stderr" in raw) {
|
|
169
|
+
const pattern = raw.stderr;
|
|
170
|
+
if (typeof pattern !== "string" || pattern === "") {
|
|
171
|
+
return "`expect.stderr` must be a non-empty pattern string";
|
|
172
|
+
}
|
|
173
|
+
return { kind: "stderr", pattern };
|
|
174
|
+
}
|
|
175
|
+
return "`expect` must be `block`, `allow`, or `{ stderr: <pattern> }`";
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Parse the optional `expectedFailure` field. Returns undefined when absent, the
|
|
179
|
+
* validated marker when present and well-formed, or an error string. The
|
|
180
|
+
* `reason` is mandatory — a defect must never be pinned without one.
|
|
181
|
+
*/
|
|
182
|
+
function parseExpectedFailure(raw) {
|
|
183
|
+
if (raw === undefined || raw === null)
|
|
184
|
+
return undefined;
|
|
185
|
+
if (!isRecord(raw))
|
|
186
|
+
return "`expectedFailure` must be a mapping with a `reason`";
|
|
187
|
+
const reason = raw.reason;
|
|
188
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
189
|
+
return "`expectedFailure` requires a non-empty `reason`";
|
|
190
|
+
}
|
|
191
|
+
return { reason };
|
|
192
|
+
}
|
|
193
|
+
/** The hook id from the document, falling back to the filename default. */
|
|
194
|
+
function resolveHookId(raw, defaultHookId) {
|
|
195
|
+
const declared = raw.hookId;
|
|
196
|
+
if (typeof declared === "string" && declared.trim() !== "")
|
|
197
|
+
return declared;
|
|
198
|
+
if (defaultHookId && defaultHookId.trim() !== "")
|
|
199
|
+
return defaultHookId;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether an observed outcome satisfies a case's expectation. `stderr` patterns
|
|
204
|
+
* are treated as regular expressions, falling back to a substring test when the
|
|
205
|
+
* pattern is not valid regex so a fixture author's literal string still works.
|
|
206
|
+
*/
|
|
207
|
+
export function outcomeMatches(expected, observed) {
|
|
208
|
+
switch (expected.kind) {
|
|
209
|
+
case "block":
|
|
210
|
+
return observed.blocked;
|
|
211
|
+
case "allow":
|
|
212
|
+
return !observed.blocked;
|
|
213
|
+
case "stderr":
|
|
214
|
+
return stderrMatches(expected.pattern, observed.stderr);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function stderrMatches(pattern, stderr) {
|
|
218
|
+
try {
|
|
219
|
+
return new RegExp(pattern).test(stderr);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return stderr.includes(pattern);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Classify a case outcome into a {@link DoctorStatus}, applying the
|
|
227
|
+
* `expectedFailure` rule:
|
|
228
|
+
*
|
|
229
|
+
* | met | expectedFailure | status |
|
|
230
|
+
* |-----|-----------------|--------------|
|
|
231
|
+
* | yes | no | PASS |
|
|
232
|
+
* | no | no | FAIL |
|
|
233
|
+
* | no | yes | KNOWN-DEFECT | (the pinned defect is still present)
|
|
234
|
+
* | yes | yes | WARN | (marker is stale — remove it)
|
|
235
|
+
*
|
|
236
|
+
* `met` is whether the observed outcome matched the expectation
|
|
237
|
+
* ({@link outcomeMatches}). This is the single source of truth for what a case
|
|
238
|
+
* result means; US-008 executes the case and calls this to label it.
|
|
239
|
+
*/
|
|
240
|
+
export function classifyCaseStatus(met, expectedFailure) {
|
|
241
|
+
if (expectedFailure)
|
|
242
|
+
return met ? "WARN" : "KNOWN-DEFECT";
|
|
243
|
+
return met ? "PASS" : "FAIL";
|
|
244
|
+
}
|
|
245
|
+
function isRecord(value) {
|
|
246
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the three INDEPENDENT allowlists inside `.claude/hooks/hook-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* `hook-gate.sh` defines three separate profile functions —
|
|
5
|
+
* `is_in_minimal_profile`, `is_in_standard_profile`, `is_in_strict_profile` —
|
|
6
|
+
* each a plain `case "$1" in …` allowlist. They are NOT supersets of one
|
|
7
|
+
* another: a hook id added to only one list silently no-ops under any other
|
|
8
|
+
* profile (the gate reads stdin, discards it, and returns pass-through `exit 0`
|
|
9
|
+
* — the hook never runs and there is no error). The default runtime profile is
|
|
10
|
+
* `standard`.
|
|
11
|
+
*
|
|
12
|
+
* A documented incident (policy `hq-hook-gate-three-profile-lists`) is the whole
|
|
13
|
+
* reason this parser exists: `block-hq-root-git-mutation.sh` was added only to
|
|
14
|
+
* `is_in_minimal_profile`, passed a 17-case direct-invocation test matrix, and
|
|
15
|
+
* still no-opped under the default `standard` profile. This module lets the
|
|
16
|
+
* doctor mechanically verify that every gated hook id is a member of ALL THREE
|
|
17
|
+
* profiles and name the specific missing ones when it is not.
|
|
18
|
+
*
|
|
19
|
+
* The parser is deliberately tolerant of formatting: it locates each profile
|
|
20
|
+
* function body, walks its `case` clauses, and collects the labels of every
|
|
21
|
+
* clause that leads to `return 0`. It never executes the shell.
|
|
22
|
+
*/
|
|
23
|
+
/** The three `hook-gate.sh` allowlist profiles. */
|
|
24
|
+
export type GateProfile = "minimal" | "standard" | "strict";
|
|
25
|
+
/** Canonical ordering of the gate profiles. */
|
|
26
|
+
export declare const GATE_PROFILES: readonly GateProfile[];
|
|
27
|
+
/** The hook ids allowlisted by each profile. */
|
|
28
|
+
export interface HookGateProfiles {
|
|
29
|
+
minimal: Set<string>;
|
|
30
|
+
standard: Set<string>;
|
|
31
|
+
strict: Set<string>;
|
|
32
|
+
}
|
|
33
|
+
/** Which profiles list a given hook id, and which are missing it. */
|
|
34
|
+
export interface GateMembership {
|
|
35
|
+
/** Profiles whose allowlist contains the id, in canonical order. */
|
|
36
|
+
present: GateProfile[];
|
|
37
|
+
/** Profiles whose allowlist is missing the id, in canonical order. */
|
|
38
|
+
missing: GateProfile[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse `hook-gate.sh` source into the three profile allowlists.
|
|
42
|
+
*
|
|
43
|
+
* For each profile, the `is_in_<profile>_profile()` function is located and its
|
|
44
|
+
* body scanned: every `case` clause that leads to `return 0` contributes its
|
|
45
|
+
* pipe-separated labels (the `*)` fall-through leads to `return 1` and is
|
|
46
|
+
* ignored). A profile whose function is absent, or whose allowlist is empty,
|
|
47
|
+
* yields an empty set rather than throwing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseHookGateProfiles(source: string): HookGateProfiles;
|
|
50
|
+
/**
|
|
51
|
+
* Determine which of the three profiles list `hookId`. Comparison is exact —
|
|
52
|
+
* profile allowlists key on the hook id, not the script filename.
|
|
53
|
+
*/
|
|
54
|
+
export declare function gateMembership(hookId: string, profiles: HookGateProfiles): GateMembership;
|
|
55
|
+
//# sourceMappingURL=hook-gate-profiles.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the three INDEPENDENT allowlists inside `.claude/hooks/hook-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* `hook-gate.sh` defines three separate profile functions —
|
|
5
|
+
* `is_in_minimal_profile`, `is_in_standard_profile`, `is_in_strict_profile` —
|
|
6
|
+
* each a plain `case "$1" in …` allowlist. They are NOT supersets of one
|
|
7
|
+
* another: a hook id added to only one list silently no-ops under any other
|
|
8
|
+
* profile (the gate reads stdin, discards it, and returns pass-through `exit 0`
|
|
9
|
+
* — the hook never runs and there is no error). The default runtime profile is
|
|
10
|
+
* `standard`.
|
|
11
|
+
*
|
|
12
|
+
* A documented incident (policy `hq-hook-gate-three-profile-lists`) is the whole
|
|
13
|
+
* reason this parser exists: `block-hq-root-git-mutation.sh` was added only to
|
|
14
|
+
* `is_in_minimal_profile`, passed a 17-case direct-invocation test matrix, and
|
|
15
|
+
* still no-opped under the default `standard` profile. This module lets the
|
|
16
|
+
* doctor mechanically verify that every gated hook id is a member of ALL THREE
|
|
17
|
+
* profiles and name the specific missing ones when it is not.
|
|
18
|
+
*
|
|
19
|
+
* The parser is deliberately tolerant of formatting: it locates each profile
|
|
20
|
+
* function body, walks its `case` clauses, and collects the labels of every
|
|
21
|
+
* clause that leads to `return 0`. It never executes the shell.
|
|
22
|
+
*/
|
|
23
|
+
/** Canonical ordering of the gate profiles. */
|
|
24
|
+
export const GATE_PROFILES = [
|
|
25
|
+
"minimal",
|
|
26
|
+
"standard",
|
|
27
|
+
"strict",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Parse `hook-gate.sh` source into the three profile allowlists.
|
|
31
|
+
*
|
|
32
|
+
* For each profile, the `is_in_<profile>_profile()` function is located and its
|
|
33
|
+
* body scanned: every `case` clause that leads to `return 0` contributes its
|
|
34
|
+
* pipe-separated labels (the `*)` fall-through leads to `return 1` and is
|
|
35
|
+
* ignored). A profile whose function is absent, or whose allowlist is empty,
|
|
36
|
+
* yields an empty set rather than throwing.
|
|
37
|
+
*/
|
|
38
|
+
export function parseHookGateProfiles(source) {
|
|
39
|
+
return {
|
|
40
|
+
minimal: extractProfileIds(source, "minimal"),
|
|
41
|
+
standard: extractProfileIds(source, "standard"),
|
|
42
|
+
strict: extractProfileIds(source, "strict"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Determine which of the three profiles list `hookId`. Comparison is exact —
|
|
47
|
+
* profile allowlists key on the hook id, not the script filename.
|
|
48
|
+
*/
|
|
49
|
+
export function gateMembership(hookId, profiles) {
|
|
50
|
+
const present = [];
|
|
51
|
+
const missing = [];
|
|
52
|
+
for (const profile of GATE_PROFILES) {
|
|
53
|
+
if (profiles[profile].has(hookId))
|
|
54
|
+
present.push(profile);
|
|
55
|
+
else
|
|
56
|
+
missing.push(profile);
|
|
57
|
+
}
|
|
58
|
+
return { present, missing };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract the allowlisted ids of one profile function from `hook-gate.sh`
|
|
62
|
+
* source. Returns an empty set when the function is absent or its allowlist is
|
|
63
|
+
* empty (an all-`*)` case body).
|
|
64
|
+
*/
|
|
65
|
+
function extractProfileIds(source, profile) {
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
const body = profileFunctionBody(source, profile);
|
|
68
|
+
if (body === null)
|
|
69
|
+
return ids;
|
|
70
|
+
// A `case` body is a series of `<labels>) <commands> ;;` clauses. Splitting on
|
|
71
|
+
// `;;` isolates each clause; the ones whose commands include `return 0` are the
|
|
72
|
+
// allowlist entries. The label portion is everything before that clause's first
|
|
73
|
+
// `)`; the first clause additionally carries the `case "$1" in` preamble, which
|
|
74
|
+
// is stripped at the `in` keyword (no hook id is the bare word `in`).
|
|
75
|
+
for (const clause of body.split(";;")) {
|
|
76
|
+
if (!/\breturn\s+0\b/.test(clause))
|
|
77
|
+
continue;
|
|
78
|
+
const closeParen = clause.indexOf(")");
|
|
79
|
+
if (closeParen < 0)
|
|
80
|
+
continue;
|
|
81
|
+
let labels = clause.slice(0, closeParen);
|
|
82
|
+
const inKeyword = /\bin\b/.exec(labels);
|
|
83
|
+
if (inKeyword)
|
|
84
|
+
labels = labels.slice(inKeyword.index + inKeyword[0].length);
|
|
85
|
+
for (const token of labels.split("|")) {
|
|
86
|
+
const id = token.trim();
|
|
87
|
+
if (id.length > 0 && id !== "*" && !/\s/.test(id))
|
|
88
|
+
ids.add(id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return ids;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Return the body (between the opening `{` and its matching line-leading `}`) of
|
|
95
|
+
* `is_in_<profile>_profile()`, or null when the function is not defined. The
|
|
96
|
+
* body contains a `case … esac`, never a nested `}`, so the first line-leading
|
|
97
|
+
* `}` after the opening brace is the function's own close.
|
|
98
|
+
*/
|
|
99
|
+
function profileFunctionBody(source, profile) {
|
|
100
|
+
const open = new RegExp(`is_in_${profile}_profile\\s*\\(\\)\\s*\\{`).exec(source);
|
|
101
|
+
if (!open)
|
|
102
|
+
return null;
|
|
103
|
+
const rest = source.slice(open.index + open[0].length);
|
|
104
|
+
const close = rest.search(/\n\s*\}/);
|
|
105
|
+
return close >= 0 ? rest.slice(0, close) : rest;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=hook-gate-profiles.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--json` output document for `hq doctor`, and its versioned contract.
|
|
3
|
+
*
|
|
4
|
+
* The JSON surface is what US-014's advisory CI job and US-012's harness-audit
|
|
5
|
+
* integration consume, so it has to be stable from the first release. Two
|
|
6
|
+
* guarantees make that possible:
|
|
7
|
+
*
|
|
8
|
+
* - {@link DOCTOR_JSON_SCHEMA_VERSION} is a single explicit integer, pinned by
|
|
9
|
+
* a snapshot test. A breaking change to the shape forces the version to
|
|
10
|
+
* move, and moving it forces the snapshot to be updated in the same diff —
|
|
11
|
+
* so the contract cannot change silently.
|
|
12
|
+
*
|
|
13
|
+
* - The document is a pure function of the resolved root, the detected
|
|
14
|
+
* platform, and the family results. It performs no I/O and no formatting
|
|
15
|
+
* beyond serialisation, so it renders identically in a TTY, a pipe, or CI.
|
|
16
|
+
*
|
|
17
|
+
* `target` and `remediation` are emitted only when present, matching the
|
|
18
|
+
* per-item result shape where both are optional.
|
|
19
|
+
*/
|
|
20
|
+
import { type DoctorPlatform, type StatusCounts } from "./report.js";
|
|
21
|
+
import type { DoctorStatus, FamilyRun } from "./types.js";
|
|
22
|
+
/**
|
|
23
|
+
* The `--json` schema version. Bump on any breaking change to the document
|
|
24
|
+
* shape; the snapshot test in report.test.ts fails until the bump is
|
|
25
|
+
* acknowledged, which is the whole point.
|
|
26
|
+
*
|
|
27
|
+
* v2 (US-011): each finding may carry a structured `fix` object (auto-fixable?,
|
|
28
|
+
* exact action, equivalent command). It appears regardless of whether `--fix`
|
|
29
|
+
* was passed, so a consumer can see what `--fix` would do without invoking it.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DOCTOR_JSON_SCHEMA_VERSION = 2;
|
|
32
|
+
/**
|
|
33
|
+
* The structured remediation of a finding, as it appears in the JSON document
|
|
34
|
+
* (US-011 AC1). Present on any actionable finding (FAIL/WARN); a `command` is ""
|
|
35
|
+
* when there is no single equivalent shell command.
|
|
36
|
+
*/
|
|
37
|
+
export interface DoctorJsonFix {
|
|
38
|
+
/** Whether `hq doctor --fix` is allowed to repair this finding mechanically. */
|
|
39
|
+
autoFixable: boolean;
|
|
40
|
+
/** Plain-language description of the exact repair. */
|
|
41
|
+
action: string;
|
|
42
|
+
/** The equivalent shell command, or "" when none exists. */
|
|
43
|
+
command: string;
|
|
44
|
+
}
|
|
45
|
+
/** One result as it appears in the JSON document. */
|
|
46
|
+
export interface DoctorJsonResult {
|
|
47
|
+
/** Status from the fixed vocabulary. */
|
|
48
|
+
status: DoctorStatus;
|
|
49
|
+
/** The family that produced this result, e.g. `hooks`. */
|
|
50
|
+
family: string;
|
|
51
|
+
/** Stable machine id for the check, e.g. `hooks.settings-present`. */
|
|
52
|
+
checkId: string;
|
|
53
|
+
/** What was inspected. Present only when the result carries one. */
|
|
54
|
+
target?: string;
|
|
55
|
+
/** Human-readable explanation. */
|
|
56
|
+
message: string;
|
|
57
|
+
/** Remediation hint. Present only when the result carries one. */
|
|
58
|
+
remediation?: string;
|
|
59
|
+
/** Structured remediation (US-011). Present on actionable findings only. */
|
|
60
|
+
fix?: DoctorJsonFix;
|
|
61
|
+
}
|
|
62
|
+
/** The complete `--json` document. */
|
|
63
|
+
export interface DoctorJsonDocument {
|
|
64
|
+
/** Output-contract version. See {@link DOCTOR_JSON_SCHEMA_VERSION}. */
|
|
65
|
+
schemaVersion: number;
|
|
66
|
+
/** The detected agent platform and its evidence (placeholder until US-003). */
|
|
67
|
+
platform: DoctorPlatform;
|
|
68
|
+
/** The resolved HQ tree root. */
|
|
69
|
+
hqRoot: string;
|
|
70
|
+
/** Per-status tallies across all results. */
|
|
71
|
+
summary: StatusCounts;
|
|
72
|
+
/** The exit code this run produced: 0, or 1 when any FAIL/UNKNOWN is present. */
|
|
73
|
+
exitCode: number;
|
|
74
|
+
/** The full, flattened result list in family/registration order. */
|
|
75
|
+
results: DoctorJsonResult[];
|
|
76
|
+
}
|
|
77
|
+
/** Input to {@link buildDoctorJson}. */
|
|
78
|
+
export interface BuildDoctorJsonInput {
|
|
79
|
+
/** The resolved HQ root. */
|
|
80
|
+
hqRoot: string;
|
|
81
|
+
/** Grouped per-family results. */
|
|
82
|
+
families: FamilyRun[];
|
|
83
|
+
/** Detected platform. Defaults to {@link UNKNOWN_PLATFORM}. */
|
|
84
|
+
platform?: DoctorPlatform;
|
|
85
|
+
}
|
|
86
|
+
/** Assemble the machine-readable document from a completed run. */
|
|
87
|
+
export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
|
|
88
|
+
/** Serialise the document as pretty-printed JSON with a trailing newline. */
|
|
89
|
+
export declare function renderJson(document: DoctorJsonDocument): string;
|
|
90
|
+
//# sourceMappingURL=json-output.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--json` output document for `hq doctor`, and its versioned contract.
|
|
3
|
+
*
|
|
4
|
+
* The JSON surface is what US-014's advisory CI job and US-012's harness-audit
|
|
5
|
+
* integration consume, so it has to be stable from the first release. Two
|
|
6
|
+
* guarantees make that possible:
|
|
7
|
+
*
|
|
8
|
+
* - {@link DOCTOR_JSON_SCHEMA_VERSION} is a single explicit integer, pinned by
|
|
9
|
+
* a snapshot test. A breaking change to the shape forces the version to
|
|
10
|
+
* move, and moving it forces the snapshot to be updated in the same diff —
|
|
11
|
+
* so the contract cannot change silently.
|
|
12
|
+
*
|
|
13
|
+
* - The document is a pure function of the resolved root, the detected
|
|
14
|
+
* platform, and the family results. It performs no I/O and no formatting
|
|
15
|
+
* beyond serialisation, so it renders identically in a TTY, a pipe, or CI.
|
|
16
|
+
*
|
|
17
|
+
* `target` and `remediation` are emitted only when present, matching the
|
|
18
|
+
* per-item result shape where both are optional.
|
|
19
|
+
*/
|
|
20
|
+
import { computeExitCode, summarize, UNKNOWN_PLATFORM, } from "./report.js";
|
|
21
|
+
import { deriveRemediation } from "./fix/remediation.js";
|
|
22
|
+
/**
|
|
23
|
+
* The `--json` schema version. Bump on any breaking change to the document
|
|
24
|
+
* shape; the snapshot test in report.test.ts fails until the bump is
|
|
25
|
+
* acknowledged, which is the whole point.
|
|
26
|
+
*
|
|
27
|
+
* v2 (US-011): each finding may carry a structured `fix` object (auto-fixable?,
|
|
28
|
+
* exact action, equivalent command). It appears regardless of whether `--fix`
|
|
29
|
+
* was passed, so a consumer can see what `--fix` would do without invoking it.
|
|
30
|
+
*/
|
|
31
|
+
export const DOCTOR_JSON_SCHEMA_VERSION = 2;
|
|
32
|
+
/** Assemble the machine-readable document from a completed run. */
|
|
33
|
+
export function buildDoctorJson(input) {
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const run of input.families) {
|
|
36
|
+
for (const result of run.results) {
|
|
37
|
+
results.push(toJsonResult(run.family.id, result));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
schemaVersion: DOCTOR_JSON_SCHEMA_VERSION,
|
|
42
|
+
platform: input.platform ?? UNKNOWN_PLATFORM,
|
|
43
|
+
hqRoot: input.hqRoot,
|
|
44
|
+
summary: summarize(input.families),
|
|
45
|
+
exitCode: computeExitCode(input.families),
|
|
46
|
+
results,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Convert one {@link CheckResult} into its JSON form, omitting empty optionals. */
|
|
50
|
+
function toJsonResult(familyId, result) {
|
|
51
|
+
const remediation = deriveRemediation(result);
|
|
52
|
+
return {
|
|
53
|
+
status: result.status,
|
|
54
|
+
family: familyId,
|
|
55
|
+
checkId: result.checkId,
|
|
56
|
+
...(result.target !== undefined ? { target: result.target } : {}),
|
|
57
|
+
message: result.message,
|
|
58
|
+
...(result.remediation !== undefined
|
|
59
|
+
? { remediation: result.remediation }
|
|
60
|
+
: {}),
|
|
61
|
+
...(remediation
|
|
62
|
+
? {
|
|
63
|
+
fix: {
|
|
64
|
+
autoFixable: remediation.autoFixable,
|
|
65
|
+
action: remediation.action,
|
|
66
|
+
command: remediation.command,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Serialise the document as pretty-printed JSON with a trailing newline. */
|
|
73
|
+
export function renderJson(document) {
|
|
74
|
+
return JSON.stringify(document, null, 2) + "\n";
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=json-output.js.map
|