solidity-argus 0.2.0 → 0.3.0
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.
- package/AGENTS.md +3 -3
- package/README.md +93 -37
- package/package.json +33 -7
- package/skills/INVENTORY.md +88 -57
- package/skills/README.md +26 -23
- package/skills/case-studies/beanstalk-governance/SKILL.md +52 -0
- package/skills/case-studies/bzx-flash-loan/SKILL.md +53 -0
- package/skills/case-studies/cream-finance/SKILL.md +52 -0
- package/skills/case-studies/curve-reentrancy/SKILL.md +52 -0
- package/skills/case-studies/dao-hack/SKILL.md +51 -0
- package/skills/case-studies/euler-finance/SKILL.md +52 -0
- package/skills/case-studies/harvest-finance/SKILL.md +52 -0
- package/skills/case-studies/level-finance/SKILL.md +51 -0
- package/skills/case-studies/mango-markets/SKILL.md +53 -0
- package/skills/case-studies/nomad-bridge/SKILL.md +51 -0
- package/skills/case-studies/parity-multisig/SKILL.md +55 -0
- package/skills/case-studies/poly-network/SKILL.md +51 -0
- package/skills/case-studies/rari-fuse/SKILL.md +51 -0
- package/skills/case-studies/ronin-bridge/SKILL.md +52 -0
- package/skills/case-studies/wormhole-bridge/SKILL.md +51 -0
- package/skills/manifests/smartbugs.json +1 -3
- package/skills/manifests/sunweb3sec.json +1 -3
- package/skills/vulnerability-patterns/access-control/SKILL.md +14 -0
- package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +13 -1
- package/skills/vulnerability-patterns/assert-violation/SKILL.md +8 -1
- package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +12 -1
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +2 -1
- package/skills/vulnerability-patterns/cross-chain-bridge-vulnerabilities/SKILL.md +217 -0
- package/skills/vulnerability-patterns/default-visibility/SKILL.md +13 -1
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +2 -1
- package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +8 -1
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +1 -0
- package/skills/vulnerability-patterns/erc4626-exchange-rate-manipulation/SKILL.md +64 -0
- package/skills/vulnerability-patterns/fee-on-transfer-tokens/SKILL.md +93 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +1 -0
- package/skills/vulnerability-patterns/floating-pragma/SKILL.md +8 -1
- package/skills/vulnerability-patterns/front-running-attacks/SKILL.md +209 -0
- package/skills/vulnerability-patterns/gas-optimization-patterns/SKILL.md +203 -0
- package/skills/vulnerability-patterns/governance-attacks/SKILL.md +208 -0
- package/skills/vulnerability-patterns/hash-collision/SKILL.md +8 -1
- package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +12 -1
- package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +8 -1
- package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +8 -1
- package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +12 -1
- package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +7 -1
- package/skills/vulnerability-patterns/logic-errors/SKILL.md +10 -0
- package/skills/vulnerability-patterns/missing-parameter-bounds/SKILL.md +44 -0
- package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +17 -1
- package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +12 -1
- package/skills/vulnerability-patterns/off-by-one/SKILL.md +7 -1
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +9 -0
- package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +8 -1
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +1 -0
- package/skills/vulnerability-patterns/proxy-vulnerabilities/SKILL.md +209 -0
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +9 -0
- package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +8 -1
- package/skills/vulnerability-patterns/share-accounting-desynchronization/SKILL.md +44 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +2 -1
- package/skills/vulnerability-patterns/stateful-parameter-update-drift/SKILL.md +44 -0
- package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +12 -1
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +2 -1
- package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +8 -1
- package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +8 -1
- package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +8 -1
- package/skills/vulnerability-patterns/unsafe-erc20-transfers/SKILL.md +132 -0
- package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +12 -1
- package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +12 -1
- package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +11 -1
- package/skills/vulnerability-patterns/unused-variables/SKILL.md +8 -1
- package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +8 -1
- package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +8 -1
- package/skills/vulnerability-patterns/weird-tokens/SKILL.md +10 -0
- package/skills/vulnerability-patterns/zero-address-misconfiguration/SKILL.md +48 -0
- package/src/agents/argus-prompt.ts +24 -7
- package/src/agents/pythia-prompt.ts +3 -4
- package/src/agents/scribe-prompt.ts +7 -2
- package/src/agents/sentinel-prompt.ts +32 -3
- package/src/cli/cli-program.ts +29 -26
- package/src/cli/commands/check-skills.ts +135 -0
- package/src/cli/commands/doctor.ts +48 -26
- package/src/cli/commands/init.ts +5 -3
- package/src/cli/commands/install.ts +7 -5
- package/src/cli/commands/lint-skills.ts +16 -12
- package/src/cli/index.ts +5 -5
- package/src/cli/types.ts +3 -3
- package/src/config/index.ts +1 -1
- package/src/config/loader.ts +4 -6
- package/src/config/schema.ts +4 -5
- package/src/config/types.ts +2 -2
- package/src/constants/defaults.ts +2 -0
- package/src/create-hooks.ts +145 -34
- package/src/create-managers.ts +10 -8
- package/src/create-tools.ts +13 -9
- package/src/features/background-agent/background-manager.ts +93 -87
- package/src/features/background-agent/index.ts +1 -1
- package/src/features/context-monitor/context-monitor.ts +3 -3
- package/src/features/context-monitor/index.ts +2 -2
- package/src/features/error-recovery/session-recovery.ts +2 -4
- package/src/features/error-recovery/tool-error-recovery.ts +12 -7
- package/src/features/index.ts +5 -5
- package/src/features/persistent-state/audit-state-manager.ts +143 -60
- package/src/features/persistent-state/global-run-index.ts +38 -0
- package/src/features/persistent-state/index.ts +1 -1
- package/src/features/persistent-state/run-journal.ts +86 -0
- package/src/hooks/config-handler.ts +28 -11
- package/src/hooks/context-budget.ts +2 -5
- package/src/hooks/event-hook.ts +47 -23
- package/src/hooks/hook-system.ts +4 -4
- package/src/hooks/index.ts +5 -5
- package/src/hooks/knowledge-sync-hook.ts +18 -21
- package/src/hooks/recon-context-builder.ts +2 -2
- package/src/hooks/safe-create-hook.ts +6 -7
- package/src/hooks/tool-tracking-hook.ts +104 -50
- package/src/hooks/types.ts +2 -1
- package/src/index.ts +23 -36
- package/src/knowledge/retry.ts +22 -22
- package/src/knowledge/scvd-client.ts +88 -95
- package/src/knowledge/scvd-errors.ts +35 -35
- package/src/knowledge/scvd-index.ts +78 -80
- package/src/knowledge/scvd-sync.ts +106 -101
- package/src/managers/index.ts +1 -1
- package/src/managers/types.ts +19 -14
- package/src/plugin-interface.ts +7 -9
- package/src/shared/binary-utils.ts +44 -35
- package/src/shared/deep-merge.ts +55 -36
- package/src/shared/file-utils.ts +21 -19
- package/src/shared/index.ts +11 -5
- package/src/shared/jsonc-parser.ts +123 -28
- package/src/shared/logger.ts +16 -3
- package/src/shared/project-utils.ts +30 -0
- package/src/skills/analysis/cluster.ts +414 -0
- package/src/skills/analysis/gates.ts +227 -0
- package/src/skills/analysis/index.ts +33 -0
- package/src/skills/analysis/normalize.ts +217 -0
- package/src/skills/analysis/similarity.ts +224 -0
- package/src/skills/argus-skill-resolver.ts +17 -6
- package/src/skills/skill-schema.ts +11 -10
- package/src/solodit-lifecycle.ts +202 -0
- package/src/state/audit-state.ts +8 -8
- package/src/state/finding-store.ts +68 -55
- package/src/state/types.ts +88 -67
- package/src/tools/argus-skill-load-tool.ts +12 -7
- package/src/tools/contract-analyzer-tool.ts +60 -77
- package/src/tools/forge-coverage-tool.ts +226 -0
- package/src/tools/forge-fuzz-tool.ts +127 -127
- package/src/tools/forge-test-tool.ts +153 -157
- package/src/tools/gas-analysis-tool.ts +264 -0
- package/src/tools/pattern-checker-tool.ts +185 -190
- package/src/tools/pattern-loader.ts +5 -111
- package/src/tools/proxy-detection-tool.ts +224 -0
- package/src/tools/report-generator-tool.ts +268 -200
- package/src/tools/slither-tool.ts +266 -218
- package/src/tools/solodit-search-tool.ts +216 -119
- package/src/tools/sync-knowledge-tool.ts +7 -11
- package/src/utils/audit-artifact-detector.ts +28 -29
- package/src/utils/dependency-scanner.ts +37 -37
- package/src/utils/project-detector.ts +111 -124
- package/src/utils/solidity-parser.ts +103 -74
- package/skills/patterns/access-control.yaml +0 -31
- package/skills/patterns/erc4626.yaml +0 -29
- package/skills/patterns/flash-loan.yaml +0 -20
- package/skills/patterns/oracle.yaml +0 -30
- package/skills/patterns/proxy.yaml +0 -30
- package/skills/patterns/reentrancy.yaml +0 -30
- package/skills/patterns/signature.yaml +0 -31
- package/src/hooks/event-hook-v2.ts +0 -99
- package/src/state/plugin-state.ts +0 -14
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 } 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(
|
|
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,117 +35,98 @@ 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")
|
|
72
58
|
}
|
|
73
59
|
|
|
74
60
|
const importLines = source
|
|
75
61
|
.split("\n")
|
|
76
62
|
.map((line) => line.trim())
|
|
77
|
-
.filter((line) => line.startsWith("import "))
|
|
78
|
-
const importText = importLines.join("\n")
|
|
63
|
+
.filter((line) => line.startsWith("import "))
|
|
64
|
+
const importText = importLines.join("\n")
|
|
79
65
|
|
|
80
66
|
const ozChecks: Array<{ pattern: RegExp; indicator: string }> = [
|
|
81
67
|
{ pattern: /\bReentrancyGuard\b/, indicator: "uses-oz-reentrancy-guard" },
|
|
82
68
|
{ pattern: /\bAccessControl\b/, indicator: "uses-oz-access-control" },
|
|
83
69
|
{ pattern: /\bOwnable\b/, indicator: "uses-oz-ownable" },
|
|
84
70
|
{ pattern: /\bPausable\b/, indicator: "uses-oz-pausable" },
|
|
85
|
-
]
|
|
71
|
+
]
|
|
86
72
|
|
|
87
73
|
for (const check of ozChecks) {
|
|
88
74
|
if (check.pattern.test(importText)) {
|
|
89
|
-
indicators.add(check.indicator)
|
|
75
|
+
indicators.add(check.indicator)
|
|
90
76
|
}
|
|
91
77
|
}
|
|
92
78
|
|
|
93
|
-
return [...indicators]
|
|
79
|
+
return [...indicators]
|
|
94
80
|
}
|
|
95
81
|
|
|
96
82
|
function withAbort<T>(signal: AbortSignal, operation: Promise<T>): Promise<T> {
|
|
97
83
|
if (signal.aborted) {
|
|
98
|
-
return Promise.reject(new DOMException("Aborted", "AbortError"))
|
|
84
|
+
return Promise.reject(new DOMException("Aborted", "AbortError"))
|
|
99
85
|
}
|
|
100
86
|
|
|
101
87
|
return new Promise<T>((resolve, reject) => {
|
|
102
88
|
const onAbort = () => {
|
|
103
|
-
reject(new DOMException("Aborted", "AbortError"))
|
|
104
|
-
}
|
|
89
|
+
reject(new DOMException("Aborted", "AbortError"))
|
|
90
|
+
}
|
|
105
91
|
|
|
106
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
92
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
107
93
|
operation.then(
|
|
108
94
|
(value) => {
|
|
109
|
-
signal.removeEventListener("abort", onAbort)
|
|
110
|
-
resolve(value)
|
|
95
|
+
signal.removeEventListener("abort", onAbort)
|
|
96
|
+
resolve(value)
|
|
111
97
|
},
|
|
112
98
|
(error) => {
|
|
113
|
-
signal.removeEventListener("abort", onAbort)
|
|
114
|
-
reject(error)
|
|
115
|
-
}
|
|
116
|
-
)
|
|
117
|
-
})
|
|
99
|
+
signal.removeEventListener("abort", onAbort)
|
|
100
|
+
reject(error)
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
})
|
|
118
104
|
}
|
|
119
105
|
|
|
120
106
|
export async function executeContractAnalyzer(
|
|
121
107
|
args: ContractAnalyzerArgs,
|
|
122
108
|
context: ToolContext,
|
|
123
|
-
dependencies: ContractAnalyzerDependencies = DEFAULT_DEPENDENCIES
|
|
109
|
+
dependencies: ContractAnalyzerDependencies = DEFAULT_DEPENDENCIES,
|
|
124
110
|
): Promise<ContractProfile> {
|
|
125
|
-
const filePath = args.file_path
|
|
126
|
-
const contractName = basename(filePath, ".sol")
|
|
111
|
+
const filePath = args.file_path
|
|
112
|
+
const contractName = basename(filePath, ".sol")
|
|
127
113
|
|
|
128
|
-
context.metadata({ title: `Analyze contract: ${contractName}` })
|
|
114
|
+
context.metadata({ title: `Analyze contract: ${contractName}` })
|
|
129
115
|
|
|
130
116
|
if (!existsSync(filePath)) {
|
|
131
|
-
return createFailureProfile(contractName, filePath, `Contract file not found: ${filePath}`)
|
|
117
|
+
return createFailureProfile(contractName, filePath, `Contract file not found: ${filePath}`)
|
|
132
118
|
}
|
|
133
119
|
|
|
134
|
-
const projectDir = args.project_dir ?? findFoundryProjectDir(filePath)
|
|
120
|
+
const projectDir = args.project_dir ?? findFoundryProjectDir(filePath)
|
|
135
121
|
|
|
136
122
|
try {
|
|
137
123
|
const [contractProfile, sourceText] = await withAbort(
|
|
138
124
|
context.abort,
|
|
139
|
-
Promise.all([
|
|
140
|
-
|
|
141
|
-
Bun.file(filePath).text(),
|
|
142
|
-
])
|
|
143
|
-
);
|
|
125
|
+
Promise.all([dependencies.extractInfo(contractName, projectDir), Bun.file(filePath).text()]),
|
|
126
|
+
)
|
|
144
127
|
|
|
145
128
|
if (context.abort.aborted) {
|
|
146
|
-
return createFailureProfile(contractName, filePath, "contract analysis aborted")
|
|
129
|
+
return createFailureProfile(contractName, filePath, "contract analysis aborted")
|
|
147
130
|
}
|
|
148
131
|
|
|
149
132
|
return {
|
|
@@ -151,23 +134,23 @@ export async function executeContractAnalyzer(
|
|
|
151
134
|
name: contractProfile.name || contractName,
|
|
152
135
|
filePath,
|
|
153
136
|
riskIndicators: collectRiskIndicators(sourceText, contractProfile.riskIndicators),
|
|
154
|
-
}
|
|
137
|
+
}
|
|
155
138
|
} catch (error) {
|
|
156
139
|
if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
|
157
|
-
return createFailureProfile(contractName, filePath, "contract analysis aborted")
|
|
140
|
+
return createFailureProfile(contractName, filePath, "contract analysis aborted")
|
|
158
141
|
}
|
|
159
142
|
|
|
160
|
-
const maybeError = error as Error & { code?: string }
|
|
143
|
+
const maybeError = error as Error & { code?: string }
|
|
161
144
|
if (maybeError.code === "ENOENT") {
|
|
162
145
|
return createFailureProfile(
|
|
163
146
|
contractName,
|
|
164
147
|
filePath,
|
|
165
|
-
"Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash"
|
|
166
|
-
)
|
|
148
|
+
"Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash",
|
|
149
|
+
)
|
|
167
150
|
}
|
|
168
151
|
|
|
169
|
-
const message = maybeError.message || "contract analysis failed"
|
|
170
|
-
return createFailureProfile(contractName, filePath, message)
|
|
152
|
+
const message = maybeError.message || "contract analysis failed"
|
|
153
|
+
return createFailureProfile(contractName, filePath, message)
|
|
171
154
|
}
|
|
172
155
|
}
|
|
173
156
|
|
|
@@ -178,7 +161,7 @@ export const contractAnalyzerTool = tool({
|
|
|
178
161
|
project_dir: tool.schema.string().optional(),
|
|
179
162
|
},
|
|
180
163
|
async execute(args, context) {
|
|
181
|
-
const contractProfile = await executeContractAnalyzer(args, context)
|
|
182
|
-
return JSON.stringify(contractProfile)
|
|
164
|
+
const contractProfile = await executeContractAnalyzer(args, context)
|
|
165
|
+
return JSON.stringify(contractProfile)
|
|
183
166
|
},
|
|
184
|
-
})
|
|
167
|
+
})
|
|
@@ -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
|
+
})
|