solidity-argus 0.2.0 → 0.3.2

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 (169) hide show
  1. package/AGENTS.md +3 -3
  2. package/README.md +93 -37
  3. package/package.json +34 -7
  4. package/skills/INVENTORY.md +88 -57
  5. package/skills/README.md +26 -23
  6. package/skills/case-studies/beanstalk-governance/SKILL.md +52 -0
  7. package/skills/case-studies/bzx-flash-loan/SKILL.md +53 -0
  8. package/skills/case-studies/cream-finance/SKILL.md +52 -0
  9. package/skills/case-studies/curve-reentrancy/SKILL.md +52 -0
  10. package/skills/case-studies/dao-hack/SKILL.md +51 -0
  11. package/skills/case-studies/euler-finance/SKILL.md +52 -0
  12. package/skills/case-studies/harvest-finance/SKILL.md +52 -0
  13. package/skills/case-studies/level-finance/SKILL.md +51 -0
  14. package/skills/case-studies/mango-markets/SKILL.md +53 -0
  15. package/skills/case-studies/nomad-bridge/SKILL.md +51 -0
  16. package/skills/case-studies/parity-multisig/SKILL.md +55 -0
  17. package/skills/case-studies/poly-network/SKILL.md +51 -0
  18. package/skills/case-studies/rari-fuse/SKILL.md +51 -0
  19. package/skills/case-studies/ronin-bridge/SKILL.md +52 -0
  20. package/skills/case-studies/wormhole-bridge/SKILL.md +51 -0
  21. package/skills/manifests/smartbugs.json +1 -3
  22. package/skills/manifests/sunweb3sec.json +1 -3
  23. package/skills/vulnerability-patterns/access-control/SKILL.md +14 -0
  24. package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +13 -1
  25. package/skills/vulnerability-patterns/assert-violation/SKILL.md +8 -1
  26. package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +12 -1
  27. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +2 -1
  28. package/skills/vulnerability-patterns/cross-chain-bridge-vulnerabilities/SKILL.md +217 -0
  29. package/skills/vulnerability-patterns/default-visibility/SKILL.md +13 -1
  30. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +2 -1
  31. package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +8 -1
  32. package/skills/vulnerability-patterns/dos-revert/SKILL.md +1 -0
  33. package/skills/vulnerability-patterns/erc4626-exchange-rate-manipulation/SKILL.md +64 -0
  34. package/skills/vulnerability-patterns/fee-on-transfer-tokens/SKILL.md +93 -0
  35. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +1 -0
  36. package/skills/vulnerability-patterns/floating-pragma/SKILL.md +8 -1
  37. package/skills/vulnerability-patterns/front-running-attacks/SKILL.md +209 -0
  38. package/skills/vulnerability-patterns/gas-optimization-patterns/SKILL.md +203 -0
  39. package/skills/vulnerability-patterns/governance-attacks/SKILL.md +208 -0
  40. package/skills/vulnerability-patterns/hash-collision/SKILL.md +8 -1
  41. package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +12 -1
  42. package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +8 -1
  43. package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +8 -1
  44. package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +12 -1
  45. package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +7 -1
  46. package/skills/vulnerability-patterns/logic-errors/SKILL.md +10 -0
  47. package/skills/vulnerability-patterns/missing-parameter-bounds/SKILL.md +44 -0
  48. package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +17 -1
  49. package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +12 -1
  50. package/skills/vulnerability-patterns/off-by-one/SKILL.md +7 -1
  51. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +9 -0
  52. package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +8 -1
  53. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +1 -0
  54. package/skills/vulnerability-patterns/proxy-vulnerabilities/SKILL.md +209 -0
  55. package/skills/vulnerability-patterns/reentrancy/SKILL.md +9 -0
  56. package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +8 -1
  57. package/skills/vulnerability-patterns/share-accounting-desynchronization/SKILL.md +44 -0
  58. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +2 -1
  59. package/skills/vulnerability-patterns/stateful-parameter-update-drift/SKILL.md +44 -0
  60. package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +12 -1
  61. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +2 -1
  62. package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +8 -1
  63. package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +8 -1
  64. package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +8 -1
  65. package/skills/vulnerability-patterns/unsafe-erc20-transfers/SKILL.md +132 -0
  66. package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +12 -1
  67. package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +12 -1
  68. package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +11 -1
  69. package/skills/vulnerability-patterns/unused-variables/SKILL.md +8 -1
  70. package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +8 -1
  71. package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +8 -1
  72. package/skills/vulnerability-patterns/weird-tokens/SKILL.md +10 -0
  73. package/skills/vulnerability-patterns/zero-address-misconfiguration/SKILL.md +48 -0
  74. package/src/agents/argus-prompt.ts +34 -7
  75. package/src/agents/pythia-prompt.ts +13 -4
  76. package/src/agents/scribe-prompt.ts +20 -2
  77. package/src/agents/sentinel-prompt.ts +45 -5
  78. package/src/cli/cli-program.ts +29 -26
  79. package/src/cli/commands/check-skills.ts +135 -0
  80. package/src/cli/commands/doctor.ts +48 -26
  81. package/src/cli/commands/init.ts +5 -3
  82. package/src/cli/commands/install.ts +7 -5
  83. package/src/cli/commands/lint-skills.ts +16 -12
  84. package/src/cli/index.ts +5 -5
  85. package/src/cli/types.ts +3 -3
  86. package/src/config/index.ts +1 -1
  87. package/src/config/loader.ts +4 -6
  88. package/src/config/schema.ts +6 -5
  89. package/src/config/types.ts +2 -2
  90. package/src/constants/defaults.ts +2 -0
  91. package/src/create-hooks.ts +145 -34
  92. package/src/create-managers.ts +10 -8
  93. package/src/create-tools.ts +13 -9
  94. package/src/features/background-agent/background-manager.ts +93 -87
  95. package/src/features/background-agent/index.ts +1 -1
  96. package/src/features/context-monitor/context-monitor.ts +3 -3
  97. package/src/features/context-monitor/index.ts +2 -2
  98. package/src/features/error-recovery/session-recovery.ts +2 -4
  99. package/src/features/error-recovery/tool-error-recovery.ts +12 -7
  100. package/src/features/index.ts +5 -5
  101. package/src/features/persistent-state/audit-state-manager.ts +143 -60
  102. package/src/features/persistent-state/global-run-index.ts +38 -0
  103. package/src/features/persistent-state/index.ts +1 -1
  104. package/src/features/persistent-state/run-journal.ts +86 -0
  105. package/src/hooks/config-handler.ts +28 -11
  106. package/src/hooks/context-budget.ts +2 -5
  107. package/src/hooks/event-hook.ts +47 -23
  108. package/src/hooks/hook-system.ts +4 -4
  109. package/src/hooks/index.ts +5 -5
  110. package/src/hooks/knowledge-sync-hook.ts +18 -21
  111. package/src/hooks/recon-context-builder.ts +2 -2
  112. package/src/hooks/safe-create-hook.ts +6 -7
  113. package/src/hooks/system-prompt-hook.ts +18 -1
  114. package/src/hooks/tool-tracking-hook.ts +110 -51
  115. package/src/hooks/types.ts +2 -1
  116. package/src/index.ts +24 -37
  117. package/src/knowledge/retry.ts +22 -22
  118. package/src/knowledge/scvd-client.ts +88 -95
  119. package/src/knowledge/scvd-errors.ts +35 -35
  120. package/src/knowledge/scvd-index.ts +78 -80
  121. package/src/knowledge/scvd-sync.ts +106 -101
  122. package/src/managers/index.ts +1 -1
  123. package/src/managers/types.ts +19 -14
  124. package/src/plugin-interface.ts +7 -9
  125. package/src/shared/binary-utils.ts +44 -35
  126. package/src/shared/deep-merge.ts +55 -36
  127. package/src/shared/file-utils.ts +21 -19
  128. package/src/shared/index.ts +11 -5
  129. package/src/shared/jsonc-parser.ts +123 -28
  130. package/src/shared/logger.ts +16 -3
  131. package/src/shared/project-utils.ts +30 -0
  132. package/src/skills/analysis/cluster.ts +414 -0
  133. package/src/skills/analysis/gates.ts +227 -0
  134. package/src/skills/analysis/index.ts +33 -0
  135. package/src/skills/analysis/normalize.ts +217 -0
  136. package/src/skills/analysis/similarity.ts +224 -0
  137. package/src/skills/argus-skill-resolver.ts +17 -6
  138. package/src/skills/skill-schema.ts +11 -10
  139. package/src/solodit-lifecycle.ts +203 -0
  140. package/src/state/audit-state.ts +8 -8
  141. package/src/state/finding-store.ts +68 -55
  142. package/src/state/types.ts +88 -67
  143. package/src/tools/argus-skill-load-tool.ts +12 -7
  144. package/src/tools/contract-analyzer-tool.ts +142 -77
  145. package/src/tools/forge-coverage-tool.ts +226 -0
  146. package/src/tools/forge-fuzz-tool.ts +127 -127
  147. package/src/tools/forge-test-tool.ts +201 -158
  148. package/src/tools/gas-analysis-tool.ts +264 -0
  149. package/src/tools/pattern-checker-tool.ts +203 -191
  150. package/src/tools/pattern-loader.ts +5 -111
  151. package/src/tools/pattern-schema.ts +3 -0
  152. package/src/tools/proxy-detection-tool.ts +224 -0
  153. package/src/tools/report-generator-tool.ts +305 -206
  154. package/src/tools/slither-tool.ts +266 -218
  155. package/src/tools/solodit-search-tool.ts +235 -119
  156. package/src/tools/sync-knowledge-tool.ts +7 -11
  157. package/src/utils/audit-artifact-detector.ts +28 -29
  158. package/src/utils/dependency-scanner.ts +37 -37
  159. package/src/utils/project-detector.ts +111 -124
  160. package/src/utils/solidity-parser.ts +175 -75
  161. package/skills/patterns/access-control.yaml +0 -31
  162. package/skills/patterns/erc4626.yaml +0 -29
  163. package/skills/patterns/flash-loan.yaml +0 -20
  164. package/skills/patterns/oracle.yaml +0 -30
  165. package/skills/patterns/proxy.yaml +0 -30
  166. package/skills/patterns/reentrancy.yaml +0 -30
  167. package/skills/patterns/signature.yaml +0 -31
  168. package/src/hooks/event-hook-v2.ts +0 -99
  169. package/src/state/plugin-state.ts +0 -14
