@haystackeditor/cli 0.15.14 → 0.15.15
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/dist/assets/skills/install-verification.md +67 -0
- package/dist/assets/skills/submit.md +14 -1
- package/dist/commands/triage.js +98 -17
- package/dist/commands/verification.d.ts +21 -0
- package/dist/commands/verification.js +291 -0
- package/dist/index.js +130 -0
- package/dist/verification/contract.d.ts +240 -0
- package/dist/verification/contract.js +115 -0
- package/dist/verification/init.d.ts +10 -0
- package/dist/verification/init.js +243 -0
- package/dist/verification/manifest.d.ts +49 -0
- package/dist/verification/manifest.js +186 -0
- package/dist/verification/runner.d.ts +20 -0
- package/dist/verification/runner.js +102 -0
- package/dist/verification/safety.d.ts +10 -0
- package/dist/verification/safety.js +252 -0
- package/dist/verification/validate.d.ts +12 -0
- package/dist/verification/validate.js +219 -0
- package/package.json +3 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Install Haystack Verification
|
|
2
|
+
|
|
3
|
+
Add a Haystack verification contract to this repository so Haystack (and any
|
|
4
|
+
coding agent) can safely verify risky PRs and produce reviewable evidence.
|
|
5
|
+
|
|
6
|
+
The deliverable is a small, reviewable PR containing:
|
|
7
|
+
|
|
8
|
+
- `.haystack/verification.yml` — the verification contract
|
|
9
|
+
- `scripts/haystack-preflight` — proves the app is up and answering
|
|
10
|
+
- `scripts/haystack-smoke` — boots the app, runs a basic logged-in check, captures evidence
|
|
11
|
+
- `.haystack/AGENTS.md` — how agents should use the contract
|
|
12
|
+
|
|
13
|
+
## Ground rules
|
|
14
|
+
|
|
15
|
+
1. **No production behavior changes.** Anything you add must be dev/test-only,
|
|
16
|
+
clearly isolated, and easy for a reviewer to verify as inert in production.
|
|
17
|
+
2. **Prefer existing test utilities** (seed scripts, factories, test logins,
|
|
18
|
+
fixtures) over new application code.
|
|
19
|
+
3. **Only add dev-only endpoints when necessary**, and gate them so they cannot
|
|
20
|
+
run in production (`NODE_ENV !== 'production'` or the framework equivalent).
|
|
21
|
+
If you add one, also add a test proving it is disabled in production mode,
|
|
22
|
+
and wire that test into `safety.production_disabled_check`.
|
|
23
|
+
4. **Never reference real credentials, real payment providers, real email
|
|
24
|
+
delivery, or customer data.** Sandbox or mock everything external.
|
|
25
|
+
5. **Keep the integration small.** A working smoke scenario beats ten
|
|
26
|
+
aspirational ones.
|
|
27
|
+
|
|
28
|
+
## Steps
|
|
29
|
+
|
|
30
|
+
1. **Scaffold.** Run `npx @haystackeditor/cli verification init` (or
|
|
31
|
+
`haystack verification init` if installed). This detects the framework and
|
|
32
|
+
writes starter files. Review everything it generated.
|
|
33
|
+
|
|
34
|
+
2. **Inspect the repo.** Identify:
|
|
35
|
+
- framework, package manager, and how the app starts locally
|
|
36
|
+
- how the app knows it's "ready" (port, health endpoint, log line)
|
|
37
|
+
- existing seed/test data and how tests authenticate
|
|
38
|
+
- existing E2E/smoke tests you can reuse as scenarios
|
|
39
|
+
|
|
40
|
+
3. **Fix the environment section.** Make `setup`, `start`, and `preflight`
|
|
41
|
+
real: `setup` must work on a clean clone; `preflight` must exit 0 only when
|
|
42
|
+
the app is actually usable (prefer a health endpoint over a port check).
|
|
43
|
+
|
|
44
|
+
4. **Wire personas if a safe login path exists.** Personas are seeded fake
|
|
45
|
+
users (admin/member/viewer). Use an existing test-login helper if there is
|
|
46
|
+
one. If none exists and one is genuinely needed, add a dev-only helper per
|
|
47
|
+
ground rule 3 — otherwise leave personas empty.
|
|
48
|
+
|
|
49
|
+
5. **Make the smoke scenario real.** It should boot the app, reach a
|
|
50
|
+
meaningful logged-in (or at least rendered) state, and leave evidence in the
|
|
51
|
+
artifact directory: server logs, the captured page, and screenshots if a
|
|
52
|
+
browser runner is available. If part of the flow can't be tested, append a
|
|
53
|
+
line to `<artifact_dir>/gaps.txt` saying what was skipped.
|
|
54
|
+
|
|
55
|
+
6. **Validate until clean.** Iterate until both pass:
|
|
56
|
+
```bash
|
|
57
|
+
haystack verification validate
|
|
58
|
+
haystack verification check-safety
|
|
59
|
+
haystack verification run smoke
|
|
60
|
+
```
|
|
61
|
+
`validate` must not report `not_ready` or `unsafe`; `run smoke` must end
|
|
62
|
+
`verified` and write a manifest + review packet under the artifact dir.
|
|
63
|
+
|
|
64
|
+
7. **Open a reviewable PR.** Keep it focused on the verification contract.
|
|
65
|
+
In the description, state explicitly: what was added, why each piece is
|
|
66
|
+
dev-only, and what the smoke scenario proves. Include the review packet
|
|
67
|
+
from your successful `run smoke` as evidence.
|
|
@@ -151,7 +151,20 @@ This uses GitHub device flow - no tokens to copy/paste.
|
|
|
151
151
|
- The command uses your current branch as the PR head
|
|
152
152
|
- Labels are applied automatically based on your chosen mode
|
|
153
153
|
- You don't need to push manually - the command handles it
|
|
154
|
-
|
|
154
|
+
|
|
155
|
+
## Close the Loop After Submitting
|
|
156
|
+
|
|
157
|
+
After `haystack submit` creates the PR, **run `haystack triage <pr>` before ending your turn.**
|
|
158
|
+
It blocks until Haystack's full analysis completes (waking instantly on the completion event),
|
|
159
|
+
then prints the rating, findings, and a per-finding `agentFixPrompt`.
|
|
160
|
+
|
|
161
|
+
- **Findings marked auto-fixing**: leave them alone — Haystack's fixer is already on them
|
|
162
|
+
- **Actionable findings**: address them (the `agentFixPrompt` is written for you), commit, and
|
|
163
|
+
push — analysis re-runs on the new commit
|
|
164
|
+
- **Good to merge**: report the rating to your user and stop
|
|
165
|
+
|
|
166
|
+
Don't end the session right after submit without checking — the verdict usually lands within a
|
|
167
|
+
few minutes, and acting on it immediately is much cheaper than a human bouncing the PR back.
|
|
155
168
|
|
|
156
169
|
---
|
|
157
170
|
|
package/dist/commands/triage.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* (default) Rich terminal output
|
|
21
21
|
*/
|
|
22
22
|
import chalk from 'chalk';
|
|
23
|
+
import WebSocket from 'ws';
|
|
23
24
|
import { checkAnalysisReady, fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
|
|
24
25
|
import { parseRemoteUrl } from '../utils/git.js';
|
|
25
26
|
import { loadToken } from '../utils/auth.js';
|
|
@@ -202,31 +203,111 @@ function printHookOutput(pr, synthesis, manifest, status) {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
// ============================================================================
|
|
205
|
-
//
|
|
206
|
+
// Waiting (event-driven with polling fallback)
|
|
206
207
|
// ============================================================================
|
|
207
208
|
const POLL_INTERVAL_MS = 5_000;
|
|
208
209
|
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
210
|
+
/** With a live event subscription, checks are event-driven; this slow cycle
|
|
211
|
+
* is only a safety net against a silently dead socket. */
|
|
212
|
+
const SUBSCRIBED_RECHECK_MS = 60_000;
|
|
213
|
+
const PR_EVENTS_WS_BASE = process.env.HAYSTACK_PR_EVENTS_WS_URL ?? 'wss://haystackeditor.com/api/pr-events';
|
|
214
|
+
/**
|
|
215
|
+
* Subscribe to the pr-events worker for this PR — the same real-time channel
|
|
216
|
+
* the web inbox uses. Any event (analysis_complete, commit_pushed, ...) calls
|
|
217
|
+
* `onEvent`, and the caller re-checks analysis state immediately instead of
|
|
218
|
+
* discovering it on the next poll tick. Returns null when a socket can't even
|
|
219
|
+
* be constructed; connection failures after that surface via isConnected().
|
|
220
|
+
*/
|
|
221
|
+
function subscribeToPrEvents(owner, repo, prNumber, token, onEvent, quiet) {
|
|
222
|
+
// Diagnostics are dim stderr lines, once per failure mode — polling is the
|
|
223
|
+
// designed fallback, but WHY live updates are off must stay visible (PR001).
|
|
224
|
+
// quiet (--hook) mode stays terse: the hook output is machine-parsed.
|
|
225
|
+
const diag = (msg) => {
|
|
226
|
+
if (!quiet)
|
|
227
|
+
process.stderr.write(`\n ${chalk.dim(`[live updates unavailable: ${msg}; using polling]`)}\n`);
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const ws = new WebSocket(`${PR_EVENTS_WS_BASE}/${owner}/${repo}/${prNumber}/subscribe`, {
|
|
231
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
232
|
+
});
|
|
233
|
+
let connected = false;
|
|
234
|
+
let reported = false;
|
|
235
|
+
ws.on('open', () => { connected = true; });
|
|
236
|
+
ws.on('message', () => onEvent());
|
|
237
|
+
ws.on('error', (err) => {
|
|
238
|
+
connected = false;
|
|
239
|
+
if (!reported) {
|
|
240
|
+
reported = true;
|
|
241
|
+
diag(err.message);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
ws.on('close', () => { connected = false; });
|
|
245
|
+
const keepalive = setInterval(() => {
|
|
246
|
+
// A failed send means the socket died between ticks; the 'error'/'close'
|
|
247
|
+
// handlers above report it and flip isConnected() — nothing extra to say.
|
|
248
|
+
try {
|
|
249
|
+
ws.send('ping');
|
|
250
|
+
}
|
|
251
|
+
catch { /* surfaced via error/close handlers */ }
|
|
252
|
+
}, 30_000);
|
|
253
|
+
keepalive.unref?.();
|
|
254
|
+
return {
|
|
255
|
+
isConnected: () => connected,
|
|
256
|
+
close: () => {
|
|
257
|
+
clearInterval(keepalive);
|
|
258
|
+
// Idempotent teardown, not a fallback: close() on an already-dead
|
|
259
|
+
// socket can throw in ws and there is genuinely nothing to handle.
|
|
260
|
+
try {
|
|
261
|
+
ws.close();
|
|
262
|
+
}
|
|
263
|
+
catch { /* already closed */ }
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
diag(err instanceof Error ? err.message : String(err));
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
209
272
|
async function waitForAnalysis(owner, repo, prNumber, timeoutMs, token, quiet) {
|
|
210
273
|
const deadline = Date.now() + timeoutMs;
|
|
211
274
|
let consecutiveErrors = 0;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
275
|
+
// Event-driven wake: a pr-events message resolves the current sleep so the
|
|
276
|
+
// re-check happens the moment analysis completes, not on the next tick.
|
|
277
|
+
let wake = null;
|
|
278
|
+
const subscription = token
|
|
279
|
+
? subscribeToPrEvents(owner, repo, prNumber, token, () => wake?.(), quiet)
|
|
280
|
+
: null;
|
|
281
|
+
try {
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
const result = await checkAnalysisReady(owner, repo, prNumber, token);
|
|
284
|
+
if (result.status === 'ready')
|
|
285
|
+
return 'ready';
|
|
286
|
+
if (result.status === 'error') {
|
|
287
|
+
consecutiveErrors++;
|
|
288
|
+
if (consecutiveErrors >= 5)
|
|
289
|
+
return 'error';
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
consecutiveErrors = 0;
|
|
293
|
+
}
|
|
294
|
+
const live = subscription?.isConnected() ?? false;
|
|
295
|
+
if (!quiet) {
|
|
296
|
+
const mode = live ? 'Waiting for analysis (live)...' : 'Waiting for analysis to complete...';
|
|
297
|
+
process.stderr.write(`\r ${chalk.dim(mode)}${' '.repeat(5)}`);
|
|
298
|
+
}
|
|
299
|
+
const interval = Math.min(live ? SUBSCRIBED_RECHECK_MS : POLL_INTERVAL_MS, Math.max(deadline - Date.now(), 0));
|
|
300
|
+
await new Promise((resolve) => {
|
|
301
|
+
const timer = setTimeout(() => { wake = null; resolve(); }, interval);
|
|
302
|
+
timer.unref?.();
|
|
303
|
+
wake = () => { clearTimeout(timer); wake = null; resolve(); };
|
|
304
|
+
});
|
|
226
305
|
}
|
|
227
|
-
|
|
306
|
+
return 'pending';
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
subscription?.close();
|
|
228
310
|
}
|
|
229
|
-
return 'pending';
|
|
230
311
|
}
|
|
231
312
|
// ============================================================================
|
|
232
313
|
// PR resolution
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function verificationInitCommand(options: {
|
|
2
|
+
force?: boolean;
|
|
3
|
+
skill?: boolean;
|
|
4
|
+
}): Promise<void>;
|
|
5
|
+
export declare function verificationValidateCommand(options: {
|
|
6
|
+
json?: boolean;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
export declare function verificationPreflightCommand(): Promise<void>;
|
|
9
|
+
export declare function verificationRunCommand(scenarioName: string, options: {
|
|
10
|
+
json?: boolean;
|
|
11
|
+
skipPreflight?: boolean;
|
|
12
|
+
setup?: boolean;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
export declare function verificationCollectCommand(options: {
|
|
16
|
+
scenario?: string;
|
|
17
|
+
json?: boolean;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
export declare function verificationCheckSafetyCommand(options: {
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack verification ...` — repo-local verification contract commands.
|
|
3
|
+
*
|
|
4
|
+
* init scaffold .haystack/verification.yml + scripts + skill
|
|
5
|
+
* validate can Haystack trust this repo's verification setup?
|
|
6
|
+
* preflight is the app up and answering?
|
|
7
|
+
* run <scenario> run a scenario, capture artifacts, write manifest + review packet
|
|
8
|
+
* collect-artifacts rebuild a manifest from artifacts already on disk
|
|
9
|
+
* check-safety prove the verification setup can't hurt anyone
|
|
10
|
+
*
|
|
11
|
+
* See docs/VERIFICATION-CONTRACT.md for the product framing and schema.
|
|
12
|
+
*/
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { CONTRACT_RELATIVE_PATH, ensureArtifactDir, loadContract, } from '../verification/contract.js';
|
|
16
|
+
import { initVerification } from '../verification/init.js';
|
|
17
|
+
import { validateContract } from '../verification/validate.js';
|
|
18
|
+
import { checkSafety } from '../verification/safety.js';
|
|
19
|
+
import { runCommand } from '../verification/runner.js';
|
|
20
|
+
import { collectArtifacts, getHeadSha, readGaps, writeManifest, writeReviewPacket, } from '../verification/manifest.js';
|
|
21
|
+
const DEFAULT_SCENARIO_TIMEOUT_S = 900;
|
|
22
|
+
const PREFLIGHT_TIMEOUT_S = 300;
|
|
23
|
+
// ─── init ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
export async function verificationInitCommand(options) {
|
|
25
|
+
const result = await initVerification(process.cwd(), {
|
|
26
|
+
force: options.force,
|
|
27
|
+
skillOnly: options.skill,
|
|
28
|
+
});
|
|
29
|
+
for (const file of result.created) {
|
|
30
|
+
console.log(chalk.green(' created ') + file);
|
|
31
|
+
}
|
|
32
|
+
for (const file of result.skipped) {
|
|
33
|
+
console.log(chalk.yellow(' skipped ') + file + chalk.dim(' (exists, use --force to overwrite)'));
|
|
34
|
+
}
|
|
35
|
+
if (result.created.length > 0) {
|
|
36
|
+
console.log();
|
|
37
|
+
console.log(chalk.white('Next steps:'));
|
|
38
|
+
console.log(chalk.cyan(' 1.') + ` Review ${CONTRACT_RELATIVE_PATH} — detection is a starting point, not truth`);
|
|
39
|
+
console.log(chalk.cyan(' 2.') + ' haystack verification validate');
|
|
40
|
+
console.log(chalk.cyan(' 3.') + ' haystack verification run smoke');
|
|
41
|
+
if (options.skill) {
|
|
42
|
+
console.log();
|
|
43
|
+
console.log(chalk.dim('Hand .haystack/skills/install-verification/SKILL.md to your coding agent to complete the integration.'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ─── validate ─────────────────────────────────────────────────────────────────
|
|
48
|
+
export async function verificationValidateCommand(options) {
|
|
49
|
+
const report = await validateContract();
|
|
50
|
+
if (options.json) {
|
|
51
|
+
console.log(JSON.stringify(report, null, 2));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
printValidationReport(report);
|
|
55
|
+
}
|
|
56
|
+
if (report.readiness === 'unsafe')
|
|
57
|
+
process.exit(2);
|
|
58
|
+
if (report.readiness === 'not_ready')
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
function printValidationReport(report) {
|
|
62
|
+
const styles = {
|
|
63
|
+
ready: chalk.green,
|
|
64
|
+
partially_ready: chalk.yellow,
|
|
65
|
+
not_ready: chalk.red,
|
|
66
|
+
unsafe: chalk.red,
|
|
67
|
+
};
|
|
68
|
+
console.log(chalk.white('Haystack Verification Contract: ') + styles[report.readiness](report.readiness));
|
|
69
|
+
console.log();
|
|
70
|
+
const sections = [
|
|
71
|
+
{ title: 'Passed', level: 'pass', bullet: chalk.green('✓') },
|
|
72
|
+
{ title: 'Missing', level: 'warn', bullet: chalk.yellow('•') },
|
|
73
|
+
{ title: 'Broken', level: 'fail', bullet: chalk.red('✗') },
|
|
74
|
+
{ title: 'Unsafe', level: 'unsafe', bullet: chalk.red('⚠') },
|
|
75
|
+
];
|
|
76
|
+
for (const { title, level, bullet } of sections) {
|
|
77
|
+
const items = report.items.filter((i) => i.level === level);
|
|
78
|
+
if (items.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
console.log(chalk.white(`${title}:`));
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
console.log(`${bullet} ${item.message}`);
|
|
83
|
+
}
|
|
84
|
+
console.log();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ─── preflight ────────────────────────────────────────────────────────────────
|
|
88
|
+
export async function verificationPreflightCommand() {
|
|
89
|
+
const contract = await requireContract();
|
|
90
|
+
const artifactDir = await ensureArtifactDir(contract);
|
|
91
|
+
const record = await runCommand(contract.environment.preflight, {
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
timeoutMs: PREFLIGHT_TIMEOUT_S * 1000,
|
|
94
|
+
logFile: path.join(artifactDir, 'logs', 'preflight.log'),
|
|
95
|
+
env: { ARTIFACT_DIR: artifactDir },
|
|
96
|
+
});
|
|
97
|
+
if (record.status === 'passed') {
|
|
98
|
+
console.log(chalk.green('✓ preflight passed') + chalk.dim(` (${(record.duration_ms / 1000).toFixed(1)}s)`));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.error(chalk.red(`✗ preflight ${record.status}`) + chalk.dim(` — see ${record.log_path}`));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ─── run <scenario> ───────────────────────────────────────────────────────────
|
|
106
|
+
export async function verificationRunCommand(scenarioName, options) {
|
|
107
|
+
const rootDir = process.cwd();
|
|
108
|
+
const contract = await requireContract();
|
|
109
|
+
const scenario = contract.scenarios[scenarioName];
|
|
110
|
+
if (!scenario) {
|
|
111
|
+
console.error(chalk.red(`Unknown scenario "${scenarioName}".`) +
|
|
112
|
+
` Available: ${Object.keys(contract.scenarios).join(', ')}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const artifactDir = await ensureArtifactDir(contract);
|
|
116
|
+
const startedAt = new Date();
|
|
117
|
+
const commandsRun = [];
|
|
118
|
+
const env = { ARTIFACT_DIR: artifactDir };
|
|
119
|
+
// In --json mode stdout must carry ONLY the manifest — send progress to
|
|
120
|
+
// stderr and keep command streaming off (output still lands in log files).
|
|
121
|
+
const quiet = Boolean(options.json);
|
|
122
|
+
const progress = (msg) => (quiet ? console.error(msg) : console.log(msg));
|
|
123
|
+
const finish = async (status) => {
|
|
124
|
+
const manifest = {
|
|
125
|
+
contract_version: '1',
|
|
126
|
+
status,
|
|
127
|
+
scenario: scenarioName,
|
|
128
|
+
commit_sha: getHeadSha(rootDir),
|
|
129
|
+
started_at: startedAt.toISOString(),
|
|
130
|
+
completed_at: new Date().toISOString(),
|
|
131
|
+
commands_run: commandsRun,
|
|
132
|
+
artifacts: await collectArtifacts({
|
|
133
|
+
rootDir,
|
|
134
|
+
artifactDir,
|
|
135
|
+
extraPaths: contract.environment.log_paths,
|
|
136
|
+
since: startedAt,
|
|
137
|
+
}),
|
|
138
|
+
gaps: await readGaps(artifactDir),
|
|
139
|
+
safety: {
|
|
140
|
+
real_email_sent: contract.safety.allow_real_email,
|
|
141
|
+
real_payment_attempted: contract.safety.allow_real_payments,
|
|
142
|
+
customer_data_accessed: contract.safety.allow_customer_data,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const manifestPath = await writeManifest(manifest, artifactDir);
|
|
146
|
+
const packetPath = await writeReviewPacket(manifest, artifactDir);
|
|
147
|
+
if (options.json) {
|
|
148
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
printRunSummary(manifest, manifestPath, packetPath, rootDir);
|
|
152
|
+
}
|
|
153
|
+
process.exit(status === 'verified' ? 0 : status === 'unsafe_to_verify' ? 2 : 1);
|
|
154
|
+
};
|
|
155
|
+
// Safety gate before anything runs.
|
|
156
|
+
const { safety } = contract;
|
|
157
|
+
if (safety.allow_real_email || safety.allow_real_payments || safety.allow_customer_data) {
|
|
158
|
+
console.error(chalk.red('Refusing to run: the safety policy permits real external side effects.') +
|
|
159
|
+
' Fix the safety section of the contract first.');
|
|
160
|
+
await finish('unsafe_to_verify');
|
|
161
|
+
}
|
|
162
|
+
if (options.setup && contract.environment.setup) {
|
|
163
|
+
progress(chalk.dim(`$ ${contract.environment.setup}`));
|
|
164
|
+
const setup = await runCommand(contract.environment.setup, {
|
|
165
|
+
cwd: rootDir,
|
|
166
|
+
timeoutMs: 15 * 60 * 1000,
|
|
167
|
+
logFile: path.join(artifactDir, 'logs', 'setup.log'),
|
|
168
|
+
env,
|
|
169
|
+
quiet,
|
|
170
|
+
});
|
|
171
|
+
commandsRun.push(setup);
|
|
172
|
+
if (setup.status !== 'passed')
|
|
173
|
+
await finish('environment_not_ready');
|
|
174
|
+
}
|
|
175
|
+
if (!options.skipPreflight && scenario.preflight) {
|
|
176
|
+
progress(chalk.dim(`$ ${contract.environment.preflight}`));
|
|
177
|
+
const preflight = await runCommand(contract.environment.preflight, {
|
|
178
|
+
cwd: rootDir,
|
|
179
|
+
timeoutMs: PREFLIGHT_TIMEOUT_S * 1000,
|
|
180
|
+
logFile: path.join(artifactDir, 'logs', 'preflight.log'),
|
|
181
|
+
env,
|
|
182
|
+
quiet,
|
|
183
|
+
});
|
|
184
|
+
commandsRun.push(preflight);
|
|
185
|
+
if (preflight.status !== 'passed')
|
|
186
|
+
await finish('environment_not_ready');
|
|
187
|
+
}
|
|
188
|
+
const timeoutS = options.timeout ?? scenario.timeout_seconds ?? DEFAULT_SCENARIO_TIMEOUT_S;
|
|
189
|
+
progress(chalk.dim(`$ ${scenario.command}`));
|
|
190
|
+
const run = await runCommand(scenario.command, {
|
|
191
|
+
cwd: rootDir,
|
|
192
|
+
timeoutMs: timeoutS * 1000,
|
|
193
|
+
logFile: path.join(artifactDir, 'logs', `scenario-${scenarioName}.log`),
|
|
194
|
+
env,
|
|
195
|
+
quiet,
|
|
196
|
+
});
|
|
197
|
+
commandsRun.push(run);
|
|
198
|
+
await finish(run.status === 'passed' ? 'verified' : 'failed');
|
|
199
|
+
}
|
|
200
|
+
function printRunSummary(manifest, manifestPath, packetPath, rootDir) {
|
|
201
|
+
const statusStyle = {
|
|
202
|
+
verified: chalk.green,
|
|
203
|
+
failed: chalk.red,
|
|
204
|
+
inconclusive: chalk.yellow,
|
|
205
|
+
environment_not_ready: chalk.yellow,
|
|
206
|
+
unsafe_to_verify: chalk.red,
|
|
207
|
+
missing_contract: chalk.red,
|
|
208
|
+
};
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(chalk.white('Scenario: ') + manifest.scenario);
|
|
211
|
+
console.log(chalk.white('Status: ') + statusStyle[manifest.status](manifest.status));
|
|
212
|
+
console.log(chalk.white('Evidence: ') + `${manifest.artifacts.length} artifact(s)`);
|
|
213
|
+
if (manifest.gaps.length > 0) {
|
|
214
|
+
console.log(chalk.white('Gaps: ') + chalk.yellow(`${manifest.gaps.length} declared`));
|
|
215
|
+
}
|
|
216
|
+
console.log();
|
|
217
|
+
console.log(chalk.dim(`Manifest: ${path.relative(rootDir, manifestPath)}`));
|
|
218
|
+
console.log(chalk.dim(`Review packet: ${path.relative(rootDir, packetPath)}`));
|
|
219
|
+
}
|
|
220
|
+
// ─── collect-artifacts ────────────────────────────────────────────────────────
|
|
221
|
+
export async function verificationCollectCommand(options) {
|
|
222
|
+
const rootDir = process.cwd();
|
|
223
|
+
const contract = await requireContract();
|
|
224
|
+
const artifactDir = await ensureArtifactDir(contract);
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
const manifest = {
|
|
227
|
+
contract_version: '1',
|
|
228
|
+
// Artifacts without a recorded run prove presence, not success.
|
|
229
|
+
status: 'inconclusive',
|
|
230
|
+
scenario: options.scenario ?? 'collected',
|
|
231
|
+
commit_sha: getHeadSha(rootDir),
|
|
232
|
+
started_at: now,
|
|
233
|
+
completed_at: now,
|
|
234
|
+
commands_run: [],
|
|
235
|
+
artifacts: await collectArtifacts({
|
|
236
|
+
rootDir,
|
|
237
|
+
artifactDir,
|
|
238
|
+
extraPaths: contract.environment.log_paths,
|
|
239
|
+
}),
|
|
240
|
+
gaps: await readGaps(artifactDir),
|
|
241
|
+
safety: {
|
|
242
|
+
real_email_sent: contract.safety.allow_real_email,
|
|
243
|
+
real_payment_attempted: contract.safety.allow_real_payments,
|
|
244
|
+
customer_data_accessed: contract.safety.allow_customer_data,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
const manifestPath = await writeManifest(manifest, artifactDir);
|
|
248
|
+
const packetPath = await writeReviewPacket(manifest, artifactDir);
|
|
249
|
+
if (options.json) {
|
|
250
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
printRunSummary(manifest, manifestPath, packetPath, rootDir);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ─── check-safety ─────────────────────────────────────────────────────────────
|
|
257
|
+
export async function verificationCheckSafetyCommand(options) {
|
|
258
|
+
const report = await checkSafety();
|
|
259
|
+
if (options.json) {
|
|
260
|
+
console.log(JSON.stringify(report, null, 2));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
for (const check of report.checks) {
|
|
264
|
+
const bullet = check.status === 'pass' ? chalk.green('✓') : check.status === 'warn' ? chalk.yellow('•') : chalk.red('✗');
|
|
265
|
+
console.log(`${bullet} ${chalk.white(check.name)}: ${check.detail}`);
|
|
266
|
+
}
|
|
267
|
+
console.log();
|
|
268
|
+
console.log(report.ok
|
|
269
|
+
? chalk.green('Safety checks passed.')
|
|
270
|
+
: chalk.red('Safety checks failed — fix the items above before running verification.'));
|
|
271
|
+
}
|
|
272
|
+
if (!report.ok)
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
// ─── shared ───────────────────────────────────────────────────────────────────
|
|
276
|
+
async function requireContract() {
|
|
277
|
+
const loaded = await loadContract();
|
|
278
|
+
if (loaded.status === 'missing') {
|
|
279
|
+
console.error(chalk.red(`No verification contract found at ${CONTRACT_RELATIVE_PATH}.`) +
|
|
280
|
+
' Run `haystack verification init` to scaffold one.');
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (loaded.status === 'invalid') {
|
|
284
|
+
console.error(chalk.red(`Invalid ${CONTRACT_RELATIVE_PATH}:`));
|
|
285
|
+
for (const error of loaded.errors) {
|
|
286
|
+
console.error(chalk.red(` ✗ ${error}`));
|
|
287
|
+
}
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
return loaded.contract;
|
|
291
|
+
}
|