solidity-argus 0.3.5 → 0.3.6

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.
@@ -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
 
@@ -30,6 +37,7 @@ type ToolExecutionMetadata = {
30
37
  export type ToolTrackingOptions = {
31
38
  getEventSink?: () => EventSink | null
32
39
  getSessionId?: () => string
40
+ getAgentName?: () => ArgusAgentName | undefined
33
41
  dropPolicy?: DropPolicy
34
42
  onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
35
43
  }
@@ -77,13 +85,35 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
77
85
  return undefined
78
86
  }
79
87
 
80
- async function emitToSink(sink: EventSink, event: AuditEvent): Promise<void> {
88
+ function toFindingSource(value: unknown): Finding["source"] {
89
+ if (
90
+ value === "slither" ||
91
+ value === "manual" ||
92
+ value === "pattern" ||
93
+ value === "scvd" ||
94
+ value === "solodit" ||
95
+ value === "fuzz"
96
+ ) {
97
+ return value
98
+ }
99
+
100
+ return "manual"
101
+ }
102
+
103
+ async function emitToSink(
104
+ sink: EventSink,
105
+ event: AuditEvent,
106
+ options?: { failFast?: boolean },
107
+ ): Promise<void> {
81
108
  try {
82
109
  await sink.append(event)
83
110
  } catch (error) {
84
- logger.error(
85
- `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
86
- )
111
+ const message = `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`
112
+ logger.error(message)
113
+
114
+ if (options?.failFast) {
115
+ throw new Error(message)
116
+ }
87
117
  }
88
118
  }
89
119
 
@@ -155,11 +185,13 @@ function identifyMissingFields(
155
185
 
156
186
  const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
157
187
  const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
188
+ const MANUAL_REQUIRED = ["check", "description", "file", "lines"] as const
158
189
 
159
190
  function processSlitherResult(
160
191
  parsed: Record<string, unknown>,
161
192
  store: FindingStore,
162
193
  diag: DropDiagnosticsCollector,
194
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
163
195
  ): number {
164
196
  const findings = parsed.findings
165
197
  if (!Array.isArray(findings)) return 0
@@ -197,6 +229,8 @@ function processSlitherResult(
197
229
  file,
198
230
  lines,
199
231
  source: "slither",
232
+ reported_by_agent: metadata.reportedByAgent,
233
+ reported_by_session_id: metadata.reportedBySessionId,
200
234
  })
201
235
  count++
202
236
  }
@@ -208,6 +242,7 @@ function processPatternResult(
208
242
  parsed: Record<string, unknown>,
209
243
  store: FindingStore,
210
244
  diag: DropDiagnosticsCollector,
245
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
211
246
  ): number {
212
247
  const sources = parsed.sources
213
248
  if (!Array.isArray(sources)) return 0
@@ -252,6 +287,8 @@ function processPatternResult(
252
287
  file,
253
288
  lines,
254
289
  source: "pattern",
290
+ reported_by_agent: metadata.reportedByAgent,
291
+ reported_by_session_id: metadata.reportedBySessionId,
255
292
  })
256
293
  count++
257
294
  }
@@ -260,6 +297,89 @@ function processPatternResult(
260
297
  return count
261
298
  }
262
299
 
300
+ function processRecordedFindingResult(
301
+ parsed: Record<string, unknown>,
302
+ store: FindingStore,
303
+ diag: DropDiagnosticsCollector,
304
+ metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
305
+ ): number {
306
+ const findings = parsed.findings
307
+ if (!Array.isArray(findings)) {
308
+ diag.error(
309
+ "MISSING_REQUIRED_FIELD",
310
+ "argus_record_finding result missing findings array",
311
+ "findings",
312
+ )
313
+ return 0
314
+ }
315
+
316
+ let count = 0
317
+ for (const raw of findings) {
318
+ const finding = toRecord(raw)
319
+ if (!finding) continue
320
+
321
+ const check = finding.check
322
+ const description = finding.description
323
+ const file = finding.file
324
+ const lines = toLines(finding.lines)
325
+
326
+ if (
327
+ typeof check !== "string" ||
328
+ typeof description !== "string" ||
329
+ typeof file !== "string" ||
330
+ !lines
331
+ ) {
332
+ const missing = identifyMissingFields(finding, MANUAL_REQUIRED)
333
+ diag.error(
334
+ "MISSING_REQUIRED_FIELD",
335
+ `Recorded finding skipped: missing ${missing.join(", ")}`,
336
+ missing[0],
337
+ )
338
+ continue
339
+ }
340
+
341
+ const reportedByAgentRaw = finding.reported_by_agent
342
+ const reportedByAgent =
343
+ reportedByAgentRaw === "argus" ||
344
+ reportedByAgentRaw === "sentinel" ||
345
+ reportedByAgentRaw === "pythia" ||
346
+ reportedByAgentRaw === "scribe" ||
347
+ reportedByAgentRaw === "unknown"
348
+ ? (reportedByAgentRaw as ArgusAgentName)
349
+ : metadata.reportedByAgent
350
+
351
+ store.addFinding({
352
+ check,
353
+ severity: toSeverity(finding.severity),
354
+ confidence: toConfidence(finding.confidence),
355
+ description,
356
+ file,
357
+ lines,
358
+ source: toFindingSource(finding.source),
359
+ remediation: typeof finding.remediation === "string" ? finding.remediation : undefined,
360
+ exploitReference:
361
+ typeof finding.exploitReference === "string" ? finding.exploitReference : undefined,
362
+ reported_by_agent: reportedByAgent,
363
+ reported_by_session_id:
364
+ typeof finding.reported_by_session_id === "string" &&
365
+ finding.reported_by_session_id.length > 0
366
+ ? finding.reported_by_session_id
367
+ : metadata.reportedBySessionId,
368
+ issue_fingerprint:
369
+ typeof finding.issue_fingerprint === "string" ? finding.issue_fingerprint : undefined,
370
+ observation_fingerprint:
371
+ typeof finding.observation_fingerprint === "string"
372
+ ? finding.observation_fingerprint
373
+ : undefined,
374
+ observation_id:
375
+ typeof finding.observation_id === "string" ? finding.observation_id : undefined,
376
+ })
377
+ count++
378
+ }
379
+
380
+ return count
381
+ }
382
+
263
383
  function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
264
384
  if (typeof parsed.filePath === "string") {
265
385
  if (!state.contractsReviewed.includes(parsed.filePath)) {
@@ -427,6 +547,10 @@ export function createToolTrackingHook(
427
547
 
428
548
  const resolved = resolveStateAndStore()
429
549
  if (!resolved) {
550
+ if (input.tool === "argus_record_finding") {
551
+ throw new Error("argus_record_finding requires active audit state")
552
+ }
553
+
430
554
  const sinkForNoState = options?.getEventSink?.()
431
555
  if (sinkForNoState) {
432
556
  const toolCallId = randomUUID()
@@ -453,6 +577,11 @@ export function createToolTrackingHook(
453
577
  const sink = options?.getEventSink?.()
454
578
  const runId = auditState.sessionId
455
579
  const sessionId = options?.getSessionId?.() ?? ""
580
+ const reportedByAgent = options?.getAgentName?.() ?? "unknown"
581
+ const findingMetadata = {
582
+ reportedByAgent,
583
+ reportedBySessionId: sessionId,
584
+ }
456
585
  const toolCallId = randomUUID()
457
586
  const policy = options?.dropPolicy ?? "warn"
458
587
  const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
@@ -464,6 +593,7 @@ export function createToolTrackingHook(
464
593
  tool: input.tool,
465
594
  args: input.args,
466
595
  }),
596
+ { failFast: input.tool === "argus_record_finding" },
467
597
  )
468
598
  }
469
599
 
@@ -502,6 +632,9 @@ export function createToolTrackingHook(
502
632
  } catch {
503
633
  diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
504
634
  lastDiagnostics = diag.getDiagnostics()
635
+ if (input.tool === "argus_record_finding") {
636
+ throw new Error("argus_record_finding returned malformed JSON")
637
+ }
505
638
  diag.throwIfStrict()
506
639
  return
507
640
  }
@@ -509,6 +642,9 @@ export function createToolTrackingHook(
509
642
  const record = toRecord(parsed)
510
643
  if (!record) {
511
644
  lastDiagnostics = diag.getDiagnostics()
645
+ if (input.tool === "argus_record_finding") {
646
+ throw new Error("argus_record_finding response must be a JSON object")
647
+ }
512
648
  return
513
649
  }
514
650
 
@@ -516,10 +652,13 @@ export function createToolTrackingHook(
516
652
 
517
653
  switch (input.tool) {
518
654
  case "argus_slither_analyze":
519
- findingsCount = processSlitherResult(record, store, diag)
655
+ findingsCount = processSlitherResult(record, store, diag, findingMetadata)
520
656
  break
521
657
  case "argus_check_patterns":
522
- findingsCount = processPatternResult(record, store, diag)
658
+ findingsCount = processPatternResult(record, store, diag, findingMetadata)
659
+ break
660
+ case "argus_record_finding":
661
+ findingsCount = processRecordedFindingResult(record, store, diag, findingMetadata)
523
662
  break
524
663
  case "argus_analyze_contract":
525
664
  processContractAnalyzerResult(record, auditState)
@@ -595,14 +734,32 @@ export function createToolTrackingHook(
595
734
  lastDiagnostics = diag.getDiagnostics()
596
735
  diag.throwIfStrict()
597
736
 
737
+ if (input.tool === "argus_record_finding" && findingsCount === 0) {
738
+ throw new Error("argus_record_finding did not persist any findings")
739
+ }
740
+
598
741
  recordToolExecution(auditState, input.tool, findingsCount)
599
742
  onStateChanged?.({ tool: input.tool, findingsCount })
600
743
 
744
+ if (input.tool === "argus_record_finding" && !sink) {
745
+ throw new Error("argus_record_finding requires an active event sink for durable persistence")
746
+ }
747
+
601
748
  if (sink) {
749
+ const failFast = input.tool === "argus_record_finding"
602
750
  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))
751
+ for (const [index, finding] of newFindings.entries()) {
752
+ const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0, {
753
+ reportedByAgent,
754
+ reportedBySessionId: sessionId,
755
+ toolCallId,
756
+ observationId: `${toolCallId}:${index + 1}`,
757
+ })
758
+ await emitToSink(
759
+ sink,
760
+ buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
761
+ { failFast },
762
+ )
606
763
  }
607
764
 
608
765
  await emitToSink(
@@ -612,6 +769,7 @@ export function createToolTrackingHook(
612
769
  findingsCount,
613
770
  success: true,
614
771
  }),
772
+ { failFast },
615
773
  )
616
774
  }
617
775
  }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { ArgusAgentName, FindingSeverity } from "./types"
3
+
4
+ type IssueFingerprintInput = {
5
+ check: string
6
+ file: string
7
+ lines: [number, number]
8
+ severity: FindingSeverity
9
+ }
10
+
11
+ type ObservationFingerprintInput = {
12
+ issueFingerprint: string
13
+ source: string
14
+ reportedByAgent: ArgusAgentName
15
+ toolCallId?: string
16
+ sessionId?: string
17
+ observationId?: string
18
+ }
19
+
20
+ function hash(parts: string[]): string {
21
+ return createHash("sha256").update(parts.join("|"), "utf8").digest("hex")
22
+ }
23
+
24
+ function normalizeText(value: string): string {
25
+ return value.trim().toLowerCase()
26
+ }
27
+
28
+ export function computeIssueFingerprint(input: IssueFingerprintInput): string {
29
+ return hash([
30
+ normalizeText(input.check),
31
+ normalizeText(input.file),
32
+ String(input.lines[0]),
33
+ String(input.lines[1]),
34
+ input.severity,
35
+ ])
36
+ }
37
+
38
+ export function computeObservationFingerprint(input: ObservationFingerprintInput): string {
39
+ return hash([
40
+ input.issueFingerprint,
41
+ normalizeText(input.source),
42
+ input.reportedByAgent,
43
+ input.toolCallId ?? "",
44
+ input.sessionId ?? "",
45
+ input.observationId ?? "",
46
+ ])
47
+ }