@@ -1,28 +1,30 @@
1
- import { dirname, basename, join } from "node:path";
2
- import { existsSync } from "node:fs";
3
- import { tool, type ToolContext } from "@opencode-ai/plugin";
4
- import { extractContractInfo } from "../utils/solidity-parser";
5
- import type { ContractProfile } from "../state/types";
1
+ import { existsSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+ import { type ToolContext, tool } from "@opencode-ai/plugin"
4
+ import { findFoundryProjectDir } from "../shared/project-utils"
5
+ import type { ContractProfile } from "../state/types"
6
+ import { extractContractInfo, parseExternalCalls } from "../utils/solidity-parser"
6
7
 
7
8
  type ContractAnalyzerArgs = {
8
- file_path: string;
9
- project_dir?: string;
10
- };
9
+ file_path: string
10
+ project_dir?: string
11
+ }
11
12
 
12
- type ExtractContractInfoFn = (
13
- contractName: string,
14
- projectDir: string
15
- ) => Promise<ContractProfile>;
13
+ type ExtractContractInfoFn = (contractName: string, projectDir: string) => Promise<ContractProfile>
16
14
 
17
15
  type ContractAnalyzerDependencies = {
18
- extractInfo: ExtractContractInfoFn;
19
- };
16
+ extractInfo: ExtractContractInfoFn
17
+ }
20
18
 
