solidity-argus 0.1.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/AGENTS.md +3 -3
  2. package/README.md +229 -13
  3. package/package.json +37 -8
  4. package/skills/INVENTORY.md +88 -57
  5. package/skills/README.md +72 -6
  6. package/skills/case-studies/beanstalk-governance/SKILL.md +52 -0
  7. package/skills/case-studies/bzx-flash-loan/SKILL.md +53 -0
  8. package/skills/case-studies/cream-finance/SKILL.md +52 -0
  9. package/skills/case-studies/curve-reentrancy/SKILL.md +52 -0
  10. package/skills/case-studies/dao-hack/SKILL.md +51 -0
  11. package/skills/case-studies/euler-finance/SKILL.md +52 -0
  12. package/skills/case-studies/harvest-finance/SKILL.md +52 -0
  13. package/skills/case-studies/level-finance/SKILL.md +51 -0
  14. package/skills/case-studies/mango-markets/SKILL.md +53 -0
  15. package/skills/case-studies/nomad-bridge/SKILL.md +51 -0
  16. package/skills/case-studies/parity-multisig/SKILL.md +55 -0
  17. package/skills/case-studies/poly-network/SKILL.md +51 -0
  18. package/skills/case-studies/rari-fuse/SKILL.md +51 -0
  19. package/skills/case-studies/ronin-bridge/SKILL.md +52 -0
  20. package/skills/case-studies/wormhole-bridge/SKILL.md +51 -0
  21. package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
  22. package/skills/manifests/cyfrin.json +16 -0
  23. package/skills/manifests/defifofum.json +25 -0
  24. package/skills/manifests/kadenzipfel.json +48 -0
  25. package/skills/manifests/scvd.json +9 -0
  26. package/skills/manifests/smartbugs.json +9 -0
  27. package/skills/manifests/solodit.json +9 -0
  28. package/skills/manifests/sunweb3sec.json +9 -0
  29. package/skills/manifests/trailofbits.json +9 -0
  30. package/skills/methodology/audit-workflow/SKILL.md +3 -0
  31. package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
  32. package/skills/references/exploit-reference/SKILL.md +3 -0
  33. package/skills/vulnerability-patterns/access-control/SKILL.md +27 -0
  34. package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +13 -1
  35. package/skills/vulnerability-patterns/assert-violation/SKILL.md +8 -1
  36. package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +12 -1
  37. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +8 -1
  38. package/skills/vulnerability-patterns/cross-chain-bridge-vulnerabilities/SKILL.md +217 -0
  39. package/skills/vulnerability-patterns/default-visibility/SKILL.md +13 -1
  40. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +8 -1
  41. package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +8 -1
  42. package/skills/vulnerability-patterns/dos-revert/SKILL.md +14 -1
  43. package/skills/vulnerability-patterns/erc4626-exchange-rate-manipulation/SKILL.md +64 -0
  44. package/skills/vulnerability-patterns/fee-on-transfer-tokens/SKILL.md +93 -0
  45. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +13 -0
  46. package/skills/vulnerability-patterns/floating-pragma/SKILL.md +8 -1
  47. package/skills/vulnerability-patterns/front-running-attacks/SKILL.md +209 -0
  48. package/skills/vulnerability-patterns/gas-optimization-patterns/SKILL.md +203 -0
  49. package/skills/vulnerability-patterns/governance-attacks/SKILL.md +208 -0
  50. package/skills/vulnerability-patterns/hash-collision/SKILL.md +8 -1
  51. package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +12 -1
  52. package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +8 -1
  53. package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +8 -1
  54. package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +12 -1
  55. package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +7 -1
  56. package/skills/vulnerability-patterns/logic-errors/SKILL.md +10 -0
  57. package/skills/vulnerability-patterns/missing-parameter-bounds/SKILL.md +44 -0
  58. package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +17 -1
  59. package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +12 -1
  60. package/skills/vulnerability-patterns/off-by-one/SKILL.md +7 -1
  61. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +22 -0
  62. package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +8 -1
  63. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +11 -1
  64. package/skills/vulnerability-patterns/proxy-vulnerabilities/SKILL.md +209 -0
  65. package/skills/vulnerability-patterns/reentrancy/SKILL.md +22 -0
  66. package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +8 -1
  67. package/skills/vulnerability-patterns/share-accounting-desynchronization/SKILL.md +44 -0
  68. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +11 -1
  69. package/skills/vulnerability-patterns/stateful-parameter-update-drift/SKILL.md +44 -0
  70. package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +12 -1
  71. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +13 -1
  72. package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +8 -1
  73. package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +8 -1
  74. package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +8 -1
  75. package/skills/vulnerability-patterns/unsafe-erc20-transfers/SKILL.md +132 -0
  76. package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +12 -1
  77. package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +12 -1
  78. package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +11 -1
  79. package/skills/vulnerability-patterns/unused-variables/SKILL.md +8 -1
  80. package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +8 -1
  81. package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +8 -1
  82. package/skills/vulnerability-patterns/weird-tokens/SKILL.md +10 -0
  83. package/skills/vulnerability-patterns/zero-address-misconfiguration/SKILL.md +48 -0
  84. package/src/agents/argus-prompt.ts +27 -10
  85. package/src/agents/pythia-prompt.ts +7 -8
  86. package/src/agents/scribe-prompt.ts +10 -5
  87. package/src/agents/sentinel-prompt.ts +36 -7
  88. package/src/cli/cli-output.ts +16 -0
  89. package/src/cli/cli-program.ts +29 -22
  90. package/src/cli/commands/check-skills.ts +135 -0
  91. package/src/cli/commands/doctor.ts +303 -23
  92. package/src/cli/commands/init.ts +8 -6
  93. package/src/cli/commands/install.ts +10 -8
  94. package/src/cli/commands/lint-skills.ts +118 -0
  95. package/src/cli/index.ts +5 -5
  96. package/src/cli/tui-prompts.ts +4 -2
  97. package/src/cli/types.ts +3 -3
  98. package/src/config/index.ts +1 -1
  99. package/src/config/loader.ts +4 -6
  100. package/src/config/schema.ts +6 -5
  101. package/src/config/types.ts +2 -2
  102. package/src/constants/defaults.ts +2 -0
  103. package/src/create-hooks.ts +225 -29
  104. package/src/create-managers.ts +10 -8
  105. package/src/create-tools.ts +14 -8
  106. package/src/features/background-agent/background-manager.ts +93 -87
  107. package/src/features/background-agent/index.ts +1 -1
  108. package/src/features/context-monitor/context-monitor.ts +3 -3
  109. package/src/features/context-monitor/index.ts +2 -2
  110. package/src/features/error-recovery/session-recovery.ts +2 -4
  111. package/src/features/error-recovery/tool-error-recovery.ts +79 -19
  112. package/src/features/index.ts +5 -5
  113. package/src/features/persistent-state/audit-state-manager.ts +158 -52
  114. package/src/features/persistent-state/global-run-index.ts +38 -0
  115. package/src/features/persistent-state/index.ts +1 -1
  116. package/src/features/persistent-state/run-journal.ts +86 -0
  117. package/src/hooks/agent-tracker.ts +53 -0
  118. package/src/hooks/compaction-hook.ts +46 -37
  119. package/src/hooks/config-handler.ts +31 -11
  120. package/src/hooks/context-budget.ts +42 -0
  121. package/src/hooks/event-hook.ts +48 -23
  122. package/src/hooks/hook-system.ts +4 -4
  123. package/src/hooks/index.ts +5 -5
  124. package/src/hooks/knowledge-sync-hook.ts +19 -21
  125. package/src/hooks/recon-context-builder.ts +66 -0
  126. package/src/hooks/safe-create-hook.ts +9 -11
  127. package/src/hooks/system-prompt-hook.ts +128 -0
  128. package/src/hooks/tool-tracking-hook.ts +162 -29
  129. package/src/hooks/types.ts +2 -1
  130. package/src/index.ts +23 -13
  131. package/src/knowledge/retry.ts +53 -0
  132. package/src/knowledge/scvd-client.ts +103 -83
  133. package/src/knowledge/scvd-errors.ts +89 -0
  134. package/src/knowledge/scvd-index.ts +110 -62
  135. package/src/knowledge/scvd-sync.ts +223 -47
  136. package/src/knowledge/source-manifest.ts +102 -0
  137. package/src/managers/index.ts +1 -1
  138. package/src/managers/types.ts +19 -14
  139. package/src/plugin-interface.ts +19 -8
  140. package/src/shared/binary-utils.ts +44 -34
  141. package/src/shared/deep-merge.ts +55 -36
  142. package/src/shared/file-utils.ts +21 -19
  143. package/src/shared/index.ts +11 -5
  144. package/src/shared/jsonc-parser.ts +123 -28
  145. package/src/shared/logger.ts +91 -17
  146. package/src/shared/project-utils.ts +30 -0
  147. package/src/skills/analysis/cluster.ts +414 -0
  148. package/src/skills/analysis/gates.ts +227 -0
  149. package/src/skills/analysis/index.ts +33 -0
  150. package/src/skills/analysis/normalize.ts +217 -0
  151. package/src/skills/analysis/similarity.ts +224 -0
  152. package/src/skills/argus-skill-resolver.ts +237 -0
  153. package/src/skills/skill-schema.ts +99 -0
  154. package/src/solodit-lifecycle.ts +202 -0
  155. package/src/state/audit-state.ts +10 -8
  156. package/src/state/finding-store.ts +68 -55
  157. package/src/state/types.ts +96 -44
  158. package/src/tools/argus-skill-load-tool.ts +78 -0
  159. package/src/tools/contract-analyzer-tool.ts +60 -77
  160. package/src/tools/forge-coverage-tool.ts +226 -0
  161. package/src/tools/forge-fuzz-tool.ts +127 -127
  162. package/src/tools/forge-test-tool.ts +153 -157
  163. package/src/tools/gas-analysis-tool.ts +264 -0
  164. package/src/tools/pattern-checker-tool.ts +206 -167
  165. package/src/tools/pattern-loader.ts +77 -0
  166. package/src/tools/pattern-schema.ts +51 -0
  167. package/src/tools/proxy-detection-tool.ts +224 -0
  168. package/src/tools/report-generator-tool.ts +333 -142
  169. package/src/tools/slither-tool.ts +300 -210
  170. package/src/tools/solodit-search-tool.ts +255 -80
  171. package/src/tools/sync-knowledge-tool.ts +7 -11
  172. package/src/utils/audit-artifact-detector.ts +118 -0
  173. package/src/utils/dependency-scanner.ts +93 -0
  174. package/src/utils/project-detector.ts +175 -86
  175. package/src/utils/solidity-parser.ts +112 -67
  176. package/src/utils/solodit-health.ts +29 -0
  177. package/src/hooks/event-hook-v2.ts +0 -99
  178. package/src/state/plugin-state.ts +0 -14
