solidity-argus 0.3.3 → 0.3.5

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/agents/argus-prompt.ts +67 -8
  3. package/src/agents/scribe-prompt.ts +13 -5
  4. package/src/cli/commands/init.ts +1 -1
  5. package/src/cli/index.ts +0 -0
  6. package/src/config/schema.ts +7 -2
  7. package/src/create-hooks.ts +116 -27
  8. package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
  9. package/src/features/migration/index.ts +14 -0
  10. package/src/features/migration/migration-adapter.ts +151 -0
  11. package/src/features/migration/parity-telemetry.ts +133 -0
  12. package/src/features/persistent-state/audit-state-manager.ts +28 -6
  13. package/src/features/persistent-state/event-sink.ts +175 -0
  14. package/src/features/persistent-state/findings-materializer.ts +51 -0
  15. package/src/features/persistent-state/index.ts +2 -0
  16. package/src/features/persistent-state/run-finalizer.ts +192 -0
  17. package/src/features/persistent-state/run-journal.ts +15 -4
  18. package/src/hooks/agent-tracker.ts +15 -0
  19. package/src/hooks/event-hook.ts +93 -1
  20. package/src/hooks/system-prompt-hook.ts +20 -0
  21. package/src/hooks/tool-tracking-hook.ts +263 -33
  22. package/src/shared/audit-artifact-resolver.ts +75 -0
  23. package/src/shared/drop-diagnostics.ts +108 -0
  24. package/src/shared/file-utils.ts +7 -2
  25. package/src/shared/index.ts +14 -0
  26. package/src/shared/path-root-resolver.ts +34 -0
  27. package/src/shared/report-path-resolver.ts +70 -0
  28. package/src/solodit-lifecycle.ts +86 -7
  29. package/src/state/adapters.ts +262 -0
  30. package/src/state/index.ts +15 -0
  31. package/src/state/projectors.ts +437 -0
  32. package/src/state/schemas.ts +453 -0
  33. package/src/state/types.ts +6 -0
  34. package/src/tools/report-generator-tool.ts +647 -36
  35. package/src/tools/report-preflight.ts +79 -0
  36. package/src/tools/solodit-search-tool.ts +15 -24
  37. package/src/utils/solodit-health.ts +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -225,6 +225,49 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