21
19
  const DEFAULT_DEPENDENCIES: ContractAnalyzerDependencies = {
22
20
  extractInfo: extractContractInfo,
23
- };
21
+ }
24
22
 
25
- function createFailureProfile(contractName: string, filePath: string, error: string): ContractProfile {
23
+ function createFailureProfile(
24
+ contractName: string,
25
+ filePath: string,
26
+ error: string,
27
+ ): ContractProfile {
26
28
  return {
27
29
  name: contractName,
28
30
  filePath,
@@ -33,141 +35,204 @@ function createFailureProfile(contractName: string, filePath: string, error: str
33
35
  externalCalls: [],
34
36
  riskIndicators: [],
35
37
  error,
36
- };
37
- }
38
-
39
- function findFoundryProjectDir(fromPath: string): string {
40
- let current = dirname(fromPath);
41
-
42
- while (true) {
43
- if (existsSync(join(current, "foundry.toml"))) {
44
- return current;
45
- }
46
-
47
- const parent = dirname(current);
48
- if (parent === current) {
49
- return dirname(fromPath);
50
- }
51
- current = parent;
52
38
  }
53
39
  }
54
40
 
55
41
  function addIndicator(indicators: Set<string>, source: string, indicator: string): void {
56
42
  if (source.includes(indicator.split("uses-")[1] ?? "")) {
57
- indicators.add(indicator);
43
+ indicators.add(indicator)
58
44
  }
59
45
  }
60
46
 
61
47
  function collectRiskIndicators(source: string, existing: string[]): string[] {
62
- const indicators = new Set(existing);
63
- const normalized = source.toLowerCase();
48
+ const indicators = new Set(existing)
49
+ const normalized = source.toLowerCase()
64
50
 
65
- addIndicator(indicators, normalized, "uses-delegatecall");
66
- addIndicator(indicators, normalized, "uses-selfdestruct");
51
+ addIndicator(indicators, normalized, "uses-delegatecall")
52
+ addIndicator(indicators, normalized, "uses-selfdestruct")
67
53
  if (/\bassembly\b/.test(normalized)) {
68
- indicators.add("uses-assembly");
54
+ indicators.add("uses-assembly")
69
55
  }
70
56
  if (/\btx\.origin\b/.test(normalized)) {
71
- indicators.add("uses-tx-origin");
57
+ indicators.add("uses-tx-origin")
58
+ }
59
+ if (/\.call\s*\{\s*value\s*:/.test(normalized)) {
60
+ indicators.add("uses-low-level-value-call")
61
+ }
62
+ if (normalized.includes(".call(")) {
63
+ indicators.add("uses-low-level-call")
64
+ }
65
+ if (normalized.includes("block.timestamp")) {
66
+ indicators.add("uses-block-timestamp")
67
+ }
68
+ if (normalized.includes("block.number")) {
69
+ indicators.add("uses-block-number")
70
+ }
71
+ if (normalized.includes("abi.encodepacked")) {
72
+ indicators.add("uses-abi-encode-packed")
73
+ }
74
+ if (/\becrecover\b/.test(normalized)) {
75
+ indicators.add("uses-ecrecover")
72
76
  }
73
77
 
74
78
  const importLines = source
75
79
  .split("\n")
76
80
  .map((line) => line.trim())
77
- .filter((line) => line.startsWith("import "));
78
- const importText = importLines.join("\n");
81
+ .filter((line) => line.startsWith("import "))
82
+ const importText = importLines.join("\n")
79
83
 
80
84
  const ozChecks: Array<{ pattern: RegExp; indicator: string }> = [
81
85
  { pattern: /\bReentrancyGuard\b/, indicator: "uses-oz-reentrancy-guard" },
82
86
  { pattern: /\bAccessControl\b/, indicator: "uses-oz-access-control" },
83
87
  { pattern: /\bOwnable\b/, indicator: "uses-oz-ownable" },
84
88
  { pattern: /\bPausable\b/, indicator: "uses-oz-pausable" },
85
- ];
89
+ ]
86
90
 
87
91
  for (const check of ozChecks) {
88
92
  if (check.pattern.test(importText)) {
89
- indicators.add(check.indicator);
93
+ indicators.add(check.indicator)
90
94
  }
91
95
  }
92
96
 
93
- return [...indicators];
97
+ return [...indicators]
94
98
  }
95
99
 
96
100
  function withAbort<T>(signal: AbortSignal, operation: Promise<T>): Promise<T> {
97
101
  if (signal.aborted) {
98
- return Promise.reject(new DOMException("Aborted", "AbortError"));
102
+ return Promise.reject(new DOMException("Aborted", "AbortError"))
99
103
  }
100
104
 
101
105
  return new Promise<T>((resolve, reject) => {
102
106
  const onAbort = () => {
103
- reject(new DOMException("Aborted", "AbortError"));
104
- };
107
+ reject(new DOMException("Aborted", "AbortError"))
108
+ }
105
109
 
106
- signal.addEventListener("abort", onAbort, { once: true });
110
+ signal.addEventListener("abort", onAbort, { once: true })
107
111
  operation.then(
108
112
  (value) => {
109
- signal.removeEventListener("abort", onAbort);
110
- resolve(value);
113
+ signal.removeEventListener("abort", onAbort)
114
+ resolve(value)
111
115
  },
112
116
  (error) => {
113
- signal.removeEventListener("abort", onAbort);
114
- reject(error);
115
- }
116
- );
117
- });
117
+ signal.removeEventListener("abort", onAbort)
118
+ reject(error)
119
+ },
120
+ )
121
+ })
118
122
  }