@@ -1,79 +1,79 @@
1
1
  export interface ScvdFinding {
2
- scvd_id: string;
3
- doc_id: string;
4
- title: string;
5
- description_md: string;
6
- severity: "Critical" | "High" | "Medium" | "Low" | "Informational";
7
- taxonomy: { swc: string[]; cwe: string[] };
8
- repo: { url: string; commit?: string; lines?: [number, number] };
9
- sections: { recommendation_md?: string; poc_md?: string };
2
+ scvd_id: string
3
+ doc_id: string
4
+ title: string
5
+ description_md: string
6
+ severity: "Critical" | "High" | "Medium" | "Low" | "Informational"
7
+ taxonomy: { swc: string[]; cwe: string[] }
8
+ repo: { url: string; commit?: string; lines?: [number, number] }
9
+ sections: { recommendation_md?: string; poc_md?: string }
10
10
  }
11
11
 
12
12
  export interface ScvdStats {
13
- total: number;
14
- by_severity: Record<string, number>;
15
- last_updated: string;
13
+ total: number
14
+ by_severity: Record<string, number>
15
+ last_updated: string
16
16
  }
17
17
 
18
- const DEFAULT_PAGE_SIZE = 100;
18
+ const DEFAULT_PAGE_SIZE = 100
19
19
 
