solidity-argus 0.3.3 → 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 -5
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +5 -0
- package/src/create-hooks.ts +78 -22
- 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 +569 -31
- 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,13 +41,17 @@ You must adhere to these strict writing standards:
|
|
|
41
41
|
|
|
42
42
|
## HOW TO GENERATE THE REPORT
|
|
43
43
|
|
|
44
|
-
Argus passes you
|
|
44
|
+
Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
|
|
45
45
|
|
|
46
46
|
**Your workflow**:
|
|
47
|
-
1.
|
|
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
48
|
2. Write the complete report in Markdown following the Report Structure and Output Format sections.
|
|
49
|
-
3.
|
|
50
|
-
4. Confirm the
|
|
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
|
|
53
|
+
|
|
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.
|
|
51
55
|
|
|
52
56
|
## QUALITY STANDARDS
|
|
53
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,
|
|
@@ -180,16 +207,10 @@ export function createHooks(args: {
|
|
|
180
207
|
if (auditState) {
|
|
181
208
|
await auditStateManager.save(auditState)
|
|
182
209
|
}
|
|
183
|
-
await auditStateManager.archive()
|
|
184
|
-
|
|
185
|
-
if (sessionId) {
|
|
186
|
-
agentTracker.clearSession(sessionId)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
210
|
runJournal.log({
|
|
190
|
-
type: "
|
|
211
|
+
type: "state.saved",
|
|
191
212
|
timestamp: Date.now(),
|
|
192
|
-
|
|
213
|
+
success: true,
|
|
193
214
|
})
|
|
194
215
|
}
|
|
195
216
|
},
|
|
@@ -253,25 +274,60 @@ export function createHooks(args: {
|
|
|
253
274
|
const toolTrackingHook = isHookEnabled("tool-tracking")
|
|
254
275
|
? safeCreateHook(
|
|
255
276
|
() =>
|
|
256
|
-
createToolTrackingHook(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
),
|
|
269
297
|
"tool-tracking",
|
|
270
298
|
)
|
|
271
299
|
: undefined
|
|
272
300
|
|
|
273
301
|
const safeEventHook = isHookEnabled("event")
|
|
274
|
-
? 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
|
+
)
|
|
275
331
|
: undefined
|
|
276
332
|
|
|
277
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
|
+
}
|