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.
@@ -1,7 +1,21 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { EventSink } from "../features/persistent-state/event-sink"
3
+ import type {
4
+ DropDiagnostic,
5
+ DropDiagnosticsCollector,
6
+ DropPolicy,
7
+ } from "../shared/drop-diagnostics"
8
+ import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
9
+ import { createLogger } from "../shared/logger"
10
+ import { normalizeToCanonicalFinding } from "../state/adapters"
1
11
  import type { FindingStore } from "../state/finding-store"
2
12
  import { createFindingStore } from "../state/finding-store"
13
+ import type { AuditEvent } from "../state/schemas"
14
+ import { SCHEMA_VERSION } from "../state/schemas"
3
15
  import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
4
16
 
17
+ const logger = createLogger()
18
+
5
19
  type ToolHookInput = {
6
20
  tool: string
7
21
  args: unknown
@@ -13,6 +27,13 @@ type ToolExecutionMetadata = {
13
27
  findingsCount: number
14
28
  }
15
29
 
30
+ export type ToolTrackingOptions = {
31
+ getEventSink?: () => EventSink | null
32
+ getSessionId?: () => string
33
+ dropPolicy?: DropPolicy
34
+ onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
35
+ }
36
+
16
37
  const VALID_SEVERITIES: ReadonlySet<string> = new Set([
17
38
  "Critical",
18
39
  "High",
@@ -56,7 +77,90 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
56
77
  return undefined
57
78
  }
58
79
 
59
- function processSlitherResult(parsed: Record<string, unknown>, store: FindingStore): number {
80
+ async function emitToSink(sink: EventSink, event: AuditEvent): Promise<void> {
81
+ try {
82
+ await sink.append(event)
83
+ } catch (error) {
84
+ logger.error(
85
+ `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
86
+ )
87
+ }
88
+ }
89
+
90
+ function buildEvent(
91
+ type: AuditEvent["type"],
92
+ runId: string,
93
+ sessionId: string,
94
+ toolCallId: string,
95
+ payload: unknown,
96
+ ): AuditEvent {
97
+ return {
98
+ type,
99
+ run_id: runId,
100
+ seq: 0,
101
+ session_id: sessionId,
102
+ tool_call_id: toolCallId,
103
+ source: "tool-tracking-hook",
104
+ schema_version: SCHEMA_VERSION,
105
+ timestamp: Date.now(),
106
+ payload,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Defensively parse a child session_id from a `task` tool result.
112
+ * The result may be JSON with a top-level or nested `session_id` field,
113
+ * or plain text with an embedded JSON fragment.
114
+ */
115
+ function parseChildSessionId(result: string): string | null {
116
+ try {
117
+ const parsed = JSON.parse(result)
118
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
119
+ if (typeof parsed.session_id === "string" && parsed.session_id.length > 0) {
120
+ return parsed.session_id
121
+ }
122
+ if (
123
+ typeof parsed.result === "object" &&
124
+ parsed.result !== null &&
125
+ !Array.isArray(parsed.result) &&
126
+ typeof parsed.result.session_id === "string" &&
127
+ parsed.result.session_id.length > 0
128
+ ) {
129
+ return parsed.result.session_id
130
+ }
131
+ }
132
+ } catch {
133
+ const match = result.match(/"session_id"\s*:\s*"([^"]+)"/)
134
+ if (match?.[1]) {
135
+ return match[1]
136
+ }
137
+ }
138
+ return null
139
+ }
140
+
141
+ function identifyMissingFields(
142
+ finding: Record<string, unknown>,
143
+ requiredFields: readonly string[],
144
+ ): string[] {
145
+ const missing: string[] = []
146
+ for (const field of requiredFields) {
147
+ if (field === "lines") {
148
+ if (!toLines(finding.lines)) missing.push(field)
149
+ } else if (typeof finding[field] !== "string") {
150
+ missing.push(field)
151
+ }
152
+ }
153
+ return missing
154
+ }
155
+
156
+ const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
157
+ const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
158
+
159
+ function processSlitherResult(
160
+ parsed: Record<string, unknown>,
161
+ store: FindingStore,
162
+ diag: DropDiagnosticsCollector,
163
+ ): number {
60
164
  const findings = parsed.findings
61
165
  if (!Array.isArray(findings)) return 0
62
166
 
@@ -76,6 +180,12 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
76
180
  typeof file !== "string" ||
77
181
  !lines
78
182
  ) {
183
+ const missing = identifyMissingFields(finding, SLITHER_REQUIRED)
184
+ diag.error(
185
+ "MISSING_REQUIRED_FIELD",
186
+ `Slither finding skipped: missing ${missing.join(", ")}`,
187
+ missing[0],
188
+ )
79
189
  continue
80
190
  }
81
191
 
@@ -94,7 +204,11 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
94
204
  return count
95
205
  }
96
206
 
97
- function processPatternResult(parsed: Record<string, unknown>, store: FindingStore): number {
207
+ function processPatternResult(
208
+ parsed: Record<string, unknown>,
209
+ store: FindingStore,
210
+ diag: DropDiagnosticsCollector,
211
+ ): number {
98
212
  const sources = parsed.sources
99
213
  if (!Array.isArray(sources)) return 0
100
214
 
@@ -121,6 +235,12 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
121
235
  typeof file !== "string" ||
122
236
  !lines
123
237
  ) {
238
+ const missing = identifyMissingFields(match, PATTERN_REQUIRED)
239
+ diag.error(
240
+ "MISSING_REQUIRED_FIELD",
241
+ `Pattern finding skipped: missing ${missing.join(", ")}`,
242
+ missing[0],
243
+ )
124
244
  continue
125
245
  }
126
246
 
@@ -141,7 +261,6 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
141
261
  }
142
262
 
143
263
  function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
144
- // Handle direct ContractProfile format (actual tool output)
145
264
  if (typeof parsed.filePath === "string") {
146
265
  if (!state.contractsReviewed.includes(parsed.filePath)) {
147
266
  state.contractsReviewed.push(parsed.filePath)
@@ -149,7 +268,6 @@ function processContractAnalyzerResult(parsed: Record<string, unknown>, state: A
149
268
  return
150
269
  }
151
270
 
152
- // Handle wrapped { contractProfile: { filePath } } format
153
271
  const profile = toRecord(parsed.contractProfile)
154
272
  if (profile && typeof profile.filePath === "string") {
155
273
  if (!state.contractsReviewed.includes(profile.filePath)) {
@@ -220,19 +338,6 @@ function processSoloditResult(parsed: Record<string, unknown>, state: AuditState
220
338
  })
221
339
  }
222
340
 
223
- /**
224
- * Records a tool execution in the audit state.
225
- *
226
- * Multiple entries per tool name are allowed — if the same tool runs multiple times
227
- * (e.g., argus_slither_analyze on different targets), each execution is recorded
228
- * with its own findingsCount.
229
- *
230
- * Timing limitation: startTime and endTime are both set to Date.now() because this
231
- * hook fires in the tool.execute.after phase, after execution has already completed.
232
- * We cannot capture the actual start time. This is a known limitation of the hook
233
- * architecture. For accurate timing, the hook would need to fire in tool.execute.before
234
- * and tool.execute.after phases separately.
235
- */
236
341
  function recordToolExecution(state: AuditState, toolName: string, findingsCount: number): void {
237
342
  const now = Date.now()
238
343
  state.toolsExecuted.push({
@@ -244,18 +349,18 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
244
349
  })
245
350
  }
246
351
 
247
- /**
248
- * Creates a tool tracking hook that intercepts argus_* tool results
249
- * and updates audit state with extracted findings.
250
- *
251
- * Non-argus tools are ignored. Malformed JSON results are silently skipped.
252
- * Findings are deduplicated via the FindingStore (by check+file+lines).
253
- */
352
+ export type ToolTrackingHook = {
353
+ (input: ToolHookInput): Promise<void>
354
+ getLastDiagnostics(): DropDiagnostic[]
355
+ }
356
+
254
357
  export function createToolTrackingHook(
255
358
  getAuditState: () => AuditState | null,
256
359
  onStateChanged?: (metadata: ToolExecutionMetadata) => void,
257
- ): (input: ToolHookInput) => Promise<void> {
360
+ options?: ToolTrackingOptions,
361
+ ): ToolTrackingHook {
258
362
  const storesByState = new WeakMap<AuditState, FindingStore>()
363
+ let lastDiagnostics: DropDiagnostic[] = []
259
364
 
260
365
  function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
261
366
  const state = getAuditState()
@@ -270,19 +375,101 @@ export function createToolTrackingHook(
270
375
  return { state, store }
271
376
  }
272
377
 
273
- return async (input: ToolHookInput): Promise<void> => {
378
+ const hookFn = async (input: ToolHookInput): Promise<void> => {
379
+ // Handle task tool (subagent dispatch) before argus_ filter
380
+ if (input.tool === "task") {
381
+ const childSessionId = parseChildSessionId(input.result)
382
+ const correlationId = randomUUID()
383
+ const resolved = resolveStateAndStore()
384
+ const sink = options?.getEventSink?.()
385
+ const sessionId = options?.getSessionId?.() ?? ""
386
+ const toolCallId = randomUUID()
387
+
388
+ if (childSessionId) {
389
+ options?.onChildSessionDetected?.(sessionId, childSessionId)
390
+ }
391
+
392
+ if (sink && resolved) {
393
+ const runId = resolved.state.sessionId
394
+ await emitToSink(
395
+ sink,
396
+ buildEvent("tool.started", runId, sessionId, toolCallId, {
397
+ tool: "task",
398
+ args: input.args,
399
+ correlation_id: correlationId,
400
+ child_session_id: childSessionId ?? null,
401
+ }),
402
+ )
403
+
404
+ await emitToSink(
405
+ sink,
406
+ buildEvent("tool.completed", runId, sessionId, toolCallId, {
407
+ tool: "task",
408
+ findingsCount: 0,
409
+ success: true,
410
+ correlation_id: correlationId,
411
+ child_session_id: childSessionId ?? null,
412
+ }),
413
+ )
414
+ }
415
+
416
+ if (resolved) {
417
+ recordToolExecution(resolved.state, "task", 0)
418
+ onStateChanged?.({ tool: "task", findingsCount: 0 })
419
+ }
420
+
421
+ return
422
+ }
423
+
274
424
  if (!input.tool.startsWith("argus_")) {
275
425
  return
276
426
  }
277
427
 
278
428
  const resolved = resolveStateAndStore()
279
- if (!resolved) return
429
+ if (!resolved) {
430
+ const sinkForNoState = options?.getEventSink?.()
431
+ if (sinkForNoState) {
432
+ const toolCallId = randomUUID()
433
+ await emitToSink(
434
+ sinkForNoState,
435
+ buildEvent("tool.started", "", "", toolCallId, {
436
+ tool: input.tool,
437
+ args: input.args,
438
+ }),
439
+ )
440
+ await emitToSink(
441
+ sinkForNoState,
442
+ buildEvent("tool.completed", "", "", toolCallId, {
443
+ tool: input.tool,
444
+ findingsCount: 0,
445
+ success: false,
446
+ }),
447
+ )
448
+ }
449
+ return
450
+ }
280
451
 
281
452
  const { state: auditState, store } = resolved
453
+ const sink = options?.getEventSink?.()
454
+ const runId = auditState.sessionId
455
+ const sessionId = options?.getSessionId?.() ?? ""
456
+ const toolCallId = randomUUID()
457
+ const policy = options?.dropPolicy ?? "warn"
458
+ const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
459
+
460
+ if (sink) {
461
+ await emitToSink(
462
+ sink,
463
+ buildEvent("tool.started", runId, sessionId, toolCallId, {
464
+ tool: input.tool,
465
+ args: input.args,
466
+ }),
467
+ )
468
+ }
469
+
470
+ const findingsCountBefore = auditState.findings.length
282
471
 
283
- // Handle argus_skill_load first — it returns markdown, not JSON
284
472
  if (input.tool === "argus_skill_load") {
285
- // Extract skill name from markdown header: "## Argus Skill: {name} [Source: ...]"
286
473
  const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
287
474
  const skillName = nameMatch?.[1]?.trim()
288
475
  if (skillName) {
@@ -293,6 +480,19 @@ export function createToolTrackingHook(
293
480
  }
294
481
  recordToolExecution(auditState, input.tool, 0)
295
482
  onStateChanged?.({ tool: input.tool, findingsCount: 0 })
483
+
484
+ if (sink) {
485
+ await emitToSink(
486
+ sink,
487
+ buildEvent("tool.completed", runId, sessionId, toolCallId, {
488
+ tool: input.tool,
489
+ findingsCount: 0,
490
+ success: true,
491
+ }),
492
+ )
493
+ }
494
+
495
+ lastDiagnostics = diag.getDiagnostics()
296
496
  return
297
497
  }
298
498
 
@@ -300,20 +500,26 @@ export function createToolTrackingHook(
300
500
  try {
301
501
  parsed = JSON.parse(input.result)
302
502
  } catch {
303
- return // non-JSON tool output nothing to track
503
+ diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
504
+ lastDiagnostics = diag.getDiagnostics()
505
+ diag.throwIfStrict()
506
+ return
304
507
  }
305
508
 
306
509
  const record = toRecord(parsed)
307
- if (!record) return
510
+ if (!record) {
511
+ lastDiagnostics = diag.getDiagnostics()
512
+ return
513
+ }
308
514
 
309
515
  let findingsCount = 0
310
516
 
311
517
  switch (input.tool) {
312
518
  case "argus_slither_analyze":
313
- findingsCount = processSlitherResult(record, store)
519
+ findingsCount = processSlitherResult(record, store, diag)
314
520
  break
315
521
  case "argus_check_patterns":
316
- findingsCount = processPatternResult(record, store)
522
+ findingsCount = processPatternResult(record, store, diag)
317
523
  break
318
524
  case "argus_analyze_contract":
319
525
  processContractAnalyzerResult(record, auditState)
@@ -386,7 +592,31 @@ export function createToolTrackingHook(
386
592
  }
387
593
  }
388
594
 
595
+ lastDiagnostics = diag.getDiagnostics()
596
+ diag.throwIfStrict()
597
+
389
598
  recordToolExecution(auditState, input.tool, findingsCount)
390
599
  onStateChanged?.({ tool: input.tool, findingsCount })
600
+
601
+ if (sink) {
602
+ 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))
606
+ }
607
+
608
+ await emitToSink(
609
+ sink,
610
+ buildEvent("tool.completed", runId, sessionId, toolCallId, {
611
+ tool: input.tool,
612
+ findingsCount,
613
+ success: true,
614
+ }),
615
+ )
616
+ }
391
617
  }
618
+
619
+ hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
620
+
621
+ return hookFn
392
622
  }
@@ -0,0 +1,74 @@
1
+ import { join } from "node:path"
2
+
3
+ export class ArtifactResolverError extends Error {
4
+ constructor(message: string) {
5
+ super(message)
6
+ this.name = "ArtifactResolverError"
7
+ }
8
+ }
9
+
10
+ export interface AuditArtifactPaths {
11
+ /** {projectDir}/.opencode/argus-state.json (legacy compat) */
12
+ stateFile: string
13
+ /** {projectDir}/.opencode/runs/{runId}/events.jsonl */
14
+ journalFile: string
15
+ /** {projectDir}/.opencode/runs/{runId}/findings.json */
16
+ findingsFile: string
17
+ /** {projectDir}/.opencode/reports */
18
+ reportDir: string
19
+ /** {projectDir}/.opencode/runs/{runId}/evidence */
20
+ evidenceDir: string
21
+ /** {projectDir}/.opencode/archives */
22
+ archiveDir: string
23
+ /** {projectDir}/.opencode/runs/{runId} */
24
+ runDir: string
25
+ }
26
+
27
+ export interface AuditArtifactResolver {
28
+ readonly runId: string
29
+ readonly projectDir: string
30
+ paths(): AuditArtifactPaths
31
+ /** Returns {reportDir}/{filename} */
32
+ reportFilePath(filename: string): string
33
+ /** Returns {evidenceDir}/{filename} */
34
+ evidenceFilePath(filename: string): string
35
+ }
36
+
37
+ export function createAuditArtifactResolver(
38
+ runId: string,
39
+ projectDir: string,
40
+ ): AuditArtifactResolver {
41
+ if (!runId || runId.trim() === "") {
42
+ throw new ArtifactResolverError("runId must not be empty")
43
+ }
44
+ if (!projectDir || projectDir.trim() === "") {
45
+ throw new ArtifactResolverError("projectDir must not be empty")
46
+ }
47
+
48
+ const opencodeDir = join(projectDir, ".opencode")
49
+ const runDir = join(opencodeDir, "runs", runId)
50
+
51
+ const cachedPaths: AuditArtifactPaths = {
52
+ stateFile: join(opencodeDir, "argus-state.json"),
53
+ journalFile: join(runDir, "events.jsonl"),
54
+ findingsFile: join(runDir, "findings.json"),
55
+ reportDir: join(opencodeDir, "reports"),
56
+ evidenceDir: join(runDir, "evidence"),
57
+ archiveDir: join(opencodeDir, "archives"),
58
+ runDir,
59
+ }
60
+
61
+ return {
62
+ runId,
63
+ projectDir,
64
+ paths(): AuditArtifactPaths {
65
+ return cachedPaths
66
+ },
67
+ reportFilePath(filename: string): string {
68
+ return join(cachedPaths.reportDir, filename)
69
+ },
70
+ evidenceFilePath(filename: string): string {
71
+ return join(cachedPaths.evidenceDir, filename)
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,108 @@
1
+ import { createLogger } from "./logger"
2
+
3
+ const logger = createLogger()
4
+
5
+ /**
6
+ * "warn": log and continue (default). "error": collect, continue, surface.
7
+ * "strict-fail": collect, then throw after all diagnostics gathered.
8
+ */
9
+ export type DropPolicy = "warn" | "error" | "strict-fail"
10
+
11
+ export type DropReason = {
12
+ code: string
13
+ field?: string
14
+ message: string
15
+ policy: DropPolicy
16
+ }
17
+
18
+ export type DropDiagnostic = {
19
+ type: "drop"
20
+ source: string
21
+ tool?: string
22
+ reason: DropReason
23
+ timestamp: number
24
+ }
25
+
26
+ export type DropDiagnosticsCollector = {
27
+ warn(code: string, message: string, field?: string): void
28
+ error(code: string, message: string, field?: string): void
29
+ getDiagnostics(): DropDiagnostic[]
30
+ hasErrors(): boolean
31
+ throwIfStrict(): void
32
+ }
33
+
34
+ /** Thrown in strict-fail mode when error-level diagnostics exist. */
35
+ export class DropDiagnosticsError extends Error {
36
+ public readonly diagnostics: DropDiagnostic[]
37
+
38
+ constructor(diagnostics: DropDiagnostic[]) {
39
+ const errorDiags = diagnostics.filter(
40
+ (d) => d.reason.policy === "strict-fail" || d.reason.policy === "error",
41
+ )
42
+ const summary = errorDiags.map((d) => `[${d.reason.code}] ${d.reason.message}`).join("; ")
43
+ super(`Drop diagnostics: ${errorDiags.length} error(s) — ${summary}`)
44
+ this.name = "DropDiagnosticsError"
45
+ this.diagnostics = diagnostics
46
+ }
47
+ }
48
+
49
+ export function createDropDiagnosticsCollector(
50
+ policy: DropPolicy,
51
+ source: string,
52
+ tool?: string,
53
+ ): DropDiagnosticsCollector {
54
+ const diagnostics: DropDiagnostic[] = []
55
+ let errorCount = 0
56
+
57
+ function push(code: string, message: string, level: "warn" | "error", field?: string): void {
58
+ const effectivePolicy: DropPolicy = level === "error" ? policy : "warn"
59
+ const diagnostic: DropDiagnostic = {
60
+ type: "drop",
61
+ source,
62
+ tool,
63
+ reason: {
64
+ code,
65
+ message,
66
+ policy: effectivePolicy,
67
+ ...(field !== undefined ? { field } : {}),
68
+ },
69
+ timestamp: Date.now(),
70
+ }
71
+ diagnostics.push(diagnostic)
72
+
73
+ if (level === "error") {
74
+ errorCount++
75
+ }
76
+
77
+ const logMsg = `[${source}${tool ? `:${tool}` : ""}] ${code}${field ? ` (field: ${field})` : ""}: ${message}`
78
+ if (level === "error") {
79
+ logger.warn(logMsg)
80
+ } else {
81
+ logger.info(logMsg)
82
+ }
83
+ }
84
+
85
+ return {
86
+ warn(code: string, message: string, field?: string): void {
87
+ push(code, message, "warn", field)
88
+ },
89
+
90
+ error(code: string, message: string, field?: string): void {
91
+ push(code, message, "error", field)
92
+ },
93
+
94
+ getDiagnostics(): DropDiagnostic[] {
95
+ return [...diagnostics]
96
+ },
97
+
98
+ hasErrors(): boolean {
99
+ return errorCount > 0
100
+ },
101
+
102
+ throwIfStrict(): void {
103
+ if (policy === "strict-fail" && errorCount > 0) {
104
+ throw new DropDiagnosticsError(diagnostics)
105
+ }
106
+ },
107
+ }
108
+ }
@@ -9,3 +9,17 @@ export {
9
9
  export { stripJsoncComments } from "./jsonc-parser"
10
10
  export { createLogger, type Logger, type LoggerConfig } from "./logger"
11
11
  export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
12
+ export {
13
+ ArtifactResolverError,
14
+ type AuditArtifactPaths,
15
+ type AuditArtifactResolver,
16
+ createAuditArtifactResolver,
17
+ } from "./audit-artifact-resolver"
18
+ export {
19
+ ReportPathError,
20
+ type ReportPathOptions,
21
+ type ResolvedReportPath,
22
+ formatReportDate,
23
+ sanitizeContractName,
24
+ resolveReportPath,
25
+ } from "./report-path-resolver"
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path"
2
+
3
+ export class ReportPathError extends Error {
4
+ constructor(message: string) {
5
+ super(message)
6
+ this.name = "ReportPathError"
7
+ }
8
+ }
9
+
10
+ export interface ReportPathOptions {
11
+ /** Contract name, e.g. "VulnerableVault" */
12
+ contractName: string
13
+ /** If not provided, use new Date() */
14
+ date?: Date
15
+ /** Canonical output directory (from config or default) */
16
+ outputDir: string
17
+ /** Optional run_id for run-scoped naming */
18
+ runId?: string
19
+ }
20
+
21
+ export interface ResolvedReportPath {
22
+ /** "VulnerableVault-security-audit-2026-02-21.md" */
23
+ filename: string
24
+ /** Full absolute path */
25
+ filePath: string
26
+ /** The directory used */
27
+ outputDir: string
28
+ /** runId if provided, else filename (deterministic identity) */
29
+ canonicalId: string
30
+ }
31
+
32
+ export function formatReportDate(date: Date): string {
33
+ const year = date.getFullYear()
34
+ const month = String(date.getMonth() + 1).padStart(2, "0")
35
+ const day = String(date.getDate()).padStart(2, "0")
36
+ return `${year}-${month}-${day}`
37
+ }
38
+
39
+ export function sanitizeContractName(name: string): string {
40
+ return name
41
+ .replace(/\s+/g, "-")
42
+ .replace(/[^a-zA-Z0-9-]/g, "")
43
+ .replace(/-+/g, "-")
44
+ .replace(/^-|-$/g, "")
45
+ }
46
+
47
+ export function resolveReportPath(options: ReportPathOptions): ResolvedReportPath {
48
+ const { contractName, date, outputDir, runId } = options
49
+
50
+ if (!contractName || contractName.trim() === "") {
51
+ throw new ReportPathError("contractName must not be empty")
52
+ }
53
+ if (!outputDir || outputDir.trim() === "") {
54
+ throw new ReportPathError("outputDir must not be empty")
55
+ }
56
+
57
+ const resolvedDate = date ?? new Date()
58
+ const dateStr = formatReportDate(resolvedDate)
59
+ const sanitizedName = sanitizeContractName(contractName)
60
+ const filename = `${sanitizedName}-security-audit-${dateStr}.md`
61
+ const filePath = join(outputDir, filename)
62
+ const canonicalId = runId ?? filename
63
+
64
+ return {
65
+ filename,
66
+ filePath,
67
+ outputDir,
68
+ canonicalId,
69
+ }
70
+ }