20
20
  function isRecord(value: unknown): value is Record<string, unknown> {
21
- return typeof value === "object" && value !== null;
21
+ return typeof value === "object" && value !== null
22
22
  }
23
23
 
24
24
  function toStringArray(value: unknown): string[] {
25
25
  if (!Array.isArray(value)) {
26
- return [];
26
+ return []
27
27
  }
28
28
 
29
- return value.filter((item): item is string => typeof item === "string");
29
+ return value.filter((item): item is string => typeof item === "string")
30
30
  }
31
31
 
32
32
  function toNumberRecord(value: unknown): Record<string, number> {
33
33
  if (!isRecord(value)) {
34
- return {};
34
+ return {}
35
35
  }
36
36
 
37
- const output: Record<string, number> = {};
37
+ const output: Record<string, number> = {}
38
38
  for (const [key, rawValue] of Object.entries(value)) {
39
39
  if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
40
- output[key] = rawValue;
40
+ output[key] = rawValue
41
41
  }
42
42
  }
43
43
 
44
- return output;
44
+ return output
45
45
  }
46
46
 
47
47
  function parseLines(value: unknown): [number, number] | undefined {
48
48
  if (!Array.isArray(value) || value.length !== 2) {
49
- return undefined;
49
+ return undefined
50
50
  }
51
51
 
52
- const start = value[0];
53
- const end = value[1];
52
+ const start = value[0]
53
+ const end = value[1]
54
54
 
55
55
  if (typeof start !== "number" || typeof end !== "number") {
56
- return undefined;
56
+ return undefined
57
57
  }
58
58
 
59
- return [start, end];
59
+ return [start, end]
60
60
  }