225
225
  \`\`\`
226
226
  - Wait for both to complete before synthesizing their results.
227
227
 
228
+ ### STATE-FIRST SYNTHESIS POLICY
229
+
230
+ **Synthesize and report from durable evidence — not transcript tails.**
231
+
232
+ When building the final report or synthesizing findings:
233
+ 1. **Primary source**: \`toolsExecuted\` records, \`findings\` from state, and event stream data persisted via argus_* tool outputs.
234
+ 2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
235
+ 3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
236
+
237
+ **Bounded background fan-out**: For deep audits, limit concurrent high-context background delegations to max 2 at a time. Split larger workloads into sequential waves. This prevents retrieval blind spots from simultaneous long-running tasks.
238
+
239
+ Example — correct fan-out:
240
+ - Wave 1: [Sentinel: slither + pattern check] + [Pythia: solodit search] (2 background tasks)
241
+ - Wait for both. Then Wave 2: [Sentinel: forge tests] (1 background task)
242
+
243
+ ## SYNTHESIS BARRIER: MUST NOT PROCEED WITHOUT DURABLE EVIDENCE
244
+
245
+ You **must not proceed** to synthesis or report generation until required durable evidence is confirmed present:
246
+ - \`toolsExecuted\` records exist for all planned tools
247
+ - Expected findings coverage is populated in state
248
+ - Lifecycle invariants are satisfied (no orphaned tool starts)
249
+
250
+ ### Adaptive Retrieval Budget
251
+
252
+ When waiting for background tasks, use bounded retrieval budgets by workload class:
253
+
254
+ | Class | Budget | Criteria |
255
+ |----------|---------|---------------------------------------------|
256
+ | quick | 60s | Single-tool or single-contract checks |
257
+ | standard | 180s | Multi-tool single-agent batches |
258
+ | deep | 600s | Multi-agent or synthesis-heavy runs |
259
+
260
+ Poll until the task reaches a terminal state: \`completed\`, \`error\`, \`cancelled\`, or \`interrupt\`.
261
+
262
+ ### Re-dispatch (LAST RESORT)
263
+
264
+ Re-dispatch is only justified when ALL of these are true:
265
+ 1. The task has reached terminal state OR retrieval budget has expired
266
+ 2. Required durable evidence is STILL missing from state/events
267
+ 3. The gap is specific and bounded (not a general "redo everything")
268
+
269
+ **When re-dispatching**: Target only missing evidence segments. Use \`run_in_background=false\` (foreground only) for re-dispatch pivots. Do NOT re-dispatch routinely after a single transcript retrieval miss if durable state evidence is already complete.
270
+
228
271
  ## TASK COMPLETION TRACKING
229
272
 
230
273
  You must track which audit phases are complete to avoid redundant work and tool re-execution.
@@ -272,7 +315,7 @@ Your subagents have access to these specialized tools. Know when to delegate eac
272
315
  - **\`argus_generate_report\`**:
273
316
  - **Use**: During Reporting.
274
317
  - **Purpose**: Generates the final artifact.
275
- - **Note**: Requires structured input of findings. Ensure the tone is objective and helpful.
318
+ - **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
319
 
277
320
  - **\`argus_sync_knowledge\`**:
278
321
  - **Use**: Maintenance.
@@ -422,18 +465,28 @@ Tools may fail. You must be resilient.
422
465
 
423
466
  **An audit without a report is an incomplete audit.** Your FINAL action before finishing MUST be delegating to Scribe. No exceptions.
424
467
 
425
- After you have synthesized your findings, compile them into a structured list and invoke Scribe:
468
+ After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
469
+
470
+ **State-first requirement**: Before invoking Scribe, verify that \`toolsExecuted\` in your ReportInput contains entries for each tool you ran. Do NOT proceed to report generation if required tool coverage is missing from durable state — re-run the missing tool instead. Use \`preflight_policy: "strict-fail"\` for the final report invocation.
426
471
 
427
472
  \`\`\`
428
473
  Task(subagent_type="scribe", prompt="Generate the final security audit report.
429
474
 
430
475
  Project: {name}
431
476
  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
- ...
477
+ ReportInput JSON (pass EXACTLY, no prose substitution):
478
+ {
479
+ "run_id": "{run-id}",
480
+ "seq": {last-seq},
481
+ "session_id": "{session-id}",
482
+ "tool_call_id": "{tool-call-id}",
483
+ "source": "argus",
484
+ "schema_version": "1.0.0",
485
+ "projectDir": "{project-dir}",
486
+ "findings": [canonical findings],
487
+ "toolsExecuted": [canonical tool executions],
488
+ "scope": ["..."]
489
+ }
437
490
 
438
491
  Additional context:
439
492
  - Tools used: Slither, Forge, Pattern Checker, Solodit
@@ -442,7 +495,13 @@ Additional context:
442
495
  ")
443
496
  \`\`\`
444
497
 
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.
498
+ Scribe must call argus_generate_report with:
499
+ - project_name: project name
500
+ - scope: audited file list
501
+ - report_input: serialized ReportInput JSON string
502
+ - preflight_policy: "strict-fail" (non-negotiable for final report)
503
+
504
+ Legacy audit_state is transitional-only and deprecated.
446
505
 
447
506
  **If you have zero findings, still invoke Scribe** with an empty findings list. A clean report is still a report.
448
507
 
@@ -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 and writing it to disk.
11
+ 4. **Report Generation**: Producing the final Markdown artifact via \`argus_generate_report\`.
12
12
 
13
13
  ## REPORT STRUCTURE
14
14
 
@@ -41,13 +41,21 @@ You must adhere to these strict writing standards:
41
41
 
42
42
  ## HOW TO GENERATE THE REPORT
43
43
 
44
- Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
44
+ Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
45
45
 
46
46
  **Your workflow**:
47
- 1. Read the findings Argus provides. Deduplicate, cross-reference, and assess severity.
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. **Execution integrity check**: \`toolsExecuted\` must be non-empty for the audit to be considered complete. If \`toolsExecuted\` is empty or missing key tool families (slither, forge, patterns), add a \`## Limitations\` section to the report noting which tool coverage is absent.
48
48
  2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
- 3. Save the report to disk using the \`write\` tool. Path: \`.opencode/reports/{ProjectName}-audit-{YYYY-MM-DD}.md\` relative to the project root.
50
- 4. Confirm the file path in your response to Argus: "Report written to: {filePath}".
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. **Limitations disclosure** (MANDATORY when tools fail): If any tool was unavailable, timed out, or failed, add a \`## Limitations\` section to the report BEFORE \`## Findings\`. Use this format:
51
+ - \`**Tool name**: [reason \u2014 unavailable/failed/timed out]. [Impact on finding coverage if any.]\`
52
+ - Example: \`**argus_solodit_search**: External database was unavailable. Known-vulnerability cross-referencing was performed using local patterns only.\`
53
+ - Never silently omit limitations — incomplete coverage must be disclosed.
54
+ 5. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
55
+
56
+ ## SINGLE-WRITER POLICY
57
+
58
+ **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.
51
59
 
52
60
  ## QUALITY STANDARDS
53
61
 
@@ -20,7 +20,7 @@ export const initCommand: CliCommand = {
20
20
  description: "Initialize Argus configuration for this project",
21
21
  async execute(_args: string[]): Promise<number> {
22
22
  const cwd = process.cwd()
23
- const configDir = join(cwd, ".opencode")
23
+ const configDir = join(cwd, ".argus")
24
24
  const configPath = join(configDir, "solidity-argus.json")
25
25
 
26
26
  if (existsSync(configPath)) {
package/src/cli/index.ts CHANGED
File without changes
@@ -31,7 +31,7 @@ const ReportingConfigSchema = z.object({
31
31
  format: z.enum(["markdown"]).default("markdown"),
32
32
  severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
33
33
  gasAnalysis: z.boolean().default(false),
34
- output_dir: z.string().default(".opencode/reports/"),
34
+ output_dir: z.string().default(".argus/reports/"),
35
35
  })
36
36
 
37
37
  const SoloditConfigSchema = z.object({
@@ -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({
@@ -70,7 +74,7 @@ export const ArgusConfigSchema = z.object({
70
74
  format: "markdown",
71
75
  severityThreshold: "low",
72
76
  gasAnalysis: false,
73
- output_dir: ".opencode/reports/",
77
+ output_dir: ".argus/reports/",
74
78
  }),
75
79
  solodit: SoloditConfigSchema.default({
76
80
  enabled: true,
@@ -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
  })
@@ -1,4 +1,3 @@
1
- import { join } from "node:path"
2
1
  import type { Hooks as PluginHooks } from "@opencode-ai/plugin"
3
2
  import type { ArgusConfig } from "./config/types"
4
3
  import { createAuditEnforcer } from "./features/audit-enforcer/audit-enforcer"
@@ -7,7 +6,12 @@ import {
7
6
  createSessionRecoveryHandler,
8
7
  createToolErrorRecoveryHandler,
9
8
  } from "./features/error-recovery"
9
+ import { getMigrationMode } from "./features/migration"
10
+ import { adaptLegacyFindings } from "./features/migration/migration-adapter"
11
+ import { computeParityMetrics, formatParityReport } from "./features/migration/parity-telemetry"
10
12
  import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
13
+ import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
14
+ import { materializeFindings } from "./features/persistent-state/findings-materializer"
11
15
  import { recordRun } from "./features/persistent-state/global-run-index"
12
16
  import { createRunJournal } from "./features/persistent-state/run-journal"
13
17
  import { createAgentTracker } from "./hooks/agent-tracker"
@@ -22,6 +26,7 @@ import { createSystemPromptHook } from "./hooks/system-prompt-hook"
22
26
  import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
23
27
  import type { HookName } from "./hooks/types"
24
28
  import type { Managers } from "./managers/types"
29
+ import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
25
30
  import { createLogger } from "./shared/logger"
26
31
  import type { AuditState } from "./state/types"
27
32
  import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
@@ -77,6 +82,9 @@ export function createHooks(args: {
77
82
  const agentTracker = createAgentTracker()
78
83
  _agentTrackerRef = agentTracker
79
84
 
85
+ const migrationMode = getMigrationMode(config)
86
+ logger.debug(`Migration mode: ${migrationMode}`)
87
+
80
88
  const contextMonitor = createContextMonitor()
81
89
  const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
82
90
  const debouncedSave = createDebouncedSave(auditStateManager.save)
@@ -88,15 +96,21 @@ export function createHooks(args: {
88
96
  )
89
97
  const outputTruncator = createToolOutputTruncator()
90
98
 
99
+ let currentEventSink: EventSink | null = null
100
+ let currentOpencodeSessionId = ""
101
+
91
102
  // Sub-handlers run sequentially. The state persistence handler MUST be first:
92
103
  // it loads persisted state on session.created, overriding the fresh default.
93
104
  const {
94
105
  hook: eventHook,
95
106
  getAuditState,
96
107
  setAuditState,
108
+ setEventSink,
109
+ getLastFinalizationResult,
97
110
  } = createEventHook(projectDir, [
98
111
  async ({ type, sessionId, auditState, setAuditState: setState }) => {
99
112
  if (type === "session.created") {
113
+ currentOpencodeSessionId = sessionId ?? ""
100
114
  const timestamp = Date.now()
101
115
  let recoveredState: AuditState | null = null
102
116
 
@@ -123,12 +137,24 @@ export function createHooks(args: {
123
137
 
124
138
  const effectiveState = recoveredState ?? auditStateManager.get()
125
139
  if (effectiveState) {
140
+ const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
141
+ try {
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
+ }
126
152
  void recordRun({
127
153
  runId: effectiveState.sessionId,
128
154
  opencodeSessionId: sessionId,
129
155
  projectDir: effectiveState.projectDir,
130
- statePath: join(effectiveState.projectDir, ".opencode", "argus-state.json"),
131
- journalPath: join(effectiveState.projectDir, ".opencode", "argus-journal.jsonl"),
156
+ statePath: resolver.paths().stateFile,
157
+ journalPath: resolver.paths().journalFile,
132
158
  startedAt: effectiveState.startTime,
133
159
  phase: effectiveState.currentPhase,
134
160
  findingsCount: effectiveState.findings.length,
@@ -161,17 +187,36 @@ export function createHooks(args: {
161
187
  toolsExecutedCount: auditState.toolsExecuted.length,
162
188
  })
163
189
 
190
+ const idleResolver = createAuditArtifactResolver(
191
+ auditState.sessionId,
192
+ auditState.projectDir,
193
+ )
164
194
  void recordRun({
165
195
  runId: auditState.sessionId,
166
196
  opencodeSessionId: sessionId,
167
197
  projectDir: auditState.projectDir,
168
- statePath: join(auditState.projectDir, ".opencode", "argus-state.json"),
169
- journalPath: join(auditState.projectDir, ".opencode", "argus-journal.jsonl"),
198
+ statePath: idleResolver.paths().stateFile,
199
+ journalPath: idleResolver.paths().journalFile,
170
200
  startedAt: auditState.startTime,
171
201
  phase: auditState.currentPhase,
172
202
  findingsCount: auditState.findings.length,
173
203
  })
174
204
 
205
+ if (migrationMode !== "legacy") {
206
+ try {
207
+ const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
208
+ auditState,
209
+ migrationMode,
210
+ auditState.sessionId,
211
+ )
212
+ const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
213
+ logger.debug(formatParityReport(parityMetrics))
214
+ } catch (error) {
215
+ logger.warn(
216
+ `Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
217
+ )
218
+ }
219
+ }
175
220
  return
