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 +1 -1
- package/src/agents/argus-prompt.ts +21 -8
- package/src/agents/scribe-prompt.ts +9 -26
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +5 -0
- package/src/create-hooks.ts +81 -20
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/event-sink.ts +171 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +175 -0
- package/src/features/persistent-state/run-journal.ts +1 -1
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +74 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/index.ts +14 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +356 -0
- package/src/tools/report-generator-tool.ts +692 -20
- package/src/tools/solodit-search-tool.ts +11 -24
- 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
|
+
"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
|
|
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,
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
66
|
-
2.
|
|
67
|
-
3.
|
|
68
|
-
4.
|
|
69
|
-
|
|
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
|
|
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
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
})
|
package/src/create-hooks.ts
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
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: "
|
|
211
|
+
type: "state.saved",
|
|
186
212
|
timestamp: Date.now(),
|
|
187
|
-
|
|
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(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
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
|
+
}
|