119
123
 
120
124
  export async function executeContractAnalyzer(
121
125
  args: ContractAnalyzerArgs,
122
126
  context: ToolContext,
123
- dependencies: ContractAnalyzerDependencies = DEFAULT_DEPENDENCIES
127
+ dependencies: ContractAnalyzerDependencies = DEFAULT_DEPENDENCIES,
124
128
  ): Promise<ContractProfile> {
125
- const filePath = args.file_path;
126
- const contractName = basename(filePath, ".sol");
129
+ const filePath = args.file_path
130
+ const contractName = basename(filePath, ".sol")
127
131
 
128
- context.metadata({ title: `Analyze contract: ${contractName}` });
132
+ context.metadata({ title: `Analyze contract: ${contractName}` })
129
133
 
130
134
  if (!existsSync(filePath)) {
131
- return createFailureProfile(contractName, filePath, `Contract file not found: ${filePath}`);
135
+ return createFailureProfile(contractName, filePath, `Contract file not found: ${filePath}`)
132
136
  }
133
137
 
134
- const projectDir = args.project_dir ?? findFoundryProjectDir(filePath);
138
+ const projectDir = args.project_dir ?? findFoundryProjectDir(filePath)
135
139
 
136
140
  try {
137
141
  const [contractProfile, sourceText] = await withAbort(
138
142
  context.abort,
139
- Promise.all([
140
- dependencies.extractInfo(contractName, projectDir),
141
- Bun.file(filePath).text(),
142
- ])
143
- );
143
+ Promise.all([dependencies.extractInfo(contractName, projectDir), Bun.file(filePath).text()]),
144
+ )
144
145
 
145
146
  if (context.abort.aborted) {
146
- return createFailureProfile(contractName, filePath, "contract analysis aborted");
147
+ return createFailureProfile(contractName, filePath, "contract analysis aborted")
148
+ }
149
+
150
+ const inheritanceRegex = /contract\s+(\w+)\s+is\s+([^{]+)/g
151
+ let sourceInheritance: string[] = []
152
+ let firstMatchParents: string[] | undefined
153
+ let regexMatch: RegExpExecArray | null = null
154
+
155
+ regexMatch = inheritanceRegex.exec(sourceText)
156
+ while (regexMatch !== null) {
157
+ const matchedName = regexMatch.at(1) ?? ""
158
+ const parents = (regexMatch.at(2) ?? "")
159
+ .split(",")
160
+ .map((p) => p.trim())
161
+ .filter(Boolean)
162
+
163
+ if (!firstMatchParents) {
164
+ firstMatchParents = parents
165
+ }
166
+
167
+ if (matchedName === contractName) {
168
+ sourceInheritance = parents
169
+ break
170
+ }
171
+
172
+ regexMatch = inheritanceRegex.exec(sourceText)
173
+ }
174
+
175
+ if (sourceInheritance.length === 0 && firstMatchParents) {
176
+ sourceInheritance = firstMatchParents
177
+ }
178
+
179
+ const mergedInheritance = [...new Set([...contractProfile.inheritance, ...sourceInheritance])]
180
+ const mergedExternalCalls = [
181
+ ...new Set([...contractProfile.externalCalls, ...parseExternalCalls(sourceText)]),
182
+ ]
183
+
184
+ // Extract modifiers from source text for each function
185
+ const visibilityKeywords = new Set([
186
+ "external",
187
+ "public",
188
+ "internal",
189
+ "private",
190
+ "view",
191
+ "pure",
192
+ "payable",
193
+ "virtual",
194
+ "override",
195
+ "returns",
196
+ ])
197
+ for (const fn of contractProfile.functions) {
198
+ if (!fn.name) continue
199
+ const escapedName = fn.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
200
+ const fnPattern = new RegExp(`function\\s+${escapedName}\\s*\\([^)]*\\)\\s*([^{;]*)`)
201
+ const fnMatch = fnPattern.exec(sourceText)
202
+ if (!fnMatch?.[1]) continue
203
+
204
+ const afterParams = fnMatch[1]
205
+ .replace(/returns\s*\([^)]*\)/g, "")
206
+ .replace(/\([^)]*\)/g, "")
207
+ .trim()
208
+ const tokens = afterParams.match(/\b\w+\b/g) ?? []
209
+ fn.modifiers = tokens.filter((t) => !visibilityKeywords.has(t))
147
210
  }
