heyspex 0.2.5 → 0.2.7

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.
Files changed (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -0,0 +1,1046 @@
1
+ import { createRequire as __hsCreateRequire } from 'node:module';
2
+ const require = __hsCreateRequire(import.meta.url);
3
+
4
+ // spex-console/bin/hs-test.ts
5
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
6
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path2 from "node:path";
9
+ import process3 from "node:process";
10
+
11
+ // spex-console/src/lib/cli/mutation-gate.ts
12
+ var EXIT = {
13
+ /** The suite passed the gate. */
14
+ OK: 0,
15
+ /** Usage error — unknown flag, bad verb, or out-of-range threshold. */
16
+ USAGE: 1,
17
+ /** Report file / target not found. */
18
+ NOT_FOUND: 10,
19
+ /** Report present but malformed (not the mutation-testing-elements schema). */
20
+ INVALID_REPORT: 11,
21
+ /** The mutation runner is not installed / not on PATH. */
22
+ RUNNER_MISSING: 12,
23
+ /** The gate ran and rejected the suite (a real reject, not a harness error). */
24
+ GATE_FAILED: 20
25
+ };
26
+ function emptyCounts() {
27
+ return { killed: 0, survived: 0, noCoverage: 0, timeout: 0, invalid: 0 };
28
+ }
29
+ function parseGateArgs(argv) {
30
+ const verbRaw = argv[0];
31
+ if (verbRaw === "--help" || verbRaw === "-h") return { help: true };
32
+ if (verbRaw !== "gate") {
33
+ return { error: { code: EXIT.USAGE, message: `unknown verb: ${verbRaw ?? "(none)"}` } };
34
+ }
35
+ let target;
36
+ let reportPath = null;
37
+ let threshold = null;
38
+ let json = false;
39
+ for (let i = 1; i < argv.length; i++) {
40
+ const arg = argv[i];
41
+ if (arg === "--help" || arg === "-h") return { help: true };
42
+ else if (arg === "--json") json = true;
43
+ else if (arg === "--report" || arg.startsWith("--report=")) {
44
+ const value = arg.includes("=") ? arg.slice("--report=".length) : argv[++i];
45
+ if (value === void 0 || value.trim() === "") {
46
+ return { error: { code: EXIT.USAGE, message: "--report requires a path" } };
47
+ }
48
+ reportPath = value;
49
+ } else if (arg === "--threshold" || arg.startsWith("--threshold=")) {
50
+ const value = arg.includes("=") ? arg.slice("--threshold=".length) : argv[++i];
51
+ if (value === void 0) {
52
+ return { error: { code: EXIT.USAGE, message: "--threshold requires a value (0\u2013100)" } };
53
+ }
54
+ const n = Number(value);
55
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
56
+ return {
57
+ error: { code: EXIT.USAGE, message: `--threshold must be a number 0\u2013100, got: ${value}` }
58
+ };
59
+ }
60
+ threshold = n;
61
+ } else if (arg.startsWith("-")) {
62
+ return { error: { code: EXIT.USAGE, message: `unknown flag: ${arg}` } };
63
+ } else if (target === void 0) {
64
+ target = arg;
65
+ } else {
66
+ return { error: { code: EXIT.USAGE, message: `unexpected argument: ${arg}` } };
67
+ }
68
+ }
69
+ return { args: { verb: "gate", target: target ?? ".", reportPath, threshold, json } };
70
+ }
71
+ function tally(counts, rawStatus) {
72
+ const status = String(rawStatus ?? "").trim().toLowerCase();
73
+ switch (status) {
74
+ case "killed":
75
+ counts.killed += 1;
76
+ break;
77
+ case "survived":
78
+ counts.survived += 1;
79
+ break;
80
+ case "nocoverage":
81
+ counts.noCoverage += 1;
82
+ break;
83
+ case "timeout":
84
+ counts.timeout += 1;
85
+ break;
86
+ // CompileError / RuntimeError / Ignored / Pending don't count toward the score.
87
+ default:
88
+ counts.invalid += 1;
89
+ break;
90
+ }
91
+ }
92
+ function parseMutationReport(text) {
93
+ let raw;
94
+ try {
95
+ raw = JSON.parse(text);
96
+ } catch (err) {
97
+ return {
98
+ error: `report is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
99
+ };
100
+ }
101
+ const files = raw?.files;
102
+ if (typeof raw !== "object" || raw === null || typeof files !== "object" || files === null) {
103
+ return { error: "report must be a mutation-testing-elements object with a `files` map" };
104
+ }
105
+ const total = emptyCounts();
106
+ const perFile = [];
107
+ for (const [path3, fileRaw] of Object.entries(files)) {
108
+ const mutants = fileRaw?.mutants;
109
+ if (!Array.isArray(mutants)) {
110
+ return { error: `files["${path3}"] is missing a "mutants" array` };
111
+ }
112
+ const fileCounts = emptyCounts();
113
+ for (const m of mutants) {
114
+ const status = m?.status;
115
+ tally(fileCounts, status);
116
+ tally(total, status);
117
+ }
118
+ perFile.push({ path: path3, counts: fileCounts });
119
+ }
120
+ return { report: { counts: total, files: perFile } };
121
+ }
122
+ function computeScores(counts) {
123
+ const detected = counts.killed + counts.timeout;
124
+ const undetected = counts.survived + counts.noCoverage;
125
+ const valid = detected + undetected;
126
+ const covered = detected + counts.survived;
127
+ return {
128
+ detected,
129
+ undetected,
130
+ valid,
131
+ covered,
132
+ mutationScore: valid > 0 ? detected / valid * 100 : null,
133
+ mutationScoreBasedOnCoveredCode: covered > 0 ? detected / covered * 100 : null
134
+ };
135
+ }
136
+ function evaluateGate(report, threshold) {
137
+ const scores = computeScores(report.counts);
138
+ const reasons = [];
139
+ if (scores.valid === 0) {
140
+ reasons.push("no valid mutants were produced \u2014 nothing to prove the suite against");
141
+ } else if (scores.detected === 0) {
142
+ reasons.push(
143
+ `the suite killed 0 of ${scores.valid} mutants \u2014 it detects no injected bug (AC1)`
144
+ );
145
+ }
146
+ if (threshold !== null && scores.mutationScore !== null && scores.detected > 0 && scores.mutationScore < threshold) {
147
+ reasons.push(
148
+ `mutation score ${scores.mutationScore.toFixed(1)}% is below the required ${threshold}% threshold`
149
+ );
150
+ }
151
+ return {
152
+ passed: reasons.length === 0,
153
+ counts: report.counts,
154
+ scores,
155
+ threshold,
156
+ reasons,
157
+ files: report.files.map((f) => ({
158
+ path: f.path,
159
+ counts: f.counts,
160
+ scores: computeScores(f.counts)
161
+ }))
162
+ };
163
+ }
164
+ function gateExitCode(report) {
165
+ return report.passed ? EXIT.OK : EXIT.GATE_FAILED;
166
+ }
167
+ function scoreLabel(score) {
168
+ return score === null ? "n/a" : `${score.toFixed(1)}%`;
169
+ }
170
+ function toText(report) {
171
+ const { counts, scores } = report;
172
+ const lines = [];
173
+ const verdict = report.passed ? "PASS" : "REJECT";
174
+ lines.push(
175
+ `[${verdict}] mutation gate \u2014 score ${scoreLabel(scores.mutationScore)} (detected ${scores.detected}/${scores.valid})`
176
+ );
177
+ lines.push(
178
+ ` killed ${counts.killed} \xB7 timeout ${counts.timeout} \xB7 survived ${counts.survived} \xB7 no-coverage ${counts.noCoverage}` + (counts.invalid > 0 ? ` \xB7 uncounted ${counts.invalid}` : "")
179
+ );
180
+ if (report.threshold !== null) {
181
+ lines.push(
182
+ ` threshold ${report.threshold}% (covered-code score ${scoreLabel(scores.mutationScoreBasedOnCoveredCode)})`
183
+ );
184
+ }
185
+ if (report.files.length > 1) {
186
+ for (const f of report.files) {
187
+ lines.push(` ${scoreLabel(f.scores.mutationScore).padStart(6)} ${f.path}`);
188
+ }
189
+ }
190
+ if (!report.passed) {
191
+ lines.push("");
192
+ for (const r of report.reasons) lines.push(` \u2717 ${r}`);
193
+ }
194
+ return lines.join("\n");
195
+ }
196
+ function formatGateReport(report) {
197
+ return toText(report);
198
+ }
199
+ function gateReportToJson(report) {
200
+ return {
201
+ ok: report.passed,
202
+ mutationScore: report.scores.mutationScore,
203
+ mutationScoreBasedOnCoveredCode: report.scores.mutationScoreBasedOnCoveredCode,
204
+ threshold: report.threshold,
205
+ counts: report.counts,
206
+ detected: report.scores.detected,
207
+ valid: report.scores.valid,
208
+ reasons: report.reasons,
209
+ files: report.files.map((f) => ({
210
+ path: f.path,
211
+ mutationScore: f.scores.mutationScore,
212
+ counts: f.counts
213
+ }))
214
+ };
215
+ }
216
+ var GATE_HELP = [
217
+ "Usage:",
218
+ " hs test gate [<project-dir>] [--report <mutation-report.json>] [--threshold <0-100>] [--json]",
219
+ "",
220
+ "The deterministic mutation-testing gate (HS-EPIC-X7C8). A mutation runner",
221
+ "(Stryker / equivalent) injects bugs; a suite that kills NO mutants detects no",
222
+ "injected bug and is REJECTED. Tests may be written by your own AI agent \u2014 the",
223
+ "gate is what makes them trustworthy. HeySpex builds no AI and adds no COGS.",
224
+ "",
225
+ "Input is a Stryker mutation-report.json in the cross-tool",
226
+ "mutation-testing-elements schema, so any runner that emits it can feed the gate.",
227
+ "",
228
+ "Arguments:",
229
+ " <project-dir> Where to run the mutation runner (default: current dir).",
230
+ " Ignored when --report points at an existing report.",
231
+ "",
232
+ "Flags:",
233
+ " --report <path> Gate an existing mutation-report.json (no runner spawn).",
234
+ " --threshold <n> Also reject if the mutation score is below n% (0\u2013100).",
235
+ " --json Emit a machine-readable JSON result.",
236
+ " --help, -h Show this help.",
237
+ "",
238
+ "Exit codes:",
239
+ " 0 pass \xB7 1 usage \xB7 10 not-found \xB7 11 bad-report \xB7 12 runner-missing \xB7 20 gate-rejected"
240
+ ].join("\n");
241
+
242
+ // spex-console/src/lib/cli/schema-conformance.ts
243
+ var EXIT2 = {
244
+ /** The implementation conforms to the spec — no drift. */
245
+ OK: 0,
246
+ /** Usage error — unknown flag, bad verb, or missing required argument. */
247
+ USAGE: 1,
248
+ /** Spec / report file not found. */
249
+ NOT_FOUND: 10,
250
+ /** Report present but malformed (not parseable JUnit XML). */
251
+ INVALID_REPORT: 11,
252
+ /** Schemathesis is not installed / not on PATH. */
253
+ RUNNER_MISSING: 12,
254
+ /** Conformance ran and found drift (a real divergence, not a harness error). */
255
+ DRIFT_FOUND: 20
256
+ };
257
+ function parseConformArgs(argv) {
258
+ const verbRaw = argv[0];
259
+ if (verbRaw === "--help" || verbRaw === "-h") return { help: true };
260
+ if (verbRaw !== "conform") {
261
+ return { error: { code: EXIT2.USAGE, message: `unknown verb: ${verbRaw ?? "(none)"}` } };
262
+ }
263
+ let spec;
264
+ let baseUrl = null;
265
+ let reportPath = null;
266
+ let json = false;
267
+ for (let i = 1; i < argv.length; i++) {
268
+ const arg = argv[i];
269
+ if (arg === "--help" || arg === "-h") return { help: true };
270
+ else if (arg === "--json") json = true;
271
+ else if (arg === "--url" || arg.startsWith("--url=")) {
272
+ const value = arg.includes("=") ? arg.slice("--url=".length) : argv[++i];
273
+ if (value === void 0 || value.trim() === "") {
274
+ return { error: { code: EXIT2.USAGE, message: "--url requires a base URL" } };
275
+ }
276
+ baseUrl = value;
277
+ } else if (arg === "--report" || arg.startsWith("--report=")) {
278
+ const value = arg.includes("=") ? arg.slice("--report=".length) : argv[++i];
279
+ if (value === void 0 || value.trim() === "") {
280
+ return { error: { code: EXIT2.USAGE, message: "--report requires a path" } };
281
+ }
282
+ reportPath = value;
283
+ } else if (arg.startsWith("-")) {
284
+ return { error: { code: EXIT2.USAGE, message: `unknown flag: ${arg}` } };
285
+ } else if (spec === void 0) {
286
+ spec = arg;
287
+ } else {
288
+ return { error: { code: EXIT2.USAGE, message: `unexpected argument: ${arg}` } };
289
+ }
290
+ }
291
+ if (reportPath === null) {
292
+ if (!spec) {
293
+ return {
294
+ error: {
295
+ code: EXIT2.USAGE,
296
+ message: "conform requires an OpenAPI spec (or --report <junit.xml>)"
297
+ }
298
+ };
299
+ }
300
+ if (!baseUrl) {
301
+ return {
302
+ error: {
303
+ code: EXIT2.USAGE,
304
+ message: "conform requires --url <base-url> when running Schemathesis"
305
+ }
306
+ };
307
+ }
308
+ }
309
+ return { args: { verb: "conform", spec: spec ?? "", baseUrl, reportPath, json } };
310
+ }
311
+ function decodeXml(s) {
312
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
313
+ }
314
+ function attr(attrs, key) {
315
+ const m = attrs.match(new RegExp(`\\b${key}\\s*=\\s*"([^"]*)"|\\b${key}\\s*=\\s*'([^']*)'`));
316
+ if (!m) return null;
317
+ return decodeXml(m[1] ?? m[2] ?? "");
318
+ }
319
+ function parseJUnitReport(xml) {
320
+ if (typeof xml !== "string" || !/<testsuites?\b|<testcase\b/.test(xml)) {
321
+ return { error: "report is not JUnit XML (no <testsuite>/<testcase> found)" };
322
+ }
323
+ const findings = [];
324
+ let total = 0;
325
+ const caseRe = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
326
+ let m;
327
+ while ((m = caseRe.exec(xml)) !== null) {
328
+ total += 1;
329
+ const attrs = m[1];
330
+ const body = m[3] ?? "";
331
+ const operation = attr(attrs, "name") ?? attr(attrs, "classname") ?? "(unnamed operation)";
332
+ const failRe = /<(failure|error)\b([^>]*?)(\/>|>([\s\S]*?)<\/\1>)/g;
333
+ let f;
334
+ while ((f = failRe.exec(body)) !== null) {
335
+ const kind = f[1] === "error" ? "error" : "failure";
336
+ const failAttrs = f[2];
337
+ const text = (f[4] ?? "").trim();
338
+ const messageAttr = attr(failAttrs, "message");
339
+ const message = messageAttr && messageAttr.trim() || decodeXml(text) || "conformance check failed";
340
+ findings.push({ operation, kind, message });
341
+ }
342
+ }
343
+ const failed = new Set(findings.map((x) => x.operation)).size;
344
+ return { report: { findings, total, failed, passed: findings.length === 0 } };
345
+ }
346
+ function conformExitCode(report) {
347
+ return report.passed ? EXIT2.OK : EXIT2.DRIFT_FOUND;
348
+ }
349
+ function oneLine(message) {
350
+ const line = message.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? message.trim();
351
+ return line.length > 160 ? `${line.slice(0, 157)}\u2026` : line;
352
+ }
353
+ function toText2(report) {
354
+ if (report.total === 0) {
355
+ return "No operations exercised (empty Schemathesis report).";
356
+ }
357
+ if (report.passed) {
358
+ return `[PASS] OpenAPI conformance \u2014 ${report.total} operation(s) conform, no drift.`;
359
+ }
360
+ const lines = [
361
+ `[DRIFT] OpenAPI conformance \u2014 ${report.failed}/${report.total} operation(s) diverge from the spec:`
362
+ ];
363
+ for (const f of report.findings) {
364
+ lines.push(` \u2717 ${f.operation} \u2014 ${oneLine(f.message)}`);
365
+ }
366
+ return lines.join("\n");
367
+ }
368
+ function formatConformReport(report) {
369
+ return toText2(report);
370
+ }
371
+ function conformReportToJson(report) {
372
+ return {
373
+ ok: report.passed,
374
+ total: report.total,
375
+ failed: report.failed,
376
+ drift: report.findings.map((f) => ({
377
+ operation: f.operation,
378
+ kind: f.kind,
379
+ message: f.message
380
+ }))
381
+ };
382
+ }
383
+ var CONFORM_HELP = [
384
+ "Usage:",
385
+ " hs test conform <openapi-spec> --url <base-url> [--json]",
386
+ " hs test conform --report <schemathesis-junit.xml> [--json]",
387
+ "",
388
+ "OpenAPI conformance + docs\u2194implementation drift (HS-EPIC-X7C8 AC2). Schemathesis",
389
+ "generates deterministic property-based cases from the spec and runs them against",
390
+ "the live API; any response/schema divergence is surfaced as a drift finding. The",
391
+ "spec is the docs, the live responses are the implementation \u2014 a divergence is",
392
+ "docs\u2194impl drift. HeySpex builds no AI and adds no COGS.",
393
+ "",
394
+ "Arguments:",
395
+ " <openapi-spec> Path or URL to the OpenAPI/Swagger document.",
396
+ "",
397
+ "Flags:",
398
+ " --url <base> Base URL of the running API under test (required to run).",
399
+ " --report <path> Evaluate an existing Schemathesis JUnit report (no spawn).",
400
+ " --json Emit a machine-readable JSON result.",
401
+ " --help, -h Show this help.",
402
+ "",
403
+ "Requires Schemathesis on PATH (pip install schemathesis). Exit codes:",
404
+ " 0 conforms \xB7 1 usage \xB7 10 not-found \xB7 11 bad-report \xB7 12 schemathesis-missing \xB7 20 drift-found"
405
+ ].join("\n");
406
+
407
+ // spex-console/src/lib/cli/test-run.ts
408
+ var EXIT3 = {
409
+ /** Success — every test (and thus every linked acceptance criterion) passed. */
410
+ OK: 0,
411
+ /** Usage error — unknown flag, missing target, or bad verb. */
412
+ USAGE: 1,
413
+ /** Manifest / target not found. */
414
+ NOT_FOUND: 10,
415
+ /** Manifest present but malformed (not `{ tests: [...] }`, or a test lacks `file`). */
416
+ INVALID_MANIFEST: 11,
417
+ /** The Hurl runner is not installed / not on PATH. */
418
+ RUNNER_MISSING: 12,
419
+ /** Tests ran and at least one failed (a real red, not a harness error). */
420
+ TESTS_FAILED: 20
421
+ };
422
+ var UNLINKED = "(unlinked)";
423
+ function parseTestRunArgs(argv) {
424
+ const verbRaw = argv[0];
425
+ if (verbRaw === "--help" || verbRaw === "-h") return { help: true };
426
+ if (verbRaw !== "run") {
427
+ return { error: { code: EXIT3.USAGE, message: `unknown verb: ${verbRaw ?? "(none)"}` } };
428
+ }
429
+ let target;
430
+ let format = "text";
431
+ let json = false;
432
+ for (let i = 1; i < argv.length; i++) {
433
+ const arg = argv[i];
434
+ if (arg === "--help" || arg === "-h") return { help: true };
435
+ else if (arg === "--json") json = true;
436
+ else if (arg === "--format" || arg.startsWith("--format=")) {
437
+ const value = arg.includes("=") ? arg.slice("--format=".length) : argv[++i];
438
+ if (value === void 0) {
439
+ return {
440
+ error: { code: EXIT3.USAGE, message: "--format requires a value (text|junit|tap)" }
441
+ };
442
+ }
443
+ if (value !== "text" && value !== "junit" && value !== "tap") {
444
+ return {
445
+ error: {
446
+ code: EXIT3.USAGE,
447
+ message: `unknown format: ${value} (expected text|junit|tap)`
448
+ }
449
+ };
450
+ }
451
+ format = value;
452
+ } else if (arg === "--manifest" || arg.startsWith("--manifest=")) {
453
+ const value = arg.includes("=") ? arg.slice("--manifest=".length) : argv[++i];
454
+ if (value === void 0) {
455
+ return { error: { code: EXIT3.USAGE, message: "--manifest requires a value" } };
456
+ }
457
+ target = value;
458
+ } else if (arg.startsWith("-")) {
459
+ return { error: { code: EXIT3.USAGE, message: `unknown flag: ${arg}` } };
460
+ } else if (target === void 0) {
461
+ target = arg;
462
+ } else {
463
+ return { error: { code: EXIT3.USAGE, message: `unexpected argument: ${arg}` } };
464
+ }
465
+ }
466
+ if (!target) {
467
+ return { error: { code: EXIT3.USAGE, message: "run requires a spec id or manifest path" } };
468
+ }
469
+ return { args: { verb: "run", target, format, json } };
470
+ }
471
+ function basename(file) {
472
+ const parts = file.split(/[\\/]/);
473
+ return parts[parts.length - 1] || file;
474
+ }
475
+ function parseManifest(text) {
476
+ let raw;
477
+ try {
478
+ raw = JSON.parse(text);
479
+ } catch (err) {
480
+ return {
481
+ error: `manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
482
+ };
483
+ }
484
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.tests)) {
485
+ return { error: "manifest must be an object with a `tests` array" };
486
+ }
487
+ const tests = [];
488
+ const rawTests = raw.tests;
489
+ for (let i = 0; i < rawTests.length; i++) {
490
+ const t = rawTests[i];
491
+ if (typeof t !== "object" || t === null) {
492
+ return { error: `tests[${i}] must be an object` };
493
+ }
494
+ const rec = t;
495
+ if (typeof rec.file !== "string" || rec.file.trim() === "") {
496
+ return { error: `tests[${i}] is missing a non-empty "file"` };
497
+ }
498
+ const file = rec.file;
499
+ const name = typeof rec.name === "string" && rec.name.trim() !== "" ? rec.name : basename(file);
500
+ const acRaw = rec.linked_ac ?? rec.linkedAc;
501
+ const linkedAc = typeof acRaw === "string" && acRaw.trim() !== "" ? acRaw : null;
502
+ tests.push({ file, name, linkedAc });
503
+ }
504
+ return { manifest: { tests } };
505
+ }
506
+ function aggregateByAc(executions) {
507
+ const order = [];
508
+ const byAc = /* @__PURE__ */ new Map();
509
+ for (const ex of executions) {
510
+ const ac = ex.entry.linkedAc ?? UNLINKED;
511
+ let agg = byAc.get(ac);
512
+ if (!agg) {
513
+ agg = { total: 0, failed: 0 };
514
+ byAc.set(ac, agg);
515
+ order.push(ac);
516
+ }
517
+ agg.total += 1;
518
+ if (!ex.passed) agg.failed += 1;
519
+ }
520
+ return order.map((ac) => {
521
+ const agg = byAc.get(ac);
522
+ return { ac, passed: agg.failed === 0, total: agg.total, failed: agg.failed };
523
+ });
524
+ }
525
+ async function runManifest(tests, deps) {
526
+ const executions = [];
527
+ for (const entry of tests) {
528
+ let outcome;
529
+ try {
530
+ outcome = await deps.runHurl(entry.file);
531
+ } catch (err) {
532
+ outcome = {
533
+ passed: false,
534
+ durationMs: 0,
535
+ message: err instanceof Error ? err.message : String(err)
536
+ };
537
+ }
538
+ executions.push({
539
+ entry,
540
+ passed: outcome.passed,
541
+ durationMs: outcome.durationMs,
542
+ message: outcome.passed ? void 0 : outcome.message
543
+ });
544
+ }
545
+ const failedCount = executions.filter((e) => !e.passed).length;
546
+ const durationMs = executions.reduce((sum, e) => sum + e.durationMs, 0);
547
+ return {
548
+ executions,
549
+ acResults: aggregateByAc(executions),
550
+ passed: failedCount === 0,
551
+ total: executions.length,
552
+ failedCount,
553
+ durationMs
554
+ };
555
+ }
556
+ function reportExitCode(report) {
557
+ return report.passed ? EXIT3.OK : EXIT3.TESTS_FAILED;
558
+ }
559
+ function escapeXml(s) {
560
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
561
+ }
562
+ function secs(ms) {
563
+ return (ms / 1e3).toFixed(3);
564
+ }
565
+ function toJUnit(report) {
566
+ const lines = [];
567
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
568
+ lines.push(
569
+ `<testsuites name="hs test run" tests="${report.total}" failures="${report.failedCount}" time="${secs(report.durationMs)}">`
570
+ );
571
+ for (const ac of report.acResults) {
572
+ const acExecs = report.executions.filter((e) => (e.entry.linkedAc ?? UNLINKED) === ac.ac);
573
+ const acTime = acExecs.reduce((s, e) => s + e.durationMs, 0);
574
+ lines.push(
575
+ ` <testsuite name="${escapeXml(ac.ac)}" tests="${ac.total}" failures="${ac.failed}" time="${secs(acTime)}">`
576
+ );
577
+ for (const e of acExecs) {
578
+ const open = ` <testcase name="${escapeXml(e.entry.name)}" time="${secs(e.durationMs)}">`;
579
+ if (e.passed) {
580
+ lines.push(`${open}</testcase>`);
581
+ } else {
582
+ lines.push(open);
583
+ lines.push(` <failure message="${escapeXml(e.message ?? "test failed")}"></failure>`);
584
+ lines.push(" </testcase>");
585
+ }
586
+ }
587
+ lines.push(" </testsuite>");
588
+ }
589
+ lines.push("</testsuites>");
590
+ return lines.join("\n");
591
+ }
592
+ function toTap(report) {
593
+ const lines = ["TAP version 13", `1..${report.total}`];
594
+ report.executions.forEach((e, i) => {
595
+ const status = e.passed ? "ok" : "not ok";
596
+ const ac = e.entry.linkedAc ? ` # AC ${e.entry.linkedAc}` : "";
597
+ lines.push(`${status} ${i + 1} - ${e.entry.name}${ac}`);
598
+ if (!e.passed && e.message) {
599
+ lines.push(` ---`);
600
+ lines.push(` message: ${e.message}`);
601
+ lines.push(` ...`);
602
+ }
603
+ });
604
+ return lines.join("\n");
605
+ }
606
+ function toText3(report) {
607
+ if (report.total === 0) {
608
+ return "No tests in manifest.";
609
+ }
610
+ const lines = [];
611
+ for (const ac of report.acResults) {
612
+ const mark = ac.passed ? "PASS" : "FAIL";
613
+ lines.push(`[${mark}] ${ac.ac} (${ac.total - ac.failed}/${ac.total})`);
614
+ for (const e of report.executions.filter((x) => (x.entry.linkedAc ?? UNLINKED) === ac.ac)) {
615
+ const m = e.passed ? "ok " : "FAIL";
616
+ const detail = !e.passed && e.message ? ` \u2014 ${e.message}` : "";
617
+ lines.push(` ${m} ${e.entry.name} (${e.durationMs}ms)${detail}`);
618
+ }
619
+ }
620
+ const overall = report.passed ? "PASS" : "FAIL";
621
+ lines.push("");
622
+ lines.push(
623
+ `${overall}: ${report.total - report.failedCount}/${report.total} tests, ${report.acResults.filter((a) => a.passed).length}/${report.acResults.length} acceptance criteria (${report.durationMs}ms)`
624
+ );
625
+ return lines.join("\n");
626
+ }
627
+ function formatReport(report, format) {
628
+ switch (format) {
629
+ case "junit":
630
+ return toJUnit(report);
631
+ case "tap":
632
+ return toTap(report);
633
+ case "text":
634
+ default:
635
+ return toText3(report);
636
+ }
637
+ }
638
+ function reportToJson(report) {
639
+ return {
640
+ ok: report.passed,
641
+ total: report.total,
642
+ failed: report.failedCount,
643
+ durationMs: report.durationMs,
644
+ acceptanceCriteria: report.acResults,
645
+ tests: report.executions.map((e) => ({
646
+ name: e.entry.name,
647
+ file: e.entry.file,
648
+ linkedAc: e.entry.linkedAc,
649
+ passed: e.passed,
650
+ durationMs: e.durationMs,
651
+ ...e.passed ? {} : { message: e.message }
652
+ }))
653
+ };
654
+ }
655
+ var HELP = [
656
+ "Usage:",
657
+ " hs test run <spec-id|manifest.json> [--format text|junit|tap] [--json]",
658
+ "",
659
+ "Run open-format API/contract tests (Hurl/.http) bound to acceptance criteria,",
660
+ "and report pass/fail per acceptance criterion. Tests live as files in the repo",
661
+ "(tests-as-code), referenced by a thin JSON manifest carrying each test's",
662
+ "`linked_ac`. Supersedes the proprietary .spex plan (HS-EPIC-Q0KU).",
663
+ "",
664
+ "Manifest shape:",
665
+ ' { "tests": [ { "file": "tests/get-spec.hurl",',
666
+ ' "name": "get spec by id",',
667
+ ' "linked_ac": "[[HS-TASK-XYZ]]#AC1" } ] }',
668
+ "",
669
+ "Arguments:",
670
+ " <spec-id|manifest.json> A manifest path, or a spec id the shell resolves to one.",
671
+ "",
672
+ "Flags:",
673
+ " --format <fmt> text (default), junit, or tap. JUnit/TAP are CI-consumable.",
674
+ " --manifest <p> Manifest path (alias for the positional argument).",
675
+ " --json Emit a machine-readable JSON result.",
676
+ " --help, -h Show this help.",
677
+ "",
678
+ "Requires the Hurl runner on PATH (https://hurl.dev). Exit codes:",
679
+ " 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-manifest \xB7 12 hurl-missing \xB7 20 tests-failed"
680
+ ].join("\n");
681
+
682
+ // spex-console/src/lib/server/api-test-host.ts
683
+ import { spawn, spawnSync } from "node:child_process";
684
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
685
+ import path from "node:path";
686
+
687
+ // spex-console/src/lib/resolution/api-test-failures.ts
688
+ var API_TEST_RUN_SNAPSHOT_PATH = ".heyspex/code-watcher/api-test-run.json";
689
+
690
+ // spex-console/src/lib/server/api-test-host.ts
691
+ function manifestForSpecId(vaultRoot, specId) {
692
+ return path.join(vaultRoot, "tests", `${specId}.manifest.json`);
693
+ }
694
+ function resolveManifestPath(target, vaultRoot, cwd = process.cwd()) {
695
+ const literal = path.resolve(cwd, target);
696
+ if (existsSync(literal)) return literal;
697
+ if (target.endsWith(".json")) return null;
698
+ const byId = manifestForSpecId(vaultRoot, target);
699
+ return existsSync(byId) ? byId : null;
700
+ }
701
+ function hurlAvailable() {
702
+ try {
703
+ const res = spawnSync("hurl", ["--version"], { stdio: "ignore" });
704
+ return res.status === 0;
705
+ } catch {
706
+ return false;
707
+ }
708
+ }
709
+ function runHurlFile(file) {
710
+ return new Promise((resolve) => {
711
+ const started = Date.now();
712
+ const child = spawn("hurl", ["--test", file], { stdio: ["ignore", "ignore", "pipe"] });
713
+ let stderr = "";
714
+ child.stderr?.on("data", (chunk) => {
715
+ stderr += chunk.toString();
716
+ });
717
+ child.on("error", (err) => {
718
+ resolve({ passed: false, durationMs: Date.now() - started, message: err.message });
719
+ });
720
+ child.on("close", (code) => {
721
+ const durationMs = Date.now() - started;
722
+ if (code === 0) {
723
+ resolve({ passed: true, durationMs });
724
+ } else {
725
+ const trimmed = stderr.trim();
726
+ resolve({
727
+ passed: false,
728
+ durationMs,
729
+ message: trimmed.length > 0 ? trimmed.split("\n").slice(-3).join(" ") : `hurl exited ${code}`
730
+ });
731
+ }
732
+ });
733
+ });
734
+ }
735
+ function writeRunSnapshot(vaultRoot, target, report) {
736
+ const snapshot = {
737
+ schemaVersion: 1,
738
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
739
+ target,
740
+ total: report.total,
741
+ failures: report.executions.filter((e) => !e.passed).map((e) => ({
742
+ file: path.relative(vaultRoot, e.entry.file).split(path.sep).join("/"),
743
+ name: e.entry.name,
744
+ linkedAc: e.entry.linkedAc,
745
+ ...e.message ? { message: e.message } : {}
746
+ }))
747
+ };
748
+ const outFile = path.join(vaultRoot, API_TEST_RUN_SNAPSHOT_PATH);
749
+ mkdirSync(path.dirname(outFile), { recursive: true });
750
+ writeFileSync(outFile, `${JSON.stringify(snapshot, null, 2)}
751
+ `, "utf8");
752
+ return snapshot;
753
+ }
754
+
755
+ // spex-console/bin/run-entry.ts
756
+ import process2 from "node:process";
757
+ var BinExit = class extends Error {
758
+ code;
759
+ constructor(code) {
760
+ super(`exit ${code}`);
761
+ this.name = "BinExit";
762
+ this.code = code;
763
+ }
764
+ };
765
+ function runEntry(name, main2, options = {}) {
766
+ const { fallbackCode = 1, unhandledLabel = "unhandled" } = options;
767
+ void (async () => {
768
+ try {
769
+ await main2();
770
+ } catch (err) {
771
+ if (err instanceof BinExit) {
772
+ process2.exitCode = err.code;
773
+ return;
774
+ }
775
+ const message = err instanceof Error ? err.message : String(err);
776
+ process2.stderr.write(`${name}: ${unhandledLabel}: ${message}
777
+ `);
778
+ process2.exitCode = fallbackCode;
779
+ }
780
+ })();
781
+ }
782
+
783
+ // spex-console/bin/hs-test.ts
784
+ var invokedCwd = process3.env.HS_INVOKED_CWD?.trim() || process3.cwd();
785
+ async function runVerb(vaultRoot) {
786
+ const parsed = parseTestRunArgs(process3.argv.slice(3));
787
+ if (parsed.help) {
788
+ process3.stdout.write(`${HELP}
789
+ `);
790
+ process3.exitCode = EXIT3.OK;
791
+ return;
792
+ }
793
+ if (parsed.error) {
794
+ process3.stderr.write(`hs test: ${parsed.error.message}
795
+ `);
796
+ process3.exitCode = parsed.error.code;
797
+ return;
798
+ }
799
+ const args = parsed.args;
800
+ const manifestPath = resolveManifestPath(args.target, vaultRoot, invokedCwd);
801
+ if (!manifestPath) {
802
+ process3.stderr.write(
803
+ `hs test: no manifest for "${args.target}".
804
+ Pass a manifest path, or create ${path2.join("tests", `${args.target}.manifest.json`)} under the vault root.
805
+ `
806
+ );
807
+ process3.exitCode = EXIT3.NOT_FOUND;
808
+ return;
809
+ }
810
+ const { manifest, error } = parseManifest(readFileSync(manifestPath, "utf-8"));
811
+ if (error) {
812
+ process3.stderr.write(`hs test: ${manifestPath}: ${error}
813
+ `);
814
+ process3.exitCode = EXIT3.INVALID_MANIFEST;
815
+ return;
816
+ }
817
+ const manifestDir = path2.dirname(manifestPath);
818
+ const tests = manifest.tests.map((t) => ({
819
+ ...t,
820
+ file: path2.isAbsolute(t.file) ? t.file : path2.resolve(manifestDir, t.file)
821
+ }));
822
+ if (tests.length > 0 && !hurlAvailable()) {
823
+ process3.stderr.write(
824
+ "hs test: the Hurl runner is not installed or not on PATH.\n Install it from https://hurl.dev (e.g. `winget install Orange-OpenSource.Hurl`),\n or `scoop install hurl` / `brew install hurl`.\n"
825
+ );
826
+ process3.exitCode = EXIT3.RUNNER_MISSING;
827
+ return;
828
+ }
829
+ const report = await runManifest(tests, { runHurl: runHurlFile });
830
+ try {
831
+ writeRunSnapshot(vaultRoot, args.target, report);
832
+ } catch (err) {
833
+ const message = err instanceof Error ? err.message : String(err);
834
+ process3.stderr.write(`hs test: warning \u2014 could not write the run snapshot: ${message}
835
+ `);
836
+ }
837
+ if (args.json) {
838
+ const sink = report.passed ? process3.stdout : process3.stderr;
839
+ sink.write(`${JSON.stringify(reportToJson(report))}
840
+ `);
841
+ } else {
842
+ const sink = report.passed ? process3.stdout : process3.stderr;
843
+ sink.write(`${formatReport(report, args.format)}
844
+ `);
845
+ }
846
+ process3.exitCode = reportExitCode(report);
847
+ }
848
+ function strykerAvailable(projectDir) {
849
+ try {
850
+ const res = process3.platform === "win32" ? spawnSync2("cmd", ["/c", "npx", "--no-install", "stryker", "--version"], {
851
+ cwd: projectDir,
852
+ stdio: "ignore"
853
+ }) : spawnSync2("npx", ["--no-install", "stryker", "--version"], {
854
+ cwd: projectDir,
855
+ stdio: "ignore"
856
+ });
857
+ return res.status === 0;
858
+ } catch {
859
+ return false;
860
+ }
861
+ }
862
+ function runStryker(projectDir) {
863
+ return new Promise((resolve) => {
864
+ const child = process3.platform === "win32" ? spawn2("cmd", ["/c", "npx", "--no-install", "stryker", "run"], {
865
+ cwd: projectDir,
866
+ stdio: "inherit"
867
+ }) : spawn2("npx", ["--no-install", "stryker", "run"], { cwd: projectDir, stdio: "inherit" });
868
+ child.on("error", () => resolve(-1));
869
+ child.on("close", (code) => resolve(code ?? -1));
870
+ });
871
+ }
872
+ async function gateVerb(vaultRoot) {
873
+ const parsed = parseGateArgs(process3.argv.slice(3));
874
+ if (parsed.help) {
875
+ process3.stdout.write(`${GATE_HELP}
876
+ `);
877
+ process3.exitCode = EXIT.OK;
878
+ return;
879
+ }
880
+ if (parsed.error) {
881
+ process3.stderr.write(`hs test: ${parsed.error.message}
882
+ `);
883
+ process3.exitCode = parsed.error.code;
884
+ return;
885
+ }
886
+ const args = parsed.args;
887
+ const projectDir = path2.isAbsolute(args.target) ? args.target : path2.resolve(vaultRoot, args.target);
888
+ const DEFAULT_REPORT = path2.join(projectDir, "reports", "mutation", "mutation.json");
889
+ let reportFile;
890
+ if (args.reportPath) {
891
+ reportFile = path2.isAbsolute(args.reportPath) ? args.reportPath : path2.resolve(invokedCwd, args.reportPath);
892
+ if (!existsSync2(reportFile)) {
893
+ process3.stderr.write(`hs test: mutation report not found: ${reportFile}
894
+ `);
895
+ process3.exitCode = EXIT.NOT_FOUND;
896
+ return;
897
+ }
898
+ } else {
899
+ if (!strykerAvailable(projectDir)) {
900
+ process3.stderr.write(
901
+ "hs test: the mutation runner (Stryker) is not installed in this project.\n Add it (e.g. `npm i -D @stryker-mutator/core`) and enable the json reporter,\n or pass an existing report with --report <mutation-report.json>.\n"
902
+ );
903
+ process3.exitCode = EXIT.RUNNER_MISSING;
904
+ return;
905
+ }
906
+ const code = await runStryker(projectDir);
907
+ if (code !== 0) {
908
+ process3.stderr.write(
909
+ `hs test: the mutation runner exited ${code} before producing a report.
910
+ `
911
+ );
912
+ process3.exitCode = EXIT.RUNNER_MISSING;
913
+ return;
914
+ }
915
+ reportFile = DEFAULT_REPORT;
916
+ if (!existsSync2(reportFile)) {
917
+ process3.stderr.write(
918
+ `hs test: the runner finished but no report was found at ${reportFile}.
919
+ Enable Stryker's json reporter so it writes reports/mutation/mutation.json.
920
+ `
921
+ );
922
+ process3.exitCode = EXIT.NOT_FOUND;
923
+ return;
924
+ }
925
+ }
926
+ const { report, error } = parseMutationReport(readFileSync(reportFile, "utf-8"));
927
+ if (error) {
928
+ process3.stderr.write(`hs test: ${reportFile}: ${error}
929
+ `);
930
+ process3.exitCode = EXIT.INVALID_REPORT;
931
+ return;
932
+ }
933
+ const gate = evaluateGate(report, args.threshold);
934
+ const sink = gate.passed ? process3.stdout : process3.stderr;
935
+ if (args.json) {
936
+ sink.write(`${JSON.stringify(gateReportToJson(gate))}
937
+ `);
938
+ } else {
939
+ sink.write(`${formatGateReport(gate)}
940
+ `);
941
+ }
942
+ process3.exitCode = gateExitCode(gate);
943
+ }
944
+ function schemathesisAvailable() {
945
+ try {
946
+ const res = process3.platform === "win32" ? spawnSync2("cmd", ["/c", "st", "--version"], { stdio: "ignore" }) : spawnSync2("st", ["--version"], { stdio: "ignore" });
947
+ return res.status === 0;
948
+ } catch {
949
+ return false;
950
+ }
951
+ }
952
+ function runSchemathesis(spec, baseUrl, junitOut) {
953
+ const stArgs = ["run", spec, "--url", baseUrl, "--junit-xml", junitOut];
954
+ return new Promise((resolve) => {
955
+ const child = process3.platform === "win32" ? spawn2("cmd", ["/c", "st", ...stArgs], { stdio: "inherit" }) : spawn2("st", stArgs, { stdio: "inherit" });
956
+ child.on("error", () => resolve(-1));
957
+ child.on("close", (code) => resolve(code ?? -1));
958
+ });
959
+ }
960
+ async function conformVerb(vaultRoot) {
961
+ const parsed = parseConformArgs(process3.argv.slice(3));
962
+ if (parsed.help) {
963
+ process3.stdout.write(`${CONFORM_HELP}
964
+ `);
965
+ process3.exitCode = EXIT2.OK;
966
+ return;
967
+ }
968
+ if (parsed.error) {
969
+ process3.stderr.write(`hs test: ${parsed.error.message}
970
+ `);
971
+ process3.exitCode = parsed.error.code;
972
+ return;
973
+ }
974
+ const args = parsed.args;
975
+ let reportFile;
976
+ if (args.reportPath) {
977
+ reportFile = path2.isAbsolute(args.reportPath) ? args.reportPath : path2.resolve(invokedCwd, args.reportPath);
978
+ if (!existsSync2(reportFile)) {
979
+ process3.stderr.write(`hs test: conformance report not found: ${reportFile}
980
+ `);
981
+ process3.exitCode = EXIT2.NOT_FOUND;
982
+ return;
983
+ }
984
+ } else {
985
+ const specIsUrl = /^https?:\/\//i.test(args.spec);
986
+ const specPath = specIsUrl ? args.spec : path2.isAbsolute(args.spec) ? args.spec : path2.resolve(vaultRoot, args.spec);
987
+ if (!specIsUrl && !existsSync2(specPath)) {
988
+ process3.stderr.write(`hs test: OpenAPI spec not found: ${specPath}
989
+ `);
990
+ process3.exitCode = EXIT2.NOT_FOUND;
991
+ return;
992
+ }
993
+ if (!schemathesisAvailable()) {
994
+ process3.stderr.write(
995
+ "hs test: Schemathesis is not installed or not on PATH.\n Install it (e.g. `pip install schemathesis` / `uv tool install schemathesis`),\n or pass an existing report with --report <schemathesis-junit.xml>.\n"
996
+ );
997
+ process3.exitCode = EXIT2.RUNNER_MISSING;
998
+ return;
999
+ }
1000
+ reportFile = path2.join(os.tmpdir(), "hs-conform-junit.xml");
1001
+ await runSchemathesis(specPath, args.baseUrl, reportFile);
1002
+ if (!existsSync2(reportFile)) {
1003
+ process3.stderr.write(
1004
+ `hs test: Schemathesis finished but wrote no report at ${reportFile}.
1005
+ `
1006
+ );
1007
+ process3.exitCode = EXIT2.NOT_FOUND;
1008
+ return;
1009
+ }
1010
+ }
1011
+ const { report, error } = parseJUnitReport(readFileSync(reportFile, "utf-8"));
1012
+ if (error) {
1013
+ process3.stderr.write(`hs test: ${reportFile}: ${error}
1014
+ `);
1015
+ process3.exitCode = EXIT2.INVALID_REPORT;
1016
+ return;
1017
+ }
1018
+ const sink = report.passed ? process3.stdout : process3.stderr;
1019
+ if (args.json) {
1020
+ sink.write(`${JSON.stringify(conformReportToJson(report))}
1021
+ `);
1022
+ } else {
1023
+ sink.write(`${formatConformReport(report)}
1024
+ `);
1025
+ }
1026
+ process3.exitCode = conformExitCode(report);
1027
+ }
1028
+ async function main() {
1029
+ const vaultRoot = process3.env.HS_VAULT_ROOT;
1030
+ if (!vaultRoot) {
1031
+ process3.stderr.write(
1032
+ "hs test: HS_VAULT_ROOT is not set (the dispatcher must resolve and pass it).\n"
1033
+ );
1034
+ process3.exitCode = EXIT3.USAGE;
1035
+ return;
1036
+ }
1037
+ const verb = process3.argv[3];
1038
+ if (verb === "gate") {
1039
+ await gateVerb(vaultRoot);
1040
+ } else if (verb === "conform") {
1041
+ await conformVerb(vaultRoot);
1042
+ } else {
1043
+ await runVerb(vaultRoot);
1044
+ }
1045
+ }
1046
+ runEntry("hs test", main, { fallbackCode: EXIT3.RUNNER_MISSING });