61
61
 
62
62
  function parseFinding(raw: unknown): ScvdFinding | null {
63
63
  if (!isRecord(raw)) {
64
- return null;
64
+ return null
65
65
  }
66
66
 
67
- const taxonomyRaw = isRecord(raw.taxonomy) ? raw.taxonomy : {};
68
- const repoRaw = isRecord(raw.repo) ? raw.repo : {};
69
- const sectionsRaw = isRecord(raw.sections) ? raw.sections : {};
67
+ const taxonomyRaw = isRecord(raw.taxonomy) ? raw.taxonomy : {}
68
+ const repoRaw = isRecord(raw.repo) ? raw.repo : {}
69
+ const sectionsRaw = isRecord(raw.sections) ? raw.sections : {}
70
70
 
71
- const scvdId = raw.scvd_id;
72
- const docId = raw.doc_id;
73
- const title = raw.title;
74
- const description = raw.description_md;
75
- const severity = raw.severity;
76
- const repoUrl = repoRaw.url;
71
+ const scvdId = raw.scvd_id
72
+ const docId = raw.doc_id
73
+ const title = raw.title
74
+ const description = raw.description_md
75
+ const severity = raw.severity
76
+ const repoUrl = repoRaw.url
77
77
 
78
78
  if (
79
79
  typeof scvdId !== "string" ||
@@ -82,7 +82,7 @@ function parseFinding(raw: unknown): ScvdFinding | null {
82
82
  typeof description !== "string" ||
83
83
  typeof repoUrl !== "string"
84
84
  ) {
85
- return null;
85
+ return null
86
86
  }
87
87
 
88
88
  if (
@@ -92,7 +92,7 @@ function parseFinding(raw: unknown): ScvdFinding | null {
92
92
  severity !== "Low" &&
93
93
  severity !== "Informational"
94
94
  ) {
95
- return null;
95
+ return null
96
96
  }
97
97
 
98
98
  return {
@@ -117,126 +117,146 @@ function parseFinding(raw: unknown): ScvdFinding | null {
117
117
  : undefined,
118
118
  poc_md: typeof sectionsRaw.poc_md === "string" ? sectionsRaw.poc_md : undefined,
119
119
  },
120
- };
120
+ }
121
121
  }
122
122
 
123
123
  function parseFindings(raw: unknown): ScvdFinding[] {
124
124
  if (!Array.isArray(raw)) {
125
125
  if (isRecord(raw) && Array.isArray(raw.data)) {
126
- return raw.data.map(parseFinding).filter((value): value is ScvdFinding => value !== null);
126
+ return raw.data.map(parseFinding).filter((value): value is ScvdFinding => value !== null)
127
127
  }
128
- return [];
128
+ return []
129
129
  }
130
130
 
131
- return raw.map(parseFinding).filter((value): value is ScvdFinding => value !== null);
131
+ return raw.map(parseFinding).filter((value): value is ScvdFinding => value !== null)
132
132
  }
133
133
 
134
134
  function parseStats(raw: unknown): ScvdStats {
135
135
  if (!isRecord(raw)) {
136
- throw new Error("Invalid SCVD stats response payload");
136
+ throw new Error("Invalid SCVD stats response payload")
137
137
  }
138
138
 
139
- const total = raw.total;
140
- const lastUpdated = raw.last_updated;
139
+ const total = raw.total
140
+ const lastUpdated = raw.last_updated
141
141
 
142
142
  if (typeof total !== "number" || typeof lastUpdated !== "string") {
143
- throw new Error("Invalid SCVD stats fields in response");
143
+ throw new Error("Invalid SCVD stats fields in response")
144
144
  }
145
145
 
146
146
  return {
147
147
  total,
148
148
  by_severity: toNumberRecord(raw.by_severity),
149
149
  last_updated: lastUpdated,
150
- };
150
+ }
151
+ }
152
+
153
+ export class ScvdNetworkError extends Error {
154
+ override readonly name = "ScvdNetworkError" as const
155
+ }
156
+
157
+ export class ScvdApiError extends Error {
158
+ override readonly name = "ScvdApiError" as const
159
+ readonly httpStatus: number
160
+
161
+ constructor(httpStatus: number, message?: string) {
162
+ super(message ?? `SCVD API error: HTTP ${httpStatus}`)
163
+ this.httpStatus = httpStatus
164
+ }
151
165
  }
