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.
- package/AGENTS.md +3 -3
- package/README.md +93 -37
- package/package.json +34 -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 +34 -7
- package/src/agents/pythia-prompt.ts +13 -4
- package/src/agents/scribe-prompt.ts +20 -2
- package/src/agents/sentinel-prompt.ts +45 -5
- 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 +6 -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/system-prompt-hook.ts +18 -1
- package/src/hooks/tool-tracking-hook.ts +110 -51
- package/src/hooks/types.ts +2 -1
- package/src/index.ts +24 -37
- 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 +203 -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 +142 -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 +201 -158
- package/src/tools/gas-analysis-tool.ts +264 -0
- package/src/tools/pattern-checker-tool.ts +203 -191
- package/src/tools/pattern-loader.ts +5 -111
- package/src/tools/pattern-schema.ts +3 -0
- package/src/tools/proxy-detection-tool.ts +224 -0
- package/src/tools/report-generator-tool.ts +305 -206
- package/src/tools/slither-tool.ts +266 -218
- package/src/tools/solodit-search-tool.ts +235 -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 +175 -75
- 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, 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(
|
|
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
|
-
|
|
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
|
+
})
|