148
211
 
149
212
  return {
150
213
  ...contractProfile,
151
214
  name: contractProfile.name || contractName,
152
215
  filePath,
216
+ inheritance: mergedInheritance,
217
+ externalCalls: mergedExternalCalls,
153
218
  riskIndicators: collectRiskIndicators(sourceText, contractProfile.riskIndicators),
154
- };
219
+ }
155
220
  } catch (error) {
156
221
  if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
157
- return createFailureProfile(contractName, filePath, "contract analysis aborted");
222
+ return createFailureProfile(contractName, filePath, "contract analysis aborted")
158
223
  }
159
224
 
160
- const maybeError = error as Error & { code?: string };
225
+ const maybeError = error as Error & { code?: string }
161
226
  if (maybeError.code === "ENOENT") {
162
227
  return createFailureProfile(
163
228
  contractName,
164
229
  filePath,
165
- "Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash"
166
- );
230
+ "Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash",
231
+ )
167
232
  }
168
233
 
169
- const message = maybeError.message || "contract analysis failed";
170
- return createFailureProfile(contractName, filePath, message);
234
+ const message = maybeError.message || "contract analysis failed"
235
+ return createFailureProfile(contractName, filePath, message)
171
236
  }
172
237
  }
173
238
 
@@ -178,7 +243,7 @@ export const contractAnalyzerTool = tool({
178
243
  project_dir: tool.schema.string().optional(),
179
244
  },
180
245
  async execute(args, context) {
181
- const contractProfile = await executeContractAnalyzer(args, context);
182
- return JSON.stringify(contractProfile);
246
+ const contractProfile = await executeContractAnalyzer(args, context)
247
+ return JSON.stringify(contractProfile)
183
248
  },
184
- });
249
+ })
@@ -0,0 +1,226 @@
1
+ import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { resolveProjectDir } from "../shared/project-utils"
3
+
4
+ type ForgeCoverageArgs = {
5
+ target?: string
6
+ }
7
+
8
+ type NormalizedForgeCoverageArgs = {
9
+ target: string
10
+ }
11
+
12
+ type ForgeCoverageFile = {
13
+ path: string
14
+ linesPct: number
15
+ statementsPct: number
16
+ branchesPct: number
17
+ functionsPct: number
18
+ }
19
+
20
+ type ForgeCoverageSummary = {
21
+ totalLinesPct: number
22
+ totalStatementsPct: number
23
+ totalBranchesPct: number
24
+ totalFunctionsPct: number
25
+ }
26
+
27
+ type ForgeCoverageReport = {
28
+ files: ForgeCoverageFile[]
29
+ summary: ForgeCoverageSummary
30
+ }
31
+
32
+ type ForgeCoverageResult = {
33
+ success: boolean
34
+ report: ForgeCoverageReport
35
+ executionTime: number
36
+ error?: string
37
+ }
38
+
39
+ export type ForgeCommandRunner = (
40
+ command: string[],
41
+ signal: AbortSignal,
42
+ cwd: string,
43
+ ) => Promise<{ stdout: string; stderr: string; exitCode: number }>
44
+
45
+ const EMPTY_SUMMARY: ForgeCoverageSummary = {
46
+ totalLinesPct: 0,
47
+ totalStatementsPct: 0,
48
+ totalBranchesPct: 0,
49
+ totalFunctionsPct: 0,
50
+ }
51
+
52
+ function normalizeArgs(args: ForgeCoverageArgs, context: ToolContext): NormalizedForgeCoverageArgs {
53
+ return {
54
+ target: args.target ?? resolveProjectDir(context),
55
+ }
56
+ }
57
+
58
+ function parsePercent(input: string): number {
59
+ const match = input.match(/(\d+(?:\.\d+)?)%/)
60
+ if (!match?.[1]) {
61
+ return 0
62
+ }
63
+
64
+ const value = Number.parseFloat(match[1])
65
+ return Number.isFinite(value) ? value : 0
66
+ }
67
+
68
+ function parseTableRow(line: string): string[] {
69
+ if (!line.startsWith("|")) {
70
+ return []
71
+ }
72
+ return line
73
+ .split("|")
74
+ .slice(1, -1)
75
+ .map((item) => item.trim())
76
+ }
77
+
78
+ function isSeparatorRow(cells: string[]): boolean {
79
+ if (cells.length === 0) {
80
+ return false
81
+ }
82
+ return cells.every((cell) => /^-+$/.test(cell))
83
+ }
84
+
85
+ function parseCoverageReport(output: string): ForgeCoverageReport {
86
+ const lines = output
87
+ .split(/\r?\n/)
88
+ .map((line) => line.trim())
89
+ .filter((line) => line.startsWith("|"))
90
+
91
+ const files: ForgeCoverageFile[] = []
92
+ let summary: ForgeCoverageSummary = { ...EMPTY_SUMMARY }
93
+ let hasSummary = false
94
+
95
+ for (const line of lines) {
96
+ const cells = parseTableRow(line)
97
+ if (cells.length < 5) {
98
+ continue
99
+ }
100
+
101
+ if (isSeparatorRow(cells)) {
102
+ continue
103
+ }
104
+
105
+ const label = cells[0]?.toLowerCase()
106
+ if (label === "file") {
107
+ continue
108
+ }
109
+
110
+ const rowValues = {
111
+ linesPct: parsePercent(cells[1] ?? "0"),
112
+ statementsPct: parsePercent(cells[2] ?? "0"),
113
+ branchesPct: parsePercent(cells[3] ?? "0"),
114
+ functionsPct: parsePercent(cells[4] ?? "0"),
115
+ }
116
+
117
+ if (label === "total") {
118
+ summary = {
119
+ totalLinesPct: rowValues.linesPct,
120
+ totalStatementsPct: rowValues.statementsPct,
121
+ totalBranchesPct: rowValues.branchesPct,
122
+ totalFunctionsPct: rowValues.functionsPct,
123
+ }
124
+ hasSummary = true
125
+ continue
126
+ }
127
+
128
+ files.push({
129
+ path: cells[0] ?? "unknown",
130
+ ...rowValues,
131
+ })
132
+ }
133
+
134
+ if (!hasSummary) {
135
+ throw new Error("Invalid tabular output from forge coverage")
136
+ }
137
+
138
+ return { files, summary }
139
+ }
140
+
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
+ export async function executeForgeCoverage(
163
+ args: ForgeCoverageArgs,
164
+ context: ToolContext,
165
+ runCommand: ForgeCommandRunner = runForgeCommand,
166
+ ): Promise<ForgeCoverageResult> {
167
+ const startedAt = Date.now()
168
+ const normalizedArgs = normalizeArgs(args, context)
169
+ context.metadata({ title: `Run forge coverage: ${normalizedArgs.target}` })
170
+
171
+ const fail = (error: string): ForgeCoverageResult => ({
172
+ success: false,
173
+ report: { files: [], summary: { ...EMPTY_SUMMARY } },
174
+ executionTime: Date.now() - startedAt,
175
+ error,
176
+ })
177
+
178
+ try {
179
+ const runResult = await runCommand(["forge", "coverage"], context.abort, normalizedArgs.target)
180
+
181
+ if (runResult.exitCode !== 0) {
182
+ return fail(
183
+ runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}`,
184
+ )
185
+ }
186
+
187
+ let report: ForgeCoverageReport
188
+ try {
189
+ report = parseCoverageReport(runResult.stdout)
190
+ } catch {
191
+ return fail("Invalid tabular output from forge coverage")
192
+ }
193
+
194
+ return {
195
+ success: true,
196
+ report,
197
+ executionTime: Date.now() - startedAt,
198
+ }
199
+ } 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
+ }
211
+
212
+ return fail(maybeError.message || "forge coverage failed")
213
+ }
214
+ }
215
+
216
+ export const forgeCoverageTool = tool({
217
+ description:
218
+ "Run forge coverage analysis and return structured per-file coverage metrics (lines, statements, branches, functions).",
219
+ args: {
220
+ target: tool.schema.string().optional(),
221
+ },
222
+ async execute(args, context) {
223
+ const result = await executeForgeCoverage(args, context)
224
+ return JSON.stringify(result)
225
+ },
226
+ })