@npm-safe/core-dsh 1.0.5

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 (86) hide show
  1. package/LICENSE +204 -0
  2. package/dist/index.d.ts +513 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +711 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/llm/anthropic.d.ts +47 -0
  7. package/dist/llm/anthropic.d.ts.map +1 -0
  8. package/dist/llm/anthropic.js +161 -0
  9. package/dist/llm/anthropic.js.map +1 -0
  10. package/dist/llm/gemini.d.ts +47 -0
  11. package/dist/llm/gemini.d.ts.map +1 -0
  12. package/dist/llm/gemini.js +165 -0
  13. package/dist/llm/gemini.js.map +1 -0
  14. package/dist/llm/llm-config.d.ts +97 -0
  15. package/dist/llm/llm-config.d.ts.map +1 -0
  16. package/dist/llm/llm-config.js +188 -0
  17. package/dist/llm/llm-config.js.map +1 -0
  18. package/dist/llm/parse.d.ts +95 -0
  19. package/dist/llm/parse.d.ts.map +1 -0
  20. package/dist/llm/parse.js +158 -0
  21. package/dist/llm/parse.js.map +1 -0
  22. package/dist/llm/provider.d.ts +122 -0
  23. package/dist/llm/provider.d.ts.map +1 -0
  24. package/dist/llm/provider.js +206 -0
  25. package/dist/llm/provider.js.map +1 -0
  26. package/dist/registry/client.d.ts +164 -0
  27. package/dist/registry/client.d.ts.map +1 -0
  28. package/dist/registry/client.js +378 -0
  29. package/dist/registry/client.js.map +1 -0
  30. package/dist/registry/types.d.ts +226 -0
  31. package/dist/registry/types.d.ts.map +1 -0
  32. package/dist/registry/types.js +32 -0
  33. package/dist/registry/types.js.map +1 -0
  34. package/dist/registry/validator.d.ts +87 -0
  35. package/dist/registry/validator.d.ts.map +1 -0
  36. package/dist/registry/validator.js +214 -0
  37. package/dist/registry/validator.js.map +1 -0
  38. package/dist/scanner/ci-scan.d.ts +82 -0
  39. package/dist/scanner/ci-scan.d.ts.map +1 -0
  40. package/dist/scanner/ci-scan.js +130 -0
  41. package/dist/scanner/ci-scan.js.map +1 -0
  42. package/dist/scanner/rule-config.d.ts +61 -0
  43. package/dist/scanner/rule-config.d.ts.map +1 -0
  44. package/dist/scanner/rule-config.js +103 -0
  45. package/dist/scanner/rule-config.js.map +1 -0
  46. package/dist/scanner/rule-loader.d.ts +28 -0
  47. package/dist/scanner/rule-loader.d.ts.map +1 -0
  48. package/dist/scanner/rule-loader.js +67 -0
  49. package/dist/scanner/rule-loader.js.map +1 -0
  50. package/dist/scanner/static-rules.d.ts +88 -0
  51. package/dist/scanner/static-rules.d.ts.map +1 -0
  52. package/dist/scanner/static-rules.js +723 -0
  53. package/dist/scanner/static-rules.js.map +1 -0
  54. package/dist/scanner/types.d.ts +177 -0
  55. package/dist/scanner/types.d.ts.map +1 -0
  56. package/dist/scanner/types.js +53 -0
  57. package/dist/scanner/types.js.map +1 -0
  58. package/dist/scheduler/rate-limiter.d.ts +74 -0
  59. package/dist/scheduler/rate-limiter.d.ts.map +1 -0
  60. package/dist/scheduler/rate-limiter.js +182 -0
  61. package/dist/scheduler/rate-limiter.js.map +1 -0
  62. package/dist/scheduler/refresh-scheduler.d.ts +201 -0
  63. package/dist/scheduler/refresh-scheduler.d.ts.map +1 -0
  64. package/dist/scheduler/refresh-scheduler.js +295 -0
  65. package/dist/scheduler/refresh-scheduler.js.map +1 -0
  66. package/dist/store/cache-manager.d.ts +166 -0
  67. package/dist/store/cache-manager.d.ts.map +1 -0
  68. package/dist/store/cache-manager.js +356 -0
  69. package/dist/store/cache-manager.js.map +1 -0
  70. package/dist/store/database.d.ts +81 -0
  71. package/dist/store/database.d.ts.map +1 -0
  72. package/dist/store/database.js +182 -0
  73. package/dist/store/database.js.map +1 -0
  74. package/dist/store/schema.d.ts +42 -0
  75. package/dist/store/schema.d.ts.map +1 -0
  76. package/dist/store/schema.js +126 -0
  77. package/dist/store/schema.js.map +1 -0
  78. package/dist/translator/provider.d.ts +152 -0
  79. package/dist/translator/provider.d.ts.map +1 -0
  80. package/dist/translator/provider.js +159 -0
  81. package/dist/translator/provider.js.map +1 -0
  82. package/dist/translator/types.d.ts +83 -0
  83. package/dist/translator/types.d.ts.map +1 -0
  84. package/dist/translator/types.js +58 -0
  85. package/dist/translator/types.js.map +1 -0
  86. package/package.json +42 -0
