solidity-argus 0.1.7 → 0.2.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.
Files changed (87) hide show
  1. package/README.md +161 -1
  2. package/package.json +5 -2
  3. package/skills/README.md +63 -0
  4. package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
  5. package/skills/manifests/cyfrin.json +16 -0
  6. package/skills/manifests/defifofum.json +25 -0
  7. package/skills/manifests/kadenzipfel.json +48 -0
  8. package/skills/manifests/scvd.json +9 -0
  9. package/skills/manifests/smartbugs.json +11 -0
  10. package/skills/manifests/solodit.json +9 -0
  11. package/skills/manifests/sunweb3sec.json +11 -0
  12. package/skills/manifests/trailofbits.json +9 -0
  13. package/skills/methodology/audit-workflow/SKILL.md +3 -0
  14. package/skills/patterns/access-control.yaml +31 -0
  15. package/skills/patterns/erc4626.yaml +29 -0
  16. package/skills/patterns/flash-loan.yaml +20 -0
  17. package/skills/patterns/oracle.yaml +30 -0
  18. package/skills/patterns/proxy.yaml +30 -0
  19. package/skills/patterns/reentrancy.yaml +30 -0
  20. package/skills/patterns/signature.yaml +31 -0
  21. package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
  22. package/skills/references/exploit-reference/SKILL.md +3 -0
  23. package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
  24. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
  25. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
  26. package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
  27. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
  28. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
  29. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
  30. package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
  31. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
  32. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
  33. package/src/agents/argus-prompt.ts +7 -7
  34. package/src/agents/pythia-prompt.ts +11 -11
  35. package/src/agents/scribe-prompt.ts +6 -6
  36. package/src/agents/sentinel-prompt.ts +7 -7
  37. package/src/cli/cli-output.ts +16 -0
  38. package/src/cli/cli-program.ts +9 -5
  39. package/src/cli/commands/doctor.ts +274 -16
  40. package/src/cli/commands/init.ts +5 -5
  41. package/src/cli/commands/install.ts +5 -5
  42. package/src/cli/commands/lint-skills.ts +114 -0
  43. package/src/cli/tui-prompts.ts +4 -2
  44. package/src/config/schema.ts +2 -0
  45. package/src/create-hooks.ts +141 -32
  46. package/src/create-tools.ts +2 -0
  47. package/src/features/error-recovery/session-recovery.ts +7 -1
  48. package/src/features/error-recovery/tool-error-recovery.ts +74 -19
  49. package/src/features/persistent-state/audit-state-manager.ts +36 -13
  50. package/src/hooks/agent-tracker.ts +53 -0
  51. package/src/hooks/compaction-hook.ts +46 -37
  52. package/src/hooks/config-handler.ts +22 -9
  53. package/src/hooks/context-budget.ts +45 -0
  54. package/src/hooks/event-hook-v2.ts +8 -2
  55. package/src/hooks/event-hook.ts +5 -4
  56. package/src/hooks/knowledge-sync-hook.ts +2 -1
  57. package/src/hooks/recon-context-builder.ts +66 -0
  58. package/src/hooks/safe-create-hook.ts +4 -5
  59. package/src/hooks/system-prompt-hook.ts +92 -221
  60. package/src/hooks/tool-tracking-hook.ts +108 -9
  61. package/src/hooks/types.ts +0 -1
  62. package/src/index.ts +28 -6
  63. package/src/knowledge/retry.ts +53 -0
  64. package/src/knowledge/scvd-client.ts +37 -10
  65. package/src/knowledge/scvd-errors.ts +89 -0
  66. package/src/knowledge/scvd-index.ts +53 -3
  67. package/src/knowledge/scvd-sync.ts +205 -34
  68. package/src/knowledge/source-manifest.ts +102 -0
  69. package/src/plugin-interface.ts +11 -3
  70. package/src/shared/binary-utils.ts +1 -0
  71. package/src/shared/logger.ts +78 -17
  72. package/src/skills/argus-skill-resolver.ts +226 -0
  73. package/src/skills/skill-schema.ts +98 -0
  74. package/src/state/audit-state.ts +2 -0
  75. package/src/state/types.ts +32 -1
  76. package/src/tools/argus-skill-load-tool.ts +73 -0
  77. package/src/tools/pattern-checker-tool.ts +56 -12
  78. package/src/tools/pattern-loader.ts +183 -0
  79. package/src/tools/pattern-schema.ts +51 -0
  80. package/src/tools/report-generator-tool.ts +134 -11
  81. package/src/tools/slither-tool.ts +61 -19
  82. package/src/tools/solodit-search-tool.ts +92 -14
  83. package/src/utils/audit-artifact-detector.ts +119 -0
  84. package/src/utils/dependency-scanner.ts +93 -0
  85. package/src/utils/project-detector.ts +128 -26
  86. package/src/utils/solidity-parser.ts +20 -4
  87. package/src/utils/solodit-health.ts +29 -0
