solidity-argus 0.1.8 → 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 (84) 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 +4 -4
  34. package/src/agents/pythia-prompt.ts +4 -4
  35. package/src/agents/scribe-prompt.ts +3 -3
  36. package/src/agents/sentinel-prompt.ts +4 -4
  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 +99 -14
  46. package/src/create-tools.ts +2 -0
  47. package/src/features/error-recovery/tool-error-recovery.ts +74 -19
  48. package/src/features/persistent-state/audit-state-manager.ts +36 -13
  49. package/src/hooks/agent-tracker.ts +53 -0
  50. package/src/hooks/compaction-hook.ts +46 -37
  51. package/src/hooks/config-handler.ts +3 -0
  52. package/src/hooks/context-budget.ts +45 -0
  53. package/src/hooks/event-hook.ts +5 -4
  54. package/src/hooks/knowledge-sync-hook.ts +2 -1
  55. package/src/hooks/recon-context-builder.ts +66 -0
  56. package/src/hooks/safe-create-hook.ts +4 -5
  57. package/src/hooks/system-prompt-hook.ts +128 -0
  58. package/src/hooks/tool-tracking-hook.ts +86 -7
  59. package/src/index.ts +24 -1
  60. package/src/knowledge/retry.ts +53 -0
  61. package/src/knowledge/scvd-client.ts +37 -10
  62. package/src/knowledge/scvd-errors.ts +89 -0
  63. package/src/knowledge/scvd-index.ts +53 -3
  64. package/src/knowledge/scvd-sync.ts +205 -34
  65. package/src/knowledge/source-manifest.ts +102 -0
  66. package/src/plugin-interface.ts +14 -1
  67. package/src/shared/binary-utils.ts +1 -0
  68. package/src/shared/logger.ts +78 -17
  69. package/src/skills/argus-skill-resolver.ts +226 -0
  70. package/src/skills/skill-schema.ts +98 -0
  71. package/src/state/audit-state.ts +2 -0
  72. package/src/state/types.ts +32 -1
  73. package/src/tools/argus-skill-load-tool.ts +73 -0
  74. package/src/tools/pattern-checker-tool.ts +56 -12
  75. package/src/tools/pattern-loader.ts +183 -0
  76. package/src/tools/pattern-schema.ts +51 -0
  77. package/src/tools/report-generator-tool.ts +134 -11
  78. package/src/tools/slither-tool.ts +61 -19
  79. package/src/tools/solodit-search-tool.ts +92 -14
  80. package/src/utils/audit-artifact-detector.ts +119 -0
  81. package/src/utils/dependency-scanner.ts +93 -0
  82. package/src/utils/project-detector.ts +128 -26
  83. package/src/utils/solidity-parser.ts +20 -4
  84. package/src/utils/solodit-health.ts +29 -0
