solidity-argus 0.3.5 → 0.3.7

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.
@@ -2,6 +2,7 @@ import type { EventSink } from "../features/persistent-state/event-sink"
2
2
  import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
3
3
  import { finalizeRun } from "../features/persistent-state/run-finalizer"
4
4
  import { createLogger } from "../shared/logger"
5
+ import { ARGUS_PLUGIN_VERSION } from "../shared/plugin-metadata"
5
6
  import { createAuditState } from "../state/audit-state"
6
7
  import type { AuditEvent } from "../state/schemas"
7
8
  import { SCHEMA_VERSION } from "../state/schemas"
@@ -112,7 +113,6 @@ export function createEventHook(
112
113
 
113
114
  case "session.deleted": {
114
115
  preDeleteState = currentAuditState
115
- currentAuditState = null
116
116
  break
117
117
  }
118
118
 
@@ -140,6 +140,7 @@ export function createEventHook(
140
140
  await emitToSink("session.created", currentAuditState.sessionId, sessionId, {
141
141
  projectDir: currentAuditState.projectDir,
142
142
  sessionId: currentAuditState.sessionId,
143
+ plugin_version: ARGUS_PLUGIN_VERSION,
143
144
  })
144
145
  }
145
146
  break
@@ -160,6 +161,7 @@ export function createEventHook(
160
161
  if (preDeleteState) {
161
162
  await emitToSink("session.deleted", preDeleteState.sessionId, sessionId, {
162
163
  archived: true,
164
+ plugin_version: ARGUS_PLUGIN_VERSION,
163
165
  })
164
166
 
165
167
  if (eventSink) {
@@ -176,6 +178,7 @@ export function createEventHook(
176
178
  }
177
179
  }
178
180
  }
181
+ currentAuditState = null
179
182
  eventSink = null
180
183
  break
181
184
  }
@@ -12,7 +12,6 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
12
12
  }
13
13
  const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
14
14
 