@@ -3,6 +3,8 @@ import { tool, type ToolContext } from "@opencode-ai/plugin";
3
3
  const SOLODIT_MCP_SERVER = "solodit-mcp";
4
4
  const SOLODIT_MCP_TOOL = "search_findings";
5
5
  const DEFAULT_LIMIT = 10;
6
+ const DEFAULT_SOLODIT_PORT = 3000;
7
+ const SOLODIT_HTTP_TIMEOUT_MS = 10_000;
6
8
 
7
9
  type SoloditSearchArgs = {
8
10
  query: string;
@@ -68,6 +70,91 @@ function parseFindings(response: unknown): SoloditFinding[] {
68
70
  return response.map(parseFinding);
69
71
  }
70
72
 
73
+ function parseSseData(body: string): unknown {
74
+ for (const line of body.split("\n")) {
75
+ if (line.startsWith("data: ")) {
76
+ try {
77
+ return JSON.parse(line.slice(6));
78
+ } catch {
79
+ continue;
80
+ }
81
+ }
82
+ }
83
+ try {
84
+ return JSON.parse(body);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function extractFindingsFromMcpResponse(envelope: unknown): SoloditFinding[] {
91
+ if (typeof envelope !== "object" || envelope === null) return [];
92
+ const result = (envelope as Record<string, unknown>).result;
93
+ if (typeof result !== "object" || result === null) return [];
94
+
95
+ const structured = (result as Record<string, unknown>).structuredContent;
96
+ const reportsJson =
97
+ typeof structured === "object" && structured !== null
98
+ ? (structured as Record<string, unknown>).reportsJSON
99
+ : undefined;
100
+
101
+ if (typeof reportsJson === "string") {
102
+ try {
103
+ const parsed = JSON.parse(reportsJson);
104
+ if (Array.isArray(parsed)) return parsed.map(parseFinding);
105
+ } catch { /* fall through */ }
106
+ }
107
+
108
+ const content = (result as Record<string, unknown>).content;
109
+ if (Array.isArray(content) && content.length > 0) {
110
+ const first = content[0] as Record<string, unknown> | undefined;
111
+ if (typeof first?.text === "string") {
112
+ try {
113
+ const parsed = JSON.parse(first.text);
114
+ if (Array.isArray(parsed)) return parsed.map(parseFinding);
115
+ } catch { /* fall through */ }
116
+ }
117
+ }
118
+
119
+ return [];
120
+ }
121
+
122
+ async function callSoloditHttp(
123
+ query: string,
124
+ limit: number,
125
+ port: number = DEFAULT_SOLODIT_PORT,
126
+ ): Promise<SoloditSearchResult> {
127
+ try {
128
+ const response = await fetch(`http://localhost:${port}/mcp`, {
129
+ method: "POST",
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json, text/event-stream",
133
+ },
134
+ body: JSON.stringify({
135
+ jsonrpc: "2.0",
136
+ method: "tools/call",
137
+ params: { name: "search", arguments: { keywords: query } },
138
+ id: 1,
139
+ }),
140
+ signal: AbortSignal.timeout(SOLODIT_HTTP_TIMEOUT_MS),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ return { results: [], totalFound: 0, query, error: `Solodit HTTP ${response.status}` };
145
+ }
146
+
147
+ const body = await response.text();
148
+ const envelope = parseSseData(body);
149
+ const findings = extractFindingsFromMcpResponse(envelope);
150
+
151
+ return { results: findings.slice(0, limit), totalFound: findings.length, query };
152
+ } catch (error) {
153
+ const message = error instanceof Error ? error.message : "Unknown error";
154
+ return { results: [], totalFound: 0, query, error: `Solodit MCP unreachable: ${message}` };
155
+ }
156
+ }
157
+
71
158
  export async function executeSoloditSearch(
72
159
  args: SoloditSearchArgs,
73
160
  context: ToolContext,
@@ -82,12 +169,7 @@ export async function executeSoloditSearch(
82
169
  callMcpTool ?? (hasMcpCapability(context) ? context.callMcpTool : undefined);
83
170
 
84
171
  if (!mcpCaller) {
85
- return {
86
- results: [],
87
- totalFound: 0,
88
- query,
89
- error: `Solodit MCP not available. Add to opencode.json mcp section or ensure solodit-mcp is running. Use @solodit-mcp directly: search_findings({query: '${query}', limit: ${limit}})`,
90
- };
172
+ return callSoloditHttp(query, limit);
91
173
  }
92
174
 
93
175
  try {
@@ -105,14 +187,10 @@ export async function executeSoloditSearch(
105
187
  totalFound: findings.length,
106
188
  query,
107
189
  };
108
- } catch (error) {
109
- const message = error instanceof Error ? error.message : "Unknown error";
110
- return {
111
- results: [],
112
- totalFound: 0,
113
- query,
114
- error: `Solodit MCP error: ${message}`,
115
- };
190
+ } catch {
191
+ // MCP bridge failed (upstream crash, connection error, etc.)
192
+ // Fall through to HTTP fallback before giving up
193
+ return callSoloditHttp(query, limit);
116
194
  }
117
195
  }
118
196
 
@@ -0,0 +1,119 @@
1
+ import { existsSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ export interface AuditArtifact {
5
+ type: "audit-report" | "slither-output" | "deployment-artifact" | "security-tool-output";
6
+ path: string;
7
+ name: string;
8
+ }
9
+
10
+ /**
11
+ * Detects audit artifacts in a project directory (shallow scan, top-level only)
12
+ * @param projectDir Directory to scan for audit artifacts
13
+ * @returns Array of detected audit artifacts
14
+ */
15
+ export function detectAuditArtifacts(projectDir: string): AuditArtifact[] {
16
+ const artifacts: AuditArtifact[] = [];
17
+
18
+ if (!existsSync(projectDir)) {
19
+ return artifacts;
20
+ }
21
+
22
+ try {
23
+ const entries = readdirSync(projectDir, { withFileTypes: true });
24
+
25
+ for (const entry of entries) {
26
+ const fullPath = join(projectDir, entry.name);
27
+
28
+ // Check directories
29
+ if (entry.isDirectory()) {
30
+ // Audit report directories
31
+ if (["audit", "audits", "security"].includes(entry.name)) {
32
+ artifacts.push({
33
+ type: "audit-report",
34
+ path: fullPath,
35
+ name: entry.name,
36
+ });
37
+ continue;
38
+ }
39
+
40
+ // Deployment artifact directories
41
+ if (entry.name === ".openzeppelin") {
42
+ artifacts.push({
43
+ type: "deployment-artifact",
44
+ path: fullPath,
45
+ name: entry.name,
46
+ });
47
+ continue;
48
+ }
49
+
50
+ // docs/audit* directories
51
+ if (entry.name === "docs") {
52
+ try {
53
+ const docsEntries = readdirSync(fullPath, { withFileTypes: true });
54
+ for (const docsEntry of docsEntries) {
55
+ if (docsEntry.isDirectory() && docsEntry.name.startsWith("audit")) {
56
+ artifacts.push({
57
+ type: "audit-report",
58
+ path: join(fullPath, docsEntry.name),
59
+ name: docsEntry.name,
60
+ });
61
+ }
62
+ }
63
+ } catch {
64
+ // Ignore errors reading docs directory
65
+ }
66
+ }
67
+ continue;
68
+ }
69
+
70
+ // Check files
71
+ if (entry.isFile()) {
72
+ // Audit report files
73
+ if (
74
+ /^.*audit.*\.(md|pdf)$/i.test(entry.name) ||
75
+ /^.*security-review.*\.(md|pdf)$/i.test(entry.name)
76
+ ) {
77
+ artifacts.push({
78
+ type: "audit-report",
79
+ path: fullPath,
80
+ name: entry.name,
81
+ });
82
+ continue;
83
+ }
84
+
85
+ // Slither output files
86
+ if (
87
+ entry.name === "slither.json" ||
88
+ entry.name === "slither.sarif" ||
89
+ /^slither-report.*/.test(entry.name)
90
+ ) {
91
+ artifacts.push({
92
+ type: "slither-output",
93
+ path: fullPath,
94
+ name: entry.name,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ // Security tool output files
100
+ if (
101
+ /^mythril-report.*/.test(entry.name) ||
102
+ /^securify-report.*/.test(entry.name)
103
+ ) {
104
+ artifacts.push({
105
+ type: "security-tool-output",
106
+ path: fullPath,
107
+ name: entry.name,
108
+ });
109
+ continue;
110
+ }
111
+ }
112
+ }
113
+ } catch {
114
+ // Return empty array if directory cannot be read
115
+ return [];
116
+ }
117
+
118
+ return artifacts;
119
+ }
@@ -0,0 +1,93 @@
1
+ export interface DependencyRisk {
2
+ package: string;
3
+ version: string;
4
+ risk: "high" | "medium" | "low";
5
+ category: string;
6
+ recommendation: string;
7
+ }
8
+
9
+ interface DependencyInput {
10
+ dependencies?: Record<string, string>;
11
+ devDependencies?: Record<string, string>;
12
+ }
13
+
14
+ function parseVersion(raw: string): [number, number, number] {
15
+ const cleaned = raw.replace(/^[^0-9]*/, "");
16
+ const parts = cleaned.split(".");
17
+ return [
18
+ parseInt(parts[0] ?? "0", 10),
19
+ parseInt(parts[1] ?? "0", 10),
20
+ parseInt(parts[2] ?? "0", 10),
21
+ ];
22
+ }
23
+
24
+ function versionLt(
25
+ raw: string,
26
+ major: number,
27
+ minor: number,
28
+ patch = 0
29
+ ): boolean {
30
+ const [a, b, c] = parseVersion(raw);
31
+ if (a !== major) return a < major;
32
+ if (b !== minor) return b < minor;
33
+ return c < patch;
34
+ }
35
+
36
+ export function scanDependencyRisks(input: DependencyInput): DependencyRisk[] {
37
+ const risks: DependencyRisk[] = [];
38
+ const deps = input.dependencies ?? {};
39
+ const devDeps = input.devDependencies ?? {};
40
+ const allDeps = { ...deps, ...devDeps };
41
+
42
+ const ozVersion = deps["@openzeppelin/contracts"];
43
+ if (ozVersion) {
44
+ if (versionLt(ozVersion, 4, 9)) {
45
+ risks.push({
46
+ package: "@openzeppelin/contracts",
47
+ version: ozVersion,
48
+ risk: "high",
49
+ category: "known-vulnerability",
50
+ recommendation:
51
+ "Upgrade to @openzeppelin/contracts >= 4.9.0 — known vulnerabilities in OZ < 4.9",
52
+ });
53
+ } else if (versionLt(ozVersion, 5, 0)) {
54
+ risks.push({
55
+ package: "@openzeppelin/contracts",
56
+ version: ozVersion,
57
+ risk: "low",
58
+ category: "upgrade-available",
59
+ recommendation:
60
+ "Consider upgrading to OZ v5 for latest patterns and Solidity 0.8.20+ support",
61
+ });
62
+ }
63
+ }
64
+
65
+ const ozUpgradeableVersion = deps["@openzeppelin/contracts-upgradeable"];
66
+ if (ozUpgradeableVersion) {
67
+ const hasUpgradeTooling =
68
+ "@openzeppelin/hardhat-upgrades" in allDeps;
69
+ if (!hasUpgradeTooling) {
70
+ risks.push({
71
+ package: "@openzeppelin/contracts-upgradeable",
72
+ version: ozUpgradeableVersion,
73
+ risk: "medium",
74
+ category: "missing-tooling",
75
+ recommendation:
76
+ "Add @openzeppelin/hardhat-upgrades to devDependencies for safe upgrade workflows",
77
+ });
78
+ }
79
+ }
80
+
81
+ const solmateVersion = deps["solmate"];
82
+ if (solmateVersion && versionLt(solmateVersion, 6, 0)) {
83
+ risks.push({
84
+ package: "solmate",
85
+ version: solmateVersion,
86
+ risk: "medium",
87
+ category: "outdated",
88
+ recommendation: "Upgrade solmate to >= 6.0.0 for latest fixes",
89
+ });
90
+ }
91
+
92
+ return risks;
93
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "fs";
2
2
  import { join, resolve } from "path";
3
+ import { scanDependencyRisks, type DependencyRisk } from "./dependency-scanner";
3
4
 
4
5
  export interface ProjectConfig {
5
6
  type: "foundry" | "hardhat" | "mixed" | "unknown";
@@ -9,6 +10,16 @@ export interface ProjectConfig {
9
10
  remappings: string[];
10
11
  viaIr: boolean;
11
12
  rootDir: string;
13
+ optimizer?: { enabled: boolean; runs?: number };
14
+ evmVersion?: string;
15
+ profiles?: string[];
16
+ hasHardhat: boolean;
17
+ hasFoundry: boolean;
18
+ dependencies?: Record<string, string>;
19
+ devDependencies?: Record<string, string>;
20
+ isUpgradeable: boolean;
21
+ outDir?: string;
22
+ dependencyRisks: DependencyRisk[];
12
23
  }
13
24
 
14
25
  /**
@@ -39,14 +50,16 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
39
50
  type = "unknown";
40
51
  }
41
52
 
42
- // Default values
43
53
  let srcDir = "src";
44
54
  let testDir = "test";
45
55
  let solcVersion: string | undefined;
46
56
  let remappings: string[] = [];
47
57
  let viaIr = false;
58
+ let optimizer: { enabled: boolean; runs?: number } | undefined;
59
+ let evmVersion: string | undefined;
60
+ let profiles: string[] | undefined;
61
+ let outDir: string | undefined;
48
62
 
49
- // Parse Foundry config if present
50
63
  if (hasFoundry) {
51
64
  const foundryConfig = await parseFoundryToml(foundryTomlPath);
52
65
  srcDir = foundryConfig.srcDir || srcDir;
@@ -54,13 +67,25 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
54
67
  solcVersion = foundryConfig.solcVersion;
55
68
  remappings = foundryConfig.remappings;
56
69
  viaIr = foundryConfig.viaIr;
70
+ optimizer = foundryConfig.optimizer;
71
+ evmVersion = foundryConfig.evmVersion;
72
+ profiles = foundryConfig.profiles;
73
+ outDir = foundryConfig.outDir;
74
+ }
75
+
76
+ const remappingsFromTxt = parseRemappingsTxt(rootDir);
77
+ if (remappingsFromTxt.length > 0 && remappings.length === 0) {
78
+ remappings = remappingsFromTxt;
57
79
  }
58
80
 
59
- // Set Hardhat defaults if it's a Hardhat project
60
81
  if (hasHardhat && !hasFoundry) {
61
82
  srcDir = "contracts";
62
83
  }
63
84
 
85
+ const isUpgradeable = existsSync(join(rootDir, ".openzeppelin"));
86
+
87
+ const { dependencies, devDependencies } = await parsePackageJson(rootDir);
88
+
64
89
  return {
65
90
  type,
66
91
  srcDir,
@@ -69,32 +94,53 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
69
94
  remappings,
70
95
  viaIr,
71
96
  rootDir,
97
+ optimizer,
98
+ evmVersion,
99
+ profiles,
100
+ hasHardhat,
101
+ hasFoundry,
102
+ dependencies,
103
+ devDependencies,
104
+ isUpgradeable,
105
+ outDir,
106
+ dependencyRisks: scanDependencyRisks({ dependencies, devDependencies }),
72
107
  };
73
108
  }
74
109
 
75
110
  /**
76
111
  * Parses foundry.toml file using regex-based parsing
77
112
  */
78
- async function parseFoundryToml(
79
- filePath: string
80
- ): Promise<{
113
+ interface FoundryTomlResult {
81
114
  srcDir?: string;
82
115
  testDir?: string;
83
116
  solcVersion?: string;
84
117
  remappings: string[];
85
118
  viaIr: boolean;
86
- }> {
119
+ optimizer?: { enabled: boolean; runs?: number };
120
+ evmVersion?: string;
121
+ profiles?: string[];
122
+ outDir?: string;
123
+ }
124
+
125
+ async function parseFoundryToml(filePath: string): Promise<FoundryTomlResult> {
87
126
  const content = await Bun.file(filePath).text();
88
127
 
89
- const result = {
90
- srcDir: undefined as string | undefined,
91
- testDir: undefined as string | undefined,
92
- solcVersion: undefined as string | undefined,
93
- remappings: [] as string[],
128
+ const result: FoundryTomlResult = {
129
+ srcDir: undefined,
130
+ testDir: undefined,
131
+ solcVersion: undefined,
132
+ remappings: [],
94
133
  viaIr: false,
95
134
  };
96
135
 
97
- // Extract [profile.default] section - stop at next section or EOF
136
+ const profileNames = Array.from(
137
+ content.matchAll(/\[profile\.(\w+)\]/g),
138
+ (m) => m[1]!
139
+ );
140
+ if (profileNames.length > 0) {
141
+ result.profiles = profileNames;
142
+ }
143
+
98
144
  const profileDefaultMatch = content.match(
99
145
  /\[profile\.default\]([\s\S]*?)(?:\n\[|$)/
100
146
  );
@@ -104,38 +150,57 @@ async function parseFoundryToml(
104
150
 
105
151
  const profileSection = profileDefaultMatch[1];
106
152
 
107
- // Parse src = "..."
108
153
  const srcMatch = profileSection.match(/^\s*src\s*=\s*["']([^"']+)["']/m);
109
- if (srcMatch && srcMatch[1]) {
154
+ if (srcMatch?.[1]) {
110
155
  result.srcDir = srcMatch[1];
111
156
  }
112
157
 
113
- // Parse test = "..."
114
158
  const testMatch = profileSection.match(/^\s*test\s*=\s*["']([^"']+)["']/m);
115
- if (testMatch && testMatch[1]) {
159
+ if (testMatch?.[1]) {
116
160
  result.testDir = testMatch[1];
117
161
  }
118
162
 
119
- // Parse solc = "..."
120
163
  const solcMatch = profileSection.match(/^\s*solc\s*=\s*["']([^"']+)["']/m);
121
- if (solcMatch && solcMatch[1]) {
164
+ if (solcMatch?.[1]) {
122
165
  result.solcVersion = solcMatch[1];
123
166
  }
124
167
 
125
- // Parse via_ir = true/false
126
168
  const viaIrMatch = profileSection.match(/^\s*via[_-]ir\s*=\s*(true|false)/m);
127
- if (viaIrMatch && viaIrMatch[1] === "true") {
169
+ if (viaIrMatch?.[1] === "true") {
128
170
  result.viaIr = true;
129
171
  }
130
172
 
131
- // Parse remappings array - handles both single line and multiline
173
+ const optimizerMatch = profileSection.match(
174
+ /^\s*optimizer\s*=\s*(true|false)/m
175
+ );
176
+ if (optimizerMatch?.[1]) {
177
+ const enabled = optimizerMatch[1] === "true";
178
+ const runsMatch = profileSection.match(
179
+ /^\s*optimizer_runs\s*=\s*(\d+)/m
180
+ );
181
+ result.optimizer = {
182
+ enabled,
183
+ runs: runsMatch?.[1] ? parseInt(runsMatch[1], 10) : undefined,
184
+ };
185
+ }
186
+
187
+ const evmMatch = profileSection.match(
188
+ /^\s*evm_version\s*=\s*["']([^"']+)["']/m
189
+ );
190
+ if (evmMatch?.[1]) {
191
+ result.evmVersion = evmMatch[1];
192
+ }
193
+
194
+ const outMatch = profileSection.match(/^\s*out\s*=\s*["']([^"']+)["']/m);
195
+ if (outMatch?.[1]) {
196
+ result.outDir = outMatch[1];
197
+ }
198
+
132
199
  const remappingsMatch = profileSection.match(
133
200
  /remappings\s*=\s*\[([\s\S]*?)\]/
134
201
  );
135
- if (remappingsMatch && remappingsMatch[1]) {
136
- const remappingsContent = remappingsMatch[1];
137
- // Extract quoted strings from the array
138
- const remappingMatches = remappingsContent.match(/["']([^"']+)["']/g);
202
+ if (remappingsMatch?.[1]) {
203
+ const remappingMatches = remappingsMatch[1].match(/["']([^"']+)["']/g);
139
204
  if (remappingMatches) {
140
205
  result.remappings = remappingMatches.map((m) => m.slice(1, -1));
141
206
  }
@@ -143,3 +208,40 @@ async function parseFoundryToml(
143
208
 
144
209
  return result;
145
210
  }
211
+
212
+ async function parsePackageJson(
213
+ rootDir: string
214
+ ): Promise<{
215
+ dependencies?: Record<string, string>;
216
+ devDependencies?: Record<string, string>;
217
+ }> {
218
+ const pkgPath = join(rootDir, "package.json");
219
+ if (!existsSync(pkgPath)) {
220
+ return {};
221
+ }
222
+ try {
223
+ const content = JSON.parse(await Bun.file(pkgPath).text());
224
+ return {
225
+ dependencies: content.dependencies,
226
+ devDependencies: content.devDependencies,
227
+ };
228
+ } catch {
229
+ return {};
230
+ }
231
+ }
232
+
233
+ function parseRemappingsTxt(rootDir: string): string[] {
234
+ const remappingsPath = join(rootDir, "remappings.txt");
235
+ if (!existsSync(remappingsPath)) {
236
+ return [];
237
+ }
238
+ try {
239
+ const content = require("fs").readFileSync(remappingsPath, "utf-8");
240
+ return content
241
+ .split("\n")
242
+ .map((line: string) => line.trim())
243
+ .filter((line: string) => line.length > 0);
244
+ } catch {
245
+ return [];
246
+ }
247
+ }
@@ -19,6 +19,20 @@ interface StorageLayout {
19
19
  types: Record<string, { label: string }>;
20
20
  }
21
21
 
22
+ /**
23
+ * Extract the first JSON value from a string that may contain non-JSON
24
+ * prefix (e.g. forge table-format output, compilation progress).
25
+ * Falls back to the original string if no JSON delimiter is found.
26
+ */
27
+ function extractJson(raw: string, opener: "[" | "{"): string {
28
+ const closer = opener === "[" ? "]" : "}";
29
+ const start = raw.indexOf(opener);
30
+ if (start === -1) return raw;
31
+ const end = raw.lastIndexOf(closer);
32
+ if (end === -1) return raw;
33
+ return raw.slice(start, end + 1);
34
+ }
35
+
22
36
  /**
23
37
  * Extract contract information using forge inspect
24
38
  * Runs forge inspect <contractName> abi and storage-layout
@@ -43,7 +57,7 @@ export async function extractContractInfo(
43
57
  try {
44
58
  // Run forge inspect abi
45
59
  const abiResult = Bun.spawnSync(
46
- ["forge", "inspect", contractName, "abi"],
60
+ ["forge", "inspect", contractName, "abi", "--json"],
47
61
  {
48
62
  cwd: projectDir,
49
63
  stdout: "pipe",
@@ -59,7 +73,7 @@ export async function extractContractInfo(
59
73
 
60
74
  // Run forge inspect storage-layout
61
75
  const storageResult = Bun.spawnSync(
62
- ["forge", "inspect", contractName, "storage-layout"],
76
+ ["forge", "inspect", contractName, "storage-layout", "--json"],
63
77
  {
64
78
  cwd: projectDir,
65
79
  stdout: "pipe",
@@ -74,7 +88,8 @@ export async function extractContractInfo(
74
88
  }
75
89
 
76
90
  // Parse ABI
77
- const abiOutput = abiResult.stdout?.toString() || "[]";
91
+ const abiRaw = abiResult.stdout?.toString() || "[]";
92
+ const abiOutput = extractJson(abiRaw, "[");
78
93
  let abi: ABIFunction[] = [];
79
94
  try {
80
95
  abi = JSON.parse(abiOutput);
@@ -84,7 +99,8 @@ export async function extractContractInfo(
84
99
  }
85
100
 
86
101
  // Parse storage layout
87
- const storageOutput = storageResult.stdout?.toString() || "{}";
102
+ const storageRaw = storageResult.stdout?.toString() || "{}";
103
+ const storageOutput = extractJson(storageRaw, "{");
88
104
  let storageLayout: StorageLayout = { storage: [], types: {} };
89
105
  try {
90
106
  storageLayout = JSON.parse(storageOutput);
@@ -0,0 +1,29 @@
1
+ export interface SoloditHealthStatus {
2
+ reachable: boolean
3
+ enabled: boolean
4
+ port: number
5
+ error?: string
6
+ }
7
+
8
+ export async function checkSoloditHealth(
9
+ port: number,
10
+ enabled: boolean,
11
+ ): Promise<SoloditHealthStatus> {
12
+ if (!enabled) {
13
+ return { reachable: false, enabled: false, port }
14
+ }
15
+
16
+ try {
17
+ const response = await fetch(`http://localhost:${port}/mcp`, {
18
+ signal: AbortSignal.timeout(2000),
19
+ })
20
+ return { reachable: response.ok, enabled: true, port }
21
+ } catch (error) {
22
+ return {
23
+ reachable: false,
24
+ enabled: true,
25
+ port,
26
+ error: error instanceof Error ? error.message : "Unknown error",
27
+ }
28
+ }
29
+ }