solidity-argus 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
@@ -272,7 +272,7 @@ Your subagents have access to these specialized tools. Know when to delegate eac
272
272
  - **\`argus_generate_report\`**:
273
273
  - **Use**: During Reporting.
274
274
  - **Purpose**: Generates the final artifact.
275
- - **Note**: Requires structured input of findings. Ensure the tone is objective and helpful.
275
+ - **Note**: Requires a versioned report_input JSON string matching the ReportInput contract (schema_version 1.0.0). Do not send natural-language-only findings to Scribe for tool invocation.
276
276
 
277
277
  - **\`argus_sync_knowledge\`**:
278
278
  - **Use**: Maintenance.
@@ -422,18 +422,26 @@ Tools may fail. You must be resilient.
422
422
 
423
423
  **An audit without a report is an incomplete audit.** Your FINAL action before finishing MUST be delegating to Scribe. No exceptions.
424
424
 
425
- After you have synthesized your findings, compile them into a structured list and invoke Scribe:
425
+ After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
426
426
 
427
427
  \`\`\`
428
428
  Task(subagent_type="scribe", prompt="Generate the final security audit report.
429
429
 
430
430
  Project: {name}
431
431
  Scope: {list of audited files}
432
-
433
- Findings (pass ALL of these):
434
- 1. [SEVERITY] Title — File:Lines — Description — Impact — Recommendation
435
- 2. [SEVERITY] Title — File:Lines — Description — Impact — Recommendation
436
- ...
432
+ ReportInput JSON (pass EXACTLY, no prose substitution):
433
+ {
434
+ "run_id": "{run-id}",
435
+ "seq": {last-seq},
436
+ "session_id": "{session-id}",
437
+ "tool_call_id": "{tool-call-id}",
438
+ "source": "argus",
439
+ "schema_version": "1.0.0",
440
+ "projectDir": "{project-dir}",
441
+ "findings": [canonical findings],
442
+ "toolsExecuted": [canonical tool executions],
443
+ "scope": ["..."]
444
+ }
437
445
 
438
446
  Additional context:
439
447
  - Tools used: Slither, Forge, Pattern Checker, Solodit
@@ -442,7 +450,12 @@ Additional context:
442
450
  ")
443
451
  \`\`\`
444
452
 
445
- You do NOT need to pass raw JSON or serialized audit state. Just pass your findings as a structured list in natural language — Scribe will format them professionally.
453
+ Scribe must call argus_generate_report with:
454
+ - project_name: project name
455
+ - scope: audited file list
456
+ - report_input: serialized ReportInput JSON string
457
+
458
+ Legacy audit_state is transitional-only and deprecated.
446
459
 
447
460
  **If you have zero findings, still invoke Scribe** with an empty findings list. A clean report is still a report.
448
461
 
@@ -8,7 +8,7 @@ Your core responsibilities are:
8
8
  1. **Aggregation**: Collecting findings from various tools and subagents.
9
9
  2. **Deduplication**: Merging similar findings (e.g., multiple Slither warnings for the same issue).
10
10
  3. **Contextualization**: Explaining *why* a finding matters in the context of the specific protocol.
11
- 4. **Report Generation**: Producing the final Markdown artifact using \`argus_generate_report\`.
11
+ 4. **Report Generation**: Producing the final Markdown artifact via \`argus_generate_report\`.
12
12
 
13
13
  ## REPORT STRUCTURE
14
14
 
@@ -41,34 +41,17 @@ You must adhere to these strict writing standards:
41
41
 
42
42
  ## HOW TO GENERATE THE REPORT
43
43
 
44
- You have two approaches. Use whichever fits the input you receive from Argus.
45
-
46
- ### Approach 1: Use \`argus_generate_report\` tool
47
- If you have structured findings data, call the tool:
48
- - \`project_name\` (string): The name of the protocol or project.
49
- - \`scope\` (string[]): List of files or contracts that were audited.
50
- - \`include_executive_summary\` (boolean): Default \`true\`.
51
- - \`severity_threshold\` (string): "critical", "high", "medium", "low", or "informational". Usually "low" or "informational" to include everything.
52
- - \`audit_state\` (string): JSON string of findings. Format each finding as: \`{"id":"f1","check":"name","severity":"High","confidence":"High","description":"...","file":"Contract.sol","lines":[1,10],"source":"manual"}\`
53
-
54
- ### Approach 2: Write the report directly as Markdown
55
- If Argus passes findings in natural language (which is common), write the full report yourself in Markdown following the Report Structure below. This is often faster and produces better results than trying to serialize findings into JSON for the tool.
56
-
57
- **Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
58
- **Choose Approach 1 when**: You have structured JSON finding data ready to pass.
59
-
60
- ## FILE PERSISTENCE
61
-
62
- **Critical Operational Block**: You must ALWAYS use the \`argus_generate_report\` tool to write the audit report to disk. This tool now automatically writes the report to the filesystem via \`Bun.write()\` and returns the file path in its result.
44
+ Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
63
45
 
64
46
  **Your workflow**:
65
- 1. Prepare your findings data (either structured JSON or natural language context).
66
- 2. Call \`argus_generate_report\` with the appropriate parameters.
67
- 3. After the tool returns, extract the \`filePath\` field from the result.
68
- 4. **Always confirm the file path in your response to Argus**: "Report written to: {filePath}".
69
- 5. If the result does not include a \`filePath\` field, warn Argus: "Warning: filePath missing from tool result. The report may not have been written to disk."
47
+ 1. Validate Argus provided a serialized ReportInput JSON string (schema_version 1.0.0) with required fields: run_id, seq, session_id, tool_call_id, source, schema_version, projectDir, findings, toolsExecuted, scope.
48
+ 2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
+ 3. Call \`argus_generate_report\` with arguments { project_name, scope, report_input }. Use legacy \`audit_state\` only for transitional compatibility and treat it as deprecated.
50
+ 4. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
51
+
52
+ ## SINGLE-WRITER POLICY
70
53
 
71
- This ensures the audit report is persisted and Argus can verify the output location.
54
+ **CRITICAL**: You must NEVER write final report files directly to disk. All report persistence MUST go through \`argus_generate_report\`. This tool enforces the single-writer policy — it is the sole component authorized to create report artifacts on disk. Direct file writes for report output are a policy violation and will be rejected.
72
55
 
73
56
  ## QUALITY STANDARDS
74
57
 
package/src/cli/index.ts CHANGED
File without changes
@@ -43,6 +43,10 @@ const BackgroundConfigSchema = z.object({
43
43
  max_concurrent: z.number().positive().default(3),
44
44
  })
45
45
 
46
+ const MigrationConfigSchema = z.object({
47
+ mode: z.enum(["legacy", "dual", "strict"]).default("legacy"),
48
+ })
49
+
46
50
  export const ArgusConfigSchema = z.object({
47
51
  agents: z
48
52
  .object({
@@ -82,4 +86,5 @@ export const ArgusConfigSchema = z.object({
82
86
  background: BackgroundConfigSchema.default({
83
87
  max_concurrent: 3,
84
88
  }),
89
+ migration: MigrationConfigSchema.optional(),
85
90
  })
@@ -8,6 +8,8 @@ import {
8
8
  createToolErrorRecoveryHandler,
9
9
  } from "./features/error-recovery"
10
10
  import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
11
+ import { getMigrationMode } from "./features/migration"
12
+ import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
11
13
  import { recordRun } from "./features/persistent-state/global-run-index"
12
14
  import { createRunJournal } from "./features/persistent-state/run-journal"
13
15
  import { createAgentTracker } from "./hooks/agent-tracker"
@@ -23,6 +25,7 @@ import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
23
25
  import type { HookName } from "./hooks/types"
24
26
  import type { Managers } from "./managers/types"
25
27
  import { createLogger } from "./shared/logger"
28
+ import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
26
29
  import type { AuditState } from "./state/types"
27
30
  import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
28
31
  import { detectProject, type ProjectConfig } from "./utils/project-detector"
@@ -77,6 +80,9 @@ export function createHooks(args: {
77
80
  const agentTracker = createAgentTracker()
78
81
  _agentTrackerRef = agentTracker
79
82
 
83
+ const migrationMode = getMigrationMode(config)
84
+ logger.debug(`Migration mode: ${migrationMode}`)
85
+
80
86
  const contextMonitor = createContextMonitor()
81
87
  const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
82
88
  const debouncedSave = createDebouncedSave(auditStateManager.save)
@@ -88,15 +94,21 @@ export function createHooks(args: {
88
94
  )
89
95
  const outputTruncator = createToolOutputTruncator()
90
96
 
97
+ let currentEventSink: EventSink | null = null
98
+ let currentOpencodeSessionId = ""
99
+
91
100
  // Sub-handlers run sequentially. The state persistence handler MUST be first:
92
101
  // it loads persisted state on session.created, overriding the fresh default.
93
102
  const {
94
103
  hook: eventHook,
95
104
  getAuditState,
96
105
  setAuditState,
106
+ setEventSink,
107
+ getLastFinalizationResult,
97
108
  } = createEventHook(projectDir, [
98
109
  async ({ type, sessionId, auditState, setAuditState: setState }) => {
99
110
  if (type === "session.created") {
111
+ currentOpencodeSessionId = sessionId ?? ""
100
112
  const timestamp = Date.now()
101
113
  let recoveredState: AuditState | null = null
102
114
 
@@ -123,6 +135,21 @@ export function createHooks(args: {
123
135
 
124
136
  const effectiveState = recoveredState ?? auditStateManager.get()
125
137
  if (effectiveState) {
138
+ try {
139
+ // createAuditArtifactResolver is the canonical source for all run artifact paths.
140
+ // The journal file path is: {projectDir}/.opencode/runs/{runId}/events.jsonl
141
+ const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
142
+ const journalFile = resolver.paths().journalFile
143
+ // createEventSink builds the same path internally; the resolver makes it explicit.
144
+ currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
145
+ setEventSink(currentEventSink)
146
+ logger.debug(`Event sink journal path: ${journalFile}`)
147
+ } catch (error) {
148
+ logger.error(
149
+ `Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
150
+ )
151
+ }
152
+
126
153
  void recordRun({
127
154
  runId: effectiveState.sessionId,
128
155
  opencodeSessionId: sessionId,
@@ -176,15 +203,14 @@ export function createHooks(args: {
176
203
  }
177
204
 
178
205
  if (type === "session.deleted") {
179
- if (sessionId) {
180
- agentTracker.clearSession(sessionId)
206
+ await debouncedSave.flush()
207
+ if (auditState) {
208
+ await auditStateManager.save(auditState)
181
209
  }
182
-
183
- await auditStateManager.archive()
184
210
  runJournal.log({
185
- type: "session.deleted",
211
+ type: "state.saved",
186
212
  timestamp: Date.now(),
187
- archived: true,
213
+ success: true,
188
214
  })
189
215
  }
190
216
  },
@@ -248,25 +274,60 @@ export function createHooks(args: {
248
274
  const toolTrackingHook = isHookEnabled("tool-tracking")
249
275
  ? safeCreateHook(
250
276
  () =>
251
- createToolTrackingHook(getAuditState, ({ tool, findingsCount }) => {
252
- const currentState = getAuditState()
253
- if (currentState) {
254
- debouncedSave.save(currentState)
255
- }
256
-
257
- runJournal.log({
258
- type: "tool.executed",
259
- tool,
260
- timestamp: Date.now(),
261
- findingsCount,
262
- })
263
- }),
277
+ createToolTrackingHook(
278
+ getAuditState,
279
+ ({ tool, findingsCount }) => {
280
+ const currentState = getAuditState()
281
+ if (currentState) {
282
+ debouncedSave.save(currentState)
283
+ }
284
+
285
+ runJournal.log({
286
+ type: "tool.executed",
287
+ tool,
288
+ timestamp: Date.now(),
289
+ findingsCount,
290
+ })
291
+ },
292
+ {
293
+ getEventSink: () => currentEventSink,
294
+ getSessionId: () => currentOpencodeSessionId,
295
+ },
296
+ ),
264
297
  "tool-tracking",
265
298
  )
266
299
  : undefined
267
300
 
268
301
  const safeEventHook = isHookEnabled("event")
269
- ? safeCreateHook(() => eventHook, "event")
302
+ ? safeCreateHook(
303
+ () => async (input: Parameters<typeof eventHook>[0]) => {
304
+ const isSessionDeleted = input.event.type === "session.deleted"
305
+
306
+ try {
307
+ await eventHook(input)
308
+ } finally {
309
+ if (isSessionDeleted) {
310
+ await auditStateManager.archive()
311
+
312
+ const deletedSessionId = input.event.sessionId
313
+ if (deletedSessionId) {
314
+ agentTracker.clearSession(deletedSessionId)
315
+ }
316
+
317
+ runJournal.log({
318
+ type: "session.deleted",
319
+ timestamp: Date.now(),
320
+ archived: true,
321
+ finalizationPassed: getLastFinalizationResult()?.invariantsPassed ?? null,
322
+ })
323
+
324
+ currentEventSink = null
325
+ currentOpencodeSessionId = ""
326
+ }
327
+ }
328
+ },
329
+ "event",
330
+ )
270
331
  : undefined
271
332
 
272
333
  return {
@@ -0,0 +1,14 @@
1
+ export {
2
+ adaptLegacyFindings,
3
+ adaptLegacyStateToReportInput,
4
+ getMigrationMode,
5
+ type MigrationMode,
6
+ validateStrictCompatibility,
7
+ } from "./migration-adapter"
8
+
9
+ export {
10
+ computeParityMetrics,
11
+ formatParityReport,
12
+ type ParityMetrics,
13
+ type SeverityDistribution,
14
+ } from "./parity-telemetry"
@@ -0,0 +1,151 @@
1
+ import {
2
+ createDropDiagnosticsCollector,
3
+ type DropDiagnosticsCollector,
4
+ } from "../../shared/drop-diagnostics"
5
+ import { normalizeLegacyFindingsArray, normalizeToCanonicalFinding } from "../../state/adapters"
6
+ import type { CanonicalFinding, ReportInput } from "../../state/schemas"
7
+ import { SCHEMA_VERSION } from "../../state/schemas"
8
+ import type { AuditState, Finding } from "../../state/types"
9
+
10
+ export type MigrationMode = "legacy" | "dual" | "strict"
11
+
12
+ /**
13
+ * Returns the active migration mode from config, defaulting to "legacy".
14
+ */
15
+ export function getMigrationMode(config: { migration?: { mode?: MigrationMode } }): MigrationMode {
16
+ return config.migration?.mode ?? "legacy"
17
+ }
18
+
19
+ /**
20
+ * Adapts a legacy `AuditState` into canonical `CanonicalFinding[]`.
21
+ *
22
+ * In legacy mode: returns the raw findings as-is (backward compatible).
23
+ * In dual mode: normalizes findings to canonical AND returns both.
24
+ * In strict mode: normalizes to canonical, rejects payloads missing required canonical fields.
25
+ */
26
+ export function adaptLegacyFindings(
27
+ state: AuditState,
28
+ mode: MigrationMode,
29
+ runId: string,
30
+ ): {
31
+ legacyFindings: Finding[]
32
+ canonicalFindings: CanonicalFinding[]
33
+ diagnostics: ReturnType<DropDiagnosticsCollector["getDiagnostics"]>
34
+ } {
35
+ const legacyFindings = state.findings
36
+
37
+ if (mode === "legacy") {
38
+ return {
39
+ legacyFindings,
40
+ canonicalFindings: [],
41
+ diagnostics: [],
42
+ }
43
+ }
44
+
45
+ const policy = mode === "strict" ? "strict-fail" : "warn"
46
+ const diag = createDropDiagnosticsCollector(policy, "migration-adapter")
47
+
48
+ const { findings: canonicalFindings, diagnostics: adapterDiags } = normalizeLegacyFindingsArray(
49
+ legacyFindings as unknown as unknown[],
50
+ runId,
51
+ )
52
+
53
+ for (const d of adapterDiags) {
54
+ if (d.level === "error") {
55
+ diag.error(d.code, d.message, d.field)
56
+ } else {
57
+ diag.warn(d.code, d.message, d.field)
58
+ }
59
+ }
60
+
61
+ // In strict mode, validate that all legacy findings survived normalization
62
+ if (mode === "strict" && canonicalFindings.length < legacyFindings.length) {
63
+ const dropped = legacyFindings.length - canonicalFindings.length
64
+ diag.error(
65
+ "STRICT_FINDINGS_DROPPED",
66
+ `${dropped} legacy finding(s) could not be normalized to canonical format`,
67
+ )
68
+ }
69
+
70
+ // Throws DropDiagnosticsError in strict mode if errors exist
71
+ diag.throwIfStrict()
72
+
73
+ return {
74
+ legacyFindings,
75
+ canonicalFindings,
76
+ diagnostics: diag.getDiagnostics(),
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Adapts a legacy `AuditState` into a canonical `ReportInput`.
82
+ *
83
+ * Maps legacy AuditState fields to the canonical ReportInput contract.
84
+ */
85
+ export function adaptLegacyStateToReportInput(
86
+ state: AuditState,
87
+ mode: MigrationMode,
88
+ runId: string,
89
+ ): {
90
+ reportInput: ReportInput
91
+ diagnostics: ReturnType<DropDiagnosticsCollector["getDiagnostics"]>
92
+ } {
93
+ const { canonicalFindings, diagnostics } = adaptLegacyFindings(
94
+ state,
95
+ mode === "legacy" ? "dual" : mode,
96
+ runId,
97
+ )
98
+
99
+ const reportInput: ReportInput = {
100
+ run_id: runId,
101
+ seq: 0,
102
+ session_id: state.sessionId,
103
+ tool_call_id: "",
104
+ source: "migration-adapter",
105
+ schema_version: SCHEMA_VERSION,
106
+ projectDir: state.projectDir,
107
+ findings: canonicalFindings,
108
+ toolsExecuted: state.toolsExecuted.map((t) => ({
109
+ ...t,
110
+ run_id: runId,
111
+ schema_version: SCHEMA_VERSION,
112
+ })),
113
+ scope: state.scope,
114
+ soloditResults: state.soloditResults,
115
+ fuzzCounterexamples: state.fuzzCounterexamples,
116
+ coverageReport: state.coverageReport,
117
+ gasHotspots: state.gasHotspots,
118
+ proxyContracts: state.proxyContracts,
119
+ }
120
+
121
+ return { reportInput, diagnostics }
122
+ }
123
+
124
+ /**
125
+ * Validates that a legacy AuditState is compatible with strict mode.
126
+ * Returns true if ALL findings can be normalized without errors.
127
+ */
128
+ export function validateStrictCompatibility(
129
+ state: AuditState,
130
+ runId: string,
131
+ ): { compatible: boolean; errors: string[] } {
132
+ const errors: string[] = []
133
+
134
+ for (const [index, finding] of state.findings.entries()) {
135
+ const result = normalizeToCanonicalFinding(
136
+ finding as unknown as Record<string, unknown>,
137
+ runId,
138
+ index + 1,
139
+ )
140
+ const hasErrors = result.diagnostics.some((d) => d.level === "error")
141
+ if (hasErrors) {
142
+ errors.push(
143
+ ...result.diagnostics
144
+ .filter((d) => d.level === "error")
145
+ .map((d) => `[finding:${index}] ${d.message}`),
146
+ )
147
+ }
148
+ }
149
+
150
+ return { compatible: errors.length === 0, errors }
151
+ }
@@ -0,0 +1,133 @@
1
+ import { stableHash } from "../../state/projectors"
2
+ import type { CanonicalFinding } from "../../state/schemas"
3
+ import type { Finding, FindingSeverity } from "../../state/types"
4
+
5
+ const SEVERITIES: readonly FindingSeverity[] = [
6
+ "Critical",
7
+ "High",
8
+ "Medium",
9
+ "Low",
10
+ "Informational",
11
+ ] as const
12
+
13
+ export interface SeverityDistribution {
14
+ Critical: number
15
+ High: number
16
+ Medium: number
17
+ Low: number
18
+ Informational: number
19
+ }
20
+
21
+ export interface ParityMetrics {
22
+ legacyFindingCount: number
23
+ canonicalFindingCount: number
24
+ findingCountDiff: number
25
+ legacySeverityDistribution: SeverityDistribution
26
+ canonicalSeverityDistribution: SeverityDistribution
27
+ severityDiffs: Partial<Record<FindingSeverity, number>>
28
+ legacyContentHash: string
29
+ canonicalContentHash: string
30
+ hashMatch: boolean
31
+ onlyInLegacy: string[]
32
+ onlyInCanonical: string[]
33
+ timestamp: number
34
+ }
35
+
36
+ function computeSeverityDistribution(
37
+ findings: Array<{ severity: FindingSeverity }>,
38
+ ): SeverityDistribution {
39
+ const dist: SeverityDistribution = {
40
+ Critical: 0,
41
+ High: 0,
42
+ Medium: 0,
43
+ Low: 0,
44
+ Informational: 0,
45
+ }
46
+ for (const f of findings) {
47
+ if (f.severity in dist) {
48
+ dist[f.severity]++
49
+ }
50
+ }
51
+ return dist
52
+ }
53
+
54
+ function findingIds(findings: Array<{ id: string }>): Set<string> {
55
+ return new Set(findings.map((f) => f.id))
56
+ }
57
+
58
+ export function computeParityMetrics(
59
+ legacyFindings: Finding[],
60
+ canonicalFindings: CanonicalFinding[],
61
+ ): ParityMetrics {
62
+ const legacySeverity = computeSeverityDistribution(legacyFindings)
63
+ const canonicalSeverity = computeSeverityDistribution(canonicalFindings)
64
+
65
+ const severityDiffs: Partial<Record<FindingSeverity, number>> = {}
66
+ for (const sev of SEVERITIES) {
67
+ const diff = canonicalSeverity[sev] - legacySeverity[sev]
68
+ if (diff !== 0) {
69
+ severityDiffs[sev] = diff
70
+ }
71
+ }
72
+
73
+ const legacyIds = findingIds(legacyFindings)
74
+ const canonicalIds = findingIds(canonicalFindings)
75
+
76
+ const onlyInLegacy = [...legacyIds].filter((id) => !canonicalIds.has(id))
77
+ const onlyInCanonical = [...canonicalIds].filter((id) => !legacyIds.has(id))
78
+
79
+ const legacyContentHash = stableHash(
80
+ legacyFindings.map((f) => ({ id: f.id, check: f.check, severity: f.severity, file: f.file })),
81
+ )
82
+ const canonicalContentHash = stableHash(
83
+ canonicalFindings.map((f) => ({
84
+ id: f.id,
85
+ check: f.check,
86
+ severity: f.severity,
87
+ file: f.file,
88
+ })),
89
+ )
90
+
91
+ return {
92
+ legacyFindingCount: legacyFindings.length,
93
+ canonicalFindingCount: canonicalFindings.length,
94
+ findingCountDiff: canonicalFindings.length - legacyFindings.length,
95
+ legacySeverityDistribution: legacySeverity,
96
+ canonicalSeverityDistribution: canonicalSeverity,
97
+ severityDiffs,
98
+ legacyContentHash,
99
+ canonicalContentHash,
100
+ hashMatch: legacyContentHash === canonicalContentHash,
101
+ onlyInLegacy,
102
+ onlyInCanonical,
103
+ timestamp: Date.now(),
104
+ }
105
+ }
106
+
107
+ export function formatParityReport(metrics: ParityMetrics): string {
108
+ const lines: string[] = [
109
+ "=== Migration Parity Report ===",
110
+ `Finding count: legacy=${metrics.legacyFindingCount} canonical=${metrics.canonicalFindingCount} diff=${metrics.findingCountDiff}`,
111
+ `Content hash match: ${metrics.hashMatch}`,
112
+ ]
113
+
114
+ const sevDiffs = Object.entries(metrics.severityDiffs)
115
+ if (sevDiffs.length > 0) {
116
+ lines.push(
117
+ `Severity diffs: ${sevDiffs.map(([k, v]) => `${k}=${v > 0 ? "+" : ""}${v}`).join(", ")}`,
118
+ )
119
+ }
120
+
121
+ if (metrics.onlyInLegacy.length > 0) {
122
+ lines.push(
123
+ `Only in legacy (${metrics.onlyInLegacy.length}): ${metrics.onlyInLegacy.join(", ")}`,
124
+ )
125
+ }
126
+ if (metrics.onlyInCanonical.length > 0) {
127
+ lines.push(
128
+ `Only in canonical (${metrics.onlyInCanonical.length}): ${metrics.onlyInCanonical.join(", ")}`,
129
+ )
130
+ }
131
+
132
+ return lines.join("\n")
133
+ }