152
166
 
153
167
  export class ScvdClient {
154
- private readonly baseUrl: string;
155
- private readonly signal?: AbortSignal;
168
+ private readonly baseUrl: string
169
+ private readonly signal?: AbortSignal
156
170
 
157
171
  constructor(apiUrl: string, signal?: AbortSignal) {
158
- this.baseUrl = apiUrl.replace(/\/$/, "");
159
- this.signal = signal;
172
+ this.baseUrl = apiUrl.replace(/\/$/, "")
173
+ this.signal = signal
160
174
  }
161
175
 
162
176
  async fetchStats(): Promise<ScvdStats> {
163
- const url = `${this.baseUrl}/stats`;
177
+ const url = `${this.baseUrl}/stats`
164
178
 
165
- let response: Response;
179
+ let response: Response
166
180
  try {
167
- response = await fetch(url, { signal: this.signal });
181
+ response = await fetch(url, { signal: this.signal })
168
182
  } catch (error) {
169
- const message = error instanceof Error ? error.message : "unknown network error";
170
- throw new Error(`Failed to fetch SCVD stats from ${url}: ${message}`);
183
+ const message = error instanceof Error ? error.message : "unknown network error"
184
+ throw new ScvdNetworkError(`Failed to fetch SCVD stats from ${url}: ${message}`)
171
185
  }
172
186
 
173
187
  if (!response.ok) {
174
- throw new Error(`Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`);
188
+ throw new ScvdApiError(
189
+ response.status,
190
+ `Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`,
191
+ )
175
192
  }
176
193
 
177
- const body = (await response.json()) as unknown;
178
- return parseStats(body);
194
+ const body = (await response.json()) as unknown
195
+ return parseStats(body)
179
196
  }
180
197
 
181
198
  async fetchFindings(params: {
182
- severity?: string;
183
- limit?: number;
184
- offset?: number;
199
+ severity?: string
200
+ limit?: number
201
+ offset?: number
185
202
  }): Promise<ScvdFinding[]> {
186
- const searchParams = new URLSearchParams();
203
+ const searchParams = new URLSearchParams()
187
204
 
188
205
  if (params.severity) {
189
- searchParams.set("severity", params.severity);
206
+ searchParams.set("severity", params.severity)
190
207
  }
191
208
  if (typeof params.limit === "number") {
192
- searchParams.set("limit", String(params.limit));
209
+ searchParams.set("limit", String(params.limit))
193
210
  }
194
211
  if (typeof params.offset === "number") {
195
- searchParams.set("offset", String(params.offset));
212
+ searchParams.set("offset", String(params.offset))
196
213
  }
197
214
 
198
- const query = searchParams.toString();
199
- const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`;
215
+ const query = searchParams.toString()
216
+ const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`
200
217
 
218
+ let response: Response
201
219
  try {
202
- const response = await fetch(url, { signal: this.signal });
203
- if (!response.ok) {
204
- return [];
205
- }
220
+ response = await fetch(url, { signal: this.signal })
221
+ } catch (error) {
222
+ const message = error instanceof Error ? error.message : "unknown network error"
223
+ throw new ScvdNetworkError(`Failed to fetch SCVD findings from ${url}: ${message}`)
224
+ }
206
225
 
207
- const body = (await response.json()) as unknown;
208
- return parseFindings(body);
209
- } catch {
210
- return []; // network error — treat as empty page
226
+ if (!response.ok) {
227
+ throw new ScvdApiError(response.status, `SCVD API error: HTTP ${response.status} for ${url}`)
211
228
  }
229
+
230
+ const body = (await response.json()) as unknown
231
+ return parseFindings(body)
212
232
  }
213
233
 
214
234
  async fetchAllFindings(onProgress?: (count: number) => void): Promise<ScvdFinding[]> {
215
- const results: ScvdFinding[] = [];
216
- let offset = 0;
235
+ const results: ScvdFinding[] = []
236
+ let offset = 0
217
237
 
218
238
  while (true) {
219
239
  const page = await this.fetchFindings({
220
240
  limit: DEFAULT_PAGE_SIZE,
221
241
  offset,
222
- });
242
+ })
223
243
 
224
244
  if (page.length === 0) {
225
- break;
245
+ break
226
246
  }
227
247
 
228
- results.push(...page);
229
- offset += page.length;
248
+ results.push(...page)
249
+ offset += page.length
230
250
 
231
251
  if (onProgress) {
232
- onProgress(results.length);
252
+ onProgress(results.length)
233
253
  }
234
254
 
235
255
  if (page.length < DEFAULT_PAGE_SIZE) {
236
- break;
256
+ break
237
257
  }
238
258
  }
