solidity-argus 0.3.7 → 0.5.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.
Files changed (108) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto"
2
+ import { isRecord } from "../shared/type-guards"
3
+ import { SEVERITY_RANK } from "../shared/validation-constants"
2
4
  import {
3
5
  type AuditEvent,
4
6
  type CanonicalFinding,
@@ -32,36 +34,39 @@ export class ProjectorError extends Error {
32
34
  }
33
35
  }
34
36
 
35
- export const SEVERITY_RANK: Record<CanonicalFinding["severity"], number> = {
36
- Critical: 0,
37
- High: 1,
38
- Medium: 2,
39
- Low: 3,
40
- Informational: 4,
41
- }
42
-
43
- function isRecord(value: unknown): value is Record<string, unknown> {
44
- return typeof value === "object" && value !== null && !Array.isArray(value)
45
- }
46
-
47
37
  function extractScope(payload: unknown): string[] {
48
38
  if (!isRecord(payload) || !Array.isArray(payload.scope)) return []
49
39
  return payload.scope.filter((entry): entry is string => typeof entry === "string")
50
40
  }
51
41
 
42
+ const VALID_PHASES = new Set<string>([
43
+ "reconnaissance",
44
+ "scanning",
45
+ "manual-review",
46
+ "attack-surface",
47
+ "research",
48
+ "testing",
49
+ "reporting",
50
+ "complete",
51
+ ])
52
+
53
+ function isAuditPhase(value: string): value is AuditPhase {
54
+ return VALID_PHASES.has(value)
55
+ }
56
+
52
57
  function extractPhase(payload: unknown): AuditPhase | undefined {
53
58
  if (typeof payload === "string") {
54
- return payload as AuditPhase
59
+ return isAuditPhase(payload) ? payload : undefined
55
60
  }
56
61
 
57
62
  if (!isRecord(payload)) return undefined
58
63
 
59
- if (typeof payload.phase === "string") {
60
- return payload.phase as AuditPhase
64
+ if (typeof payload.phase === "string" && isAuditPhase(payload.phase)) {
65
+ return payload.phase
61
66
  }
62
67
 
63
- if (typeof payload.currentPhase === "string") {
64
- return payload.currentPhase as AuditPhase
68
+ if (typeof payload.currentPhase === "string" && isAuditPhase(payload.currentPhase)) {
69
+ return payload.currentPhase
65
70
  }
66
71
 
67
72
  return undefined
@@ -86,7 +91,8 @@ function resolveToolName(event: AuditEvent, payload: Record<string, unknown>): s
86
91
  }
87
92
 
88
93
  function resolveFindingsCount(payload: Record<string, unknown>): number {
89
- return typeof payload.findingsCount === "number" ? payload.findingsCount : 0
94
+ const count = typeof payload.findingsCount === "number" ? payload.findingsCount : 0
95
+ return Number.isFinite(count) ? Math.max(0, count) : 0
90
96
  }
91
97
 
92
98
  function resolveToolSuccess(payload: Record<string, unknown>): boolean {
@@ -406,12 +412,13 @@ export function projectReportInput(
406
412
  const proxyContracts = extractLatestFromPayload(events, "proxyContracts", asProxyContracts)
407
413
  const patternVersion = extractLatestFromPayload(events, "patternVersion", asString)
408
414
  const skillsLoaded = extractLatestFromPayload(events, "skillsLoaded", asStringArray)
415
+ const unavailableTools = extractLatestFromPayload(events, "unavailableTools", asStringArray)
409
416
 
410
417
  return {
411
418
  run_id: runId,
412
419
  seq: events.at(-1)?.seq ?? 0,
413
420
  session_id: sessionCreated?.session_id ?? events[0]?.session_id ?? "",
414
- tool_call_id: latestFinalized?.tool_call_id ?? "",
421
+ tool_call_id: latestFinalized?.tool_call_id ?? "pending-finalization",
415
422
  source: latestFinalized?.source ?? events[0]?.source ?? "projector",
416
423
  schema_version: latestFinalized?.schema_version ?? events[0]?.schema_version ?? SCHEMA_VERSION,
417
424
  projectDir,
@@ -425,6 +432,7 @@ export function projectReportInput(
425
432
  proxyContracts,
426
433
  patternVersion,
427
434
  skillsLoaded,
435
+ unavailableTools,
428
436
  }
429
437
  }
430
438
 
@@ -1,3 +1,10 @@
1
+ import { isRecord } from "../shared/type-guards"
2
+ import {
3
+ VALID_AGENTS,
4
+ VALID_CONFIDENCES,
5
+ VALID_SEVERITIES,
6
+ VALID_SOURCES,
7
+ } from "../shared/validation-constants"
1
8
  import type {
2
9
  ArgusAgentName,
3
10
  AuditPhase,
@@ -112,6 +119,7 @@ export interface ReportInput {
112
119
  proxyContracts?: ProxyContract[]
113
120
  patternVersion?: string
114
121
  skillsLoaded?: string[]
122
+ unavailableTools?: string[]
115
123
  }
116
124
 
117
125
  function pushRequiredRootStringError(
@@ -142,38 +150,6 @@ function pushRequiredRootNumberError(
142
150
  }
143
151
  }
144
152
 
145
- const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
146
- "Critical",
147
- "High",
148
- "Medium",
149
- "Low",
150
- "Informational",
151
- ])
152
- const VALID_CONFIDENCES: ReadonlySet<CanonicalFinding["confidence"]> = new Set([
153
- "High",
154
- "Medium",
155
- "Low",
156
- ])
157
- const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
158
- "slither",
159
- "manual",
160
- "pattern",
161
- "scvd",
162
- "solodit",
163
- "fuzz",
164
- ])
165
- const VALID_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
166
- "argus",
167
- "sentinel",
168
- "pythia",
169
- "scribe",
170
- "unknown",
171
- ])
172
-
173
- function isRecord(value: unknown): value is Record<string, unknown> {
174
- return typeof value === "object" && value !== null && !Array.isArray(value)
175
- }
176
-
177
153
  function pushRequiredStringError(
178
154
  errors: ValidationError[],
179
155
  obj: Record<string, unknown>,
@@ -28,6 +28,9 @@ export interface Finding {
28
28
  reported_by_agents?: string[]
29
29
  sources?: string[]
30
30
  observation_count?: number
31
+ impact?: string
32
+ recommendation?: string
33
+ proofOfConcept?: string
31
34
  remediation?: string
32
35
  exploitReference?: string
33
36
  provenance?: {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { basename } from "node:path"
3
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
4
+ import { FOUNDRY_NOT_FOUND_MESSAGE } from "../shared/forge-errors"
4
5
  import { findFoundryProjectDir } from "../shared/project-utils"
5
6
  import type { ContractProfile } from "../state/types"
6
7
  import { extractContractInfo, parseExternalCalls } from "../utils/solidity-parser"
@@ -39,7 +40,8 @@ function createFailureProfile(
39
40
  }
40
41
 
41
42
  function addIndicator(indicators: Set<string>, source: string, indicator: string): void {
42
- if (source.includes(indicator.split("uses-")[1] ?? "")) {
43
+ const keyword = indicator.split("uses-")[1]
44
+ if (keyword && source.includes(keyword)) {
43
45
  indicators.add(indicator)
44
46
  }
45
47
  }
@@ -224,11 +226,7 @@ export async function executeContractAnalyzer(
224
226
 
225
227
  const maybeError = error as Error & { code?: string }
226
228
  if (maybeError.code === "ENOENT") {
227
- return createFailureProfile(
228
- contractName,
229
- filePath,
230
- "Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash",
231
- )
229
+ return createFailureProfile(contractName, filePath, FOUNDRY_NOT_FOUND_MESSAGE)
232
230
  }
233
231
 
234
232
  const message = maybeError.message || "contract analysis failed"
@@ -1,4 +1,6 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { classifyForgeError } from "../shared/forge-errors"
3
+ import { runForgeCommand } from "../shared/forge-runner"
2
4
  import { resolveProjectDir } from "../shared/project-utils"
3
5
 
4
6
  type ForgeCoverageArgs = {
@@ -38,8 +40,7 @@ type ForgeCoverageResult = {
38
40
 
39
41
  export type ForgeCommandRunner = (
40
42
  command: string[],
41
- signal: AbortSignal,
42
- cwd: string,
43
+ options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
43
44
  ) => Promise<{ stdout: string; stderr: string; exitCode: number }>
44
45
 
45
46
  const EMPTY_SUMMARY: ForgeCoverageSummary = {
@@ -138,27 +139,6 @@ function parseCoverageReport(output: string): ForgeCoverageReport {
138
139
  return { files, summary }
139
140
  }
140
141
 
141
- const runForgeCommand: ForgeCommandRunner = async (command, signal, cwd) => {
142
- const child = Bun.spawn(command, {
143
- cwd,
144
- stdout: "pipe",
145
- stderr: "pipe",
146
- signal,
147
- })
148
-
149
- const [exitCode, stdout, stderr] = await Promise.all([
150
- child.exited,
151
- new Response(child.stdout).text(),
152
- new Response(child.stderr).text(),
153
- ])
154
-
155
- return {
156
- stdout,
157
- stderr,
158
- exitCode,
159
- }
160
- }
161
-
162
142
  export async function executeForgeCoverage(
163
143
  args: ForgeCoverageArgs,
164
144
  context: ToolContext,
@@ -176,7 +156,10 @@ export async function executeForgeCoverage(
176
156
  })
177
157
 
178
158
  try {
179
- const runResult = await runCommand(["forge", "coverage"], context.abort, normalizedArgs.target)
159
+ const runResult = await runCommand(["forge", "coverage"], {
160
+ signal: context.abort,
161
+ cwd: normalizedArgs.target,
162
+ })
180
163
 
181
164
  if (runResult.exitCode !== 0) {
182
165
  return fail(
@@ -197,18 +180,10 @@ export async function executeForgeCoverage(
197
180
  executionTime: Date.now() - startedAt,
198
181
  }
199
182
  } catch (error) {
200
- if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
201
- return fail("forge coverage aborted")
202
- }
203
-
204
- const maybeError = error as Error & { code?: string }
205
- if (maybeError.code === "ENOENT") {
206
- return fail("Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash")
207
- }
208
- if (maybeError.code === "ETIMEDOUT" || maybeError.message.toLowerCase().includes("timed out")) {
209
- return fail("forge coverage timed out")
210
- }
183
+ const classified = classifyForgeError(error, context, "forge coverage")
184
+ if (classified) return fail(classified)
211
185
 
186
+ const maybeError = error as Error
212
187
  return fail(maybeError.message || "forge coverage failed")
213
188
  }
214
189
  }
@@ -1,4 +1,7 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { classifyForgeError } from "../shared/forge-errors"
3
+ import { runForgeCommand } from "../shared/forge-runner"
4
+ import { assertContained, validateUrlScheme } from "../shared/path-containment"
2
5
  import { resolveProjectDir } from "../shared/project-utils"
3
6
 
4
7
  type ForgeFuzzArgs = {
@@ -47,16 +50,20 @@ export type ForgeFuzzCommandResult = {
47
50
 
48
51
  type RunForgeFuzzCommand = (
49
52
  command: string[],
50
- signal: AbortSignal,
51
- cwd: string,
52
- env: Record<string, string>,
53
+ options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
53
54
  ) => Promise<ForgeFuzzCommandResult>
54
55
 
55
56
  function normalizeArgs(args: ForgeFuzzArgs, context: ToolContext): NormalizedForgeFuzzArgs {
56
57
  const requestedRuns =
57
58
  typeof args.runs === "number" && Number.isFinite(args.runs) ? args.runs : 256
58
59
  const clampedRuns = Math.max(1, Math.min(10000, Math.floor(requestedRuns)))
59
- const target = args.target && args.target !== "." ? args.target : resolveProjectDir(context)
60
+ const projectRoot = resolveProjectDir(context)
61
+ const target =
62
+ args.target && args.target !== "." ? assertContained(args.target, projectRoot) : projectRoot
63
+
64
+ if (args.fork_url && !validateUrlScheme(args.fork_url)) {
65
+ throw new Error(`fork_url must use http:// or https:// scheme, got: "${args.fork_url}"`)
66
+ }
60
67
 
61
68
  return {
62
69
  target,
@@ -177,36 +184,12 @@ function parseCounterexampleLine(line: string):
177
184
  }
178
185
  }
179
186
 
180
- const runForgeFuzzCommand: RunForgeFuzzCommand = async (command, signal, cwd, env) => {
181
- const child = Bun.spawn(command, {
182
- cwd,
183
- stdout: "pipe",
184
- stderr: "pipe",
185
- signal,
186
- env,
187
- })
188
-
189
- const [exitCode, stdout, stderr] = await Promise.all([
190
- child.exited,
191
- new Response(child.stdout).text(),
192
- new Response(child.stderr).text(),
193
- ])
194
-
195
- return {
196
- stdout,
197
- stderr,
198
- exitCode,
199
- }
200
- }
201
-
202
187
  export async function executeForgeFuzz(
203
188
  args: ForgeFuzzArgs,
204
189
  context: ToolContext,
205
- runCommand: RunForgeFuzzCommand = runForgeFuzzCommand,
190
+ runCommand: RunForgeFuzzCommand = runForgeCommand,
206
191
  ): Promise<ForgeFuzzResult> {
207
192
  const startedAt = Date.now()
208
- const normalized = normalizeArgs(args, context)
209
- context.metadata({ title: `Run forge fuzz: ${normalized.target}` })
210
193
 
211
194
  const fail = (error: string): ForgeFuzzResult => ({
212
195
  success: false,
@@ -218,17 +201,12 @@ export async function executeForgeFuzz(
218
201
  })
219
202
 
220
203
  try {
221
- const env = {
222
- ...Bun.env,
223
- FOUNDRY_FUZZ_RUNS: String(normalized.runs),
224
- }
225
-
226
- const runResult = await runCommand(
227
- buildForgeFuzzCommand(normalized),
228
- context.abort,
229
- normalized.target,
230
- env,
231
- )
204
+ const normalized = normalizeArgs(args, context)
205
+ context.metadata({ title: `Run forge fuzz: ${normalized.target}` })
206
+ const runResult = await runCommand(buildForgeFuzzCommand(normalized), {
207
+ signal: context.abort,
208
+ cwd: normalized.target,
209
+ })
232
210
 
233
211
  const lines = `${runResult.stdout}\n${runResult.stderr}`
234
212
  .split(/\r?\n/)
@@ -278,18 +256,10 @@ export async function executeForgeFuzz(
278
256
 
279
257
  return output
280
258
  } catch (error) {
281
- if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
282
- return fail("forge fuzz aborted")
283
- }
284
-
285
- const maybeError = error as Error & { code?: string }
286
- if (maybeError.code === "ENOENT") {
287
- return fail("Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash")
288
- }
289
- if (maybeError.code === "ETIMEDOUT" || maybeError.message.toLowerCase().includes("timed out")) {
290
- return fail("forge fuzz timed out")
291
- }
259
+ const classified = classifyForgeError(error, context, "forge fuzz")
260
+ if (classified) return fail(classified)
292
261
 
262
+ const maybeError = error as Error
293
263
  return fail(maybeError.message || "forge fuzz failed")
294
264
  }
295
265
  }
@@ -1,4 +1,7 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { classifyForgeError } from "../shared/forge-errors"
3
+ import { runForgeCommand } from "../shared/forge-runner"
4
+ import { assertContained, validateUrlScheme } from "../shared/path-containment"
2
5
  import { resolveProjectDir } from "../shared/project-utils"
3
6
  import { extractJson } from "../utils/solidity-parser"
4
7
 
@@ -62,8 +65,7 @@ export type ForgeCommandResult = {
62
65
 
63
66
  type RunForgeCommand = (
64
67
  command: string[],
65
- signal: AbortSignal,
66
- cwd: string,
68
+ options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
67
69
  ) => Promise<ForgeCommandResult>
68
70
 
69
71
  type ForgeTestPayload = {
@@ -277,7 +279,14 @@ function parseCoverage(payload: CoveragePayload): { files: ForgeCoverageFile[] }
277
279
  }
278
280
 
279
281
  function normalizeArgs(args: ForgeTestArgs, context: ToolContext): NormalizedForgeTestArgs {
280
- const target = args.target && args.target !== "." ? args.target : resolveProjectDir(context)
282
+ const projectRoot = resolveProjectDir(context)
283
+ const target =
284
+ args.target && args.target !== "." ? assertContained(args.target, projectRoot) : projectRoot
285
+
286
+ if (args.fork_url && !validateUrlScheme(args.fork_url)) {
287
+ throw new Error(`fork_url must use http:// or https:// scheme, got: "${args.fork_url}"`)
288
+ }
289
+
281
290
  return {
282
291
  target,
283
292
  match_test: args.match_test,
@@ -311,35 +320,12 @@ function buildForgeTestCommand(args: NormalizedForgeTestArgs): string[] {
311
320
  return command
312
321
  }
313
322
 
314
- const runForgeCommand: RunForgeCommand = async (command, signal, cwd) => {
315
- const child = Bun.spawn(command, {
316
- cwd,
317
- stdout: "pipe",
318
- stderr: "pipe",
319
- signal,
320
- })
321
-
322
- const [exitCode, stdout, stderr] = await Promise.all([
323
- child.exited,
324
- new Response(child.stdout).text(),
325
- new Response(child.stderr).text(),
326
- ])
327
-
328
- return {
329
- stdout,
330
- stderr,
331
- exitCode,
332
- }
333
- }
334
-
335
323
  export async function executeForgeTest(
336
324
  args: ForgeTestArgs,
337
325
  context: ToolContext,
338
326
  runCommand: RunForgeCommand = runForgeCommand,
339
327
  ): Promise<ForgeTestResult> {
340
328
  const startedAt = Date.now()
341
- const normalizedArgs = normalizeArgs(args, context)
342
- context.metadata({ title: `Run forge test: ${normalizedArgs.target}` })
343
329
 
344
330
  const fail = (error: string): ForgeTestResult => ({
345
331
  success: false,
@@ -350,11 +336,12 @@ export async function executeForgeTest(
350
336
  })
351
337
 
352
338
  try {
353
- const testResult = await runCommand(
354
- buildForgeTestCommand(normalizedArgs),
355
- context.abort,
356
- normalizedArgs.target,
357
- )
339
+ const normalizedArgs = normalizeArgs(args, context)
340
+ context.metadata({ title: `Run forge test: ${normalizedArgs.target}` })
341
+ const testResult = await runCommand(buildForgeTestCommand(normalizedArgs), {
342
+ signal: context.abort,
343
+ cwd: normalizedArgs.target,
344
+ })
358
345
 
359
346
  let payload: ForgeTestPayload
360
347
  try {
@@ -380,11 +367,10 @@ export async function executeForgeTest(
380
367
  }
381
368
 
382
369
  if (normalizedArgs.coverage) {
383
- const coverageResult = await runCommand(
384
- ["forge", "coverage", "--report", "json"],
385
- context.abort,
386
- normalizedArgs.target,
387
- )
370
+ const coverageResult = await runCommand(["forge", "coverage", "--report", "json"], {
371
+ signal: context.abort,
372
+ cwd: normalizedArgs.target,
373
+ })
388
374
  if (coverageResult.exitCode !== 0) {
389
375
  output.error = coverageResult.stderr.trim() || "forge coverage failed"
390
376
  output.success = false
@@ -406,18 +392,10 @@ export async function executeForgeTest(
406
392
 
407
393
  return output
408
394
  } catch (error) {
409
- if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
410
- return fail("forge test aborted")
411
- }
412
-
413
- const maybeError = error as Error & { code?: string }
414
- if (maybeError.code === "ENOENT") {
415
- return fail("Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash")
416
- }
417
- if (maybeError.code === "ETIMEDOUT" || maybeError.message.toLowerCase().includes("timed out")) {
418
- return fail("forge test timed out")
419
- }
395
+ const classified = classifyForgeError(error, context, "forge test")
396
+ if (classified) return fail(classified)
420
397
 
398
+ const maybeError = error as Error
421
399
  return fail(maybeError.message || "forge test failed")
422
400
  }
423
401
  }
@@ -1,4 +1,6 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { classifyForgeError } from "../shared/forge-errors"
3
+ import { runForgeCommand } from "../shared/forge-runner"
2
4
  import { resolveProjectDir } from "../shared/project-utils"
3
5
 
4
6
  type GasAnalysisArgs = {
@@ -43,8 +45,7 @@ type GasAnalysisResult = {
43
45
 
44
46
  export type ForgeCommandRunner = (
45
47
  command: string[],
46
- signal: AbortSignal,
47
- cwd: string,
48
+ options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
48
49
  ) => Promise<{ stdout: string; stderr: string; exitCode: number }>
49
50
 
50
51
  function toNumber(value: string): number {
@@ -58,7 +59,7 @@ function toNumber(value: string): number {
58
59
 
59
60
  function parseCells(line: string): string[] {
60
61
  return line
61
- .split(/[│┆]/)
62
+ .split(/[│┆|]/)
62
63
  .map((cell) => cell.trim())
63
64
  .filter((cell) => cell.length > 0)
64
65
  }
@@ -75,7 +76,7 @@ function parseGasReport(stdout: string): ContractGasReport[] {
75
76
  let inFunctionSection = false
76
77
 
77
78
  for (const line of lines) {
78
- if (!line.includes("│") && !line.includes("┆")) {
79
+ if (!line.includes("│") && !line.includes("┆") && !line.includes("|")) {
79
80
  continue
80
81
  }
81
82
 
@@ -161,27 +162,6 @@ function normalizeArgs(args: GasAnalysisArgs, context: ToolContext): NormalizedG
161
162
  }
162
163
  }
163
164
 
164
- const runForgeCommand: ForgeCommandRunner = async (command, signal, cwd) => {
165
- const child = Bun.spawn(command, {
166
- cwd,
167
- stdout: "pipe",
168
- stderr: "pipe",
169
- signal,
170
- })
171
-
172
- const [exitCode, stdout, stderr] = await Promise.all([
173
- child.exited,
174
- new Response(child.stdout).text(),
175
- new Response(child.stderr).text(),
176
- ])
177
-
178
- return {
179
- stdout,
180
- stderr,
181
- exitCode,
182
- }
183
- }
184
-
185
165
  export async function executeGasAnalysis(
186
166
  args: GasAnalysisArgs,
187
167
  context: ToolContext,
@@ -200,11 +180,10 @@ export async function executeGasAnalysis(
200
180
  })
201
181
 
202
182
  try {
203
- const forgeResult = await runCommand(
204
- ["forge", "test", "--gas-report"],
205
- context.abort,
206
- normalizedArgs.target,
207
- )
183
+ const forgeResult = await runCommand(["forge", "test", "--gas-report"], {
184
+ signal: context.abort,
185
+ cwd: normalizedArgs.target,
186
+ })
208
187
 
209
188
  const contracts = parseGasReport(forgeResult.stdout)
210
189
  const hotspots = contracts
@@ -234,18 +213,10 @@ export async function executeGasAnalysis(
234
213
 
235
214
  return output
236
215
  } catch (error) {
237
- if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
238
- return fail("forge gas analysis aborted")
239
- }
240
-
241
- const maybeError = error as Error & { code?: string }
242
- if (maybeError.code === "ENOENT") {
243
- return fail("Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash")
244
- }
245
- if (maybeError.code === "ETIMEDOUT" || maybeError.message.toLowerCase().includes("timed out")) {
246
- return fail("forge gas analysis timed out")
247
- }
216
+ const classified = classifyForgeError(error, context, "forge gas analysis")
217
+ if (classified) return fail(classified)
248
218
 
219
+ const maybeError = error as Error
249
220
  return fail(maybeError.message || "forge gas analysis failed")
250
221
  }
251
222
  }