@stravica-ai/rcf-verify-lite 0.1.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.
@@ -0,0 +1,302 @@
1
+ // Verifier-agent launcher (spec §5, §7.3, §9). Verify does NOT hand-code
2
+ // browser scripts; it launches a fresh, isolated agent (Claude Code by
3
+ // default) configured with browser tooling (Playwright) and the §7.3
4
+ // isolation env, and that agent drives the running app and returns structured
5
+ // findings. This keeps verify harness-agnostic.
6
+ //
7
+ // Shared-parent-state caution (§9): the launched agent MUST get its OWN
8
+ // isolated browser context. We provision the Playwright MCP server explicitly
9
+ // via --mcp-config + --strict-mcp-config so the launcher does NOT inherit the
10
+ // operator's ambient user-level MCP config (which may be absent on a fresh
11
+ // machine, or may leak the parent's shared browser/OAuth session). The spawned
12
+ // agent gets exactly the tool surface it needs — scoped via --allowedTools —
13
+ // and nothing more (no --dangerously-skip-permissions).
14
+
15
+ import { isolationEnv, isolationProvenance } from '@stravica-ai/rcf-lite-core/isolation';
16
+
17
+ /** Env var naming a module (exporting `launchAgent`) to use instead of the default spawn launcher. The integration + manual-e2e seam. */
18
+ export const LAUNCHER_ENV = 'RCF_VERIFY_LAUNCHER';
19
+
20
+ /**
21
+ * The minimal, SCOPED tool surface the verifier agent needs to drive a live
22
+ * app and collect runtime evidence (spec §9 method). Deliberately narrow —
23
+ * NOT --dangerously-skip-permissions. The Playwright browser family (provided
24
+ * by the explicitly-provisioned MCP server below), WebFetch, and a curl/grep
25
+ * Bash surface for raw HTTP probing cover the empirically-observed method.
26
+ * `mcp__playwright` grants every tool exported by the provisioned `playwright`
27
+ * server.
28
+ */
29
+ export const DEFAULT_ALLOWED_TOOLS = Object.freeze([
30
+ 'mcp__playwright',
31
+ 'WebFetch',
32
+ 'Bash(curl:*)',
33
+ 'Bash(grep:*)',
34
+ ]);
35
+
36
+ /**
37
+ * Explicit Playwright MCP provisioning for the spawned agent. Provisioned by
38
+ * value (not by reference to ambient user config) so the launcher works on a
39
+ * machine with no user-level MCP setup, and so the agent gets its OWN browser
40
+ * context (§9). Server name `playwright` -> tool prefix `mcp__playwright__*`.
41
+ * `npx` is resolved off PATH for portability (no machine-specific absolute).
42
+ */
43
+ export const DEFAULT_MCP_CONFIG = Object.freeze({
44
+ mcpServers: {
45
+ playwright: {
46
+ type: 'stdio',
47
+ command: 'npx',
48
+ args: ['-y', '@playwright/mcp@latest'],
49
+ env: {},
50
+ },
51
+ },
52
+ });
53
+
54
+ /**
55
+ * Build the child-process launch configuration for the verifier agent. Pure
56
+ * and testable: asserts the §7.3 isolation env is applied, the agent is pointed
57
+ * at the brief + live URL, the network-capable tool surface is scoped, and the
58
+ * Playwright MCP server is provisioned explicitly. The command defaults to
59
+ * Claude Code headless; callers may override via deps for other harnesses.
60
+ *
61
+ * @param {object} opts
62
+ * @param {object} opts.brief - the composed adversarial brief
63
+ * @param {string} opts.url
64
+ * @param {object} [deps]
65
+ * @param {string} [deps.command] - the agent CLI (default: 'claude')
66
+ * @param {Record<string,string|undefined>} [deps.baseEnv]
67
+ * @param {string[]} [deps.allowedTools] - override the scoped tool surface
68
+ * @param {object} [deps.mcpConfig] - override the provisioned MCP servers
69
+ * @returns {{ command: string, args: string[], env: Record<string,string|undefined>, isolation: {autoMemory: boolean, nonEssentialTraffic: boolean}, briefUrl: string, brief: object }}
70
+ */
71
+ export function buildLaunchConfig({ brief, url }, deps = {}) {
72
+ const command = deps.command ?? 'claude';
73
+ const env = isolationEnv(deps.baseEnv ?? process.env);
74
+ const allowedTools = deps.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
75
+ const mcpConfig = deps.mcpConfig ?? DEFAULT_MCP_CONFIG;
76
+ // The brief is passed to the agent over stdin by the concrete launcher; here
77
+ // we assemble the invariant config. --output-format json wraps the agent's
78
+ // reply in a result envelope (robust ingestion + runStats, see parseAgentOutput);
79
+ // --allowedTools scopes the network-capable surface; --mcp-config +
80
+ // --strict-mcp-config provision Playwright without touching ambient user config.
81
+ const args = [
82
+ '--print',
83
+ '--output-format', 'json',
84
+ '--allowedTools', allowedTools.join(','),
85
+ '--mcp-config', JSON.stringify(mcpConfig),
86
+ '--strict-mcp-config',
87
+ ];
88
+ return { command, args, env, isolation: isolationProvenance(), briefUrl: url, brief };
89
+ }
90
+
91
+ /**
92
+ * Brace-balanced extraction of the LAST `{"findings" ...}` object from an
93
+ * arbitrary text blob. The verifier agent empirically ALWAYS prepends prose
94
+ * even when told to emit only JSON, so whole-string JSON.parse cannot be
95
+ * trusted (both shakedown transcripts prove it). We scan for the last
96
+ * `{ "findings"` opener (tolerant of whitespace) and walk braces — respecting
97
+ * string literals and escapes — to its matching close.
98
+ *
99
+ * @param {string} text
100
+ * @returns {string | null} the JSON substring, or null if none found
101
+ */
102
+ export function extractFindingsObject(text) {
103
+ if (typeof text !== 'string' || text.length === 0) return null;
104
+ // Find the last opener `{ "findings"` (allow whitespace between { and key).
105
+ const opener = /\{\s*"findings"/g;
106
+ let start = -1;
107
+ let m;
108
+ while ((m = opener.exec(text)) !== null) start = m.index;
109
+ if (start < 0) return null;
110
+ let depth = 0;
111
+ let inStr = false;
112
+ let esc = false;
113
+ for (let j = start; j < text.length; j += 1) {
114
+ const c = text[j];
115
+ if (inStr) {
116
+ if (esc) esc = false;
117
+ else if (c === '\\') esc = true;
118
+ else if (c === '"') inStr = false;
119
+ continue;
120
+ }
121
+ if (c === '"') inStr = true;
122
+ else if (c === '{') depth += 1;
123
+ else if (c === '}') {
124
+ depth -= 1;
125
+ if (depth === 0) return text.slice(start, j + 1);
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Map the `--output-format json` result envelope's usage/timing to a camelCase
133
+ * runStats block for the report (spec §5.3, additive). Omit-not-fake: any
134
+ * field absent from the envelope is simply left off.
135
+ *
136
+ * @param {object} envelope
137
+ * @returns {object | null}
138
+ */
139
+ export function extractRunStats(envelope) {
140
+ if (!envelope || typeof envelope !== 'object') return null;
141
+ const stats = {};
142
+ if (typeof envelope.duration_ms === 'number') stats.durationMs = envelope.duration_ms;
143
+ if (typeof envelope.num_turns === 'number') stats.numTurns = envelope.num_turns;
144
+ if (typeof envelope.total_cost_usd === 'number') stats.totalCostUsd = envelope.total_cost_usd;
145
+ const u = envelope.usage;
146
+ if (u && typeof u === 'object') {
147
+ const tokens = {};
148
+ if (typeof u.input_tokens === 'number') tokens.inputTokens = u.input_tokens;
149
+ if (typeof u.output_tokens === 'number') tokens.outputTokens = u.output_tokens;
150
+ if (typeof u.cache_read_input_tokens === 'number') tokens.cacheReadInputTokens = u.cache_read_input_tokens;
151
+ if (typeof u.cache_creation_input_tokens === 'number') tokens.cacheCreationInputTokens = u.cache_creation_input_tokens;
152
+ if (Object.keys(tokens).length > 0) stats.tokens = tokens;
153
+ }
154
+ return Object.keys(stats).length > 0 ? stats : null;
155
+ }
156
+
157
+ /**
158
+ * Two-layer robust ingestion of the raw agent stdout (spec §5.4 — the report
159
+ * is build-lite's next input, so parse must not lose the verdict to a stray
160
+ * prose prefix):
161
+ * Layer A — parse the whole stdout as the `--output-format json` result
162
+ * envelope; the agent's text reply is in `.result`, and usage/timing
163
+ * give runStats.
164
+ * Layer B — brace-balanced extraction of the `{"findings":[...]}` object,
165
+ * first from the envelope's `.result` text, then (belt) from the
166
+ * whole raw stdout in case the envelope shape ever changes.
167
+ * Both layers, never either. Success requires a `{ findings: [...] }` object.
168
+ *
169
+ * @param {string} rawStdout
170
+ * @returns {{ ok: true, findings: object[], runStats: (object|null) } | { ok: false, reason: string }}
171
+ */
172
+ export function parseAgentOutput(rawStdout) {
173
+ const raw = typeof rawStdout === 'string' ? rawStdout : '';
174
+ let envelope = null;
175
+ let resultText = raw;
176
+ let runStats = null;
177
+ // Layer A: the result envelope.
178
+ try {
179
+ const parsed = JSON.parse(raw);
180
+ if (parsed && typeof parsed === 'object' && parsed.type === 'result') {
181
+ envelope = parsed;
182
+ runStats = extractRunStats(envelope);
183
+ if (typeof envelope.result === 'string') resultText = envelope.result;
184
+ }
185
+ } catch {
186
+ // Not an envelope (text-mode stdout, or truncated). Fall through to Layer B on raw.
187
+ }
188
+ // Layer B: brace-balanced extraction — from the envelope text first, then raw.
189
+ const candidate = extractFindingsObject(resultText) ?? extractFindingsObject(raw);
190
+ if (!candidate) {
191
+ const detail = envelope
192
+ ? `agent produced no {"findings"} object (envelope subtype=${envelope.subtype ?? 'unknown'}, is_error=${envelope.is_error ?? 'unknown'})`
193
+ : 'agent output was neither a result envelope nor did it contain a {"findings"} object';
194
+ return { ok: false, reason: detail };
195
+ }
196
+ let obj;
197
+ try {
198
+ obj = JSON.parse(candidate);
199
+ } catch (err) {
200
+ return { ok: false, reason: `extracted {"findings"} object failed to parse: ${err.message}` };
201
+ }
202
+ if (!obj || !Array.isArray(obj.findings)) {
203
+ return { ok: false, reason: 'extracted object had no findings array' };
204
+ }
205
+ return { ok: true, findings: obj.findings, runStats };
206
+ }
207
+
208
+ /**
209
+ * Resolve the launchAgent function for this run. Precedence:
210
+ * 1. an explicitly injected `deps.launchAgent` (unit tests, in-process harness);
211
+ * 2. a module named by RCF_VERIFY_LAUNCHER (integration + manual e2e seam);
212
+ * 3. the default spawn launcher (live Claude Code + Playwright).
213
+ *
214
+ * @param {object} [deps]
215
+ * @returns {Promise<(ctx: object) => Promise<{findings: object[], runStats?: object}>>}
216
+ */
217
+ export async function resolveLauncher(deps = {}) {
218
+ if (typeof deps.launchAgent === 'function') return deps.launchAgent;
219
+ const envModule = process.env[LAUNCHER_ENV];
220
+ if (envModule) {
221
+ const mod = await import(envModule);
222
+ const fn = mod.launchAgent ?? mod.default;
223
+ if (typeof fn !== 'function') {
224
+ throw new Error(`${LAUNCHER_ENV} module "${envModule}" does not export launchAgent`);
225
+ }
226
+ return fn;
227
+ }
228
+ return defaultSpawnLauncher;
229
+ }
230
+
231
+ /**
232
+ * Default launcher: spawn the live verifier agent (Claude Code) with the
233
+ * isolation env + scoped tools + provisioned Playwright, and let it drive the
234
+ * app. Ingests its output through the two-layer parser.
235
+ *
236
+ * v1 honesty (§9, §11): the live-agent-drives-live-app path is NOT unit-
237
+ * testable and is exercised only by the recorded manual e2e; the PARSE logic
238
+ * (parseAgentOutput / extractFindingsObject) IS unit-tested against real
239
+ * captured transcripts. When ingestion fails, the raw transcript is written to
240
+ * disk and its path carried on the thrown error — never lose the evidence, and
241
+ * never fabricate a PASS (§9). Callers (engine) turn the throw into a
242
+ * LAUNCH-FAILURE report so the §5.4 fix loop still has something to ingest.
243
+ *
244
+ * @param {object} ctx
245
+ * @returns {Promise<{ findings: object[], runStats: (object|null) }>}
246
+ */
247
+ export async function defaultSpawnLauncher(ctx) {
248
+ const { spawn } = await import('node:child_process');
249
+ const config = buildLaunchConfig(ctx, {});
250
+ const out = await new Promise((resolve, reject) => {
251
+ let child;
252
+ try {
253
+ child = spawn(config.command, config.args, { env: config.env, stdio: ['pipe', 'pipe', 'inherit'] });
254
+ } catch (err) {
255
+ reject(new Error(`could not launch verifier agent "${config.command}": ${err.message}. `
256
+ + `Set ${LAUNCHER_ENV} to a module exporting launchAgent, or install the agent CLI.`));
257
+ return;
258
+ }
259
+ let buf = '';
260
+ child.stdout.on('data', (d) => { buf += d.toString(); });
261
+ child.on('error', (err) => reject(new Error(`verifier agent failed to start: ${err.message}. Set ${LAUNCHER_ENV} to inject a launcher.`)));
262
+ child.on('close', (code) => {
263
+ if (code !== 0) {
264
+ reject(new Error(`verifier agent exited ${code}`));
265
+ return;
266
+ }
267
+ resolve(buf);
268
+ });
269
+ // Hand the brief to the agent on stdin.
270
+ child.stdin.write(JSON.stringify(config.brief));
271
+ child.stdin.end();
272
+ });
273
+
274
+ const parsed = parseAgentOutput(out);
275
+ if (!parsed.ok) {
276
+ const rawPath = await persistRawOutput(out);
277
+ const err = new Error(`verifier agent output could not be ingested: ${parsed.reason}. Raw transcript: ${rawPath}`);
278
+ err.rawOutputPath = rawPath;
279
+ throw err;
280
+ }
281
+ return { findings: parsed.findings, runStats: parsed.runStats };
282
+ }
283
+
284
+ /**
285
+ * Persist an un-ingestible raw agent transcript so it is never lost (spec §5.4
286
+ * — the transcript is the only evidence of what the agent actually emitted).
287
+ *
288
+ * @param {string} raw
289
+ * @returns {Promise<string>} the path written (or a marker if the write failed)
290
+ */
291
+ async function persistRawOutput(raw) {
292
+ try {
293
+ const { writeFile } = await import('node:fs/promises');
294
+ const { tmpdir } = await import('node:os');
295
+ const { join } = await import('node:path');
296
+ const path = join(tmpdir(), `rcf-verify-agent-output-${Date.now()}.txt`);
297
+ await writeFile(path, raw ?? '', 'utf8');
298
+ return path;
299
+ } catch (err) {
300
+ return `(could not persist raw transcript: ${err.message})`;
301
+ }
302
+ }
@@ -0,0 +1,107 @@
1
+ // MCP tool registry for verify (spec §10 MCP, mirrors build's mcp/ shape). A
2
+ // thin adapter over the SAME in-process engine the CLI uses (runVerification),
3
+ // never spawning the CLI. Input schemas are JSON Schema 2020-12, camelCase,
4
+ // closed objects. Argument validation maps to tool execution errors
5
+ // (isError: true) — the self-correction channel — not protocol errors.
6
+
7
+ import { JsonRpcError, INVALID_PARAMS } from '@stravica-ai/rcf-lite-core/mcp-shell';
8
+ import { isRcfError } from '@stravica-ai/rcf-lite-core/errors';
9
+
10
+ import { runVerification } from '../engine/index.js';
11
+ import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
12
+
13
+ const RUN_INPUT_SCHEMA = {
14
+ type: 'object',
15
+ additionalProperties: false,
16
+ properties: {
17
+ repo: { type: 'string', description: 'RCF chain source (the acceptance contract)' },
18
+ chainRef: { type: 'string', description: 'Which PRD/chain (default: the repo\'s)' },
19
+ profile: { type: 'string', enum: ['deployed', 'ci', 'local-dev'], description: 'Runtime profile (scopes the verdict authority)' },
20
+ url: { type: 'string', description: 'The running app for this profile' },
21
+ parityEnv: { type: 'boolean', description: 'Assert a non-deployed runtime is edge-identical to prod (the only path to SHIP from a non-deployed profile)' },
22
+ provision: { type: 'string', description: 'Provisioning credentials FILE path (never inline)' },
23
+ provisionMode: { type: 'string', enum: ['run', 'skip'] },
24
+ persona: { type: 'string' },
25
+ severityGate: { type: 'string', enum: FINDING_SEVERITIES },
26
+ },
27
+ required: ['repo', 'profile', 'url'],
28
+ };
29
+
30
+ const RUN_OUTPUT_SCHEMA = {
31
+ type: 'object',
32
+ properties: {
33
+ ok: { type: 'boolean' },
34
+ gateTripped: { type: 'boolean' },
35
+ report: { type: 'object' },
36
+ errors: { type: 'array' },
37
+ },
38
+ required: ['ok'],
39
+ };
40
+
41
+ /**
42
+ * Build the tool registry. `definitions` is the tools/list payload;
43
+ * `call(name, args)` dispatches a tools/call.
44
+ *
45
+ * @param {object} [deps] - injectable engine seams (launchAgent, fetchImpl, signup, ...)
46
+ * @returns {{ definitions: object[], call: (name: string, args: object) => Promise<object> }}
47
+ */
48
+ export function createToolRegistry(deps = {}) {
49
+ const definitions = [
50
+ {
51
+ name: 'rcf_verify_run',
52
+ title: 'RCF Verify — run adversarial verification',
53
+ description: 'Launch a fresh-context adversarial verifier against a running app under a declared runtime profile and return a structured verdict stamped with the runtime it ran against. Returns the report inline (no --out file needed over MCP).',
54
+ inputSchema: RUN_INPUT_SCHEMA,
55
+ outputSchema: RUN_OUTPUT_SCHEMA,
56
+ annotations: { readOnlyHint: false, openWorldHint: true },
57
+ },
58
+ ];
59
+
60
+ async function call(name, args) {
61
+ if (name !== 'rcf_verify_run') {
62
+ throw new JsonRpcError(INVALID_PARAMS, `Unknown tool: ${name}`);
63
+ }
64
+ const a = args ?? {};
65
+ const errors = [];
66
+ if (typeof a.repo !== 'string') errors.push({ kind: 'usage', field: 'repo', message: 'repo is required' });
67
+ if (!['deployed', 'ci', 'local-dev'].includes(a.profile)) errors.push({ kind: 'usage', field: 'profile', message: 'profile must be deployed|ci|local-dev' });
68
+ if (typeof a.url !== 'string') errors.push({ kind: 'usage', field: 'url', message: 'url is required' });
69
+ if (errors.length > 0) {
70
+ return errorResult(errors);
71
+ }
72
+
73
+ const result = await runVerification({
74
+ repo: a.repo,
75
+ chainRef: a.chainRef,
76
+ profile: a.profile,
77
+ url: a.url,
78
+ parityEnv: Boolean(a.parityEnv),
79
+ provision: a.provision,
80
+ provisionMode: a.provisionMode ?? 'run',
81
+ persona: a.persona,
82
+ severityGate: a.severityGate,
83
+ }, deps);
84
+
85
+ if (isRcfError(result)) {
86
+ return errorResult([{ kind: result.kind, field: result.field ?? null, message: result.message }]);
87
+ }
88
+ const { report } = result;
89
+ const tripped = gateTripped({ verdict: report.verdict, findings: report.findings, gate: a.severityGate });
90
+ const payload = { ok: true, gateTripped: tripped, report };
91
+ return {
92
+ content: [{ type: 'text', text: `verdict ${report.verdict} [${report.verdictAuthority}]${tripped ? ' — gate tripped' : ''}` }],
93
+ structuredContent: payload,
94
+ isError: false,
95
+ };
96
+ }
97
+
98
+ return { definitions, call };
99
+ }
100
+
101
+ function errorResult(errors) {
102
+ return {
103
+ content: [{ type: 'text', text: errors.map((e) => `[${e.kind}] ${e.message}`).join('\n') }],
104
+ structuredContent: { ok: false, errors },
105
+ isError: true,
106
+ };
107
+ }
@@ -0,0 +1,146 @@
1
+ // Runtime-profile module (spec §4, amendments 1 + 3) — the load-bearing
2
+ // correctness property of the whole tool. The single most expensive miss in
3
+ // the persona programme was "verified against one runtime, reported as
4
+ // another" (run-05: verified against wrangler-dev on localhost, reported as a
5
+ // live Worker). This module makes that failure structurally impossible: every
6
+ // verdict carries the runtime it ran against, and only `deployed` (or a
7
+ // declared-parity runtime) may carry the authority of a ship gate.
8
+ //
9
+ // Provenance-honesty, NOT a URL blocklist. localhost is a first-class target
10
+ // for ci/local-dev (that is the CI use case, amendment 3). What is forbidden
11
+ // is a lower profile claiming the authority of `deployed`.
12
+
13
+ import { rcfError } from '@stravica-ai/rcf-lite-core/errors';
14
+
15
+ /** The three runtime profiles (spec §4). */
16
+ export const PROFILES = Object.freeze(['deployed', 'ci', 'local-dev']);
17
+
18
+ /**
19
+ * Resolve + validate the runtime declaration from parsed CLI flags. Errors
20
+ * are returned as data (RcfError, kind 'usage'), never thrown — the CLI maps
21
+ * them to a non-zero usage exit (spec §3 hard rules 1-2, §10).
22
+ *
23
+ * @param {{ profile?: string, url?: string, parityEnv?: boolean }} flags
24
+ * @returns {{ profile: string, url: string, parityEnv: boolean } | import('@stravica-ai/rcf-lite-core/errors').RcfError}
25
+ */
26
+ export function resolveProfile(flags = {}) {
27
+ const { profile, url } = flags;
28
+ if (!profile) {
29
+ return rcfError({ kind: 'usage', message: '--profile is required (one of: deployed, ci, local-dev)', field: 'profile' });
30
+ }
31
+ if (!PROFILES.includes(profile)) {
32
+ return rcfError({ kind: 'usage', message: `--profile must be one of: ${PROFILES.join(', ')} (got "${profile}")`, field: 'profile' });
33
+ }
34
+ if (!url) {
35
+ return rcfError({ kind: 'usage', message: '--url is required (the running app for this profile)', field: 'url' });
36
+ }
37
+ return { profile, url, parityEnv: Boolean(flags.parityEnv) };
38
+ }
39
+
40
+ /**
41
+ * Heuristic local-hostname detection. Pure/sync — no network. Errs toward
42
+ * flagging local so the deployed reachability gate is advisory-strict.
43
+ *
44
+ * @param {string} url
45
+ * @returns {boolean}
46
+ */
47
+ export function looksLocal(url) {
48
+ let host;
49
+ try {
50
+ host = new URL(url).hostname.toLowerCase();
51
+ } catch {
52
+ // Unparseable URL — treat as suspicious (advisory-strict).
53
+ return true;
54
+ }
55
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]') {
56
+ return true;
57
+ }
58
+ // *.local / *.localhost mDNS + dev conventions, and RFC1918 private ranges.
59
+ if (host.endsWith('.local') || host.endsWith('.localhost')) return true;
60
+ if (/^10\./.test(host) || /^192\.168\./.test(host)) return true;
61
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * Deployed reachability gate (spec §4). Only consulted for
67
+ * profile === 'deployed'. `looksLocal` is derived purely; `reachable` is
68
+ * probed over the wire via an injectable fetch (unit tests fake it — no real
69
+ * network). Advisory-strict: any probe failure yields reachable:false, which
70
+ * routes the run to NOT-DEPLOYED rather than a false SHIP.
71
+ *
72
+ * @param {string} url
73
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [deps]
74
+ * @returns {Promise<{ reachable: boolean, looksLocal: boolean }>}
75
+ */
76
+ export async function checkDeployedReachability(url, deps = {}) {
77
+ const local = looksLocal(url);
78
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
79
+ const timeoutMs = deps.timeoutMs ?? 5000;
80
+ let reachable = false;
81
+ if (typeof fetchImpl === 'function') {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
84
+ try {
85
+ const res = await fetchImpl(url, { method: 'HEAD', redirect: 'manual', signal: controller.signal });
86
+ // Any HTTP response (even 4xx/5xx/3xx) proves something is listening at
87
+ // the wire address. A thrown/aborted probe means unreachable.
88
+ reachable = Boolean(res && typeof res.status === 'number');
89
+ } catch {
90
+ reachable = false;
91
+ } finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+ return { reachable, looksLocal: local };
96
+ }
97
+
98
+ /**
99
+ * Whether a profile+parity combination carries ship authority (spec §4).
100
+ * `deployed` is always a ship gate; a non-deployed profile is a ship gate
101
+ * ONLY with an explicit `--parity-env` operator assertion. Everything else is
102
+ * a correctness/regression verdict.
103
+ *
104
+ * @param {string} profile
105
+ * @param {boolean} parityEnv
106
+ * @returns {'ship' | 'correctness'}
107
+ */
108
+ export function verdictAuthorityFor(profile, parityEnv) {
109
+ if (profile === 'deployed') return 'ship';
110
+ if (parityEnv && (profile === 'ci' || profile === 'local-dev')) return 'ship';
111
+ return 'correctness';
112
+ }
113
+
114
+ /**
115
+ * Whether a `deployed` run must refuse to issue a verdict (NOT-DEPLOYED).
116
+ * Only meaningful for profile === 'deployed'. Errs toward refusal.
117
+ *
118
+ * @param {string} profile
119
+ * @param {{ reachable: boolean, looksLocal: boolean }} reachability
120
+ * @returns {boolean}
121
+ */
122
+ export function isNotDeployed(profile, reachability) {
123
+ if (profile !== 'deployed') return false;
124
+ if (!reachability) return true;
125
+ return reachability.looksLocal === true || reachability.reachable === false;
126
+ }
127
+
128
+ /**
129
+ * Stamp runtime provenance onto a verdict object (spec §4, §5.3). Returns a
130
+ * new object; does not mutate the input.
131
+ *
132
+ * @param {object} verdict
133
+ * @param {{ profile: string, url: string, parityEnv: boolean, reachability?: object }} ctx
134
+ * @returns {object}
135
+ */
136
+ export function stampProvenance(verdict, ctx) {
137
+ return {
138
+ ...verdict,
139
+ provenance: {
140
+ profile: ctx.profile,
141
+ url: ctx.url,
142
+ parityEnv: Boolean(ctx.parityEnv),
143
+ reachability: ctx.reachability ?? null,
144
+ },
145
+ };
146
+ }