239
259
 
240
- return results;
260
+ return results
241
261
  }
242
262
  }
@@ -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
+ }
@@ -1,39 +1,66 @@
1
- import type { ScvdFinding } from "./scvd-client";
1
+ import type { ScvdFinding } from "./scvd-client"
2
2
 
3
3
  export interface ScvdIndexEntry {
4
- id: string;
5
- title: string;
6
- severity: string;
7
- swc: string[];
8
- cwe: string[];
9
- keywords: string[];
10
- repoUrl: string;
4
+ id: string
5
+ title: string
6
+ severity: string
7
+ swc: string[]
8
+ cwe: string[]
9
+ keywords: string[]
10
+ repoUrl: string
11
+ }
12
+
13
+ export interface ScvdIndexMetadata {
14
+ lastSuccess: string | null
15
+ lastAttempt: string | null
16
+ errorCount: number
17
+ lastError: string | null
18
+ lastErrorReason: string | null
11
19
  }
12
20
 
13
21
  export interface ScvdIndex {
14
- version: number;
15
- lastSync: string;
16
- totalFindings: number;
17
- entries: ScvdIndexEntry[];
22
+ version: number
23
+ lastSync: string
24
+ totalFindings: number
25
+ entries: ScvdIndexEntry[]
26
+ metadata?: ScvdIndexMetadata
18
27
  }
19
28
 
