solidity-argus 0.3.3 → 0.3.5

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/agents/argus-prompt.ts +67 -8
  3. package/src/agents/scribe-prompt.ts +13 -5
  4. package/src/cli/commands/init.ts +1 -1
  5. package/src/cli/index.ts +0 -0
  6. package/src/config/schema.ts +7 -2
  7. package/src/create-hooks.ts +116 -27
  8. package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
  9. package/src/features/migration/index.ts +14 -0
  10. package/src/features/migration/migration-adapter.ts +151 -0
  11. package/src/features/migration/parity-telemetry.ts +133 -0
  12. package/src/features/persistent-state/audit-state-manager.ts +28 -6
  13. package/src/features/persistent-state/event-sink.ts +175 -0
  14. package/src/features/persistent-state/findings-materializer.ts +51 -0
  15. package/src/features/persistent-state/index.ts +2 -0
  16. package/src/features/persistent-state/run-finalizer.ts +192 -0
  17. package/src/features/persistent-state/run-journal.ts +15 -4
  18. package/src/hooks/agent-tracker.ts +15 -0
  19. package/src/hooks/event-hook.ts +93 -1
  20. package/src/hooks/system-prompt-hook.ts +20 -0
  21. package/src/hooks/tool-tracking-hook.ts +263 -33
  22. package/src/shared/audit-artifact-resolver.ts +75 -0
  23. package/src/shared/drop-diagnostics.ts +108 -0
  24. package/src/shared/file-utils.ts +7 -2
  25. package/src/shared/index.ts +14 -0
  26. package/src/shared/path-root-resolver.ts +34 -0
  27. package/src/shared/report-path-resolver.ts +70 -0
  28. package/src/solodit-lifecycle.ts +86 -7
  29. package/src/state/adapters.ts +262 -0
  30. package/src/state/index.ts +15 -0
  31. package/src/state/projectors.ts +437 -0
  32. package/src/state/schemas.ts +453 -0
  33. package/src/state/types.ts +6 -0
  34. package/src/tools/report-generator-tool.ts +647 -36
  35. package/src/tools/report-preflight.ts +79 -0
  36. package/src/tools/solodit-search-tool.ts +15 -24
  37. package/src/utils/solodit-health.ts +18 -0
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import { stripJsoncComments } from "./jsonc-parser"
4
+ import { defaultRootResolver } from "./path-root-resolver"
4
5
 
5
6
  export type ConfigFormat = "json" | "jsonc" | "none"
6
7
 
@@ -10,9 +11,13 @@ export interface ConfigFileInfo {
10
11
  }
11
12
 