15
-
16
15
  /** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
17
16
  const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
18
17
  slither: "slither",
@@ -77,12 +76,8 @@ export function buildDynamicContext(
77
76
  (t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
78
77
  ).join(" ")
79
78
  const unavailable = auditState.unavailableTools ?? []
80
- const excusedTools = new Set(
81
- unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
82
- )
83
- const pendingKeyTools = KEY_TOOLS.filter(
84
- (t) => !executedToolNames.has(t) && !excusedTools.has(t),
85
- )
79
+ const excusedTools = new Set(unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean))
80
+ const pendingKeyTools = KEY_TOOLS.filter((t) => !executedToolNames.has(t) && !excusedTools.has(t))
86
81
  const gateStatus =
87
82
  pendingKeyTools.length > 0
88
83
  ? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
@@ -12,7 +12,14 @@ import type { FindingStore } from "../state/finding-store"
12
12
  import { createFindingStore } from "../state/finding-store"
13
13
  import type { AuditEvent } from "../state/schemas"
14
14
  import { SCHEMA_VERSION } from "../state/schemas"
15
- import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
15
+ import type {
16
+ ArgusAgentName,
17
+ AuditState,
18
+ Finding,
19
+ FindingSeverity,
20
+ FuzzCounterexample,
21
+ SoloditResult,
22
+ } from "../state/types"
16
23
 
17
24
  const logger = createLogger()
18
25
 
@@ -20,6 +27,8 @@ type ToolHookInput = {
20
27
  tool: string
21
28
  args: unknown
22
29
  result: string
30
+ sessionID?: string
31
+ callID?: string
23
32
  }
24
33
 
25
34
  type ToolExecutionMetadata = {
@@ -30,6 +39,8 @@ type ToolExecutionMetadata = {
30
39
  export type ToolTrackingOptions = {
31
40
  getEventSink?: () => EventSink | null
32
41
  getSessionId?: () => string
42
+ getAgentName?: () => ArgusAgentName | undefined
43
+ getAgentNameForSession?: (sessionId: string) => ArgusAgentName | undefined
33
44
  dropPolicy?: DropPolicy
34
45
  onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
35
46
  }
@@ -77,13 +88,35 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
77
88
  return undefined
78
89
  }
79
90
 
80
- async function emitToSink(sink: EventSink, event: AuditEvent): Promise<void> {
91
+ function toFindingSource(value: unknown): Finding["source"] {
92
+ if (
93
+ value === "slither" ||
94
+ value === "manual" ||
95
+ value === "pattern" ||
96
+ value === "scvd" ||
97
+ value === "solodit" ||
98
+ value === "fuzz"
99
+ ) {
100
+ return value
101
+ }
102
+
103
+ return "manual"
104
+ }
105
+
106
+ async function emitToSink(
107
+ sink: EventSink,
108
+ event: AuditEvent,
109
+ options?: { failFast?: boolean },
110
+ ): Promise<void> {
81
111
  try {
82
112
  await sink.append(event)
83
113
  } catch (error) {
84
- logger.error(
85
- `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
86
- )
114
+ const message = `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`
115
+ logger.error(message)
116
+
117
+ if (options?.failFast) {
118
+ throw new Error(message)
119
+ }
87
120
  }
88
121
  }
89
122
 
@@ -155,11 +188,13 @@ function identifyMissingFields(
155
188
 
156
189
  const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
157
190
  const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
191
+ const MANUAL_REQUIRED = ["check", "description", "file", "lines"] as const
158
192
 
159
193
  function processSlitherResult(
160
194
  parsed: Record<string, unknown>,
161
195
  store: FindingStore,
162
196
  diag: DropDiagnosticsCollector,
197
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
163
198
  ): number {
164
199
  const findings = parsed.findings
165
200
  if (!Array.isArray(findings)) return 0
@@ -197,6 +232,8 @@ function processSlitherResult(
197
232
  file,
198
233
  lines,
199
234
  source: "slither",
235
+ reported_by_agent: metadata.reportedByAgent,
236
+ reported_by_session_id: metadata.reportedBySessionId,
200
237
  })
201
238
  count++
202
239
  }
@@ -208,6 +245,7 @@ function processPatternResult(
208
245
  parsed: Record<string, unknown>,
209
246
  store: FindingStore,
210
247
  diag: DropDiagnosticsCollector,
248
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
211
249
  ): number {
212
250
  const sources = parsed.sources
213
251
  if (!Array.isArray(sources)) return 0
@@ -252,6 +290,8 @@ function processPatternResult(
252
290
  file,
253
291
  lines,
254
292
  source: "pattern",
293
+ reported_by_agent: metadata.reportedByAgent,
294
+ reported_by_session_id: metadata.reportedBySessionId,
255
295
  })
256
296
  count++
257
297
  }
@@ -260,6 +300,89 @@ function processPatternResult(
260
300
  return count
261
301
  }
262
302
 
303
+ function processRecordedFindingResult(
304
+ parsed: Record<string, unknown>,
305
+ store: FindingStore,
306
+ diag: DropDiagnosticsCollector,
307
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
308
+ ): number {
309
+ const findings = parsed.findings
310
+ if (!Array.isArray(findings)) {
311
+ diag.error(
312
+ "MISSING_REQUIRED_FIELD",
313
+ "argus_record_finding result missing findings array",
314
+ "findings",
315
+ )
316
+ return 0
317
+ }
318
+
319
+ let count = 0
320
+ for (const raw of findings) {
321
+ const finding = toRecord(raw)
322
+ if (!finding) continue
323
+
324
+ const check = finding.check
325
+ const description = finding.description
326
+ const file = finding.file
327
+ const lines = toLines(finding.lines)
328
+
329
+ if (
330
+ typeof check !== "string" ||
331
+ typeof description !== "string" ||
332
+ typeof file !== "string" ||
333
+ !lines
334
+ ) {
335
+ const missing = identifyMissingFields(finding, MANUAL_REQUIRED)
336
+ diag.error(
337
+ "MISSING_REQUIRED_FIELD",
338
+ `Recorded finding skipped: missing ${missing.join(", ")}`,
339
+ missing[0],
340
+ )
341
+ continue
342
+ }
343
+
344
+ const reportedByAgentRaw = finding.reported_by_agent
345
+ const reportedByAgent =
346
+ reportedByAgentRaw === "argus" ||
347
+ reportedByAgentRaw === "sentinel" ||
348
+ reportedByAgentRaw === "pythia" ||
349
+ reportedByAgentRaw === "scribe" ||
350
+ reportedByAgentRaw === "unknown"
351
+ ? (reportedByAgentRaw as ArgusAgentName)
352
+ : metadata.reportedByAgent
353
+
354
+ store.addFinding({
355
+ check,
356
+ severity: toSeverity(finding.severity),
357
+ confidence: toConfidence(finding.confidence),
358
+ description,
359
+ file,
360
+ lines,
361
+ source: toFindingSource(finding.source),
362
+ remediation: typeof finding.remediation === "string" ? finding.remediation : undefined,
363
+ exploitReference:
364
+ typeof finding.exploitReference === "string" ? finding.exploitReference : undefined,
365
+ reported_by_agent: reportedByAgent,
366
+ reported_by_session_id:
367
+ typeof finding.reported_by_session_id === "string" &&
368
+ finding.reported_by_session_id.length > 0
369
+ ? finding.reported_by_session_id
370
+ : metadata.reportedBySessionId,
371
+ issue_fingerprint:
372
+ typeof finding.issue_fingerprint === "string" ? finding.issue_fingerprint : undefined,
373
+ observation_fingerprint:
374
+ typeof finding.observation_fingerprint === "string"
375
+ ? finding.observation_fingerprint
376
+ : undefined,
377
+ observation_id:
378
+ typeof finding.observation_id === "string" ? finding.observation_id : undefined,
379
+ })
380
+ count++
381
+ }
382
+
383
+ return count
384
+ }
385
+
263
386
  function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
264
387
  if (typeof parsed.filePath === "string") {
265
388
  if (!state.contractsReviewed.includes(parsed.filePath)) {
@@ -382,7 +505,7 @@ export function createToolTrackingHook(
382
505
  const correlationId = randomUUID()
383
506
  const resolved = resolveStateAndStore()
384
507
  const sink = options?.getEventSink?.()
385
- const sessionId = options?.getSessionId?.() ?? ""
508
+ const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
386
509
  const toolCallId = randomUUID()
387
510
 
388
511
  if (childSessionId) {
@@ -427,6 +550,10 @@ export function createToolTrackingHook(
427
550
 
428
551
  const resolved = resolveStateAndStore()
429
552
  if (!resolved) {
553
+ if (input.tool === "argus_record_finding") {
554
+ throw new Error("argus_record_finding requires active audit state")
555
+ }
556
+
430
557
  const sinkForNoState = options?.getEventSink?.()
431
558
  if (sinkForNoState) {
432
559
  const toolCallId = randomUUID()
@@ -452,7 +579,15 @@ export function createToolTrackingHook(
452
579
  const { state: auditState, store } = resolved
453
580
  const sink = options?.getEventSink?.()
454
581
  const runId = auditState.sessionId
455
- const sessionId = options?.getSessionId?.() ?? ""
582
+ const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
583
+ const reportedByAgent =
584
+ (input.sessionID ? options?.getAgentNameForSession?.(input.sessionID) : undefined) ??
585
+ options?.getAgentName?.() ??
586
+ "unknown"
587
+ const findingMetadata = {
588
+ reportedByAgent,
589
+ reportedBySessionId: sessionId,
590
+ }
456
591
  const toolCallId = randomUUID()
457
592
  const policy = options?.dropPolicy ?? "warn"
458
593
  const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
@@ -464,6 +599,7 @@ export function createToolTrackingHook(
464
599
  tool: input.tool,
465
600
  args: input.args,
466
601
  }),
602
+ { failFast: input.tool === "argus_record_finding" },
467
603
  )
468
604
  }
469
605
 
@@ -502,6 +638,9 @@ export function createToolTrackingHook(
502
638
  } catch {
503
639
  diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
504
640
  lastDiagnostics = diag.getDiagnostics()
641
+ if (input.tool === "argus_record_finding") {
642
+ throw new Error("argus_record_finding returned malformed JSON")
643
+ }
505
644
  diag.throwIfStrict()
506
645
  return
507
646
  }
@@ -509,6 +648,9 @@ export function createToolTrackingHook(
509
648
  const record = toRecord(parsed)
510
649
  if (!record) {
511
650
  lastDiagnostics = diag.getDiagnostics()
651
+ if (input.tool === "argus_record_finding") {
652
+ throw new Error("argus_record_finding response must be a JSON object")
653
+ }
512
654
  return
513
655
  }
514
656
 
@@ -516,10 +658,13 @@ export function createToolTrackingHook(
516
658
 
517
659
  switch (input.tool) {
518
660
  case "argus_slither_analyze":
519
- findingsCount = processSlitherResult(record, store, diag)
661
+ findingsCount = processSlitherResult(record, store, diag, findingMetadata)
520
662
  break
521
663
  case "argus_check_patterns":
522
- findingsCount = processPatternResult(record, store, diag)
664
+ findingsCount = processPatternResult(record, store, diag, findingMetadata)
665
+ break
666
+ case "argus_record_finding":
667
+ findingsCount = processRecordedFindingResult(record, store, diag, findingMetadata)
523
668
  break
524
669
  case "argus_analyze_contract":
525
670
  processContractAnalyzerResult(record, auditState)
@@ -595,14 +740,32 @@ export function createToolTrackingHook(
595
740
  lastDiagnostics = diag.getDiagnostics()
596
741
  diag.throwIfStrict()
597
742
 
743
+ if (input.tool === "argus_record_finding" && findingsCount === 0) {
744
+ throw new Error("argus_record_finding did not persist any findings")
745
+ }
746
+
598
747
  recordToolExecution(auditState, input.tool, findingsCount)
599
748
  onStateChanged?.({ tool: input.tool, findingsCount })
600
749
 
750
+ if (input.tool === "argus_record_finding" && !sink) {
751
+ throw new Error("argus_record_finding requires an active event sink for durable persistence")
752
+ }
753
+
601
754
  if (sink) {
755
+ const failFast = input.tool === "argus_record_finding"
602
756
  const newFindings = auditState.findings.slice(findingsCountBefore)
603
- for (const finding of newFindings) {
604
- const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0)
605
- await emitToSink(sink, buildEvent("finding.added", runId, sessionId, toolCallId, canonical))
757
+ for (const [index, finding] of newFindings.entries()) {
758
+ const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0, {
759
+ reportedByAgent,
760
+ reportedBySessionId: sessionId,
761
+ toolCallId,
762
+ observationId: `${toolCallId}:${index + 1}`,
763
+ })
764
+ await emitToSink(
765
+ sink,
766
+ buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
767
+ { failFast },
768
+ )
606
769
  }
607
770
 
608
771
  await emitToSink(
@@ -612,6 +775,7 @@ export function createToolTrackingHook(
612
775
  findingsCount,
613
776
  success: true,
614
777
  }),
778
+ { failFast },
615
779
  )
616
780
  }
617
781
  }
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ function resolvePluginVersion(): string {
6
+ try {
7
+ const currentDir = dirname(fileURLToPath(import.meta.url))
8
+ const packageJsonPath = resolve(currentDir, "../../package.json")
9
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
10
+ version?: unknown
11
+ }
12
+
13
+ if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
14
+ return packageJson.version
15
+ }
16
+ } catch (_error) {
17
+ return "unknown"
18
+ }
19
+
20
+ return "unknown"
21
+ }
22
+
23
+ export const ARGUS_PLUGIN_VERSION = resolvePluginVersion()
@@ -1,10 +1,11 @@
1
+ import { computeIssueFingerprint, computeObservationFingerprint } from "./finding-fingerprint"
1
2
  import {
2
3
  type CanonicalFinding,
3
4
  SCHEMA_VERSION,
4
5
  type ValidationError,
5
6
  validateCanonicalFinding,
6
7
  } from "./schemas"
7
- import type { AuditPhase, Finding, FindingSeverity } from "./types"
8
+ import type { ArgusAgentName, AuditPhase, Finding, FindingSeverity } from "./types"
8
9
 
9
10
  export interface Diagnostic {
10
11
  level: "warn" | "error"
@@ -35,6 +36,13 @@ const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
35
36
  "solodit",
36
37
  "fuzz",
37
38
  ])
39
+ const VALID_REPORTED_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
40
+ "argus",
41
+ "sentinel",
42
+ "pythia",
43
+ "scribe",
44
+ "unknown",
45
+ ])
38
46
 
39
47
  const KNOWN_INPUT_FIELDS = new Set([
40
48
  "id",
@@ -59,9 +67,26 @@ const KNOWN_INPUT_FIELDS = new Set([
59
67
  "session_id",
60
68
  "tool_call_id",
61
69
  "schema_version",
70
+ "observation_id",
71
+ "observation_fingerprint",
72
+ "issue_fingerprint",
73
+ "reported_by_agent",
74
+ "reported_by_session_id",
75
+ "reportedByAgent",
76
+ "reportedBySessionId",
77
+ "observationId",
78
+ "observationFingerprint",
79
+ "issueFingerprint",
62
80
  "elements",
63
81
  ])
64
82
 
83
+ export interface NormalizeFindingOptions {
84
+ reportedByAgent?: ArgusAgentName
85
+ reportedBySessionId?: string
86
+ toolCallId?: string
87
+ observationId?: string
88
+ }
89
+
65
90
  function isRecord(value: unknown): value is Record<string, unknown> {
66
91
  return typeof value === "object" && value !== null && !Array.isArray(value)
67
92
  }
@@ -143,6 +168,7 @@ export function normalizeToCanonicalFinding(
143
168
  raw: Finding | Record<string, unknown>,
144
169
  runId: string,
145
170
  seq: number,
171
+ options: NormalizeFindingOptions = {},
146
172
  ): AdapterResult<CanonicalFinding> {
147
173
  const diagnostics: Diagnostic[] = []
148
174
  const input = isRecord(raw) ? raw : {}
@@ -189,11 +215,74 @@ export function normalizeToCanonicalFinding(
189
215
  ? (input.source as CanonicalFinding["source"])
190
216
  : "manual"
191
217
 
218
+ const reportedByAgentRaw =
219
+ (typeof input.reported_by_agent === "string" ? input.reported_by_agent : undefined) ??
220
+ (typeof input.reportedByAgent === "string" ? input.reportedByAgent : undefined) ??
221
+ options.reportedByAgent ??
222
+ "unknown"
223
+ const reportedByAgent: ArgusAgentName = VALID_REPORTED_AGENTS.has(
224
+ reportedByAgentRaw as ArgusAgentName,
225
+ )
226
+ ? (reportedByAgentRaw as ArgusAgentName)
227
+ : "unknown"
228
+
229
+ const reportedBySessionId =
230
+ (typeof input.reported_by_session_id === "string" && input.reported_by_session_id.length > 0
231
+ ? input.reported_by_session_id
232
+ : undefined) ??
233
+ (typeof input.reportedBySessionId === "string" && input.reportedBySessionId.length > 0
234
+ ? input.reportedBySessionId
235
+ : undefined) ??
236
+ options.reportedBySessionId
237
+
238
+ const issueFingerprint =
239
+ (typeof input.issue_fingerprint === "string" && input.issue_fingerprint.length > 0
240
+ ? input.issue_fingerprint
241
+ : undefined) ??
242
+ (typeof input.issueFingerprint === "string" && input.issueFingerprint.length > 0
243
+ ? input.issueFingerprint
244
+ : undefined) ??
245
+ computeIssueFingerprint({
246
+ check,
247
+ file,
248
+ lines: lines ?? [0, 0],
249
+ severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
250
+ })
251
+
252
+ const observationId =
253
+ (typeof input.observation_id === "string" && input.observation_id.length > 0
254
+ ? input.observation_id
255
+ : undefined) ??
256
+ (typeof input.observationId === "string" && input.observationId.length > 0
257
+ ? input.observationId
258
+ : undefined) ??
259
+ options.observationId ??
260
+ `${runId}:${seq}:${computeObservationFingerprint({
261
+ issueFingerprint,
262
+ source,
263
+ reportedByAgent,
264
+ toolCallId: options.toolCallId,
265
+ sessionId: reportedBySessionId,
266
+ })}`
267
+
268
+ const observationFingerprint =
269
+ (typeof input.observation_fingerprint === "string" && input.observation_fingerprint.length > 0
270
+ ? input.observation_fingerprint
271
+ : undefined) ??
272
+ (typeof input.observationFingerprint === "string" && input.observationFingerprint.length > 0
273
+ ? input.observationFingerprint
274
+ : undefined) ??
275
+ computeObservationFingerprint({
276
+ issueFingerprint,
277
+ source,
278
+ reportedByAgent,
279
+ toolCallId: options.toolCallId,
280
+ sessionId: reportedBySessionId,
281
+ observationId,
282
+ })
283
+
192
284
  const canonical: CanonicalFinding = {
193
- id:
194
- typeof input.id === "string" && input.id.length > 0
195
- ? input.id
196
- : `${check}:${file}:${lines?.[0] ?? 0}`,
285
+ id: observationId,
197
286
  check,
198
287
  severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
199
288
  confidence: VALID_CONFIDENCES.has(confidence) ? confidence : "Low",
@@ -201,6 +290,11 @@ export function normalizeToCanonicalFinding(
201
290
  file,
202
291
  lines: lines ?? [0, 0],
203
292
  source,
293
+ reported_by_agent: reportedByAgent,
294
+ reported_by_session_id: reportedBySessionId,
295
+ issue_fingerprint: issueFingerprint,
296
+ observation_fingerprint: observationFingerprint,
297
+ observation_id: observationId,
204
298
  remediation: typeof input.remediation === "string" ? input.remediation : undefined,
205
299
  exploitReference:
206
300
  typeof input.exploitReference === "string" ? input.exploitReference : undefined,
@@ -0,0 +1,100 @@
1
+ import type { CanonicalFinding } from "./schemas"
2
+
3
+ const SEVERITY_RANK: Record<CanonicalFinding["severity"], number> = {
4
+ Critical: 0,
5
+ High: 1,
6
+ Medium: 2,
7
+ Low: 3,
8
+ Informational: 4,
9
+ }
10
+
11
+ function uniqueSorted(values: string[]): string[] {
12
+ return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
13
+ }
14
+
15
+ function compareObservations(left: CanonicalFinding, right: CanonicalFinding): number {
16
+ if (left.seq !== right.seq) return left.seq - right.seq
17
+ return left.observation_id.localeCompare(right.observation_id)
18
+ }
19
+
20
+ function compareFinalFindings(left: CanonicalFinding, right: CanonicalFinding): number {
21
+ const bySeverity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
22
+ if (bySeverity !== 0) return bySeverity
23
+
24
+ const byFile = left.file.localeCompare(right.file)
25
+ if (byFile !== 0) return byFile
26
+
27
+ const byLine = left.lines[0] - right.lines[0]
28
+ if (byLine !== 0) return byLine
29
+
30
+ return left.issue_fingerprint.localeCompare(right.issue_fingerprint)
31
+ }
32
+
33
+ export function dedupeFindingsForFinalOutput(findings: CanonicalFinding[]): CanonicalFinding[] {
34
+ const byIssue = new Map<string, CanonicalFinding[]>()
35
+ for (const finding of findings) {
36
+ const group = byIssue.get(finding.issue_fingerprint)
37
+ if (group) {
38
+ group.push(finding)
39
+ } else {
40
+ byIssue.set(finding.issue_fingerprint, [finding])
41
+ }
42
+ }
43
+
44
+ const merged: CanonicalFinding[] = []
45
+
46
+ for (const [issueFingerprint, observations] of byIssue.entries()) {
47
+ const sortedObservations = observations.slice().sort(compareObservations)
48
+ const base = sortedObservations[0]
49
+ if (!base) continue
50
+
51
+ const reportedByAgents = uniqueSorted(
52
+ sortedObservations.map((finding) => finding.reported_by_agent),
53
+ )
54
+ const sources = uniqueSorted(sortedObservations.map((finding) => finding.source))
55
+ const observationIds = sortedObservations
56
+ .map((finding) => finding.observation_id)
57
+ .sort((left, right) => left.localeCompare(right))
58
+
59
+ merged.push({
60
+ ...base,
61
+ id: issueFingerprint,
62
+ sources,
63
+ reported_by_agents: reportedByAgents,
64
+ observation_ids: observationIds,
65
+ observation_count: sortedObservations.length,
66
+ })
67
+ }
68
+
69
+ return merged.sort(compareFinalFindings)
70
+ }
71
+
72
+ export function issueFingerprintSet(findings: CanonicalFinding[]): Set<string> {
73
+ const set = new Set<string>()
74
+ for (const finding of findings) {
75
+ set.add(finding.issue_fingerprint)
76
+ }
77
+ return set
78
+ }
79
+
80
+ export function compareIssueFingerprintSets(
81
+ expected: CanonicalFinding[],
82
+ actual: CanonicalFinding[],
83
+ ): { missing: string[]; extra: string[]; matches: boolean } {
84
+ const expectedSet = issueFingerprintSet(expected)
85
+ const actualSet = issueFingerprintSet(actual)
86
+
87
+ const missing = Array.from(expectedSet)
88
+ .filter((fingerprint) => !actualSet.has(fingerprint))
89
+ .sort((left, right) => left.localeCompare(right))
90
+
91
+ const extra = Array.from(actualSet)
92
+ .filter((fingerprint) => !expectedSet.has(fingerprint))
93
+ .sort((left, right) => left.localeCompare(right))
94
+
95
+ return {
96
+ missing,
97
+ extra,
98
+ matches: missing.length === 0 && extra.length === 0,
99
+ }
100
+ }