20
- const INDEX_VERSION = 1;
21
- const DEFAULT_LIMIT = 10;
29
+ const INDEX_VERSION = 1
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
25
52
  .toLowerCase()
26
53
  .split(/[^a-z0-9]+/g)
27
54
  .map((word) => word.trim())
28
- .filter((word) => word.length > 1);
55
+ .filter((word) => word.length > 1)
29
56
  }
30
57
 
31
58
  function uniqueWords(words: string[]): string[] {
32
- return Array.from(new Set(words));
59
+ return Array.from(new Set(words))
33
60
  }
34
61
 
35
62
  function findingToEntry(finding: ScvdFinding): ScvdIndexEntry {
36
- const keywordSource = `${finding.title} ${finding.description_md}`;
63
+ const keywordSource = `${finding.title} ${finding.description_md}`
37
64
 
38
65
  return {
39
66
  id: finding.scvd_id,
@@ -43,84 +70,84 @@ function findingToEntry(finding: ScvdFinding): ScvdIndexEntry {
43
70
  cwe: finding.taxonomy.cwe,
44
71
  keywords: uniqueWords(normalizeKeywordInput(keywordSource)),
45
72
  repoUrl: finding.repo.url,
46
- };
73
+ }
47
74
  }
48
75
 
49
76
  export function buildIndex(findings: ScvdFinding[]): ScvdIndex {
50
- const now = new Date().toISOString();
51
- const entries = findings.map(findingToEntry);
77
+ const now = new Date().toISOString()
78
+ const entries = findings.map(findingToEntry)
52
79
 
53
80
  return {
54
81
  version: INDEX_VERSION,
55
82
  lastSync: now,
56
83
  totalFindings: entries.length,
57
84
  entries,
58
- };
85
+ }
59
86
  }
60
87
 
61
88
  export function searchIndex(
62
89
  index: ScvdIndex,
63
90
  query: {
64
- swc?: string;
65
- severity?: string;
66
- keyword?: string;
67
- limit?: number;
68
- }
91
+ swc?: string
92
+ severity?: string
93
+ keyword?: string
94
+ limit?: number
95
+ },
69
96
  ): ScvdIndexEntry[] {
70
- const normalizedKeyword = query.keyword?.toLowerCase().trim();
71
- const limit = query.limit ?? DEFAULT_LIMIT;
97
+ const normalizedKeyword = query.keyword?.toLowerCase().trim()
98
+ const limit = query.limit ?? DEFAULT_LIMIT
72
99
 
73
100
  const filtered = index.entries.filter((entry) => {
74
101
  if (query.swc && !entry.swc.includes(query.swc)) {
75
- return false;
102
+ return false
76
103
  }
77
104
 
78
105
  if (query.severity && entry.severity !== query.severity) {
79
- return false;
106
+ return false
80
107
  }
81
108
 
82
109
  if (normalizedKeyword && normalizedKeyword.length > 0) {
83
- const matchesKeyword = entry.keywords.some((keyword) =>
84
- keyword.includes(normalizedKeyword)
85
- );
110
+ const matchesKeyword = entry.keywords.some((keyword) => keyword.includes(normalizedKeyword))
86
111
 
87
112
  if (!matchesKeyword) {
88
- return false;
113
+ return false
89
114
  }
90
115
  }
91
116
 
92
- return true;
93
- });
117
+ return true
118
+ })
94
119
 
95
- return filtered.slice(0, limit);
120
+ return filtered.slice(0, limit)
96
121
  }
97
122
 
98
123
  export async function saveIndex(index: ScvdIndex, filePath: string): Promise<void> {
99
- const json = JSON.stringify(index, null, 2);
100
- await Bun.write(filePath, json);
124
+ const tmpPath = `${filePath}.tmp.${Date.now()}`
125
+ const { renameSync } = await import("node:fs")
126
+ await Bun.write(tmpPath, JSON.stringify(index, null, 2))
127
+ renameSync(tmpPath, filePath)
101
128
  }
102
129
 
103
130
  function isRecord(value: unknown): value is Record<string, unknown> {
104
- return typeof value === "object" && value !== null;
131
+ return typeof value === "object" && value !== null
105
132
  }
