/** * lib/support-report.ts — the deterministic engine behind /support-report. * * When a colocated skill CLI fails at a CLIENT site, Claude must never "fix" * the deployed copy under ~/.claude (no source access, overwritten on update) * — and must never report a HYPOTHESIS as a bug. This module is the * fail-closed verifier: it classifies a captured failure from evidence alone, * and only two proofs make it reportable: * * 1. a stack trace pointing INSIDE the deployed skills tree (deterministic * proof the crash happened in CLI code — one run suffices), or * 2. ≥ 2 runs whose normalized signatures are IDENTICAL (proven stable) * while the CLI failed OUTSIDE its envelope contract. * * Everything else is refused: a controlled `success:false` envelope is the * CLI *answering its contract* (fix the spec, not the CLI), a missing * toolchain is the environment, a single unproven run is `unverified`, and * diverging signatures are `flaky`. The refusal message says exactly what to * do next — the model never gets to improvise a bug report. * * Also owns: signature normalization + fingerprinting (dedup — one bug, one * report), secret scrubbing (a report travels by email to support@atlashub.ch, * it must never carry a connection string), and the French report rendering. * * Consumers: support-report/cli/create (the report writer), tested in * lib/__tests__/support-report.test.ts. */ import { createHash } from 'node:crypto' import { contradictionSignature, detectRuleContradictions, mergeContradictions, scopeLabel, type EnvelopeFinding, type EnvelopeFindingScope, type FindingSeverity, type RuleContradiction, } from './rule-contradictions.js' import type { BundleSummary } from './support-bundle.js' // The contradiction detector is shared with the audit-ba engine (it runs it on // its own findings at the end of every run) — re-exported so every consumer // of this module keeps its imports. export { CONTRADICTION_MIN_SEVERITY, contradictionExcerpt, contradictionKey, contradictionSignature, detectRuleContradictions, mergeContradictions, scopeLabel, type EnvelopeFinding, type EnvelopeFindingScope, type FindingSeverity, type RuleContradiction, } from './rule-contradictions.js' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** One captured execution of the failing command (verbatim outputs). */ export interface CapturedRun { exitCode: number stdout: string stderr: string } export type FailureClass = | 'cli-internal' // reportable: proven crash inside the deployed CLI code | 'rule-contradiction' // reportable: the envelope contradicts ITSELF — a `dedupOf` mirror finding in err while its primary rule is ok on a covering scope | 'usage-error' // the CLI refused via its envelope — the SPEC is wrong, not the CLI | 'no-failure' // envelope answered (success or verdict exit code) — nothing crashed | 'environment' // toolchain/network broken (npx/tsx/node missing, EPERM, offline) | 'unverified' // one run, no deterministic proof — go reproduce | 'flaky' // several runs, diverging signatures — not a stable defect export type EvidenceKind = | 'stack-in-skills-tree' | 'stable-across-runs' | 'controlled-envelope' | 'dedup-contradiction' // mechanical: read from the envelope's report.findings[] | 'argued-verdict-dispute' // no-failure + argued dispute naming rules PRESENT in the envelope | 'environment-signature' | 'insufficient' export interface FailureVerdict { failureClass: FailureClass evidence: EvidenceKind /** Normalized signature per run (input of the fingerprint). */ signatures: string[] /** Stable id of the defect — null when the failure is not reportable. */ fingerprint: string | null /** The envelope's own errors when the CLI answered its contract. */ envelopeErrors: string[] /** What to do instead, for the non-reportable classes. */ guidance: string[] /** Proven mirror ⇄ primary disagreements (rule-contradiction only). */ contradictions: RuleContradiction[] /** Every ruleId the envelope carries, sorted — the anchor a verdict dispute must name. */ envelopeRuleIds: string[] } // --------------------------------------------------------------------------- // Envelope parsing (mirror of lib/output.ts shapes, tolerant) // --------------------------------------------------------------------------- export interface ParsedEnvelope { success: boolean errors: string[] /** `report.findings[]` when the envelope is an audit verdict — `[]` otherwise. */ findings: EnvelopeFinding[] } const SEVERITIES: ReadonlySet = new Set(['ok', 'warn', 'err']) /** One `report.findings[]` item → EnvelopeFinding, or null when malformed * (a malformed item is dropped, never guessed — the proof rests on the rest). */ function toEnvelopeFinding(raw: unknown): EnvelopeFinding | null { if (typeof raw !== 'object' || raw === null) return null const r = raw as Record if (typeof r.ruleId !== 'string' || typeof r.severity !== 'string' || !SEVERITIES.has(r.severity)) return null const scopeRaw = typeof r.scope === 'object' && r.scope !== null ? (r.scope as Record) : {} const scope: EnvelopeFindingScope = {} for (const k of ['app', 'module', 'section'] as const) { if (typeof scopeRaw[k] === 'string') scope[k] = scopeRaw[k] as string } return { ruleId: r.ruleId, severity: r.severity as FindingSeverity, scope, message: typeof r.message === 'string' ? r.message : '', evidence: Array.isArray(r.evidence) ? (r.evidence as unknown[]).map(String) : [], ...(typeof r.dedupOf === 'string' ? { dedupOf: r.dedupOf } : {}), } } /** Parse a run's stdout as a CLI envelope (lib/output.ts contract). Tolerant: * accepts leading/trailing noise around the JSON object. Null when stdout is * not an envelope at all — which IS the cli-internal smell (a throw exits * with a Node stack on stderr and empty/garbage stdout). */ export function parseEnvelope(stdout: string): ParsedEnvelope | null { const candidates = [stdout.trim()] const first = stdout.indexOf('{') const last = stdout.lastIndexOf('}') if (first >= 0 && last > first) candidates.push(stdout.slice(first, last + 1)) for (const raw of candidates) { if (!raw) continue try { const parsed = JSON.parse(raw) as Record if (typeof parsed?.success === 'boolean' && Array.isArray(parsed?.errors)) { const report = typeof parsed.report === 'object' && parsed.report !== null ? (parsed.report as Record) : {} const findings = Array.isArray(report.findings) ? (report.findings as unknown[]).map(toEnvelopeFinding).filter((f): f is EnvelopeFinding => f !== null) : [] return { success: parsed.success, errors: (parsed.errors as unknown[]).map(String), findings } } } catch { /* not JSON — try next candidate */ } } return null } // --------------------------------------------------------------------------- // Verdict dispute — the argued escape hatch on a coherent envelope // --------------------------------------------------------------------------- export function verdictDisputeSignature(ruleIds: string[]): string { return `disputed-verdict:${[...new Set(ruleIds)].sort().join(',')}` } // --------------------------------------------------------------------------- // Signature normalization + fingerprint // --------------------------------------------------------------------------- const GUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi const ISO_TS_RE = /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?/gi const HEX_ADDR_RE = /0x[0-9a-f]{4,}/gi const PID_RE = /\b(pid|process)\s*[:=]?\s*\d+/gi const TMP_PATH_RE = /(?:[a-z]:)?\/(?:tmp|var\/folders|users\/[^/\s]+\/appdata\/local\/temp)\/[^\s"':)]+/gi /** Lowercase + forward slashes — the comparison space for Windows paths. */ function foldPath(p: string): string { return p.replace(/\\/g, '/').replace(/^file:\/\/\//i, '').toLowerCase() } /** * Normalize an error output into a stable signature: paths inside the skills * tree become `/…` (the frame that matters), volatile values (GUIDs, * timestamps, addresses, temp paths, pids) are masked, remaining absolute * paths collapse to their last two segments (client identity out, shape kept). * Line/column numbers are deliberately KEPT — they are the stable, valuable * part of a stack frame. */ export function normalizeSignature(text: string, skillsRoot: string): string { const root = foldPath(skillsRoot).replace(/\/+$/, '') let out = foldPath(text) if (root) out = out.split(root).join('') out = out.replace(TMP_PATH_RE, '') out = out.replace(GUID_RE, '') out = out.replace(ISO_TS_RE, '') out = out.replace(HEX_ADDR_RE, '') out = out.replace(PID_RE, '$1=') // Remaining absolute paths (the client's project) → keep only the tail. // A path continuing a `` (or other) placeholder is already handled — // collapsing it would destroy the very frame the fingerprint depends on. out = out.replace(/(?:[a-z]:)?\/[^\s"':)]+/g, (m, offset: number, whole: string) => { if (whole.lastIndexOf('>', offset) === offset - 1) return m const segments = m.split('/').filter(Boolean) return segments.length <= 2 ? m : `/${segments.slice(-2).join('/')}` }) return out.replace(/[ \t]+/g, ' ').trim() } /** Normalize the failing command the same way (the --spec-file temp path, an * INLINE --spec payload and the invocation cwd must not split fingerprints — * the same defect reproduced with a narrower spec is the same defect; the * signature, not the spec, carries its identity). */ export function normalizeCommand(command: string, skillsRoot: string): string { // Mask the inline payload on the RAW command: normalizeSignature folds `\` // to `/`, which would break a JSON's escaped quotes before they are matched. const masked = command.replace(/--spec\s+('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|\S+)/g, '--spec ') return normalizeSignature(masked, skillsRoot).replace(/--spec-file \S+/g, '--spec-file ') } /** Stable short id of one defect: same command shape + same signature → same * fingerprint, across runs, sessions and path layouts. */ export function fingerprintOf(command: string, signature: string, skillsRoot: string): string { return createHash('sha256') .update(normalizeCommand(command, skillsRoot)) .update('\n') .update(signature) .digest('hex') .slice(0, 12) } /** The signature source of a run: stderr when present, stdout otherwise. */ function signalOf(run: CapturedRun): string { return run.stderr.trim() ? run.stderr : run.stdout } // --------------------------------------------------------------------------- // Stack / environment detection // --------------------------------------------------------------------------- /** Stack frames (or thrown-error path mentions) pointing INSIDE the deployed * skills tree — the deterministic proof of a CLI-internal crash. */ export function stackFramesInSkillsTree(stderr: string, skillsRoot: string): string[] { const root = foldPath(skillsRoot).replace(/\/+$/, '') if (!root) return [] const folded = foldPath(stderr) const frames: string[] = [] let from = 0 for (;;) { const at = folded.indexOf(root, from) if (at < 0) break const line = folded.slice(folded.lastIndexOf('\n', at) + 1, (folded.indexOf('\n', at) + 1 || folded.length + 1) - 1) if (/\.(ts|js|cjs|mjs)(:\d+(:\d+)?)?\b/.test(line)) frames.push(line.trim()) from = at + root.length } return frames } const ENVIRONMENT_RE = new RegExp( [ /'?(npx|tsx|node|npm|dotnet)'? is not recognized/.source, /(npx|tsx|node|npm|dotnet): (command )?not found/.source, /cannot find (module|package) '(tsx|zod|commander|handlebars|js-yaml|minimatch)/.source, /\b(eacces|eperm|emfile|enospc)\b/.source, /\b(econnrefused|econnreset|etimedout|enotfound|eai_again)\b/.source, /getaddrinfo/.source, ].join('|'), 'i', ) /** Toolchain/network signature — only meaningful when NO frame lands in the * skills tree (a CLI can legitimately throw ENOENT from its own code). */ export function looksEnvironmental(stderr: string): boolean { return ENVIRONMENT_RE.test(stderr) } // --------------------------------------------------------------------------- // Classification — the fail-closed verifier // --------------------------------------------------------------------------- /** * Classify a captured failure. Evidence only, never intent: * * 1. every run parsed as an envelope → controlled behavior (`usage-error` * when it refused with errors, `rule-contradiction` when a `dedupOf` * mirror finding is err while its primary is ok on the same scope — the * envelope disagreeing with itself is a CLI defect, one run suffices —, * `no-failure` when it answered a coherent verdict: the audit CLIs carry * verdicts in non-zero exit codes BY CONTRACT, exitCode is never read); * 2. a stack frame inside `skillsRoot` → `cli-internal` (one run suffices); * 3. an environment signature (and no skills frame) → `environment`; * 4. otherwise a non-envelope failure needs ≥ 2 runs with IDENTICAL * signatures → `cli-internal`; one run → `unverified`; diverging → * `flaky`. The guidance says exactly what to do next. */ export function classifyFailure(runs: CapturedRun[], skillsRoot: string, command: string): FailureVerdict { const signatures = runs.map((r) => normalizeSignature(signalOf(r), skillsRoot)) type VerdictInput = Omit & Partial> const verdict = (partial: VerdictInput): FailureVerdict => ({ contradictions: [], envelopeRuleIds: [], ...partial, signatures }) if (runs.length === 0) { return verdict({ failureClass: 'unverified', evidence: 'insufficient', fingerprint: null, envelopeErrors: [], guidance: ['No captured run was provided. Re-run the exact failing command and capture exitCode/stdout/stderr verbatim.'], }) } // 1. Controlled envelope — the CLI answered its stdout contract. const envelopes = runs.map((r) => parseEnvelope(r.stdout)) if (envelopes.every((e) => e !== null)) { const parsed = envelopes as ParsedEnvelope[] const refusals = parsed.flatMap((e) => (!e.success ? e.errors : [])) if (refusals.length > 0) { return verdict({ failureClass: 'usage-error', evidence: 'controlled-envelope', fingerprint: null, envelopeErrors: [...new Set(refusals)], guidance: [ 'The CLI answered its envelope contract with a controlled refusal — this is a spec/usage problem, not a CLI defect.', 'Re-read the failing CLI\'s SKILL.md contract, fix the spec, and re-run. Do NOT file a support report.', 'If you can demonstrate the refusal itself is wrong, re-invoke with a "dispute" explaining why, quoting the contract.', ], }) } // 1b. The envelope contradicts ITSELF: a `dedupOf` mirror in err while its // primary is ok on a covering scope. Mechanical, one run suffices, and a CLI // defect by construction (two ids, ONE evaluator — audit-ba rules/registry). // Only past the refusal gate: a success:false envelope did not run to // completion, its findings are not evidence. const contradictions = mergeContradictions(...parsed.map((e) => detectRuleContradictions(e.findings))) const envelopeRuleIds = [...new Set(parsed.flatMap((e) => e.findings.map((f) => f.ruleId)))].sort() if (contradictions.length > 0) { return verdict({ failureClass: 'rule-contradiction', evidence: 'dedup-contradiction', fingerprint: fingerprintOf(command, contradictionSignature(contradictions), skillsRoot), envelopeErrors: [], contradictions, envelopeRuleIds, guidance: [], }) } return verdict({ failureClass: 'no-failure', evidence: 'controlled-envelope', fingerprint: null, envelopeErrors: [], envelopeRuleIds, guidance: [ 'Every captured run produced a well-formed envelope without errors. A non-zero exit code on an audit CLI is a VERDICT, not a crash — read the envelope, act on it.', 'A mirror finding (`dedupOf`) contradicting its primary rule is detected by this CLI on its own — none was found in these runs.', envelopeRuleIds.length > 0 ? `If you can DEMONSTRATE a verdict is wrong (quote the rule's contract in its /ba-audit-* SKILL.md AND the doc line it misreads), re-invoke with "dispute" (≥ 80 chars) + "disputedRuleIds" naming rules PRESENT in this envelope: ${envelopeRuleIds.slice(0, 15).join(', ')}${envelopeRuleIds.length > 15 ? ', …' : ''}.` : 'The envelope carries no findings — there is no verdict to dispute.', ], }) } // 2. Deterministic proof: a stack frame inside the deployed skills tree. const framed = runs.find((r) => stackFramesInSkillsTree(signalOf(r), skillsRoot).length > 0) if (framed) { const sig = normalizeSignature(signalOf(framed), skillsRoot) return verdict({ failureClass: 'cli-internal', evidence: 'stack-in-skills-tree', fingerprint: fingerprintOf(command, sig, skillsRoot), envelopeErrors: [], guidance: [], }) } // 3. Environment — the toolchain, not the CLI. if (runs.some((r) => looksEnvironmental(signalOf(r)))) { return verdict({ failureClass: 'environment', evidence: 'environment-signature', fingerprint: null, envelopeErrors: [], guidance: [ 'The failure signature points at the environment (missing/blocked toolchain or network), not at the CLI code.', 'Check: node/npx on PATH, `npm install` completed in the skills directory, file permissions, network access to the npm registry.', 'Do NOT file a support report — fix the environment and re-run.', ], }) } // 4. Non-envelope failure without a stack — stability must be proven. if (runs.length < 2) { return verdict({ failureClass: 'unverified', evidence: 'insufficient', fingerprint: null, envelopeErrors: [], guidance: [ 'One run without a stack trace into the skills tree is a hypothesis, not a proof.', 'Re-run the EXACT same command a second time and capture both runs. Two identical failure signatures make it reportable.', 'For a write-CLI, only re-run if the first crash wrote nothing (empty filesCreated / clean worktree).', ], }) } const unique = new Set(signatures.map((s) => fingerprintOf(command, s, skillsRoot))) if (unique.size === 1) { return verdict({ failureClass: 'cli-internal', evidence: 'stable-across-runs', fingerprint: [...unique][0], envelopeErrors: [], guidance: [], }) } return verdict({ failureClass: 'flaky', evidence: 'insufficient', fingerprint: null, envelopeErrors: [], guidance: [ 'The runs disagree — their normalized failure signatures differ, so this is not a stable, reportable defect.', 'Look for a non-deterministic input (dirty worktree, concurrent process, partial state) and stabilize the reproduction first.', ], }) } // --------------------------------------------------------------------------- // Secret scrubbing — the report travels by email // --------------------------------------------------------------------------- const KEYED_SECRET_RE = /\b(password|pwd|passwd|secret|token|api[-_]?key|client[-_]?secret|account[-_]?key|sas[-_]?token|connectionstring)\b(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s;,&"'\r\n]+)/gi const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(\.[A-Za-z0-9_-]{8,})?/g /** Mask credentials wherever they appear (specs, stdout, stderr) before * anything is written to disk. Idempotent. */ export function scrubSecrets(text: string): string { return text .replace(KEYED_SECRET_RE, (_m, key: string, sep: string) => `${key}${sep}`) .replace(BEARER_RE, 'Bearer ') .replace(JWT_RE, '') } // --------------------------------------------------------------------------- // Semver (loose, numeric — same spirit as lib/socle-version.ts) // --------------------------------------------------------------------------- /** True when `candidate` is a strictly newer x.y.z than `installed`. Unknown / * unparsable versions never claim newer (fail-closed: no update nag on bad data). */ export function isNewerVersion(candidate: string | null, installed: string | null): boolean { if (!candidate || !installed) return false const parse = (v: string): number[] | null => { const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()) return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null } const c = parse(candidate) const i = parse(installed) if (!c || !i) return false for (let k = 0; k < 3; k++) { if (c[k] !== i[k]) return c[k] > i[k] } return false } // --------------------------------------------------------------------------- // Report rendering (French — the audience is support@atlashub.ch) // --------------------------------------------------------------------------- export const SUPPORT_EMAIL = 'support@atlashub.ch' export interface ReportVersions { cliInstalled: string cliLatest: string socle: string web: string node: string os: string } export type ReportClassification = 'cli-internal' | 'disputed-usage-error' | 'rule-contradiction' | 'disputed-verdict' export interface SupportReportRecord { fingerprint: string status: 'confirmed' | 'pending-retest-after-update' classification: ReportClassification evidence: EvidenceKind command: string context?: string analysis?: string dispute?: string /** The rules a verdict dispute names — each one present in the envelope. */ disputedRuleIds?: string[] /** Proven mirror ⇄ primary disagreements, scopes merged across occurrences. */ contradictions?: RuleContradiction[] versions: ReportVersions runCount: number occurrences: number firstSeen: string lastSeen: string spec?: unknown envelopeErrors?: string[] /** What the reproduction bundle carries (lib/support-bundle). */ inputs?: BundleSummary /** The emailable archive beside report.md. `bytes` is known only once the * archive exists — the copy of this record INSIDE the archive omits it. */ zip?: { file: string; bytes?: number } } const EVIDENCE_LABEL: Record = { 'stack-in-skills-tree': 'stack trace localisée DANS l’arbre des skills installés (preuve déterministe, 1 exécution suffit)', 'stable-across-runs': '≥ 2 exécutions avec signatures d’échec normalisées IDENTIQUES (stabilité prouvée)', 'controlled-envelope': 'refus contrôlé de l’envelope (contesté par l’analyste)', 'dedup-contradiction': 'contradiction interne de l’envelope — un finding miroir (`dedupOf`) en erreur alors que sa règle primaire est ok sur la même portée (preuve mécanique, 1 exécution suffit)', 'argued-verdict-dispute': 'verdict contesté par l’analyste (argumenté, règles nommées présentes dans l’envelope)', 'environment-signature': 'signature environnementale', insufficient: 'preuve insuffisante', } const REPORT_INPUT_ROWS_MAX = 200 /** Render `report.md` — the human-readable half of the support bundle. */ export function renderReportMd(record: SupportReportRecord, stderrExcerpt: string): string { const pending = record.status === 'pending-retest-after-update' const lines: string[] = [] lines.push(`# Rapport d'incident CLI SmartStack — ${record.fingerprint}`) lines.push('') if (pending) { lines.push( `> ⚠️ **Une version plus récente de la CLI est disponible (${record.versions.cliLatest}, installée : ${record.versions.cliInstalled}).**`, `> Mettre à jour (\`npm i -g @atlashub/smartstack-cli@latest\` puis \`ss update\`) et RETESTER avant de transmettre ce rapport.`, '', ) } lines.push(`- **Première occurrence** : ${record.firstSeen}`) lines.push(`- **Dernière occurrence** : ${record.lastSeen}`) lines.push(`- **Occurrences** : ${record.occurrences}`) lines.push(`- **Statut** : ${record.status === 'confirmed' ? 'CONFIRMÉ (vérifié, reproductible)' : 'EN ATTENTE DE RETEST après mise à jour'}`) lines.push('') lines.push('## Versions') lines.push('') lines.push('| Composant | Version |') lines.push('|---|---|') lines.push(`| CLI SmartStack installée | ${record.versions.cliInstalled} |`) lines.push(`| CLI SmartStack — dernière publiée (npm) | ${record.versions.cliLatest} |`) lines.push(`| Socle SmartStack (PackageReference) | ${record.versions.socle} |`) lines.push(`| @atlashub/smartstack (web) | ${record.versions.web} |`) lines.push(`| Node.js | ${record.versions.node} |`) lines.push(`| OS | ${record.versions.os} |`) lines.push('') lines.push('## Verdict de vérification') lines.push('') lines.push(`- **Classification** : \`${record.classification}\``) lines.push(`- **Preuve** : ${EVIDENCE_LABEL[record.evidence]}`) lines.push(`- **Exécutions capturées** : ${record.runCount}`) if (record.disputedRuleIds && record.disputedRuleIds.length > 0) { lines.push(`- **Règles contestées** : ${record.disputedRuleIds.join(', ')}`) } if (record.dispute) { const label = record.classification === 'disputed-verdict' ? 'Contestation du verdict' : 'Contestation du refus contrôlé' lines.push(`- **${label}** : ${record.dispute}`) } lines.push('') lines.push('## Commande en échec (verbatim)') lines.push('') lines.push('```') lines.push(record.command) lines.push('```') if (record.spec !== undefined) { lines.push('') lines.push('## Spec transmis au CLI (secrets masqués)') lines.push('') lines.push('```json') lines.push(typeof record.spec === 'string' ? record.spec : JSON.stringify(record.spec, null, 2)) lines.push('```') } lines.push('') lines.push(record.classification === 'rule-contradiction' ? '## Extrait de l’envelope (findings impliqués)' : '## Extrait de la sortie d’erreur') lines.push('') lines.push(record.classification === 'rule-contradiction' ? '```json' : '```') lines.push(stderrExcerpt.trim() || '(vide — voir les captures brutes dans runs/)') lines.push('```') if (record.envelopeErrors && record.envelopeErrors.length > 0) { lines.push('') lines.push('## Erreurs de l’envelope') lines.push('') for (const e of record.envelopeErrors) lines.push(`- ${e}`) } if (record.contradictions && record.contradictions.length > 0) { lines.push('') lines.push('## Contradiction de règles') lines.push('') lines.push( 'Un finding `dedupOf: Y` est, par contrat du registre audit-ba, le MÊME évaluateur que la règle primaire `Y` sous un second identifiant.', 'Les paires ci-dessous rendent pourtant deux verdicts opposés sur la même portée, dans le même envelope — le défaut est côté CLI, jamais côté corpus.', '', ) lines.push('| Règle miroir | Sévérité | Règle primaire | Portée | Message du miroir |') lines.push('|---|---|---|---|---|') for (const c of record.contradictions) { lines.push(`| \`${c.mirrorRuleId}\` | ${c.mirrorSeverity} | \`${c.primaryRuleId}\` (ok) | ${scopeLabel(c.scope)} | ${c.mirrorMessage.replace(/\|/g, '\\|')} |`) } for (const c of record.contradictions) { if (c.mirrorEvidence.length === 0) continue lines.push('') lines.push(`**${c.mirrorRuleId} — ${scopeLabel(c.scope)}** (evidence, ${c.mirrorEvidence.length}) :`) for (const e of c.mirrorEvidence.slice(0, 10)) lines.push(`- ${e}`) if (c.mirrorEvidence.length > 10) lines.push(`- … (${c.mirrorEvidence.length - 10} de plus — voir report.json)`) } lines.push('') lines.push( 'Deux hypothèses, toutes deux dans la CLI : le prédicat du miroir a divergé de la règle primaire (ex. une exemption manquante), ou le `dedupOf` du registre est faux (la paire n’est pas un miroir).', ) } if (record.context) { lines.push('') lines.push('## Contexte') lines.push('') lines.push(record.context) } if (record.analysis) { lines.push('') lines.push('## Analyse') lines.push('') lines.push(record.analysis) } if (record.inputs) { const inp = record.inputs lines.push('') lines.push('## Entrées jointes (reproduction)') lines.push('') lines.push(`- **Chemins demandés** : ${inp.requested.map((p) => `\`${p}\``).join(', ')}`) lines.push(`- **Fichiers joints** : ${inp.files.length} (${inp.totalBytes} octets, plafond ${inp.cap})`) if (inp.overCap) { lines.push('- ⚠️ **Plafond dépassé** : aucune entrée n’a été jointe — resserrer `inputs[]` à ce que la CLI a réellement lu.') } const byReason = new Map() for (const s of inp.skipped) byReason.set(s.reason, (byReason.get(s.reason) ?? 0) + 1) if (byReason.size > 0) { lines.push(`- **Ignorés** : ${[...byReason.entries()].map(([r, n]) => `${r} ×${n}`).join(', ')} (détail dans \`inputs.manifest.json\`)`) } if (inp.files.length > 0) { lines.push('') lines.push('| Fichier | Octets | Secrets masqués |') lines.push('|---|---|---|') for (const f of inp.files.slice(0, REPORT_INPUT_ROWS_MAX)) lines.push(`| \`${f.relPath}\` | ${f.bytes} | ${f.scrubbed ? 'oui' : '—'} |`) if (inp.files.length > REPORT_INPUT_ROWS_MAX) lines.push(`| … | | ${inp.files.length - REPORT_INPUT_ROWS_MAX} fichier(s) de plus — voir \`inputs.manifest.json\` |`) } lines.push('') lines.push('Les fichiers texte ont été nettoyés des secrets avant copie ; les binaires ne sont jamais joints. `repro.md` explique comment rejouer la commande sur ces entrées.') } lines.push('') lines.push('---') lines.push('') lines.push(`## → Transmission`) lines.push('') lines.push( record.zip ? `Transmettre l’archive **\`${record.zip.file}\`** (${record.zip.bytes !== undefined ? `${record.zip.bytes} octets — ` : ''}report.md + report.json + repro.md + runs/ + inputs/) à **${SUPPORT_EMAIL}**.` : `Transmettre ce dossier complet (\`report.md\` + \`report.json\` + \`runs/\`) à **${SUPPORT_EMAIL}**.`, 'Les sorties ont été nettoyées des secrets (mots de passe, tokens, chaînes de connexion) avant écriture.', ) lines.push('') return lines.join('\n') }