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.
- package/README.md +161 -1
- package/package.json +5 -2
- package/skills/README.md +63 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
- package/skills/manifests/cyfrin.json +16 -0
- package/skills/manifests/defifofum.json +25 -0
- package/skills/manifests/kadenzipfel.json +48 -0
- package/skills/manifests/scvd.json +9 -0
- package/skills/manifests/smartbugs.json +11 -0
- package/skills/manifests/solodit.json +9 -0
- package/skills/manifests/sunweb3sec.json +11 -0
- package/skills/manifests/trailofbits.json +9 -0
- package/skills/methodology/audit-workflow/SKILL.md +3 -0
- package/skills/patterns/access-control.yaml +31 -0
- package/skills/patterns/erc4626.yaml +29 -0
- package/skills/patterns/flash-loan.yaml +20 -0
- package/skills/patterns/oracle.yaml +30 -0
- package/skills/patterns/proxy.yaml +30 -0
- package/skills/patterns/reentrancy.yaml +30 -0
- package/skills/patterns/signature.yaml +31 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
- package/skills/references/exploit-reference/SKILL.md +3 -0
- package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
- package/src/agents/argus-prompt.ts +7 -7
- package/src/agents/pythia-prompt.ts +11 -11
- package/src/agents/scribe-prompt.ts +6 -6
- package/src/agents/sentinel-prompt.ts +7 -7
- package/src/cli/cli-output.ts +16 -0
- package/src/cli/cli-program.ts +9 -5
- package/src/cli/commands/doctor.ts +274 -16
- package/src/cli/commands/init.ts +5 -5
- package/src/cli/commands/install.ts +5 -5
- package/src/cli/commands/lint-skills.ts +114 -0
- package/src/cli/tui-prompts.ts +4 -2
- package/src/config/schema.ts +2 -0
- package/src/create-hooks.ts +141 -32
- package/src/create-tools.ts +2 -0
- package/src/features/error-recovery/session-recovery.ts +7 -1
- package/src/features/error-recovery/tool-error-recovery.ts +74 -19
- package/src/features/persistent-state/audit-state-manager.ts +36 -13
- package/src/hooks/agent-tracker.ts +53 -0
- package/src/hooks/compaction-hook.ts +46 -37
- package/src/hooks/config-handler.ts +22 -9
- package/src/hooks/context-budget.ts +45 -0
- package/src/hooks/event-hook-v2.ts +8 -2
- package/src/hooks/event-hook.ts +5 -4
- package/src/hooks/knowledge-sync-hook.ts +2 -1
- package/src/hooks/recon-context-builder.ts +66 -0
- package/src/hooks/safe-create-hook.ts +4 -5
- package/src/hooks/system-prompt-hook.ts +92 -221
- package/src/hooks/tool-tracking-hook.ts +108 -9
- package/src/hooks/types.ts +0 -1
- package/src/index.ts +28 -6
- package/src/knowledge/retry.ts +53 -0
- package/src/knowledge/scvd-client.ts +37 -10
- package/src/knowledge/scvd-errors.ts +89 -0
- package/src/knowledge/scvd-index.ts +53 -3
- package/src/knowledge/scvd-sync.ts +205 -34
- package/src/knowledge/source-manifest.ts +102 -0
- package/src/plugin-interface.ts +11 -3
- package/src/shared/binary-utils.ts +1 -0
- package/src/shared/logger.ts +78 -17
- package/src/skills/argus-skill-resolver.ts +226 -0
- package/src/skills/skill-schema.ts +98 -0
- package/src/state/audit-state.ts +2 -0
- package/src/state/types.ts +32 -1
- package/src/tools/argus-skill-load-tool.ts +73 -0
- package/src/tools/pattern-checker-tool.ts +56 -12
- package/src/tools/pattern-loader.ts +183 -0
- package/src/tools/pattern-schema.ts +51 -0
- package/src/tools/report-generator-tool.ts +134 -11
- package/src/tools/slither-tool.ts +61 -19
- package/src/tools/solodit-search-tool.ts +92 -14
- package/src/utils/audit-artifact-detector.ts +119 -0
- package/src/utils/dependency-scanner.ts +93 -0
- package/src/utils/project-detector.ts +128 -26
- package/src/utils/solidity-parser.ts +20 -4
- package/src/utils/solodit-health.ts +29 -0
|
@@ -1,5 +1,6 @@
|
|
|
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
|
+
import { createFindingStore } from "../state/finding-store"
|
|
3
4
|
|
|
4
5
|
type ToolHookInput = {
|
|
5
6
|
tool: string
|
|
@@ -165,16 +166,91 @@ function processContractAnalyzerResult(
|
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
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
|
+
*/
|
|
168
249
|
function recordToolExecution(
|
|
169
250
|
state: AuditState,
|
|
170
251
|
toolName: string,
|
|
171
252
|
findingsCount: number
|
|
172
253
|
): void {
|
|
173
|
-
const alreadyRecorded = state.toolsExecuted.some(
|
|
174
|
-
(execution) => execution.tool === toolName
|
|
175
|
-
)
|
|
176
|
-
if (alreadyRecorded) return
|
|
177
|
-
|
|
178
254
|
const now = Date.now()
|
|
179
255
|
state.toolsExecuted.push({
|
|
180
256
|
tool: toolName,
|
|
@@ -193,14 +269,33 @@ function recordToolExecution(
|
|
|
193
269
|
* Findings are deduplicated via the FindingStore (by check+file+lines).
|
|
194
270
|
*/
|
|
195
271
|
export function createToolTrackingHook(
|
|
196
|
-
|
|
197
|
-
store: FindingStore
|
|
272
|
+
getAuditState: () => AuditState | null
|
|
198
273
|
): (input: ToolHookInput) => Promise<void> {
|
|
274
|
+
const storesByState = new WeakMap<AuditState, FindingStore>()
|
|
275
|
+
|
|
276
|
+
function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
|
|
277
|
+
const state = getAuditState()
|
|
278
|
+
if (!state) return null
|
|
279
|
+
|
|
280
|
+
let store = storesByState.get(state)
|
|
281
|
+
if (!store) {
|
|
282
|
+
store = createFindingStore(state)
|
|
283
|
+
storesByState.set(state, store)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { state, store }
|
|
287
|
+
}
|
|
288
|
+
|
|
199
289
|
return async (input: ToolHookInput): Promise<void> => {
|
|
200
290
|
if (!input.tool.startsWith("argus_")) {
|
|
201
291
|
return
|
|
202
292
|
}
|
|
203
293
|
|
|
294
|
+
const resolved = resolveStateAndStore()
|
|
295
|
+
if (!resolved) return
|
|
296
|
+
|
|
297
|
+
const { state: auditState, store } = resolved
|
|
298
|
+
|
|
204
299
|
let parsed: unknown
|
|
205
300
|
try {
|
|
206
301
|
parsed = JSON.parse(input.result)
|
|
@@ -223,9 +318,13 @@ export function createToolTrackingHook(
|
|
|
223
318
|
case "argus_analyze_contract":
|
|
224
319
|
processContractAnalyzerResult(record, auditState)
|
|
225
320
|
break
|
|
321
|
+
case "argus_solodit_search":
|
|
322
|
+
processSoloditResult(record, auditState)
|
|
323
|
+
break
|
|
226
324
|
case "argus_forge_test":
|
|
325
|
+
break
|
|
227
326
|
case "argus_forge_fuzz":
|
|
228
|
-
|
|
327
|
+
processFuzzResult(record, auditState)
|
|
229
328
|
break
|
|
230
329
|
}
|
|
231
330
|
|
package/src/hooks/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
import { spawn } from "node:child_process"
|
|
3
2
|
import { loadArgusConfig } from "./config/loader"
|
|
4
3
|
import { createHookGuard } from "./hooks/hook-system"
|
|
5
4
|
import { createTools } from "./create-tools"
|
|
6
5
|
import { createHooks } from "./create-hooks"
|
|
7
6
|
import { createManagers } from "./create-managers"
|
|
8
7
|
import { createPluginInterface } from "./plugin-interface"
|
|
8
|
+
import { checkSoloditHealth } from "./utils/solodit-health"
|
|
9
|
+
import { createLogger } from "./shared/logger"
|
|
9
10
|
|
|
10
|
-
function startSoloditMcp(port: number): void {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|
|
20
|
+
|
|
21
|
+
const child = Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
|
|
22
|
+
stdin: "ignore",
|
|
23
|
+
stdout: "ignore",
|
|
24
|
+
stderr: "ignore",
|
|
14
25
|
env: { ...process.env, PORT: String(port) },
|
|
15
26
|
})
|
|
16
27
|
child.unref()
|
|
17
|
-
|
|
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)
|
|
18
38
|
}
|
|
19
39
|
|
|
20
40
|
const ArgusPlugin: Plugin = async (ctx) => {
|
|
@@ -22,6 +42,8 @@ const ArgusPlugin: Plugin = async (ctx) => {
|
|
|
22
42
|
const config = loadArgusConfig(projectDir)
|
|
23
43
|
|
|
24
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
|
|
25
47
|
startSoloditMcp(config.solodit?.port ?? 3000)
|
|
26
48
|
}
|
|
27
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
|
|
188
|
+
throw new ScvdNetworkError(`Failed to fetch SCVD stats from ${url}: ${message}`);
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
if (!response.ok) {
|
|
174
|
-
throw new
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
100
|
-
await Bun.write(
|
|
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
|
-
|
|
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
|
}
|