106
133
 
107
134
  function parseStringArray(value: unknown): string[] {
108
135
  if (!Array.isArray(value)) {
109
- return [];
136
+ return []
110
137
  }
111
138
 
112
- return value.filter((item): item is string => typeof item === "string");
139
+ return value.filter((item): item is string => typeof item === "string")
113
140
  }
114
141
 
115
142
  function parseEntry(value: unknown): ScvdIndexEntry | null {
116
143
  if (!isRecord(value)) {
117
- return null;
144
+ return null
118
145
  }
119
146
 
120
- const id = value.id;
121
- const title = value.title;
122
- const severity = value.severity;
123
- const repoUrl = value.repoUrl;
147
+ const id = value.id
148
+ const title = value.title
149
+ const severity = value.severity
150
+ const repoUrl = value.repoUrl
124
151
 
125
152
  if (
126
153
  typeof id !== "string" ||
@@ -128,7 +155,7 @@ function parseEntry(value: unknown): ScvdIndexEntry | null {
128
155
  typeof severity !== "string" ||
129
156
  typeof repoUrl !== "string"
130
157
  ) {
131
- return null;
158
+ return null
132
159
  }
133
160
 
134
161
  return {
@@ -139,27 +166,41 @@ function parseEntry(value: unknown): ScvdIndexEntry | null {
139
166
  cwe: parseStringArray(value.cwe),
140
167
  keywords: parseStringArray(value.keywords),
141
168
  repoUrl,
142
- };
169
+ }
170
+ }
171
+
172
+ function parseNullableString(value: unknown): string | null {
173
+ return typeof value === "string" ? value : null
174
+ }
175
+
176
+ function parseMetadata(raw: Record<string, unknown>): ScvdIndexMetadata {
177
+ return {
178
+ lastSuccess: parseNullableString(raw.lastSuccess),
179
+ lastAttempt: parseNullableString(raw.lastAttempt),
180
+ errorCount: typeof raw.errorCount === "number" ? raw.errorCount : 0,
181
+ lastError: parseNullableString(raw.lastError),
182
+ lastErrorReason: parseNullableString(raw.lastErrorReason),
183
+ }
143
184
  }
144
185
 
145
186
  export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
146
- const file = Bun.file(filePath);
147
- const exists = await file.exists();
187
+ const file = Bun.file(filePath)
188
+ const exists = await file.exists()
148
189
 
149
190
  if (!exists) {
150
- return null;
191
+ return null
151
192
  }
152
193
 
153
- const raw = (await file.json()) as unknown;
194
+ const raw = (await file.json()) as unknown
154
195
 
155
196
  if (!isRecord(raw)) {
156
- return null;
197
+ return null
157
198
  }
158
199
 
159
- const version = raw.version;
160
- const lastSync = raw.lastSync;
161
- const totalFindings = raw.totalFindings;
162
- const rawEntries = raw.entries;
200
+ const version = raw.version
201
+ const lastSync = raw.lastSync
202
+ const totalFindings = raw.totalFindings
203
+ const rawEntries = raw.entries
163
204
 
164
205
  if (
165
206
  typeof version !== "number" ||
@@ -167,17 +208,24 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
167
208
  typeof totalFindings !== "number" ||
168
209
  !Array.isArray(rawEntries)
169
210
  ) {
170
- return null;
211
+ return null
171
212
  }
172
213
 
173
214
  const entries = rawEntries
174
215
  .map(parseEntry)
175
- .filter((entry): entry is ScvdIndexEntry => entry !== null);
216
+ .filter((entry): entry is ScvdIndexEntry => entry !== null)
176
217
 
177
- return {
218
+ const index: ScvdIndex = {
178
219
  version,
179
220
  lastSync,
180
221
  totalFindings,
181
222
  entries,
182
- };
223
+ }
224
+
225
+ const rawMetadata = raw.metadata
226
+ if (isRecord(rawMetadata)) {
227
+ index.metadata = parseMetadata(rawMetadata)
228
+ }
229
+
230
+ return index
183
231
  }