/** * cli:run-smoke — execute.ts * * Runtime smoke test : boot dotnet + npm dev, probe every page + every * API endpoint declared by the PRD slice, fail on any 4xx/5xx. * * Exported helpers (for unit tests, no process spawn) : * - deriveProbesFromSlice(slice) — extract page-route probes from * pageSpec.filePath entries. * - extractApiUrlsFromSource(source, file) — extract `'/api/...'` * literals from a generated service / hook file. * * The main `runSmoke(args)` orchestrates the full flow ; in dryRun * mode it skips the boot + probe phases and returns the derived probe * list verbatim so callers can lock the URL contract in tests. */ import { readFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; import { findFiles } from '../../../../lib/fs.js'; import { collectRegisteredKeys } from '../../../../lib/routes-registry.js'; import { runBrowserProbes } from './browser.js'; import { runInteractionAxis } from './interact.js'; import type { RunSmokeArgs, SmokeProbe, SmokeProbeResult, SmokeReport, NavMenuCheck, } from './types.js'; interface PrdSlicePageSpec { filePath?: string; view?: string; entity?: string; screenCode?: string; } interface PrdSliceShape { pageSpecs?: PrdSlicePageSpec[]; } /** * Derive the list of expected page-route probes from `pageSpec.filePath`. * * Convention : the page file path encodes the route literally. A file at * `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx` * exposes a route under `/{appCode}/{module}/{section}/...`. The view * suffix tells us whether to probe the index (`list`, `dashboard`) or * a parametric path (`detail`, `form`). * * For list / dashboard / home views : `/{appCode}/{module}/{section}`. * For detail / form : `/{appCode}/{module}/{section}/`. We * synthesise a sentinel id `00000000-0000-0000-0000-000000000000` so * the probe lands on the actual route (the page is rendered ; the * data fetch then returns a 404, which the page handles via the * emptyState — that's an OK probe outcome at the routing level). */ export function deriveProbesFromSlice(slice: PrdSliceShape): SmokeProbe[] { const probes: SmokeProbe[] = []; for (const ps of slice.pageSpecs ?? []) { if (!ps.filePath) continue; const segments = parsePageFilePath(ps.filePath); if (!segments) continue; const { appCode, module, section, view } = segments; const base = `/${appCode}/${module}/${section}`; const url = view === 'detail' || view === 'form' ? `${base}/00000000-0000-0000-0000-000000000000` : base; probes.push({ source: 'pageSpec', url, verb: 'GET', expectedBy: ps.filePath, }); } return probes; } interface PagePathSegments { appCode: string; module: string; section: string; view: string; } /** Parse `src/pages/{appCode}/{module}/{section}/{Entity}{View}Page.tsx`. */ function parsePageFilePath(filePath: string): PagePathSegments | null { const norm = filePath.replace(/\\/g, '/'); const match = /^src\/pages\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)Page\.tsx$/.exec(norm); if (!match) return null; const [, appCode, module, section, entityPlusView] = match; // Heuristic : the View suffix is the trailing PascalCase token of the // file's base name. We map known suffixes to their lowercase view code. const suffixes = ['List', 'Detail', 'Form', 'Dashboard', 'Card', 'Kanban', 'AppHome', 'ModuleHome', 'SectionHome']; let view = 'list'; for (const s of suffixes) { if (entityPlusView.endsWith(s)) { view = s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); break; } } return { appCode: appCode.toLowerCase(), module, section, view }; } /** * Extract `/api/...` URL literals from a generated source file. * * Matches : * api.get(`${API_PATH}/dashboard/consolidated`, …) * api.post('/api/budgets/budgets', …) * axios.put<…>(`${BASE}/${id}`, …) * * Returns each unique URL with its inferred HTTP verb. Template literals * with `${...}` interpolations are normalised : the variable substitution * is replaced by a sentinel (`/_/`) so the probe URL hits a real route * (relying on the server's route table to match parametric segments). */ export function extractApiUrlsFromSource(source: string, fromFile: string): SmokeProbe[] { const probes: SmokeProbe[] = []; const callRe = /\bapi\.|smartstackClient|axios|apiClient/; if (!callRe.test(source)) return probes; // Statically resolve `const NAME = ''` declarations so template // literals referencing them (\${API_PATH}/...) can be expanded into the // concrete URL the runtime will hit. const stringConsts = new Map(); const constRe = /\bconst\s+(\w+)\s*=\s*['"]([^'"]+)['"]\s*;?/g; let cm: RegExpExecArray | null; while ((cm = constRe.exec(source)) !== null) { stringConsts.set(cm[1], cm[2]); } // Common shape : `.(, ...)`. Match the URL literal // (either single/double-quoted or back-ticked) right after the verb. // The optional `<...>` generic args are matched as a whole optional // group (the original `<[^>]*>?` only made the closing `>` optional and // broke on calls without generics). const verbRe = /\.(get|post|put|delete|patch)\s*(?:<[^>]*>)?\s*\(\s*([`'"][^`'"]+[`'"])/gi; let m: RegExpExecArray | null; while ((m = verbRe.exec(source)) !== null) { const verbStr = m[1].toUpperCase() as SmokeProbe['verb']; const raw = m[2].slice(1, -1); const url = expandTemplateLiteral(raw, stringConsts); if (!url.startsWith('/')) continue; probes.push({ source: 'apiClient', url, verb: verbStr, expectedBy: fromFile, }); } return probes; } /** * Expand `${VAR}` placeholders inside a template literal : * - if `VAR` is a known string const in scope, substitute its value ; * - otherwise substitute the sentinel `_` (so the resulting URL still * has a sane shape for the route-table probe). * * When the leading placeholder doesn't resolve (no matching const) AND * the URL would start with a bare sentinel, we prepend `/api/` so the * probe lands on the backend host (most apiClient URLs start with * `${API_PATH}` which is itself `/api/...`). */ function expandTemplateLiteral(raw: string, scope: Map): string { const expanded = raw.replace(/\$\{([^}]+)\}/g, (_, expr) => { const trimmed = expr.trim(); return scope.get(trimmed) ?? '_'; }); return expanded.replace(/^_(?=\/)/, '/api/_'); } /** Find every generated service / hook file and extract API URLs from it. */ export async function discoverApiProbes(projectPath: string): Promise { const webRoot = await findWebRoot(projectPath); if (!webRoot) return []; // scaffold-api-client writes services to `src/features/{app}/{module}/{entity}/services/` // (NOT `src/services/api/**`, which never existed — the historical glob found 0 // service files, so the smoke probe was blind to every generated API URL). Scan the // real location; keep the legacy glob as a harmless fallback. const serviceFiles = await findFiles('src/features/**/services/**/*.ts', { cwd: webRoot }); const legacyServiceFiles = await findFiles('src/services/api/**/*.ts', { cwd: webRoot }); const hookFiles = await findFiles('src/features/**/hooks/**/*.ts', { cwd: webRoot }); const candidates = [...serviceFiles, ...legacyServiceFiles, ...hookFiles]; const probes: SmokeProbe[] = []; for (const f of candidates) { if (!existsSync(f)) continue; const src = readFileSync(f, 'utf8'); probes.push(...extractApiUrlsFromSource(src, path.relative(projectPath, f))); } return dedupeProbes(probes); } function dedupeProbes(probes: SmokeProbe[]): SmokeProbe[] { const seen = new Set(); const out: SmokeProbe[] = []; for (const p of probes) { const k = `${p.verb} ${p.url}`; if (seen.has(k)) continue; seen.add(k); out.push(p); } return out; } async function findWebRoot(projectPath: string): Promise { // Convention : `/web/-web` (cf. SmartStack scaffolders). const webDir = path.join(projectPath, 'web'); if (!existsSync(webDir)) return null; // Pick the first sub-dir matching *-web. const candidates = await findFiles('*-web/package.json', { cwd: webDir }); if (candidates.length === 0) return null; return path.dirname(candidates[0]); } /** * Probe a single URL on the host (anonymous HTTP). Returns a structured * result with status code + duration. Wraps fetch with a short timeout * so a hung backend doesn't deadlock the smoke run. */ async function probe(url: string, verb: SmokeProbe['verb'], hostBase: string, timeoutMs: number): Promise<{ status: number | null; durationMs: number; reason?: string }> { const start = Date.now(); const controller = new AbortController(); const t = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(`${hostBase}${url}`, { method: verb, signal: controller.signal }); return { status: res.status, durationMs: Date.now() - start }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { status: null, durationMs: Date.now() - start, reason: message.includes('aborted') ? 'timeout' : 'network-error', }; } finally { clearTimeout(t); } } /** * Run the full smoke test : derive probe list, optionally boot + probe, * return the structured report. Boot orchestration is intentionally * isolated behind `dryRun: false` so unit tests can lock the URL * extraction without spawning processes. */ export async function runSmoke(args: RunSmokeArgs): Promise { // 1. Load PRD slice. if (!existsSync(args.prdSlice)) { throw new Error(`prdSlice not found at ${args.prdSlice}`); } const slice = JSON.parse(readFileSync(args.prdSlice, 'utf8')) as PrdSliceShape; // 2. Derive expected probes. const pageProbes = deriveProbesFromSlice(slice); const apiProbes = await discoverApiProbes(args.projectPath); const allProbes = dedupeProbes([...pageProbes, ...apiProbes]); if (args.dryRun) { const probes: SmokeProbeResult[] = allProbes.map((p) => ({ ...p, status: null, durationMs: 0, failed: false, failureReason: 'dry-run', })); return { passed: true, backendBootMs: null, frontendBootMs: null, totalProbes: probes.length, passedProbes: 0, failedProbes: 0, skippedProbes: probes.length, probes, failures: [], browser: { ran: false, available: false, pages: [], failures: [] }, navMenu: { ran: false, ok: true, mismatches: [] }, interaction: { ran: false, authenticated: false, actions: [], failures: [] }, }; } // 3. Boot orchestration (Wave 2.3, 2026-05-27). // // When selfBoot=true (default): spawn `dotnet run --project src/.Api` // + `npm run dev` in background, poll their health endpoints, run the // probes, then tear both processes down on the way out (try/finally // block ensures cleanup even if probes throw). // // When selfBoot=false (legacy): the caller manages the processes // externally — we skip the boot phase and probe directly. const frontendHost = `http://localhost:${args.frontendPort}`; const backendHost = `http://localhost:${args.backendPort}`; let backendBootMs: number | null = null; let frontendBootMs: number | null = null; let backendProc: ChildProcess | null = null; let frontendProc: ChildProcess | null = null; try { if (args.selfBoot) { // Best-effort discovery of the .NET project entrypoint. const apiCsproj = await firstFile('src/*.Api/*.Api.csproj', args.projectPath) ?? await firstFile('src/**/*.Api.csproj', args.projectPath); const webRoot = await firstFile('web/*-web/package.json', args.projectPath); if (apiCsproj) { const t0 = Date.now(); backendProc = spawn('dotnet', ['run', '--project', apiCsproj], { cwd: args.projectPath, stdio: 'ignore', env: { ...process.env, ASPNETCORE_URLS: backendHost }, shell: process.platform === 'win32', detached: false, }); const ok = await waitForHttp(`${backendHost}/health`, args.timeoutMs); backendBootMs = ok ? Date.now() - t0 : null; if (!ok) { return failReport('backend boot timeout', allProbes, backendBootMs, frontendBootMs); } } if (webRoot) { const t0 = Date.now(); frontendProc = spawn('npm', ['run', 'dev', '--', '--port', String(args.frontendPort)], { cwd: path.dirname(webRoot), stdio: 'ignore', shell: process.platform === 'win32', detached: false, }); const ok = await waitForHttp(frontendHost, args.timeoutMs); frontendBootMs = ok ? Date.now() - t0 : null; if (!ok) { return failReport('frontend boot timeout', allProbes, backendBootMs, frontendBootMs); } } } const results: SmokeProbeResult[] = []; for (const p of allProbes) { const isApi = p.url.startsWith('/api/'); const host = isApi ? backendHost : frontendHost; const { status, durationMs, reason } = await probe(p.url, p.verb, host, args.timeoutMs); // Probes are anonymous. A 401/403 means the route EXISTS but is auth-gated // ([Authorize]) — that's a PASS: we assert the route is served, not that // anonymous access works (real-token coverage is the /uat skill's job). Only // UNEXPECTED 4xx/5xx fail: 404 (missing route — the NavRoute mismatch this gate // exists to catch), 405 (wrong verb), 400 (broken contract/config) and 5xx. const authGated = status === 401 || status === 403; const failed = status === null ? true : !authGated && status >= 400 && status < 600; const failureReason = !failed ? undefined : reason ?? (status && status >= 500 ? 'status-5xx' : 'status-4xx'); results.push({ ...p, status, durationMs, failed, failureReason }); } const failed = results.filter((r) => r.failed); // ─── Browser pass (Wave — the class HTTP probing can't see) ────────────── // Only meaningful when a frontend is actually up: selfBoot booted it // (frontendBootMs set), or the caller manages it externally (!selfBoot). const frontendUp = !args.selfBoot || frontendBootMs !== null; let browser: SmokeReport['browser'] = { ran: false, available: false, pages: [], failures: [] }; if (frontendUp && !args.skipBrowser) { const pageRoutes = pageProbes .map((p) => p.url) .filter((u) => !u.includes('00000000-0000')); // list/home/dashboard only — skip sentinel-id detail probes const outcome = await runBrowserProbes(frontendHost, pageRoutes, args.timeoutMs); browser = { ran: true, available: outcome.available, reason: outcome.reason, pages: outcome.pages, failures: outcome.pages.filter((p) => !p.ok), }; } // ─── Nav-menu app-consistency (BUG A) — best-effort, needs a live backend ── const navMenu = await checkNavMenu(backendHost, args.timeoutMs, await loadRegisteredComponentKeys(args.projectPath)); // ─── Interaction axis (4b) — custom-action contract probes ──────────────── // Reads the module pagespecs, fires each custom action with a synthesized // VALID body at the sentinel id, and classifies 415/405/5xx as failures. Only // runs when a backend booted + --module-root supplied + not disabled. const backendUp = !args.selfBoot || backendBootMs !== null; let interaction: SmokeReport['interaction'] = { ran: false, authenticated: false, actions: [], failures: [] }; if (backendUp && args.exerciseActions) { interaction = await runInteractionAxis(backendHost, args.moduleRoot, { token: args.adminToken }); } // The gate fails on ANY axis: a failed HTTP probe, a failed browser page, an // UNAVAILABLE browser (never a silent pass — the caller records // `smoke.browser-unavailable`), a real nav-menu app mismatch, or a custom-action // contract failure (415/405/5xx on a valid body). An interaction axis that could // not authenticate (all probes auth-gated) is a NOTE, not a hard failure. const browserFailed = browser.ran && (!browser.available || browser.failures.length > 0); const passed = failed.length === 0 && !browserFailed && navMenu.ok && interaction.failures.length === 0; return { passed, backendBootMs, frontendBootMs, totalProbes: results.length, passedProbes: results.length - failed.length, failedProbes: failed.length, skippedProbes: 0, probes: results, failures: failed, browser, navMenu, interaction, }; } finally { // Best-effort teardown. SIGTERM → wait up to 5 s → SIGKILL. await teardown(backendProc); await teardown(frontendProc); } } async function firstFile(glob: string, cwd: string): Promise { const matches = await findFiles(glob, { cwd }); return matches[0] ?? null; } async function waitForHttp(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 2000); const res = await fetch(url, { signal: ctrl.signal }); clearTimeout(t); if (res.status < 500) return true; } catch { // connection refused / ECONNRESET / abort — keep polling. } await sleep(500); } return false; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function teardown(proc: ChildProcess | null): Promise { if (!proc || proc.killed) return; try { proc.kill('SIGTERM'); const exited = await Promise.race([ new Promise((resolve) => proc.once('exit', () => resolve(true))), sleep(5000).then(() => false), ]); if (!exited) proc.kill('SIGKILL'); } catch { // best-effort } } function failReport( reason: string, allProbes: SmokeProbe[], backendBootMs: number | null, frontendBootMs: number | null, ): SmokeReport { const skipped: SmokeProbeResult[] = allProbes.map((p) => ({ ...p, status: null, durationMs: 0, failed: true, failureReason: reason, })); return { passed: false, backendBootMs, frontendBootMs, totalProbes: skipped.length, passedProbes: 0, failedProbes: skipped.length, skippedProbes: 0, probes: skipped, failures: skipped, browser: { ran: false, available: false, pages: [], failures: [] }, navMenu: { ran: false, ok: true, mismatches: [] }, interaction: { ran: false, authenticated: false, actions: [], failures: [] }, }; } /** * Best-effort runtime guard for BUG A: fetch GET /api/navigation/menu and assert * every node's route app-segment matches its componentKey app-prefix. The * cross-app merge shows up as a section whose route is `/rh/…` while its * componentKey is `clients.…`. Anonymous + auth-gated menus return non-200 → the * check is SKIPPED (ok, with a reason), never a false failure. Only a real 200 * response with a mismatch fails the gate. */ async function checkNavMenu(backendHost: string, timeoutMs: number, registeredKeys: ReadonlySet | null): Promise { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), Math.min(timeoutMs, 10_000)); try { const res = await fetch(`${backendHost}/api/navigation/menu`, { signal: controller.signal }); if (res.status !== 200) { return { ran: true, ok: true, mismatches: [], skippedReason: `GET /api/navigation/menu returned ${res.status} (likely auth-gated for an anonymous probe) — app-consistency not verified at runtime; the seed-level fix + aggregator guard cover it statically.`, }; } const body: unknown = await res.json().catch(() => null); if (body == null) { return { ran: true, ok: true, mismatches: [], skippedReason: 'menu response was not JSON.' }; } const unique = findNavMenuMismatches(body); const unregistered = registeredKeys === null ? [] : findUnregisteredMenuKeys(body, registeredKeys); return { ran: true, ok: unique.length === 0 && unregistered.length === 0, mismatches: unique, unregisteredKeys: unregistered }; } catch (e) { return { ran: true, ok: true, mismatches: [], skippedReason: `menu fetch failed: ${(e as Error).message}`, }; } finally { clearTimeout(t); } } /** * Walk a `/api/navigation/menu` payload and return the cross-app app-prefix * mismatches (BUG A). A node with a `route` whose first path segment differs * from its `componentKey`/`key` app-prefix means a section got attached to the * wrong app's module during seeding. Pure + shape-tolerant (exported for tests). */ export function findNavMenuMismatches(body: unknown): string[] { const mismatches: string[] = []; const visit = (node: unknown): void => { if (node == null) return; if (Array.isArray(node)) { for (const n of node) visit(n); return; } if (typeof node !== 'object') return; const o = node as Record; const route = typeof o.route === 'string' ? o.route : undefined; const key = typeof o.componentKey === 'string' ? o.componentKey : typeof o.key === 'string' ? o.key : undefined; if (route && key && route.startsWith('/') && key.includes('.')) { const routeApp = route.split('/').filter(Boolean)[0]; const keyApp = key.split('.')[0]; if (routeApp && keyApp && routeApp !== keyApp) { mismatches.push( `componentKey "${key}" (app "${keyApp}") carries route "${route}" (app "${routeApp}") — cross-app nav merge (BUG A).`, ); } } for (const v of Object.values(o)) visit(v); }; visit(body); return [...new Set(mismatches)]; } /** * Every componentKey the project's generated registries register — read from * `web/-web/src/extensions/*.ts` with the shared PAGE_REGISTER_RE * (lib/routes-registry). Null when no registry is readable (check skipped) — * "no registries found" must never fail a legacy project. */ async function loadRegisteredComponentKeys(projectPath: string): Promise | null> { const webRoot = await findWebRoot(projectPath); if (!webRoot) return null; const extDir = path.join(webRoot, 'src', 'extensions'); if (!existsSync(extDir)) return null; const keys = new Set(); for (const abs of await findFiles('*.ts', { cwd: extDir })) { const src = readFileSync(abs, 'utf-8'); for (const k of collectRegisteredKeys(src)) keys.add(k); } return keys.size > 0 ? keys : null; } /** * Menu componentKeys that resolve NO PageRegistry.register — the runtime net * behind SCR-020: a section seeded with N sibling resources and no root page * ships a menu entry that mounts nothing (perpetual spinner). Pure + * shape-tolerant (exported for tests). Only clickable nodes count (a `route` * plus a dotted key), the same signal findNavMenuMismatches keys on. */ export function findUnregisteredMenuKeys(body: unknown, registered: ReadonlySet): string[] { const missing: string[] = []; const visit = (node: unknown): void => { if (node == null) return; if (Array.isArray(node)) { for (const n of node) visit(n); return; } if (typeof node !== 'object') return; const o = node as Record; const route = typeof o.route === 'string' ? o.route : undefined; const key = typeof o.componentKey === 'string' ? o.componentKey : typeof o.key === 'string' ? o.key : undefined; if (route && key && route.startsWith('/') && key.includes('.') && !registered.has(key)) { missing.push( `componentKey "${key}" (route "${route}") has NO PageRegistry.register — the menu entry mounts nothing ` + `(blank screen / perpetual spinner). Declare a SmartSectionHome for the section (SCR-020) or scaffold the page, then re-run scaffold-routes.`, ); } for (const v of Object.values(o)) visit(v); }; visit(body); return [...new Set(missing)]; } /** Render the on-disk `_audit/smoke-.md` report. Its PRESENCE is the * proof the gate ran; its content lists every failing axis. */ export function renderSmokeMarkdown(report: SmokeReport, moduleCode: string): string { const b = report.browser; const lines: string[] = []; lines.push(`# run-smoke — ${moduleCode}`); lines.push(''); lines.push(`- Result: **${report.passed ? 'PASS' : 'FAIL'}**`); lines.push(`- HTTP probes: ${report.passedProbes}/${report.totalProbes} passed (${report.failedProbes} failed)`); lines.push( `- Browser pass: ${ b.ran ? b.available ? `${b.pages.length - b.failures.length}/${b.pages.length} pages ok (${b.failures.length} failed)` : `UNAVAILABLE — ${b.reason ?? ''}` : 'not run' }`, ); lines.push( `- Nav-menu app-consistency: ${ report.navMenu.ran ? report.navMenu.skippedReason ? `skipped (${report.navMenu.skippedReason})` : report.navMenu.ok ? 'ok' : `${report.navMenu.mismatches.length} MISMATCH` : 'not run' }`, ); const ix = report.interaction; lines.push( `- Action contract (axis 4b): ${ ix.ran ? `${ix.actions.length - ix.failures.length}/${ix.actions.length} probes ok (${ix.failures.length} failed)` + (ix.authenticated ? '' : ix.reason ? ` — ${ix.reason}` : ' — anonymous') : 'not run' }`, ); lines.push(`- Backend boot: ${report.backendBootMs ?? 'n/a'} ms · Frontend boot: ${report.frontendBootMs ?? 'n/a'} ms`); lines.push(''); if (report.failures.length) { lines.push('## Failed HTTP probes'); for (const f of report.failures) { lines.push( `- \`${f.verb} ${f.url}\` → ${f.status ?? 'no-response'} (${f.failureReason ?? 'unknown'})${f.expectedBy ? ` — ${f.expectedBy}` : ''}`, ); } lines.push(''); } if (b.failures.length) { lines.push('## Failed browser pages'); for (const p of b.failures) { lines.push(`- \`${p.url}\` — ${p.accessState}${p.viteOverlay ? ' · vite-error-overlay' : ''}`); for (const e of p.consoleErrors.slice(0, 5)) lines.push(` - console: ${e}`); for (const r of p.failedRequests.slice(0, 5)) lines.push(` - request ${r.status}: ${r.url}`); } lines.push(''); } if (b.ran && !b.available) { lines.push('## Browser unavailable (smoke.browser-unavailable)'); lines.push(b.reason ?? ''); lines.push(''); } if (!report.navMenu.ok) { lines.push('## Nav-menu app mismatches (BUG A)'); for (const m of report.navMenu.mismatches) lines.push(`- ${m}`); for (const m of report.navMenu.unregisteredKeys ?? []) lines.push(`- ${m}`); lines.push(''); } if (ix.failures.length) { lines.push('## Failed action-contract probes (axis 4b)'); for (const a of ix.failures) { lines.push(`- \`${a.httpMethod} ${a.url}\` (${a.entity}.${a.code}, ${a.scope}) → ${a.status ?? 'no-response'} (${a.reason ?? 'unknown'})`); } lines.push(''); } if (ix.ran && !ix.authenticated && ix.reason) { lines.push('## Action axis not authenticated (smoke.interaction-unavailable)'); lines.push(ix.reason); lines.push(''); } return lines.join('\n') + '\n'; }