176
221
  }
177
222
 
@@ -180,16 +225,10 @@ export function createHooks(args: {
180
225
  if (auditState) {
181
226
  await auditStateManager.save(auditState)
182
227
  }
183
- await auditStateManager.archive()
184
-
185
- if (sessionId) {
186
- agentTracker.clearSession(sessionId)
187
- }
188
-
189
228
  runJournal.log({
190
- type: "session.deleted",
229
+ type: "state.saved",
191
230
  timestamp: Date.now(),
192
- archived: true,
231
+ success: true,
193
232
  })
194
233
  }
195
234
  },
@@ -253,25 +292,75 @@ export function createHooks(args: {
253
292
  const toolTrackingHook = isHookEnabled("tool-tracking")
254
293
  ? safeCreateHook(
255
294
  () =>
256
- createToolTrackingHook(getAuditState, ({ tool, findingsCount }) => {
257
- const currentState = getAuditState()
258
- if (currentState) {
259
- debouncedSave.save(currentState)
260
- }
261
-
262
- runJournal.log({
263
- type: "tool.executed",
264
- tool,
265
- timestamp: Date.now(),
266
- findingsCount,
267
- })
268
- }),
295
+ createToolTrackingHook(
296
+ getAuditState,
297
+ ({ tool, findingsCount }) => {
298
+ const currentState = getAuditState()
299
+ if (currentState) {
300
+ debouncedSave.save(currentState)
301
+ }
302
+
303
+ runJournal.log({
304
+ type: "tool.executed",
305
+ tool,
306
+ timestamp: Date.now(),
307
+ findingsCount,
308
+ })
309
+ },
310
+ {
311
+ getEventSink: () => currentEventSink,
312
+ getSessionId: () => currentOpencodeSessionId,
313
+ },
314
+ ),
269
315
  "tool-tracking",
270
316
  )
271
317
  : undefined
272
318
 
273
319
  const safeEventHook = isHookEnabled("event")
274
- ? safeCreateHook(() => eventHook, "event")
320
+ ? safeCreateHook(
321
+ () => async (input: Parameters<typeof eventHook>[0]) => {
322
+ const isSessionDeleted = input.event.type === "session.deleted"
323
+ const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
324
+
325
+ try {
326
+ await eventHook(input)
327
+ } finally {
328
+ if (isSessionDeleted) {
329
+ const finalizationResult = getLastFinalizationResult()
330
+ const hasNewFinalization =
331
+ finalizationResult !== null && finalizationResult !== finalizationBeforeDelete
332
+
333
+ if (hasNewFinalization && finalizationResult.runId.length > 0) {
334
+ try {
335
+ await materializeFindings(finalizationResult.runId, projectDir)
336
+ } catch (error) {
337
+ logger.warn(
338
+ `Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
339
+ )
340
+ }
341
+ }
342
+
343
+ await auditStateManager.archive()
344
+
345
+ const deletedSessionId = input.event.sessionId
346
+ if (deletedSessionId) {
347
+ agentTracker.clearSession(deletedSessionId)
348
+ }
349
+
350
+ runJournal.log({
351
+ type: "session.deleted",
352
+ timestamp: Date.now(),
353
+ archived: true,
354
+ finalizationPassed: finalizationResult?.invariantsPassed ?? null,
355
+ })
356
+
357
+ currentEventSink = null
358
+ currentOpencodeSessionId = ""
359
+ }
360
+ }
361
+ },
362
+ "event",
363
+ )
275
364
  : undefined
276
365
 
277
366
  return {
@@ -11,6 +11,24 @@ const PHASE_ORDER: AuditPhase[] = [
11
11
  "complete",
12
12
  ]
13
13
 
14
+ const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
15
+
16
+
17
+ const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
18
+ { family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
19
+ { family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
20
+ { family: "forge_fuzz", prefixes: ["argus_forge_fuzz", "forge_fuzz"] },
21
+ { family: "forge_coverage", prefixes: ["argus_forge_coverage", "forge_coverage"] },
22
+ ]
23
+
24
+ function getMissingToolFamilies(auditState: AuditState): string[] {
25
+ const executedTools = auditState.toolsExecuted.map((t) => t.tool)
26
+ return KEY_TOOL_FAMILIES.filter(
27
+ ({ prefixes }) =>
28
+ !executedTools.some((tool) => prefixes.some((prefix) => tool.startsWith(prefix))),
29
+ ).map(({ family }) => family)
30
+ }
31
+
14
32
  function getNextPhase(current: AuditPhase): AuditPhase | null {
15
33
  const idx = PHASE_ORDER.indexOf(current)
16
34
  if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null
@@ -25,10 +43,21 @@ export function createAuditEnforcer() {
25
43
  const nextPhase = getNextPhase(auditState.currentPhase)
26
44
  if (!nextPhase) return null
27
45
 
28
- return [
46
+ const parts: string[] = [
29
47
  `[Argus Audit Enforcer] Audit in progress — current phase: ${auditState.currentPhase}.`,
30
48
  `Next phase: ${nextPhase}. Do not stop until audit is complete.`,
31
49
  `Progress: ${auditState.findings.length} findings, ${auditState.contractsReviewed.length} contracts reviewed.`,
32
- ].join(" ")
50
+ ]
51
+
52
+ if (REPORTING_PHASES.includes(auditState.currentPhase)) {
53
+ const missing = getMissingToolFamilies(auditState)
54
+ if (missing.length > 0) {
55
+ parts.push(
56
+ `\u26a0\ufe0f Tool coverage incomplete: ${missing.join(", ")} have not been executed. Do not proceed to report generation until required tools are complete.`,
57
+ )
58
+ }
59
+ }
60
+
61
+ return parts.join(" ")
33
62
  }
34
63
  }
@@ -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
+ }