package/dist/index.js ADDED
@@ -0,0 +1,711 @@
1
+ /**
2
+ * Unified composition layer for @npm-safe/core.
3
+ *
4
+ * {@link NpmSafeEngine} combines the database, cache, registry client, rate
5
+ * limiter, static analyzer, and refresh scheduler into a single facade that
6
+ * exposes the full public API of the engine.
7
+ *
8
+ * @module index
9
+ */
10
+ import { DatabaseManager } from './store/database.js';
11
+ import { CacheManager } from './store/cache-manager.js';
12
+ import { NpmRegistryClient } from './registry/client.js';
13
+ import { NpmRegistryError } from './registry/types.js';
14
+ import { TokenBucket } from './scheduler/rate-limiter.js';
15
+ import { StaticAnalyzer } from './scanner/static-rules.js';
16
+ import { RefreshScheduler } from './scheduler/refresh-scheduler.js';
17
+ import { SecurityLevel } from './scanner/types.js';
18
+ import { RuleConfigManager } from './scanner/rule-config.js';
19
+ import { loadRulesFromDirectory } from './scanner/rule-loader.js';
20
+ import { LEVEL_RANK, readDependencies, readLockfileDependencies } from './scanner/ci-scan.js';
21
+ import { createLlmProvider } from './llm/provider.js';
22
+ import { LlmConfigManager } from './llm/llm-config.js';
23
+ // ============================================================================
24
+ // Engine
25
+ // ============================================================================
26
+ /**
27
+ * Unified facade that composes every @npm-safe/core module into a single
28
+ * public API surface.
29
+ *
30
+ * Construct an instance with optional {@link NpmSafeEngineOptions}, then use
31
+ * its methods to check, search, watch, refresh, and configure npm packages.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const engine = new NpmSafeEngine({ dbPath: './my-cache.db' });
36
+ * const result = await engine.checkPackage('lodash');
37
+ * console.log(result.security.overallLevel);
38
+ * await engine.close();
39
+ * ```
40
+ */
41
+ export class NpmSafeEngine {
42
+ /** Database connection manager. */
43
+ database;
44
+ /** Cache read/write layer. */
45
+ cache;
46
+ /** HTTP client for the npm registry. */
47
+ client;
48
+ /** Token bucket rate limiter. */
49
+ limiter;
50
+ /** Static analysis engine. */
51
+ analyzer;
52
+ /** Per-rule configuration (enabled / severity / options). */
53
+ ruleConfig;
54
+ /** Auto-refresh scheduler. */
55
+ scheduler;
56
+ /** LLM provider configuration manager. */
57
+ llmConfig;
58
+ /** Optional semantic security scanner. */
59
+ llmProvider;
60
+ /**
61
+ * @param options - Optional configuration overrides; see
62
+ * {@link NpmSafeEngineOptions} for available options.
63
+ */
64
+ constructor(options) {
65
+ this.database = new DatabaseManager(options?.dbPath ?? './npm-safe.db');
66
+ this.cache = new CacheManager(this.database, {
67
+ cacheTtlMs: options?.cacheTtlMs,
68
+ });
69
+ this.client = new NpmRegistryClient({
70
+ baseUrl: options?.registryUrl,
71
+ proxy: options?.proxy,
72
+ });
73
+ this.limiter = new TokenBucket(options?.rateLimit ?? 5, options?.rateLimitBurst ?? 10);
74
+ this.ruleConfig = new RuleConfigManager(options?.rulesConfigPath);
75
+ this.analyzer = new StaticAnalyzer(undefined, this.ruleConfig);
76
+ this.llmConfig = new LlmConfigManager(options?.llmConfigPath);
77
+ this.llmProvider = options?.llm
78
+ ? createLlmProvider(options.llm)
79
+ : this.llmConfig.createProvider();
80
+ this.scheduler = new RefreshScheduler(this.client, this.cache, this.limiter, this.analyzer, () => this.llmProvider);
81
+ void this.loadRulePlugins(options?.rulesDir);
82
+ }
83
+ // --------------------------------------------------------------------------
84
+ // Package checking & searching
85
+ // --------------------------------------------------------------------------
86
+ /**
87
+ * Check a package by name, returning cached data if still fresh, or fetching
88
+ * from the registry, running static analysis, and caching the result.
89
+ *
90
+ * When the package does not exist on the registry (HTTP 404) the returned
91
+ * {@link CheckResult.exists} is `false` and the security / registry info
92
+ * fields are empty. All other errors (network failure, timeout, …) are
93
+ * rethrown so the caller can handle them appropriately.
94
+ *
95
+ * Pass `{ forceRefresh: true }` to skip the cache-hit fast path entirely
96
+ * and always re-fetch from the registry. Pass `{ signal }` to make the
97
+ * check cooperatively cancellable — an abort settles the promise promptly
98
+ * with `DOMException('The operation was aborted.', 'AbortError')`.
99
+ *
100
+ * @param name - Fully-qualified package name (scope included when scoped).
101
+ * @param options - Optional check options (forceRefresh, signal).
102
+ * @returns A promise that resolves to the check result.
103
+ */
104
+ async checkPackage(name, options) {
105
+ // 1. Try the cache first — unless forceRefresh bypasses it entirely.
106
+ if (!options?.forceRefresh) {
107
+ const cached = await this.cache.getPackage(name);
108
+ if (cached !== null) {
109
+ const latestVersion = cached['dist-tags'].latest;
110
+ const staticScan = await this.cache.getSecurityReport(name, latestVersion);
111
+ const llmScan = this.llmProvider
112
+ ? (await this.cache.getLlmScanReport(name, latestVersion)) ??
113
+ await this.scanWithLlm(cached, latestVersion, options?.signal)
114
+ : undefined;
115
+ return this.buildCheckResult(name, true, latestVersion, staticScan ?? null, llmScan, {
116
+ description: cached.description ?? '',
117
+ homepage: cached.homepage ?? '',
118
+ repository: repositoryToString(cached.repository),
119
+ }, null);
120
+ }
121
+ }
122
+ // 2. Cache miss, stale, or forceRefresh — fetch from the registry.
123
+ try {
124
+ const meta = await this.client.getPackageMetadata(name, {
125
+ signal: options?.signal,
126
+ });
127
+ const latestVersion = meta['dist-tags'].latest;
128
+ // Persist the fresh metadata before running analysis so the cache is
129
+ // updated even if analysis fails downstream.
130
+ await this.cache.setPackage(meta);
131
+ // Derive a package.json-like object from the latest version manifest
132
+ // for the static analyzer. The spread + double-cast is needed because
133
+ // AbbreviatedVersion is a readonly interface, not a plain object.
134
+ const manifest = meta.versions[latestVersion];
135
+ const packageJson = manifest
136
+ ? { ...manifest }
137
+ : undefined;
138
+ const readme = meta.readme ?? '';
139
+ const report = this.analyzer.analyze(readme, packageJson);
140
+ await this.cache.setSecurityReport(report);
141
+ const llmScan = this.llmProvider
142
+ ? await this.scanWithLlm(meta, latestVersion, options?.signal)
143
+ : undefined;
144
+ return this.buildCheckResult(name, true, latestVersion, report, llmScan, {
145
+ description: meta.description ?? '',
146
+ homepage: meta.homepage ?? '',
147
+ repository: repositoryToString(meta.repository),
148
+ }, new Date().toISOString());
149
+ }
150
+ catch (err) {
151
+ // A 404 from the registry means the package simply does not exist —
152
+ // return a graceful "not found" result instead of throwing.
153
+ if (err instanceof NpmRegistryError && err.statusCode === 404) {
154
+ return this.buildCheckResult(name, false, '', null, undefined, null, null);
155
+ }
156
+ throw err;
157
+ }
158
+ }
159
+ /**
160
+ * Search the npm registry for packages matching a text query.
161
+ *
162
+ * @param query - Free-text search query.
163
+ * @param options - Optional search options. `options.size` caps the number
164
+ * of results (defaults to 20); `options.signal` is forwarded to the
165
+ * registry client for cooperative cancellation. Search is not cached, so
166
+ * there is no `forceRefresh` option here.
167
+ * @returns An array of search-result hits, ordered by relevance.
168
+ */
169
+ async searchPackages(query, options) {
170
+ return this.client.searchPackages(query, {
171
+ size: options?.size,
172
+ signal: options?.signal,
173
+ });
174
+ }
175
+ /**
176
+ * Check many packages in parallel with a shared concurrency cap.
177
+ *
178
+ * Every check consumes one token from the rate limiter, so the batch
179
+ * respects the configured request budget even when running concurrently.
180
+ * Individual failures are isolated: a package that throws (network error,
181
+ * timeout, …) yields a `{ ok: false, error }` entry instead of rejecting
182
+ * the whole batch. Use `checkPackage` when the raw error must propagate.
183
+ *
184
+ * @param names - Package names to check.
185
+ * @param options - Batch options (concurrency, progress callback).
186
+ * @returns One entry per input name, in input order.
187
+ */
188
+ async checkPackages(names, options) {
189
+ const concurrency = Math.max(1, Math.min(options?.concurrency ?? 5, names.length || 1));
190
+ const results = new Array(names.length);
191
+ let next = 0;
192
+ let done = 0;
193
+ const worker = async () => {
194
+ for (;;) {
195
+ // Short-circuit at the top of each iteration so an aborted batch
196
+ // settles promptly instead of the worker catch swallowing the
197
+ // AbortError as a per-package error and continuing to iterate
198
+ // (cooperative cancellation).
199
+ if (options?.signal?.aborted)
200
+ break;
201
+ const index = next++;
202
+ if (index >= names.length)
203
+ return;
204
+ const name = names[index];
205
+ try {
206
+ await this.limiter.consume(1);
207
+ const result = await this.checkPackage(name, {
208
+ signal: options?.signal,
209
+ });
210
+ const entry = { name, ok: true, result };
211
+ results[index] = entry;
212
+ done++;
213
+ options?.onProgress?.(done, names.length, entry);
214
+ }
215
+ catch (error) {
216
+ const entry = {
217
+ name,
218
+ ok: false,
219
+ error: error instanceof Error ? error.message : String(error),
220
+ };
221
+ results[index] = entry;
222
+ done++;
223
+ options?.onProgress?.(done, names.length, entry);
224
+ }
225
+ }
226
+ };
227
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
228
+ return results;
229
+ }
230
+ // --------------------------------------------------------------------------
231
+ // CI scan
232
+ // --------------------------------------------------------------------------
233
+ /**
234
+ * Scan a project's dependencies and return a CI report with a failure gate.
235
+ *
236
+ * Reads dependencies from `package.json` (or `package-lock.json` when
237
+ * `options.lockfile` is set), checks each one via {@link checkPackage}, and
238
+ * aggregates the results into a {@link CiReport}. The report's `failed` flag
239
+ * is `true` when any dependency reaches `options.failLevel` (inclusive) or
240
+ * when any check throws.
241
+ *
242
+ * This method is a pure computation — it does NOT log, format, or set process
243
+ * exit codes. The caller (CLI or plugin) renders the report and maps `failed`
244
+ * to an exit code.
245
+ *
246
+ * When `options.signal` is supplied, the loop short-circuits at the top of
247
+ * each iteration on abort and rejects with
248
+ * `DOMException('The operation was aborted.', 'AbortError')` so an aborted
249
+ * scan does not keep iterating deps (cooperative cancellation).
250
+ *
251
+ * @param options - Optional scan configuration. Defaults:
252
+ * `dir = process.cwd()`, `failLevel = SecurityLevel.Dangerous`.
253
+ * @returns A {@link CiReport} describing every checked dependency.
254
+ */
255
+ async ciScan(options) {
256
+ const dir = options?.dir ?? process.cwd();
257
+ const failLevel = options?.failLevel ?? SecurityLevel.Dangerous;
258
+ const deps = options?.lockfile
259
+ ? readLockfileDependencies(dir, !options?.prod)
260
+ : readDependencies(dir, !options?.prod);
261
+ const results = [];
262
+ const summary = {
263
+ safe: 0,
264
+ suspicious: 0,
265
+ dangerous: 0,
266
+ unknown: 0,
267
+ errors: 0,
268
+ };
269
+ for (const dep of deps) {
270
+ // Short-circuit at the top of each iteration so an aborted scan does
271
+ // not keep iterating deps (cooperative cancellation). The throw is
272
+ // BEFORE the try/catch so it propagates instead of being swallowed as
273
+ // a per-package error.
274
+ if (options?.signal?.aborted) {
275
+ throw new DOMException('The operation was aborted.', 'AbortError');
276
+ }
277
+ try {
278
+ const result = await this.checkPackage(dep.name, {
279
+ signal: options?.signal,
280
+ });
281
+ const level = result.security.overallLevel;
282
+ results.push({
283
+ name: dep.name,
284
+ exists: result.exists,
285
+ version: result.latestVersion,
286
+ level,
287
+ score: result.security.overallScore,
288
+ findingCount: result.security.staticScan?.findings.length ?? 0,
289
+ });
290
+ summary[level] = (summary[level] ?? 0) + 1;
291
+ if (result.exists) {
292
+ await this.recordCheckHistory(result);
293
+ }
294
+ }
295
+ catch (err) {
296
+ results.push({
297
+ name: dep.name,
298
+ exists: false,
299
+ version: '',
300
+ level: SecurityLevel.Unknown,
301
+ score: 0,
302
+ findingCount: 0,
303
+ error: err instanceof Error ? err.message : String(err),
304
+ });
305
+ summary.errors++;
306
+ }
307
+ }
308
+ const failRank = LEVEL_RANK[failLevel];
309
+ const failed = summary.errors > 0 ||
310
+ results.some((r) => r.exists &&
311
+ LEVEL_RANK[r.level] !== undefined &&
312
+ LEVEL_RANK[r.level] <= failRank);
313
+ return {
314
+ dir,
315
+ scannedAt: new Date().toISOString(),
316
+ dependencyCount: deps.length,
317
+ failLevel,
318
+ failed,
319
+ summary,
320
+ packages: results,
321
+ };
322
+ }
323
+ // --------------------------------------------------------------------------
324
+ // Watchlist
325
+ // --------------------------------------------------------------------------
326
+ /**
327
+ * Returns the list of package names currently on the watchlist.
328
+ *
329
+ * @returns All watched package names, in insertion order.
330
+ */
331
+ async getWatchlist() {
332
+ return this.cache.getWatchlist();
333
+ }
334
+ /**
335
+ * Add a package to the watchlist. Idempotent — adding a name that is already
336
+ * watched is a no-op.
337
+ *
338
+ * @param name - Fully-qualified package name to watch.
339
+ */
340
+ async addToWatchlist(name) {
341
+ return this.cache.addToWatchlist(name);
342
+ }
343
+ /**
344
+ * Remove a package from the watchlist. No-op if the name was not watched.
345
+ *
346
+ * @param name - Fully-qualified package name to stop watching.
347
+ */
348
+ async removeFromWatchlist(name) {
349
+ return this.cache.removeFromWatchlist(name);
350
+ }
351
+ // --------------------------------------------------------------------------
352
+ // Refresh
353
+ // --------------------------------------------------------------------------
354
+ /**
355
+ * Refresh a single package: fetch its latest metadata from the registry,
356
+ * re-run static analysis, and persist the results.
357
+ *
358
+ * Per-package failures are surfaced via the scheduler's `refresh:error`
359
+ * event and represented by a `false` result rather than thrown, so a
360
+ * failing package does not abort a batch. An external abort
361
+ * (`options.signal` aborted) propagates as
362
+ * `DOMException('The operation was aborted.', 'AbortError')`.
363
+ *
364
+ * @param name - Fully-qualified package name to refresh.
365
+ * @param options - Optional refresh options. `options.signal` is forwarded
366
+ * to the registry fetch and LLM scan.
367
+ */
368
+ async refreshPackage(name, options) {
369
+ return this.scheduler.refreshPackage(name, options);
370
+ }
371
+ /**
372
+ * Refresh every package whose cached metadata has passed its TTL.
373
+ *
374
+ * Packages are processed sequentially so the rate limiter is respected.
375
+ * When `options.signal` is supplied, the loop short-circuits at the top of
376
+ * each iteration on abort and rejects with
377
+ * `DOMException('The operation was aborted.', 'AbortError')`.
378
+ *
379
+ * @param options - Optional refresh options. `options.signal` is forwarded
380
+ * to each per-package refresh.
381
+ */
382
+ async refreshAll(options) {
383
+ return this.scheduler.refreshAll(options);
384
+ }
385
+ // --------------------------------------------------------------------------
386
+ // Settings
387
+ // --------------------------------------------------------------------------
388
+ /**
389
+ * Retrieve a setting value by key.
390
+ *
391
+ * @param key - Settings key.
392
+ * @returns The stored value, or `null` if the key is unset.
393
+ */
394
+ async getSetting(key) {
395
+ return this.cache.getSetting(key);
396
+ }
397
+ /**
398
+ * Upsert a setting value by key.
399
+ *
400
+ * @param key - Settings key.
401
+ * @param value - Value to persist.
402
+ */
403
+ async setSetting(key, value) {
404
+ return this.cache.setSetting(key, value);
405
+ }
406
+ // --------------------------------------------------------------------------
407
+ // Check history
408
+ // --------------------------------------------------------------------------
409
+ /**
410
+ * Record a check into the persistent history database. Used by the CLI and
411
+ * the desktop extension so history is shared across both frontends.
412
+ *
413
+ * @param result - A check result for an existing package.
414
+ */
415
+ async recordCheckHistory(result) {
416
+ if (!result.exists)
417
+ return;
418
+ await this.recordHistoryEntry({
419
+ packageName: result.packageName,
420
+ level: result.security.overallLevel,
421
+ score: result.security.overallScore,
422
+ timestamp: new Date().toISOString(),
423
+ });
424
+ }
425
+ /**
426
+ * Append a raw history entry (newest-first, capped at 1000).
427
+ */
428
+ async recordHistoryEntry(entry) {
429
+ await this.cache.addHistoryEntry(entry);
430
+ }
431
+ /**
432
+ * Return the persistent check history, newest first (capped at 1000).
433
+ */
434
+ async getCheckHistory(limit) {
435
+ return this.cache.getHistory(limit);
436
+ }
437
+ /**
438
+ * Clear the persistent check history.
439
+ */
440
+ async clearCheckHistory() {
441
+ return this.cache.clearHistory();
442
+ }
443
+ // --------------------------------------------------------------------------
444
+ // Rule plugin management
445
+ // --------------------------------------------------------------------------
446
+ /**
447
+ * Register a scan rule at runtime. A rule with the same id replaces the
448
+ * existing one.
449
+ *
450
+ * @param rule - The rule to register.
451
+ */
452
+ registerRule(rule) {
453
+ this.analyzer.registerRule(rule);
454
+ }
455
+ /**
456
+ * Remove a scan rule by id.
457
+ *
458
+ * @param ruleId - Id of the rule to remove.
459
+ * @returns `true` if a rule was removed, `false` if no such rule exists.
460
+ */
461
+ unregisterRule(ruleId) {
462
+ return this.analyzer.unregisterRule(ruleId);
463
+ }
464
+ /**
465
+ * Describe every registered rule with its effective status.
466
+ *
467
+ * @returns Rule descriptors in registration order.
468
+ */
469
+ listRules() {
470
+ return this.analyzer.listRules();
471
+ }
472
+ /**
473
+ * Enable or disable a rule (persisted in the rules config file).
474
+ *
475
+ * @param ruleId - Id of the rule.
476
+ * @param enabled - Whether the rule should run.
477
+ */
478
+ setRuleEnabled(ruleId, enabled) {
479
+ this.ruleConfig.setEnabled(ruleId, enabled);
480
+ }
481
+ /**
482
+ * Override a rule's severity (persisted). Pass `undefined` to clear the
483
+ * override and return to the rule's default severity.
484
+ *
485
+ * @param ruleId - Id of the rule.
486
+ * @param severity - Severity override, or `undefined` to clear.
487
+ */
488
+ setRuleSeverity(ruleId, severity) {
489
+ this.ruleConfig.setSeverity(ruleId, severity);
490
+ }
491
+ /**
492
+ * Set free-form options for a rule (persisted). Rule implementations can
493
+ * read these via the rule config manager.
494
+ *
495
+ * @param ruleId - Id of the rule.
496
+ * @param options - Free-form options.
497
+ */
498
+ setRuleOptions(ruleId, options) {
499
+ this.ruleConfig.setOptions(ruleId, options);
500
+ }
501
+ /**
502
+ * Access the rule configuration manager for low-level inspection.
503
+ *
504
+ * @returns The rule configuration manager backing this engine.
505
+ */
506
+ getRuleConfig() {
507
+ return this.ruleConfig;
508
+ }
509
+ /**
510
+ * Load third-party rules from a directory of ES module files.
511
+ *
512
+ * Each `*.mjs` / `*.js` file may export a `rule`, `rules`, or `default`
513
+ * binding holding one or more {@link ScanRule}s. Files that fail to load
514
+ * are skipped.
515
+ *
516
+ * @param dir - Directory to scan. Defaults to `~/.npm-safe/rules/`.
517
+ * @returns The number of rules loaded.
518
+ */
519
+ async loadRulePlugins(dir) {
520
+ const results = await loadRulesFromDirectory(dir);
521
+ let count = 0;
522
+ for (const result of results) {
523
+ for (const rule of result.rules) {
524
+ this.analyzer.registerRule(rule);
525
+ count++;
526
+ }
527
+ }
528
+ return count;
529
+ }
530
+ // --------------------------------------------------------------------------
531
+ // LLM configuration
532
+ // --------------------------------------------------------------------------
533
+ /**
534
+ * Get the raw LLM configuration, including the API key.
535
+ *
536
+ * @returns The current persisted LLM config.
537
+ */
538
+ getLlmConfig() {
539
+ return this.llmConfig.getConfig();
540
+ }
541
+ /**
542
+ * Get a masked, display-safe view of the LLM status.
543
+ *
544
+ * @returns Status object safe to render in a UI.
545
+ */
546
+ getLlmStatus() {
547
+ return this.llmConfig.getStatus();
548
+ }
549
+ /**
550
+ * Update the LLM configuration and recreate the provider.
551
+ *
552
+ * The change is persisted immediately. If LLM scanning is disabled or no
553
+ * API key is available, the provider is set to `undefined` so the rest of the
554
+ * engine continues unaffected.
555
+ *
556
+ * @param update - Partial config update. Pass `{ enabled: false }` to disable.
557
+ */
558
+ setLlmConfig(update) {
559
+ this.llmConfig.setConfig(update);
560
+ this.llmProvider = this.llmConfig.createProvider();
561
+ this.scheduler.setLlmProvider(this.llmProvider);
562
+ }
563
+ /**
564
+ * Test whether the current LLM configuration can connect to its provider.
565
+ *
566
+ * @returns `true` if the provider is enabled, configured, and the test call
567
+ * succeeds; `false` otherwise.
568
+ */
569
+ async testLlmConnection() {
570
+ return this.llmConfig.testConnection();
571
+ }
572
+ // --------------------------------------------------------------------------
573
+ // Auto-refresh lifecycle
574
+ // --------------------------------------------------------------------------
575
+ /**
576
+ * Start the periodic auto-refresh loop.
577
+ *
578
+ * The first refresh cycle kicks off immediately in the background; subsequent
579
+ * cycles repeat at `intervalMs`. Safe to call multiple times — calling while
580
+ * already running resets the interval.
581
+ *
582
+ * @param intervalMs - Milliseconds between refresh cycles.
583
+ * Defaults to 1 hour.
584
+ */
585
+ startAutoRefresh(intervalMs) {
586
+ this.scheduler.start(intervalMs);
587
+ }
588
+ /**
589
+ * Stop the periodic auto-refresh loop.
590
+ *
591
+ * Safe to call when the scheduler is not running. Any in-flight refresh
592
+ * continues to completion.
593
+ */
594
+ stopAutoRefresh() {
595
+ this.scheduler.stop();
596
+ }
597
+ // --------------------------------------------------------------------------
598
+ // Lifecycle
599
+ // --------------------------------------------------------------------------
600
+ /**
601
+ * Release all resources held by the engine.
602
+ *
603
+ * Stops the auto-refresh scheduler, disposes the rate-limiter timer, and
604
+ * closes the database connection. After calling this method the engine
605
+ * instance must not be used for further operations.
606
+ */
607
+ close() {
608
+ this.scheduler.stop();
609
+ this.limiter.dispose();
610
+ this.database.close();
611
+ }
612
+ // --------------------------------------------------------------------------
613
+ // Internal helpers
614
+ // --------------------------------------------------------------------------
615
+ /**
616
+ * Assemble a {@link CheckResult} from its constituent parts.
617
+ */
618
+ buildCheckResult(packageName, exists, latestVersion, staticScan, llmScan, registryInfo, cachedAt) {
619
+ const overallScore = combineScores(staticScan, llmScan);
620
+ return {
621
+ packageName,
622
+ exists,
623
+ latestVersion,
624
+ security: {
625
+ overallLevel: scoreToSecurityLevel(overallScore),
626
+ overallScore,
627
+ staticScan,
628
+ llmScan,
629
+ },
630
+ registryInfo,
631
+ cachedAt,
632
+ };
633
+ }
634
+ async scanWithLlm(meta, version, signal) {
635
+ if (!this.llmProvider) {
636
+ return { enabled: false, reason: 'LLM provider is not configured.' };
637
+ }
638
+ const manifest = meta.versions[version];
639
+ try {
640
+ const report = await this.llmProvider.scan({
641
+ packageName: meta.name,
642
+ version,
643
+ description: meta.description ?? '',
644
+ readme: meta.readme ?? '',
645
+ packageJson: manifest ? { ...manifest } : undefined,
646
+ signal,
647
+ });
648
+ await this.cache.setLlmScanReport(meta.name, version, report);
649
+ return report;
650
+ }
651
+ catch (error) {
652
+ // An external abort must propagate as AbortError so the caller
653
+ // settles promptly (cooperative cancellation) instead of receiving
654
+ // a disabled LLM report and returning normally.
655
+ if (signal?.aborted) {
656
+ throw new DOMException('The operation was aborted.', 'AbortError');
657
+ }
658
+ const report = {
659
+ enabled: false,
660
+ reason: error instanceof Error ? error.message : String(error),
661
+ scannedAt: new Date().toISOString(),
662
+ };
663
+ await this.cache.setLlmScanReport(meta.name, version, report);
664
+ return report;
665
+ }
666
+ }
667
+ }
668
+ // ============================================================================
669
+ // Helpers
670
+ // ============================================================================
671
+ /**
672
+ * Normalize a {@link PackageRepository} value to a plain string.
673
+ *
674
+ * Structured descriptors are rendered as `"type:url"`; shorthand strings are
675
+ * returned verbatim; `undefined` produces an empty string.
676
+ *
677
+ * @param repo - Repository descriptor from registry metadata.
678
+ * @returns A string representation suitable for display.
679
+ */
680
+ function repositoryToString(repo) {
681
+ if (repo === undefined)
682
+ return '';
683
+ if (typeof repo === 'string')
684
+ return repo;
685
+ return `${repo.type}:${repo.url}`;
686
+ }
687
+ function combineScores(staticScan, llmScan) {
688
+ if (!staticScan)
689
+ return 0;
690
+ if (!llmScan?.enabled)
691
+ return staticScan.score;
692
+ return Math.round(staticScan.score * 0.6 + (100 - (llmScan.suspiciousScore ?? 0)) * 0.4);
693
+ }
694
+ function scoreToSecurityLevel(score) {
695
+ if (score >= 80)
696
+ return SecurityLevel.Safe;
697
+ if (score >= 50)
698
+ return SecurityLevel.Suspicious;
699
+ if (score >= 20)
700
+ return SecurityLevel.Dangerous;
701
+ return SecurityLevel.Unknown;
702
+ }
703
+ export { createLlmProvider, LlmProviderError } from './llm/provider.js';
704
+ export { LlmConfigManager, getDefaultLlmConfigPath } from './llm/llm-config.js';
705
+ export { RuleConfigManager } from './scanner/rule-config.js';
706
+ export { loadRulesFromDirectory } from './scanner/rule-loader.js';
707
+ export { DatabaseManager } from './store/database.js';
708
+ export { CacheManager, DEFAULT_CACHE_TTL_MS, MAX_CHECK_HISTORY } from './store/cache-manager.js';
709
+ export { SecurityLevel, Severity } from './scanner/types.js';
710
+ export { LEVEL_RANK, readDependencies, readLockfileDependencies } from './scanner/ci-scan.js';
711
+ //# sourceMappingURL=index.js.map