12
13
  export function detectConfigFile(basePath: string): ConfigFileInfo {
14
+ const rootCandidates = defaultRootResolver.readRoots(basePath).flatMap((rootPath) => [
15
+ { path: join(rootPath, "solidity-argus.jsonc"), format: "jsonc" as const },
16
+ { path: join(rootPath, "solidity-argus.json"), format: "json" as const },
17
+ ])
18
+
13
19
  const candidates = [
14
- { path: join(basePath, ".opencode", "solidity-argus.jsonc"), format: "jsonc" as const },
15
- { path: join(basePath, ".opencode", "solidity-argus.json"), format: "json" as const },
20
+ ...rootCandidates,
16
21
  { path: join(basePath, "solidity-argus.jsonc"), format: "jsonc" as const },
17
22
  { path: join(basePath, "solidity-argus.json"), format: "json" as const },
18
23
  ]
@@ -1,3 +1,9 @@
1
+ export {
2
+ ArtifactResolverError,
3
+ type AuditArtifactPaths,
4
+ type AuditArtifactResolver,
5
+ createAuditArtifactResolver,
6
+ } from "./audit-artifact-resolver"
1
7
  export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
2
8
  export { deepMerge } from "./deep-merge"
3
9
  export {
@@ -9,3 +15,11 @@ export {
9
15
  export { stripJsoncComments } from "./jsonc-parser"
10
16
  export { createLogger, type Logger, type LoggerConfig } from "./logger"
11
17
  export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
18
+ export {
19
+ formatReportDate,
20
+ ReportPathError,
21
+ type ReportPathOptions,
22
+ type ResolvedReportPath,
23
+ resolveReportPath,
24
+ sanitizeContractName,
25
+ } from "./report-path-resolver"
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ export interface ArgusRootResolver {
5
+ writeRoot(projectDir: string): string
6
+ readRoots(projectDir: string): string[]
7
+ resolveReadPath(projectDir: string, relativePath: string): string | null
8
+ }
9
+
10
+ class DefaultArgusRootResolver implements ArgusRootResolver {
11
+ writeRoot(projectDir: string): string {
12
+ return join(projectDir, ".argus")
13
+ }
14
+
15
+ readRoots(projectDir: string): string[] {
16
+ return [this.writeRoot(projectDir), join(projectDir, ".opencode")]
17
+ }
18
+
19
+ resolveReadPath(projectDir: string, relativePath: string): string | null {
20
+ for (const root of this.readRoots(projectDir)) {
21
+ const candidatePath = join(root, relativePath)
22
+ if (existsSync(candidatePath)) {
23
+ return candidatePath
24
+ }
25
+ }
26
+ return null
27
+ }
28
+ }
29
+
30
+ export function createArgusRootResolver(): ArgusRootResolver {
31
+ return new DefaultArgusRootResolver()
32
+ }
33
+
34
+ export const defaultRootResolver: ArgusRootResolver = createArgusRootResolver()
@@ -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.getUTCFullYear()
34
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0")
35
+ const day = String(date.getUTCDate()).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
+ }
@@ -6,6 +6,15 @@ interface SoloditChildProcess {
6
6
  kill(signal?: number): void
7
7
  unref(): void
8
8
  readonly exited: Promise<number | null>
9
+ readonly pid?: number
10
+ }
11
+
12
+ export type LifecycleState = "starting" | "running" | "failed" | "stopped"
13
+
14
+ export interface LifecycleStatus {
15
+ state: LifecycleState
16
+ error?: string
17
+ pid?: number
9
18
  }
10
19
 
11
20
  let soloditChild: SoloditChildProcess | null = null
@@ -15,6 +24,9 @@ let isRestarting = false
15
24
  /** Whether the Solodit MCP server is currently available for tool calls. */
16
25
  export let soloditAvailable = false
17
26
 
27
+ let lifecycleState: LifecycleState = "stopped"
28
+ let lifecycleError: string | undefined
29
+
18
30
  const DEFAULT_RESTART_SETTLE_MS = 2_000
19
31
  const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
20
32
  const HEALTH_CHECK_INTERVAL_MS = 60_000
@@ -43,10 +55,37 @@ export function _setTestConfig(config: {
43
55
  if (config.spawnFn !== undefined) spawnFn = config.spawnFn
44
56
  }
45
57
 
58
+ /** Returns the current lifecycle status of the Solodit MCP server. */
59
+ export function getLifecycleStatus(): LifecycleStatus {
60
+ const status: LifecycleStatus = { state: lifecycleState }
61
+ if (lifecycleError) status.error = lifecycleError
62
+ if (soloditChild?.pid !== undefined) status.pid = soloditChild.pid
63
+ return status
64
+ }
65
+
66
+ function classifySpawnError(err: unknown, port: number): string {
67
+ const error = err instanceof Error ? err : new Error(String(err))
68
+ const code = (error as NodeJS.ErrnoException).code
69
+ if (code === "EADDRINUSE") {
70
+ return `Port ${port} already in use — cannot spawn Solodit MCP (EADDRINUSE)`
71
+ }
72
+ if (code === "ENOENT") {
73
+ return `Solodit MCP binary not found — ensure npx and @lyuboslavlyubenov/solodit-mcp are available (ENOENT)`
74
+ }
75
+ return `Failed to spawn Solodit MCP on port ${port}: ${error.message}`
76
+ }
77
+
46
78
  function spawnSoloditChild(port: number): SoloditChildProcess {
47
- const child = spawnFn(port)
48
- child.unref()
49
- return child
79
+ try {
80
+ const child = spawnFn(port)
81
+ child.unref()
82
+ return child
83
+ } catch (err) {
84
+ const message = classifySpawnError(err, port)
85
+ lifecycleState = "failed"
86
+ lifecycleError = message
87
+ throw new Error(message)
88
+ }
50
89
  }
51
90
 
52
91
  function trackChildExit(child: SoloditChildProcess): void {
@@ -64,6 +103,16 @@ function trackChildExit(child: SoloditChildProcess): void {
64
103
  async function restartSoloditMcp(port: number): Promise<boolean> {
65
104
  const logger = createLogger()
66
105
 
106
+ // Pre-check: if existing instance recovered, skip restart entirely
107
+ const preCheck = await checkSoloditHealth(port, true)
108
+ if (preCheck.reachable) {
109
+ soloditAvailable = true
110
+ lifecycleState = "running"
111
+ lifecycleError = undefined
112
+ logger.info("Solodit MCP already healthy — skipping restart")
113
+ return true
114
+ }
115
+
67
116
  if (soloditChild) {
68
117
  try {
69
118
  soloditChild.kill()
@@ -74,10 +123,16 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
74
123
  }
75
124
 
76
125
  try {
126
+ lifecycleState = "starting"
127
+ lifecycleError = undefined
77
128
  soloditChild = spawnSoloditChild(port)
78
129
  trackChildExit(soloditChild)
79
130
  } catch (err) {
80
- logger.warn("Failed to spawn Solodit MCP:", err)
131
+ const message = err instanceof Error ? err.message : String(err)
132
+ logger.warn(`Solodit MCP spawn failed: ${message}`)
133
+ lifecycleState = "failed"
134
+ lifecycleError = message
135
+ soloditAvailable = false
81
136
  return false
82
137
  }
83
138
 
@@ -99,10 +154,14 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
99
154
 
100
155
  if (result.success) {
101
156
  soloditAvailable = true
157
+ lifecycleState = "running"
158
+ lifecycleError = undefined
102
159
  logger.info("Solodit MCP restarted successfully")
103
160
  return true
104
161
  }
105
162
 
163
+ lifecycleState = "failed"
164
+ lifecycleError = "Solodit MCP not reachable after restart attempts"
106
165
  logger.warn("Solodit MCP restart failed — will retry next cycle")
107
166
  return false
108
167
  }
@@ -115,6 +174,8 @@ export async function _runMonitoringCycle(port: number): Promise<void> {
115
174
  if (health.reachable) {
116
175
  if (!soloditAvailable) {
117
176
  soloditAvailable = true
177
+ lifecycleState = "running"
178
+ lifecycleError = undefined
118
179
  logger.info("Solodit MCP recovered — now available")
119
180
  }
120
181
  } else if (soloditAvailable) {
@@ -155,6 +216,8 @@ export function _resetSoloditState(): void {
155
216
  stopSoloditMonitoring()
156
217
  soloditAvailable = false
157
218
  isRestarting = false
219
+ lifecycleState = "stopped"
220
+ lifecycleError = undefined
158
221
  restartSettleMs = DEFAULT_RESTART_SETTLE_MS
159
222
  retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS
160
223
  spawnFn = defaultSpawnFn
@@ -170,17 +233,30 @@ export function _resetSoloditState(): void {
170
233
 
171
234
  export async function startSoloditMcp(port: number): Promise<void> {
172
235
  const logger = createLogger()
236
+ lifecycleState = "starting"
237
+ lifecycleError = undefined
173
238
 
174
239
  const health = await checkSoloditHealth(port, true)
175
240
  if (health.reachable) {
176
241
  logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
177
242
  soloditAvailable = true
243
+ lifecycleState = "running"
178
244
  startMonitoring(port)
179
245
  return
180
246
  }
181
247
 
182
- soloditChild = spawnSoloditChild(port)
183
- trackChildExit(soloditChild)
248
+ try {
249
+ soloditChild = spawnSoloditChild(port)
250
+ trackChildExit(soloditChild)
251
+ } catch (err) {
252
+ const message = err instanceof Error ? err.message : String(err)
253
+ logger.warn(`Solodit MCP startup failed: ${message}`)
254
+ lifecycleState = "failed"
255
+ lifecycleError = message
256
+ soloditAvailable = false
257
+ startMonitoring(port)
258
+ return
259
+ }
184
260
 
185
261
  const deadline = AbortSignal.timeout(5000)
186
262
  const delays = [1000, 2000]
@@ -191,12 +267,15 @@ export async function startSoloditMcp(port: number): Promise<void> {
191
267
  const healthResult = await checkSoloditHealth(port, true)
192
268
  if (healthResult.reachable) {
193
269
  soloditAvailable = true
270
+ lifecycleState = "running"
194
271
  logger.debug(`Solodit MCP healthy on port ${port}`)
195
272
  break
196
273
  }
197
274
  }
198
275
  if (!soloditAvailable) {
199
- logger.warn(`Solodit MCP not reachable after startup — monitoring will retry`)
276
+ lifecycleState = "failed"
277
+ lifecycleError = "Solodit MCP not reachable after startup — monitoring will retry"
278
+ logger.warn(lifecycleError)
200
279
  }
201
280
 
202
281
  startMonitoring(port)
@@ -0,0 +1,262 @@
1
+ import {
2
+ type CanonicalFinding,
3
+ SCHEMA_VERSION,
4
+ type ValidationError,
5
+ validateCanonicalFinding,
6
+ } from "./schemas"
7
+ import type { AuditPhase, Finding, FindingSeverity } from "./types"
8
+
9
+ export interface Diagnostic {
10
+ level: "warn" | "error"
11
+ code: string
12
+ message: string
13
+ field?: string
14
+ }
15
+
16
+ export type AdapterResult<T> = { data: T; diagnostics: Diagnostic[] }
17
+
18
+ const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
19
+ "Critical",
20
+ "High",
21
+ "Medium",
22
+ "Low",
23
+ "Informational",
24
+ ])
25
+ const VALID_CONFIDENCES: ReadonlySet<CanonicalFinding["confidence"]> = new Set([
26
+ "High",
27
+ "Medium",
28
+ "Low",
29
+ ])
30
+ const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
31
+ "slither",
32
+ "manual",
33
+ "pattern",
34
+ "scvd",
35
+ "solodit",
36
+ "fuzz",
37
+ ])
38
+
39
+ const KNOWN_INPUT_FIELDS = new Set([
40
+ "id",
41
+ "check",
42
+ "detector",
43
+ "severity",
44
+ "confidence",
45
+ "description",
46
+ "impact",
47
+ "first_markdown_element",
48
+ "file",
49
+ "lines",
50
+ "line",
51
+ "line_start",
52
+ "line_end",
53
+ "source",
54
+ "remediation",
55
+ "exploitReference",
56
+ "provenance",
57
+ "run_id",
58
+ "seq",
59
+ "session_id",
60
+ "tool_call_id",
61
+ "schema_version",
62
+ "elements",
63
+ ])
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value)
67
+ }
68
+
69
+ function normalizeSeverity(value: unknown): CanonicalFinding["severity"] {
70
+ if (typeof value !== "string") return "Informational"
71
+ const lower = value.toLowerCase()
72
+ const map: Record<string, CanonicalFinding["severity"]> = {
73
+ critical: "Critical",
74
+ high: "High",
75
+ medium: "Medium",
76
+ low: "Low",
77
+ informational: "Informational",
78
+ info: "Informational",
79
+ }
80
+ return map[lower] ?? "Informational"
81
+ }
82
+
83
+ function normalizeConfidence(value: unknown): CanonicalFinding["confidence"] {
84
+ if (typeof value !== "string") return "Low"
85
+ const lower = value.toLowerCase()
86
+ const map: Record<string, CanonicalFinding["confidence"]> = {
87
+ high: "High",
88
+ medium: "Medium",
89
+ low: "Low",
90
+ }
91
+ return map[lower] ?? "Low"
92
+ }
93
+
94
+ function normalizeLines(
95
+ value: unknown,
96
+ input: Record<string, unknown>,
97
+ ): [number, number] | undefined {
98
+ if (
99
+ Array.isArray(value) &&
100
+ value.length === 2 &&
101
+ typeof value[0] === "number" &&
102
+ typeof value[1] === "number"
103
+ ) {
104
+ return [value[0], value[1]]
105
+ }
106
+
107
+ if (typeof input.line === "number") {
108
+ return [input.line, input.line]
109
+ }
110
+
111
+ if (typeof input.line_start === "number" && typeof input.line_end === "number") {
112
+ return [input.line_start, input.line_end]
113
+ }
114
+
115
+ return undefined
116
+ }
117
+
118
+ function slitherElementFileAlias(input: Record<string, unknown>): string | undefined {
119
+ if (!Array.isArray(input.elements) || input.elements.length === 0) {
120
+ return undefined
121
+ }
122
+
123
+ const first = input.elements[0]
124
+ if (!isRecord(first)) return undefined
125
+ const sourceMapping = first.source_mapping
126
+ if (!isRecord(sourceMapping)) return undefined
127
+ const filenameRelative = sourceMapping.filename_relative
128
+ return typeof filenameRelative === "string" && filenameRelative.length > 0
129
+ ? filenameRelative
130
+ : undefined
131
+ }
132
+
133
+ function pushValidationDiagnostics(errors: ValidationError[]): Diagnostic[] {
134
+ return errors.map((error) => ({
135
+ level: "error",
136
+ code: `validation.${error.code}`,
137
+ message: error.message,
138
+ field: error.field,
139
+ }))
140
+ }
141
+
142
+ export function normalizeToCanonicalFinding(
143
+ raw: Finding | Record<string, unknown>,
144
+ runId: string,
145
+ seq: number,
146
+ ): AdapterResult<CanonicalFinding> {
147
+ const diagnostics: Diagnostic[] = []
148
+ const input = isRecord(raw) ? raw : {}
149
+
150
+ for (const key of Object.keys(input)) {
151
+ if (!KNOWN_INPUT_FIELDS.has(key)) {
152
+ diagnostics.push({
153
+ level: "warn",
154
+ code: "field.dropped",
155
+ message: `Dropped unknown field: ${key}`,
156
+ field: key,
157
+ })
158
+ }
159
+ }
160
+
161
+ const check =
162
+ typeof input.check === "string" && input.check.length > 0
163
+ ? input.check
164
+ : typeof input.detector === "string" && input.detector.length > 0
165
+ ? input.detector
166
+ : ""
167
+
168
+ const description =
169
+ typeof input.description === "string" && input.description.length > 0
170
+ ? input.description
171
+ : typeof input.impact === "string" && input.impact.length > 0
172
+ ? input.impact
173
+ : typeof input.first_markdown_element === "string" &&
174
+ input.first_markdown_element.length > 0
175
+ ? input.first_markdown_element
176
+ : check
177
+
178
+ const file =
179
+ typeof input.file === "string" && input.file.length > 0
180
+ ? input.file
181
+ : (slitherElementFileAlias(input) ?? "")
182
+
183
+ const lines = normalizeLines(input.lines, input)
184
+ const severity = normalizeSeverity(input.severity)
185
+ const confidence = normalizeConfidence(input.confidence)
186
+ const source =
187
+ typeof input.source === "string" &&
188
+ VALID_SOURCES.has(input.source as CanonicalFinding["source"])
189
+ ? (input.source as CanonicalFinding["source"])
190
+ : "manual"
191
+
192
+ const canonical: CanonicalFinding = {
193
+ id:
194
+ typeof input.id === "string" && input.id.length > 0
195
+ ? input.id
196
+ : `${check}:${file}:${lines?.[0] ?? 0}`,
197
+ check,
198
+ severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
199
+ confidence: VALID_CONFIDENCES.has(confidence) ? confidence : "Low",
200
+ description,
201
+ file,
202
+ lines: lines ?? [0, 0],
203
+ source,
204
+ remediation: typeof input.remediation === "string" ? input.remediation : undefined,
205
+ exploitReference:
206
+ typeof input.exploitReference === "string" ? input.exploitReference : undefined,
207
+ provenance: isRecord(input.provenance)
208
+ ? {
209
+ timestamp:
210
+ typeof input.provenance.timestamp === "number"
211
+ ? input.provenance.timestamp
212
+ : Date.now(),
213
+ toolVersion:
214
+ typeof input.provenance.toolVersion === "string"
215
+ ? input.provenance.toolVersion
216
+ : undefined,
217
+ phase:
218
+ typeof input.provenance.phase === "string"
219
+ ? (input.provenance.phase as AuditPhase)
220
+ : undefined,
221
+ }
222
+ : undefined,
223
+ run_id: runId,
224
+ seq,
225
+ schema_version:
226
+ typeof input.schema_version === "string" && input.schema_version.length > 0
227
+ ? input.schema_version
228
+ : SCHEMA_VERSION,
229
+ }
230
+
231
+ const validation = validateCanonicalFinding(canonical)
232
+ if (!validation.success) {
233
+ diagnostics.push(...pushValidationDiagnostics(validation.errors))
234
+ }
235
+
236
+ return { data: canonical, diagnostics }
237
+ }
238
+
239
+ export function normalizeLegacyFindingsArray(
240
+ raw: unknown[],
241
+ runId: string,
242
+ ): { findings: CanonicalFinding[]; diagnostics: Diagnostic[] } {
243
+ const findings: CanonicalFinding[] = []
244
+ const diagnostics: Diagnostic[] = []
245
+
246
+ for (const [index, item] of raw.entries()) {
247
+ const normalized = normalizeToCanonicalFinding(isRecord(item) ? item : {}, runId, index + 1)
248
+ diagnostics.push(
249
+ ...normalized.diagnostics.map((d) => ({
250
+ ...d,
251
+ message: `[index:${index}] ${d.message}`,
252
+ })),
253
+ )
254
+
255
+ const hasErrors = normalized.diagnostics.some((d) => d.level === "error")
256
+ if (!hasErrors) {
257
+ findings.push(normalized.data)
258
+ }
259
+ }
260
+
261
+ return { findings, diagnostics }
262
+ }