@@ -0,0 +1,66 @@
1
+ import type { ProjectConfig } from "../utils/project-detector"
2
+ import type { DependencyRisk } from "../utils/dependency-scanner"
3
+ import type { AuditArtifact } from "../utils/audit-artifact-detector"
4
+
5
+ export interface ReconContext {
6
+ projectConfig: ProjectConfig | null
7
+ dependencyRisks: DependencyRisk[]
8
+ auditArtifacts: AuditArtifact[]
9
+ }
10
+
11
+ /**
12
+ * Builds an XML-like reconnaissance context block from project data.
13
+ * Returns null if no data is available (all fields empty/null).
14
+ *
15
+ * The block is injected into compaction output so Argus agents retain
16
+ * project intelligence across context window compressions.
17
+ */
18
+ export function buildReconContextBlock(recon: ReconContext): string | null {
19
+ if (
20
+ !recon.projectConfig &&
21
+ recon.dependencyRisks.length === 0 &&
22
+ recon.auditArtifacts.length === 0
23
+ ) {
24
+ return null
25
+ }
26
+
27
+ const lines: string[] = ["<argus-recon>"]
28
+
29
+ if (recon.projectConfig) {
30
+ const frameworks: string[] = []
31
+ if (recon.projectConfig.hasFoundry) frameworks.push("Foundry")
32
+ if (recon.projectConfig.hasHardhat) frameworks.push("Hardhat")
33
+ if (frameworks.length > 0) {
34
+ lines.push(`Framework: ${frameworks.join(", ")}`)
35
+ }
36
+ if (recon.projectConfig.optimizer) {
37
+ lines.push(`Optimizer: runs=${recon.projectConfig.optimizer.runs}`)
38
+ }
39
+ if (recon.projectConfig.evmVersion) {
40
+ lines.push(`EVM Version: ${recon.projectConfig.evmVersion}`)
41
+ }
42
+ if (recon.projectConfig.isUpgradeable) {
43
+ lines.push(`Upgradeable: yes`)
44
+ }
45
+ if (recon.projectConfig.profiles && recon.projectConfig.profiles.length > 0) {
46
+ lines.push(`Profiles: ${recon.projectConfig.profiles.join(", ")}`)
47
+ }
48
+ }
49
+
50
+ if (recon.dependencyRisks.length > 0) {
51
+ lines.push("Dependency Risks:")
52
+ for (const risk of recon.dependencyRisks.slice(0, 5)) {
53
+ lines.push(` - ${risk.package}@${risk.version}: ${risk.risk}`)
54
+ }
55
+ }
56
+
57
+ if (recon.auditArtifacts.length > 0) {
58
+ lines.push("Existing Audit Artifacts:")
59
+ for (const artifact of recon.auditArtifacts.slice(0, 5)) {
60
+ lines.push(` - ${artifact.type}: ${artifact.path}`)
61
+ }
62
+ }
63
+
64
+ lines.push("</argus-recon>")
65
+ return lines.join("\n")
66
+ }
@@ -1,3 +1,5 @@
1
+ import { createLogger } from "../shared/logger"
2
+
1
3
  export function safeCreateHook<T>(
2
4
  factory: () => T,
3
5
  hookName: string
@@ -5,11 +7,8 @@ export function safeCreateHook<T>(
5
7
  try {
6
8
  return factory();
7
9
  } catch (error) {
8
- console.error(
9
- `[argus-hook-error] Failed to create hook "${hookName}": ${
10
- error instanceof Error ? error.message : String(error)
11
- }`
12
- );
10
+ const logger = createLogger()
11
+ logger.error(`Failed to create hook "${hookName}": ${error instanceof Error ? error.message : String(error)}`)
13
12
  return undefined;
14
13
  }
15
14
  }
@@ -0,0 +1,128 @@
1
+ import type { AuditState, FindingSeverity } from "../state/types"
2
+
3
+ const DEFAULT_TOKEN_BUDGET = 2000
4
+ const TOKENS_PER_CHAR = 4
5
+
6
+ export interface SystemPromptHookDeps {
7
+ getAuditState: () => AuditState | null
8
+ getAgentForSession: (sessionID: string) => string | undefined
9
+ isArgusAgent: (sessionID: string) => boolean
10
+ getContextPressure?: (systemText: string) => number
11
+ getTokenBudget?: (agent: string, contextPressure: number) => number
12
+ getEnforcerReminder?: (state: AuditState) => string | null
13
+ getReconBlock?: () => string | null
14
+ }
15
+
16
+ const FALLBACK_DIRECTIVES: Record<string, string> = {
17
+ slither:
18
+ "DO NOT re-attempt argus_slither_analyze. Use `argus_analyze_contract` and `argus_check_patterns` instead. Note limitation in report.",
19
+ forge:
20
+ "DO NOT re-attempt argus_forge_test or argus_forge_fuzz. Verify findings via manual code tracing. Note limitation in report.",
21
+ solodit:
22
+ "DO NOT re-attempt argus_solodit_search. Use `argus_check_patterns` with local rules. Note limitation in report.",
23
+ }
24
+
25
+ export function buildFallbackDirectives(unavailableTools: string[]): string[] {
26
+ const directives: string[] = []
27
+ for (const tool of unavailableTools) {
28
+ const directive = FALLBACK_DIRECTIVES[tool]
29
+ if (directive) directives.push(directive)
30
+ }
31
+ return directives
32
+ }
33
+
34
+ export function estimateTokens(text: string): number {
35
+ return Math.ceil(text.length / TOKENS_PER_CHAR)
36
+ }
37
+
38
+ export function buildDynamicContext(
39
+ auditState: AuditState,
40
+ agent: string,
41
+ tokenBudget: number = DEFAULT_TOKEN_BUDGET,
42
+ ): string {
43
+ const severityCounts: Record<FindingSeverity, number> = {
44
+ Critical: 0,
45
+ High: 0,
46
+ Medium: 0,
47
+ Low: 0,
48
+ Informational: 0,
49
+ }
50
+
51
+ for (const finding of auditState.findings) {
52
+ severityCounts[finding.severity]++
53
+ }
54
+
55
+ const tools = auditState.toolsExecuted.map((tool) => tool.tool).join(", ") || "none"
56
+ const unavailable = auditState.unavailableTools ?? []
57
+ const lines: string[] = [
58
+ `<argus-context agent="${agent}">`,
59
+ `Phase: ${auditState.currentPhase}`,
60
+ `Contracts: ${auditState.contractsReviewed.length} reviewed`,
61
+ `Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
62
+ `Tools: ${tools}`,
63
+ ]
64
+
65
+ if (unavailable.length > 0) {
66
+ lines.push(`Unavailable: ${unavailable.join(", ")}`)
67
+ lines.push(...buildFallbackDirectives(unavailable))
68
+ }
69
+
70
+ lines.push("</argus-context>")
71
+
72
+ let summary = lines.join("\n")
73
+
74
+ if (estimateTokens(summary) > tokenBudget) {
75
+ summary = [
76
+ `<argus-context agent="${agent}">`,
77
+ `Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length}`,
78
+ "</argus-context>",
79
+ ].join("\n")
80
+ }
81
+
82
+ return summary
83
+ }
84
+
85
+ export function createSystemPromptHook(deps: SystemPromptHookDeps) {
86
+ return async (
87
+ input: { sessionID?: string; model: unknown },
88
+ output: { system: string[] },
89
+ ): Promise<void> => {
90
+ if (!input.sessionID) {
91
+ return
92
+ }
93
+
94
+ if (!deps.isArgusAgent(input.sessionID)) {
95
+ return
96
+ }
97
+
98
+ const auditState = deps.getAuditState()
99
+ if (!auditState) {
100
+ return
101
+ }
102
+
103
+ const agent = deps.getAgentForSession(input.sessionID)
104
+ if (!agent) {
105
+ return
106
+ }
107
+
108
+ const currentSystem = output.system.join("\n")
109
+ const pressure = deps.getContextPressure?.(currentSystem) ?? 0
110
+ const budget = deps.getTokenBudget?.(agent, pressure) ?? DEFAULT_TOKEN_BUDGET
111
+
112
+ output.system.push(buildDynamicContext(auditState, agent, budget))
113
+
114
+ if (deps.getReconBlock) {
115
+ const reconBlock = deps.getReconBlock()
116
+ if (reconBlock && estimateTokens(reconBlock) <= budget) {
117
+ output.system.push(reconBlock)
118
+ }
119
+ }
120
+
121
+ if (agent === "argus" && deps.getEnforcerReminder) {
122
+ const reminder = deps.getEnforcerReminder(auditState)
123
+ if (reminder) {
124
+ output.system.push(reminder)
125
+ }
126
+ }
127
+ }
128
+ }
@@ -1,4 +1,4 @@
1
- import type { AuditState, FindingSeverity } from "../state/types"
1
+ import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
2
2
  import type { FindingStore } from "../state/finding-store"
3
3
  import { createFindingStore } from "../state/finding-store"
4
4
 
@@ -166,16 +166,91 @@ function processContractAnalyzerResult(
166
166
  }
167
167
  }
168
168
 
169
+ function processFuzzResult(
170
+ parsed: Record<string, unknown>,
171
+ state: AuditState
172
+ ): void {
173
+ const counterexamples = parsed.counterexamples
174
+ if (!Array.isArray(counterexamples) || counterexamples.length === 0) return
175
+
176
+ const totalRuns =
177
+ typeof parsed.totalRuns === "number" ? parsed.totalRuns : 0
178
+
179
+ state.fuzzCounterexamples ??= []
180
+
181
+ for (const raw of counterexamples) {
182
+ const ce = toRecord(raw)
183
+ if (!ce) continue
184
+
185
+ const testName = ce.testName
186
+ if (typeof testName !== "string") continue
187
+
188
+ const rawInputs = toRecord(ce.inputs)
189
+ const inputs = rawInputs ? Object.values(rawInputs).map(String) : []
190
+
191
+ const entry: FuzzCounterexample = {
192
+ testName,
193
+ inputs,
194
+ runs: totalRuns,
195
+ timestamp: Date.now(),
196
+ }
197
+
198
+ if (typeof ce.revertReason === "string") {
199
+ entry.revertReason = ce.revertReason
200
+ }
201
+
202
+ state.fuzzCounterexamples.push(entry)
203
+ }
204
+ }
205
+
206
+ function processSoloditResult(
207
+ parsed: Record<string, unknown>,
208
+ state: AuditState
209
+ ): void {
210
+ const query = typeof parsed.query === "string" ? parsed.query : ""
211
+ const results = Array.isArray(parsed.results) ? parsed.results : []
212
+ const totalFound =
213
+ typeof parsed.totalFound === "number" ? parsed.totalFound : results.length
214
+
215
+ const topResults: SoloditResult["topResults"] = results
216
+ .slice(0, 5)
217
+ .map((raw) => {
218
+ const r = toRecord(raw)
219
+ return {
220
+ title: typeof r?.title === "string" ? r.title : "",
221
+ severity: typeof r?.severity === "string" ? r.severity : "",
222
+ url: typeof r?.url === "string" ? r.url : "",
223
+ protocol: typeof r?.protocol === "string" ? r.protocol : "",
224
+ }
225
+ })
226
+
227
+ state.soloditResults ??= []
228
+ state.soloditResults.push({
229
+ query,
230
+ timestamp: Date.now(),
231
+ resultCount: totalFound,
232
+ topResults,
233
+ })
234
+ }
235
+
236
+ /**
237
+ * Records a tool execution in the audit state.
238
+ *
239
+ * Multiple entries per tool name are allowed — if the same tool runs multiple times
240
+ * (e.g., argus_slither_analyze on different targets), each execution is recorded
241
+ * with its own findingsCount.
242
+ *
243
+ * Timing limitation: startTime and endTime are both set to Date.now() because this
244
+ * hook fires in the tool.execute.after phase, after execution has already completed.
245
+ * We cannot capture the actual start time. This is a known limitation of the hook
246
+ * architecture. For accurate timing, the hook would need to fire in tool.execute.before
247
+ * and tool.execute.after phases separately.
248
+ */
169
249
  function recordToolExecution(
170
250
  state: AuditState,
171
251
  toolName: string,
172
252
  findingsCount: number
173
253
  ): void {
174
- const alreadyRecorded = state.toolsExecuted.some(
175
- (execution) => execution.tool === toolName
176
- )
177
- if (alreadyRecorded) return
178
-
179
254
  const now = Date.now()
180
255
  state.toolsExecuted.push({
181
256
  tool: toolName,
@@ -243,9 +318,13 @@ export function createToolTrackingHook(
243
318
  case "argus_analyze_contract":
244
319
  processContractAnalyzerResult(record, auditState)
245
320
  break
321
+ case "argus_solodit_search":
322
+ processSoloditResult(record, auditState)
323
+ break
246
324
  case "argus_forge_test":
325
+ break
247
326
  case "argus_forge_fuzz":
248
- // No findings to extract — counterexamples are informational
327
+ processFuzzResult(record, auditState)
249
328
  break
250
329
  }
251
330
 
package/src/index.ts CHANGED
@@ -5,8 +5,19 @@ import { createTools } from "./create-tools"
5
5
  import { createHooks } from "./create-hooks"
6
6
  import { createManagers } from "./create-managers"
7
7
  import { createPluginInterface } from "./plugin-interface"
8
+ import { checkSoloditHealth } from "./utils/solodit-health"
9
+ import { createLogger } from "./shared/logger"
10
+
11
+ async function startSoloditMcp(port: number): Promise<void> {
12
+ const logger = createLogger()
13
+
14
+ // Health check before spawn: if already reachable, skip spawn
15
+ const health = await checkSoloditHealth(port, true)
16
+ if (health.reachable) {
17
+ logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
18
+ return
19
+ }
8
20
 
9
- function startSoloditMcp(port: number): void {
10
21
  const child = Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
11
22
  stdin: "ignore",
12
23
  stdout: "ignore",
@@ -14,6 +25,16 @@ function startSoloditMcp(port: number): void {
14
25
  env: { ...process.env, PORT: String(port) },
15
26
  })
16
27
  child.unref()
28
+
29
+ // Health check after spawn: wait 2s, then ping
30
+ setTimeout(async () => {
31
+ const health = await checkSoloditHealth(port, true)
32
+ if (!health.reachable) {
33
+ logger.debug(`Solodit MCP not yet reachable on port ${port} — will retry on first use`)
34
+ } else {
35
+ logger.debug(`Solodit MCP healthy on port ${port}`)
36
+ }
37
+ }, 2000)
17
38
  }
18
39
 
19
40
  const ArgusPlugin: Plugin = async (ctx) => {
@@ -21,6 +42,8 @@ const ArgusPlugin: Plugin = async (ctx) => {
21
42
  const config = loadArgusConfig(projectDir)
22
43
 
23
44
  if (config.solodit?.enabled !== false) {
45
+ // Fire-and-forget: startSoloditMcp is now async but we don't await
46
+ // to avoid blocking plugin initialization
24
47
  startSoloditMcp(config.solodit?.port ?? 3000)
25
48
  }
26
49
 
@@ -0,0 +1,53 @@
1
+ export interface RetryOptions<T> {
2
+ maxAttempts: number;
3
+ baseDelayMs: number;
4
+ shouldRetry: (error: unknown) => boolean;
5
+ onRetry?: (attempt: number, error: unknown) => void;
6
+ _valueType?: T;
7
+ }
8
+
9
+ export interface RetryResult<T> {
10
+ success: boolean;
11
+ value?: T;
12
+ error?: unknown;
13
+ attempts: number;
14
+ }
15
+
16
+ function sleep(delayMs: number): Promise<void> {
17
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
18
+ }
19
+
20
+ export async function withRetry<T>(
21
+ fn: () => Promise<T>,
22
+ options: RetryOptions<T>
23
+ ): Promise<RetryResult<T>> {
24
+ const maxAttempts = options.maxAttempts > 0 ? options.maxAttempts : 1;
25
+ let lastError: unknown;
26
+
27
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
28
+ try {
29
+ const value = await fn();
30
+ return { success: true, value, attempts: attempt };
31
+ } catch (error) {
32
+ lastError = error;
33
+ const canRetry = attempt < maxAttempts && options.shouldRetry(error);
34
+
35
+ if (!canRetry) {
36
+ return { success: false, error, attempts: attempt };
37
+ }
38
+
39
+ if (options.onRetry) {
40
+ options.onRetry(attempt, error);
41
+ }
42
+
43
+ const delay = options.baseDelayMs * 2 ** (attempt - 1);
44
+ await sleep(delay);
45
+ }
46
+ }
47
+
48
+ return {
49
+ success: false,
50
+ error: lastError,
51
+ attempts: maxAttempts,
52
+ };
53
+ }
@@ -150,6 +150,24 @@ function parseStats(raw: unknown): ScvdStats {
150
150
  };
151
151
  }
152
152
 
153
+ export class ScvdNetworkError extends Error {
154
+ override readonly name = "ScvdNetworkError" as const;
155
+
156
+ constructor(message: string) {
157
+ super(message);
158
+ }
159
+ }
160
+
161
+ export class ScvdApiError extends Error {
162
+ override readonly name = "ScvdApiError" as const;
163
+ readonly httpStatus: number;
164
+
165
+ constructor(httpStatus: number, message?: string) {
166
+ super(message ?? `SCVD API error: HTTP ${httpStatus}`);
167
+ this.httpStatus = httpStatus;
168
+ }
169
+ }
170
+
153
171
  export class ScvdClient {
154
172
  private readonly baseUrl: string;
155
173
  private readonly signal?: AbortSignal;
@@ -167,11 +185,14 @@ export class ScvdClient {
167
185
  response = await fetch(url, { signal: this.signal });
168
186
  } catch (error) {
169
187
  const message = error instanceof Error ? error.message : "unknown network error";
170
- throw new Error(`Failed to fetch SCVD stats from ${url}: ${message}`);
188
+ throw new ScvdNetworkError(`Failed to fetch SCVD stats from ${url}: ${message}`);
171
189
  }
172
190
 
173
191
  if (!response.ok) {
174
- throw new Error(`Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`);
192
+ throw new ScvdApiError(
193
+ response.status,
194
+ `Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`
195
+ );
175
196
  }
176
197
 
177
198
  const body = (await response.json()) as unknown;
@@ -198,17 +219,23 @@ export class ScvdClient {
198
219
  const query = searchParams.toString();
199
220
  const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`;
200
221
 
222
+ let response: Response;
201
223
  try {
202
- const response = await fetch(url, { signal: this.signal });
203
- if (!response.ok) {
204
- return [];
205
- }
224
+ response = await fetch(url, { signal: this.signal });
225
+ } catch (error) {
226
+ const message = error instanceof Error ? error.message : "unknown network error";
227
+ throw new ScvdNetworkError(`Failed to fetch SCVD findings from ${url}: ${message}`);
228
+ }
206
229
 
207
- const body = (await response.json()) as unknown;
208
- return parseFindings(body);
209
- } catch {
210
- return []; // network error treat as empty page
230
+ if (!response.ok) {
231
+ throw new ScvdApiError(
232
+ response.status,
233
+ `SCVD API error: HTTP ${response.status} for ${url}`
234
+ );
211
235
  }
236
+
237
+ const body = (await response.json()) as unknown;
238
+ return parseFindings(body);
212
239
  }
213
240
 
214
241
  async fetchAllFindings(onProgress?: (count: number) => void): Promise<ScvdFinding[]> {
@@ -0,0 +1,89 @@
1
+ export type SyncError = {
2
+ status: "error";
3
+ success: false;
4
+ reason: "network" | "api" | "parse";
5
+ message: string;
6
+ error: string;
7
+ httpStatus?: number;
8
+ newFindings: 0;
9
+ totalIndexed: 0;
10
+ lastSync: string;
11
+ attempts?: number;
12
+ };
13
+
14
+ export type SyncSuccess = {
15
+ status: "success";
16
+ success: true;
17
+ newFindings: number;
18
+ totalIndexed: number;
19
+ lastSync: string;
20
+ error?: undefined;
21
+ attempts?: number;
22
+ };
23
+
24
+ export type SyncStale = {
25
+ status: "stale";
26
+ success: false;
27
+ newFindings: 0;
28
+ totalIndexed: 0;
29
+ lastSync: string;
30
+ error?: undefined;
31
+ daysSinceSync: number;
32
+ attempts?: number;
33
+ };
34
+
35
+ export type SyncOutcome = SyncSuccess | SyncError | SyncStale;
36
+
37
+ export function createNetworkError(message: string): SyncError {
38
+ return {
39
+ status: "error",
40
+ success: false,
41
+ reason: "network",
42
+ message,
43
+ error: message,
44
+ newFindings: 0,
45
+ totalIndexed: 0,
46
+ lastSync: new Date().toISOString(),
47
+ };
48
+ }
49
+
50
+ export function createApiError(httpStatus: number, message: string): SyncError {
51
+ return {
52
+ status: "error",
53
+ success: false,
54
+ reason: "api",
55
+ message,
56
+ error: message,
57
+ httpStatus,
58
+ newFindings: 0,
59
+ totalIndexed: 0,
60
+ lastSync: new Date().toISOString(),
61
+ };
62
+ }
63
+
64
+ export function createParseError(message: string): SyncError {
65
+ return {
66
+ status: "error",
67
+ success: false,
68
+ reason: "parse",
69
+ message,
70
+ error: message,
71
+ newFindings: 0,
72
+ totalIndexed: 0,
73
+ lastSync: new Date().toISOString(),
74
+ };
75
+ }
76
+
77
+ export function createSyncSuccess(
78
+ data: Omit<SyncSuccess, "status" | "success" | "error"> & { attempts?: number }
79
+ ): SyncSuccess {
80
+ return {
81
+ status: "success",
82
+ success: true,
83
+ ...data,
84
+ };
85
+ }
86
+
87
+ export function isRetryableError(outcome: SyncOutcome): boolean {
88
+ return outcome.status === "error" && outcome.reason === "network";
89
+ }
@@ -10,15 +10,42 @@ export interface ScvdIndexEntry {
10
10
  repoUrl: string;
11
11
  }
12
12
 
13
+ export interface ScvdIndexMetadata {
14
+ lastSuccess: string | null;
15
+ lastAttempt: string | null;
16
+ errorCount: number;
17
+ lastError: string | null;
18
+ lastErrorReason: string | null;
19
+ }
20
+
13
21
  export interface ScvdIndex {
14
22
  version: number;
15
23
  lastSync: string;
16
24
  totalFindings: number;
17
25
  entries: ScvdIndexEntry[];
26
+ metadata?: ScvdIndexMetadata;
18
27
  }
19
28
 
20
29
  const INDEX_VERSION = 1;
21
30
  const DEFAULT_LIMIT = 10;
31
+ let syncInProgress = false;
32
+
33
+ export function acquireSyncLock(): boolean {
34
+ if (syncInProgress) {
35
+ return false;
36
+ }
37
+
38
+ syncInProgress = true;
39
+ return true;
40
+ }
41
+
42
+ export function releaseSyncLock(): void {
43
+ syncInProgress = false;
44
+ }
45
+
46
+ export function isSyncLocked(): boolean {
47
+ return syncInProgress;
48
+ }
22
49
 
23
50
  function normalizeKeywordInput(value: string): string[] {
24
51
  return value
@@ -96,8 +123,10 @@ export function searchIndex(
96
123
  }
97
124
 
98
125
  export async function saveIndex(index: ScvdIndex, filePath: string): Promise<void> {
99
- const json = JSON.stringify(index, null, 2);
100
- await Bun.write(filePath, json);
126
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
127
+ await Bun.write(tmpPath, JSON.stringify(index, null, 2));
128
+ const { renameSync } = await import("node:fs");
129
+ renameSync(tmpPath, filePath);
101
130
  }
102
131
 
103
132
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -142,6 +171,20 @@ function parseEntry(value: unknown): ScvdIndexEntry | null {
142
171
  };
143
172
  }
144
173
 
174
+ function parseNullableString(value: unknown): string | null {
175
+ return typeof value === "string" ? value : null;
176
+ }
177
+
178
+ function parseMetadata(raw: Record<string, unknown>): ScvdIndexMetadata {
179
+ return {
180
+ lastSuccess: parseNullableString(raw.lastSuccess),
181
+ lastAttempt: parseNullableString(raw.lastAttempt),
182
+ errorCount: typeof raw.errorCount === "number" ? raw.errorCount : 0,
183
+ lastError: parseNullableString(raw.lastError),
184
+ lastErrorReason: parseNullableString(raw.lastErrorReason),
185
+ };
186
+ }
187
+
145
188
  export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
146
189
  const file = Bun.file(filePath);
147
190
  const exists = await file.exists();
@@ -174,10 +217,17 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
174
217
  .map(parseEntry)
175
218
  .filter((entry): entry is ScvdIndexEntry => entry !== null);
176
219
 
177
- return {
220
+ const index: ScvdIndex = {
178
221
  version,
179
222
  lastSync,
180
223
  totalFindings,
181
224
  entries,
182
225
  };
226
+
227
+ const rawMetadata = raw.metadata;
228
+ if (isRecord(rawMetadata)) {
229
+ index.metadata = parseMetadata(rawMetadata);
230
+ }
231
+
232
+ return index;
183
233
  }