@wrongstack/techstack 0.293.0 → 0.295.1

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 (70) hide show
  1. package/dist/adapters/cpp.d.ts +1 -1
  2. package/dist/adapters/cpp.d.ts.map +1 -1
  3. package/dist/adapters/dart.d.ts +1 -2
  4. package/dist/adapters/dart.d.ts.map +1 -1
  5. package/dist/adapters/dotnet.d.ts +1 -1
  6. package/dist/adapters/dotnet.d.ts.map +1 -1
  7. package/dist/adapters/elixir.d.ts +1 -1
  8. package/dist/adapters/elixir.d.ts.map +1 -1
  9. package/dist/adapters/go.d.ts +1 -2
  10. package/dist/adapters/go.d.ts.map +1 -1
  11. package/dist/adapters/gradle.d.ts +9 -0
  12. package/dist/adapters/gradle.d.ts.map +1 -0
  13. package/dist/adapters/interface.d.ts +1 -0
  14. package/dist/adapters/interface.d.ts.map +1 -1
  15. package/dist/adapters/maven.d.ts +1 -1
  16. package/dist/adapters/maven.d.ts.map +1 -1
  17. package/dist/adapters/npm.d.ts +1 -1
  18. package/dist/adapters/npm.d.ts.map +1 -1
  19. package/dist/adapters/parse-utils.d.ts +15 -0
  20. package/dist/adapters/parse-utils.d.ts.map +1 -0
  21. package/dist/adapters/paths.d.ts +8 -1
  22. package/dist/adapters/paths.d.ts.map +1 -1
  23. package/dist/adapters/php.d.ts +1 -2
  24. package/dist/adapters/php.d.ts.map +1 -1
  25. package/dist/adapters/python.d.ts +1 -2
  26. package/dist/adapters/python.d.ts.map +1 -1
  27. package/dist/adapters/ruby.d.ts +1 -1
  28. package/dist/adapters/ruby.d.ts.map +1 -1
  29. package/dist/adapters/rust.d.ts +1 -2
  30. package/dist/adapters/rust.d.ts.map +1 -1
  31. package/dist/adapters/swift.d.ts +9 -0
  32. package/dist/adapters/swift.d.ts.map +1 -0
  33. package/dist/advisory/native-audit.d.ts +20 -8
  34. package/dist/advisory/native-audit.d.ts.map +1 -1
  35. package/dist/advisory/osv.d.ts.map +1 -1
  36. package/dist/discovery/index.d.ts +3 -13
  37. package/dist/discovery/index.d.ts.map +1 -1
  38. package/dist/index.d.ts +10 -6
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1420 -942
  41. package/dist/index.js.map +4 -4
  42. package/dist/registry/client.d.ts +23 -0
  43. package/dist/registry/client.d.ts.map +1 -1
  44. package/dist/registry/http-fetch.d.ts +25 -0
  45. package/dist/registry/http-fetch.d.ts.map +1 -0
  46. package/dist/remediation.d.ts +35 -0
  47. package/dist/remediation.d.ts.map +1 -1
  48. package/dist/research/llm.d.ts +1 -1
  49. package/dist/research/llm.d.ts.map +1 -1
  50. package/dist/research/triage.d.ts +2 -15
  51. package/dist/research/triage.d.ts.map +1 -1
  52. package/dist/service/enrich-phase.d.ts +8 -0
  53. package/dist/service/enrich-phase.d.ts.map +1 -0
  54. package/dist/service/finding-factory.d.ts +4 -0
  55. package/dist/service/finding-factory.d.ts.map +1 -0
  56. package/dist/service/inventory-phase.d.ts +9 -0
  57. package/dist/service/inventory-phase.d.ts.map +1 -0
  58. package/dist/service/report-generator.d.ts +3 -0
  59. package/dist/service/report-generator.d.ts.map +1 -0
  60. package/dist/service/research-phase.d.ts +10 -0
  61. package/dist/service/research-phase.d.ts.map +1 -0
  62. package/dist/service/techstack-engine.d.ts +33 -0
  63. package/dist/service/techstack-engine.d.ts.map +1 -0
  64. package/dist/service.d.ts +5 -106
  65. package/dist/service.d.ts.map +1 -1
  66. package/dist/store/sqlite.d.ts +4 -0
  67. package/dist/store/sqlite.d.ts.map +1 -1
  68. package/dist/trend.d.ts +36 -0
  69. package/dist/trend.d.ts.map +1 -0
  70. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -81,7 +81,7 @@ function parsePurl(purl) {
81
81
  const slashIdx = main.indexOf("/");
82
82
  if (slashIdx < 0) return void 0;
83
83
  const type = main.slice(0, slashIdx);
84
- let remainder = main.slice(slashIdx + 1);
84
+ const remainder = main.slice(slashIdx + 1);
85
85
  let namespace;
86
86
  let name;
87
87
  const nsSlashIdx = remainder.indexOf("/");
@@ -265,10 +265,12 @@ function coverageForEcosystem(ecosystem) {
265
265
  }
266
266
 
267
267
  // src/adapters/npm.ts
268
- import { existsSync, readFileSync } from "node:fs";
268
+ import { access as access2, readFile } from "node:fs/promises";
269
269
  import { dirname, join, relative, resolve as resolve2 } from "node:path";
270
270
 
271
271
  // src/adapters/paths.ts
272
+ import { existsSync } from "node:fs";
273
+ import { access } from "node:fs/promises";
272
274
  import { resolve } from "node:path";
273
275
  function workspaceRoot(workspace, options) {
274
276
  const relative2 = workspace.relativeRoot || ".";
@@ -277,9 +279,26 @@ function workspaceRoot(workspace, options) {
277
279
  function resolveIn(root, candidate) {
278
280
  return resolve(root, candidate);
279
281
  }
282
+ function manifestEvidence(path) {
283
+ return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
284
+ }
285
+ function lockfileEvidence(path) {
286
+ return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
287
+ }
288
+ function fileExists(filePath) {
289
+ return existsSync(filePath);
290
+ }
291
+ async function fileExistsAsync(filePath) {
292
+ try {
293
+ await access(filePath);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
280
299
 
281
300
  // src/adapters/npm.ts
282
- function detectLockfile(workspaceDir, stopAt) {
301
+ async function detectLockfile(workspaceDir, stopAt) {
283
302
  const candidates = [
284
303
  { file: "pnpm-lock.yaml", kind: "pnpm" },
285
304
  { file: "package-lock.json", kind: "npm" },
@@ -291,7 +310,11 @@ function detectLockfile(workspaceDir, stopAt) {
291
310
  for (; ; ) {
292
311
  for (const c of candidates) {
293
312
  const candidate = join(dir, c.file);
294
- if (existsSync(candidate)) return { kind: c.kind, path: candidate };
313
+ try {
314
+ await access2(candidate);
315
+ return { kind: c.kind, path: candidate };
316
+ } catch {
317
+ }
295
318
  }
296
319
  if (ceiling && dir === ceiling) break;
297
320
  const parent = dirname(dir);
@@ -379,19 +402,29 @@ function parseNpmLockVersions(lockContent) {
379
402
  }
380
403
  return versions;
381
404
  }
382
- function manifestEvidence(path) {
383
- return {
384
- kind: "manifest",
385
- source: path,
386
- retrievedAt: (/* @__PURE__ */ new Date()).toISOString()
387
- };
388
- }
389
- function lockfileEvidence(path) {
390
- return {
391
- kind: "lockfile",
392
- source: path,
393
- retrievedAt: (/* @__PURE__ */ new Date()).toISOString()
394
- };
405
+ function parsePnpmAllVersions(lockContent) {
406
+ const versions = /* @__PURE__ */ new Map();
407
+ let inPackages = false;
408
+ for (const raw of lockContent.split(/\r?\n/)) {
409
+ if (!/^\s/.test(raw)) {
410
+ inPackages = raw.startsWith("packages:") || raw.startsWith("snapshots:");
411
+ continue;
412
+ }
413
+ if (!inPackages) continue;
414
+ const indent = raw.length - raw.trimStart().length;
415
+ if (indent !== 2) continue;
416
+ const trimmed = raw.trim();
417
+ const separator = trimmed.indexOf(":");
418
+ if (separator < 0) continue;
419
+ const key = unquote(trimmed.slice(0, separator));
420
+ const clean = stripPeerSuffix(key);
421
+ const splitAt = clean.lastIndexOf("@");
422
+ if (splitAt <= 0) continue;
423
+ const name = clean.slice(0, splitAt);
424
+ const version = clean.slice(splitAt + 1);
425
+ if (name && /^\d/.test(version) && !versions.has(name)) versions.set(name, version);
426
+ }
427
+ return versions;
395
428
  }
396
429
  function scopeForSection(section) {
397
430
  switch (section) {
@@ -431,29 +464,32 @@ var NpmAdapter = class {
431
464
  let pkg;
432
465
  let manifestContent;
433
466
  try {
434
- manifestContent = readFileSync(manifestPath, "utf-8");
467
+ manifestContent = await readFile(manifestPath, "utf-8");
435
468
  pkg = JSON.parse(manifestContent);
436
469
  } catch {
437
470
  return [];
438
471
  }
439
472
  const manifestEv = manifestEvidence(manifestPath);
440
- const lockInfo = detectLockfile(root, options.projectRoot);
473
+ const lockInfo = await detectLockfile(root, options.projectRoot);
441
474
  const resolvedVersions = /* @__PURE__ */ new Map();
475
+ const allLockVersions = /* @__PURE__ */ new Map();
442
476
  let lockEv;
443
477
  if (lockInfo.kind === "pnpm") {
444
478
  try {
445
- const lockContent = readFileSync(lockInfo.path, "utf-8");
479
+ const lockContent = await readFile(lockInfo.path, "utf-8");
446
480
  const importerPath = relative(dirname(lockInfo.path), root).split(/[/\\]/).filter(Boolean).join("/") || ".";
447
481
  const parsed = parsePnpmImporterVersions(lockContent, importerPath);
448
482
  for (const [k, v] of parsed) resolvedVersions.set(k, v);
483
+ for (const [k, v] of parsePnpmAllVersions(lockContent)) allLockVersions.set(k, v);
449
484
  if (parsed.size > 0) lockEv = lockfileEvidence(lockInfo.path);
450
485
  } catch {
451
486
  }
452
487
  } else if (lockInfo.kind === "npm") {
453
488
  try {
454
- const lockContent = readFileSync(lockInfo.path, "utf-8");
489
+ const lockContent = await readFile(lockInfo.path, "utf-8");
455
490
  const parsed = parseNpmLockVersions(lockContent);
456
491
  for (const [k, v] of parsed) resolvedVersions.set(k, v);
492
+ for (const [k, v] of parsed) allLockVersions.set(k, v);
457
493
  lockEv = lockfileEvidence(lockInfo.path);
458
494
  } catch {
459
495
  }
@@ -494,20 +530,80 @@ var NpmAdapter = class {
494
530
  });
495
531
  }
496
532
  }
533
+ if (options.includeTransitive && lockEv) {
534
+ for (const [name, locked] of allLockVersions) {
535
+ if (seen.has(name)) continue;
536
+ seen.add(name);
537
+ observations.push({
538
+ id: `dep-${workspace.id}-${name}`,
539
+ workspaceId: workspace.id,
540
+ purl: buildPurl({ type: "npm", name, version: locked }),
541
+ ecosystem: "npm",
542
+ name,
543
+ sourceType: "registry",
544
+ direct: false,
545
+ scope: "transitive",
546
+ locked,
547
+ status: "current",
548
+ evidence: [lockEv]
549
+ });
550
+ }
551
+ }
497
552
  return observations;
498
553
  }
499
554
  };
500
555
  var npmAdapter = new NpmAdapter();
501
556
 
502
557
  // src/adapters/python.ts
503
- import { readFileSync as readFileSync2 } from "node:fs";
558
+ import { readFile as readFile2 } from "node:fs/promises";
504
559
  import { join as join2 } from "node:path";
505
- function manifestEvidence2(path) {
506
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
560
+
561
+ // src/adapters/parse-utils.ts
562
+ function stripInlineComment(line, marker = "#") {
563
+ let quote;
564
+ let escaped = false;
565
+ for (let index = 0; index < line.length; index++) {
566
+ const character = line.charAt(index);
567
+ if (escaped) {
568
+ escaped = false;
569
+ continue;
570
+ }
571
+ if (character === "\\" && quote === '"') {
572
+ escaped = true;
573
+ continue;
574
+ }
575
+ if (character === '"' || character === "'") {
576
+ quote = quote === character ? void 0 : quote ?? character;
577
+ continue;
578
+ }
579
+ if (!quote && line.startsWith(marker, index)) return line.slice(0, index).trimEnd();
580
+ }
581
+ return line;
507
582
  }
508
- function lockfileEvidence2(path) {
509
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
583
+ function parseTomlKeyValue(line) {
584
+ const cleaned = stripInlineComment(line).trim();
585
+ const match = /^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+))\s*=\s*(.+)$/.exec(cleaned);
586
+ if (!match) return void 0;
587
+ const key = match[1] ?? match[2] ?? match[3];
588
+ const value = match[4];
589
+ return key && value ? { key, value: value.trim() } : void 0;
510
590
  }
591
+ function parseXmlAttributes(source) {
592
+ const attributes = /* @__PURE__ */ new Map();
593
+ const regex = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
594
+ for (const match of source.matchAll(regex)) {
595
+ const key = match[1];
596
+ const value = match[3];
597
+ if (key !== void 0 && value !== void 0) attributes.set(key, value);
598
+ }
599
+ return attributes;
600
+ }
601
+ function xmlTagValue(source, tag) {
602
+ const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
603
+ return new RegExp(`<${escaped}(?:\\s[^>]*)?>\\s*([^<]+?)\\s*</${escaped}>`, "i").exec(source)?.[1]?.trim();
604
+ }
605
+
606
+ // src/adapters/python.ts
511
607
  function parseTomlSections(content) {
512
608
  const sections = [];
513
609
  let currentSection = "__header__";
@@ -595,6 +691,19 @@ function parsePyprojectDeps(content) {
595
691
  }
596
692
  }
597
693
  }
694
+ if (section.name === "tool.poetry.dependencies" || section.name.startsWith("tool.poetry.group.")) {
695
+ const scope = section.name === "tool.poetry.dependencies" ? "runtime" : "development";
696
+ for (const raw of section.lines) {
697
+ const entry = parseTomlKeyValue(raw);
698
+ if (!entry) continue;
699
+ const name = entry.key;
700
+ if (!name || name.toLowerCase() === "python") continue;
701
+ const rawValue = entry.value;
702
+ const inlineVersion = rawValue.match(/\bversion\s*=\s*["']([^"']+)["']/)?.[1];
703
+ const stringVersion = rawValue.match(/^["']([^"']+)["']/)?.[1];
704
+ deps.push({ name, constraint: inlineVersion ?? stringVersion, scope });
705
+ }
706
+ }
598
707
  }
599
708
  return deps;
600
709
  }
@@ -631,26 +740,60 @@ function parseRequirementsLockVersions(content) {
631
740
  const line = raw.trim();
632
741
  if (!line || line.startsWith("#") || line.startsWith("-")) continue;
633
742
  const match = line.match(/^([a-zA-Z0-9][a-zA-Z0-9._-]*)\s*==\s*([^\s;]+)/);
634
- if (match) versions.set(match[1], match[2]);
743
+ if (match?.[1] && match[2]) versions.set(normalizePkgName(match[1]), match[2]);
744
+ }
745
+ return versions;
746
+ }
747
+ function parsePipfileLock(content) {
748
+ const versions = /* @__PURE__ */ new Map();
749
+ try {
750
+ const json = JSON.parse(content);
751
+ for (const section of ["default", "develop"]) {
752
+ for (const [name, entry] of Object.entries(json[section] ?? {})) {
753
+ if (entry.version?.startsWith("==")) versions.set(normalizePkgName(name), entry.version.slice(2));
754
+ }
755
+ }
756
+ } catch {
757
+ }
758
+ return versions;
759
+ }
760
+ function parsePoetryLock(content) {
761
+ const versions = /* @__PURE__ */ new Map();
762
+ let currentName;
763
+ for (const raw of content.split("\n")) {
764
+ const line = raw.trim();
765
+ const nameMatch = line.match(/^name\s*=\s*"([^"]+)"/);
766
+ if (nameMatch) {
767
+ currentName = nameMatch[1];
768
+ continue;
769
+ }
770
+ const versionMatch = line.match(/^version\s*=\s*"([^"]+)"/);
771
+ if (versionMatch && currentName) {
772
+ versions.set(normalizePkgName(currentName), versionMatch[1]);
773
+ currentName = void 0;
774
+ }
635
775
  }
636
776
  return versions;
637
777
  }
778
+ function normalizePkgName(name) {
779
+ return name.toLowerCase().replace(/_/g, "-");
780
+ }
638
781
  var PythonAdapter = class {
639
782
  ecosystem = "python";
640
783
  async inventory(workspace, options) {
641
784
  const observations = [];
642
785
  const root = workspaceRoot(workspace, options);
643
786
  const seen = /* @__PURE__ */ new Set();
644
- const hasPyproject = workspace.manifests.some((m) => m.includes("pyproject.toml")) || this.fileExists(join2(root, "pyproject.toml"));
645
- const hasRequirements = workspace.manifests.some((m) => m.includes("requirements.txt")) || this.fileExists(join2(root, "requirements.txt"));
646
- const hasPipfile = workspace.manifests.some((m) => m.includes("Pipfile")) || this.fileExists(join2(root, "Pipfile"));
647
- const lockfilePath = this.detectLockfile(root);
648
- let allDeps = [];
787
+ const hasPyproject = workspace.manifests.some((m) => m.includes("pyproject.toml")) || await fileExistsAsync(join2(root, "pyproject.toml"));
788
+ const hasRequirements = workspace.manifests.some((m) => m.includes("requirements.txt")) || await fileExistsAsync(join2(root, "requirements.txt"));
789
+ const hasPipfile = workspace.manifests.some((m) => m.includes("Pipfile")) || await fileExistsAsync(join2(root, "Pipfile"));
790
+ const lockfilePath = await this.detectLockfile(root);
791
+ const allDeps = [];
649
792
  let pyprojectEv;
650
793
  if (hasPyproject) {
651
794
  try {
652
- const content = readFileSync2(join2(root, "pyproject.toml"), "utf-8");
653
- pyprojectEv = manifestEvidence2(join2(root, "pyproject.toml"));
795
+ const content = await readFile2(join2(root, "pyproject.toml"), "utf-8");
796
+ pyprojectEv = manifestEvidence(join2(root, "pyproject.toml"));
654
797
  const parsed = parsePyprojectDeps(content);
655
798
  for (const d of parsed) allDeps.push({ ...d, source: "pyproject.toml" });
656
799
  } catch {
@@ -660,8 +803,8 @@ var PythonAdapter = class {
660
803
  let requirementsEv;
661
804
  if (hasRequirements) {
662
805
  try {
663
- const content = readFileSync2(join2(root, "requirements.txt"), "utf-8");
664
- requirementsEv = manifestEvidence2(join2(root, "requirements.txt"));
806
+ const content = await readFile2(join2(root, "requirements.txt"), "utf-8");
807
+ requirementsEv = manifestEvidence(join2(root, "requirements.txt"));
665
808
  const parsed = parseRequirementsTxt(content);
666
809
  for (const d of parsed) {
667
810
  if (!allDeps.some((existing) => existing.name === d.name)) {
@@ -674,8 +817,8 @@ var PythonAdapter = class {
674
817
  }
675
818
  if (hasPipfile) {
676
819
  try {
677
- const content = readFileSync2(join2(root, "Pipfile"), "utf-8");
678
- if (!pyprojectEv) pyprojectEv = manifestEvidence2(join2(root, "Pipfile"));
820
+ const content = await readFile2(join2(root, "Pipfile"), "utf-8");
821
+ if (!pyprojectEv) pyprojectEv = manifestEvidence(join2(root, "Pipfile"));
679
822
  const parsed = parsePipfileDeps(content);
680
823
  for (const d of parsed) {
681
824
  if (!allDeps.some((existing) => existing.name === d.name)) {
@@ -686,10 +829,13 @@ var PythonAdapter = class {
686
829
  }
687
830
  }
688
831
  let lockEv;
832
+ const lockVersions = new Map(reqLockVersions);
689
833
  if (lockfilePath) {
690
834
  try {
691
- readFileSync2(lockfilePath, "utf-8");
692
- lockEv = lockfileEvidence2(lockfilePath);
835
+ const lockContent = await readFile2(lockfilePath, "utf-8");
836
+ const parsed = lockfilePath.endsWith("poetry.lock") || lockfilePath.endsWith("uv.lock") ? parsePoetryLock(lockContent) : parsePipfileLock(lockContent);
837
+ for (const [name, version] of parsed) lockVersions.set(name, version);
838
+ lockEv = lockfileEvidence(lockfilePath);
693
839
  } catch {
694
840
  }
695
841
  }
@@ -697,7 +843,7 @@ var PythonAdapter = class {
697
843
  for (const dep of allDeps) {
698
844
  if (seen.has(dep.name)) continue;
699
845
  seen.add(dep.name);
700
- const locked = reqLockVersions.get(dep.name) || void 0;
846
+ const locked = lockVersions.get(normalizePkgName(dep.name));
701
847
  const isRegistry = !dep.constraint || !dep.constraint.startsWith("file:") && !dep.constraint.startsWith("git+") && !dep.constraint.startsWith("-e");
702
848
  const purl = isRegistry && locked ? buildPurl({ type: "python", name: dep.name, version: locked }) : isRegistry ? buildPurl({ type: "python", name: dep.name }) : void 0;
703
849
  const evidence = [];
@@ -722,23 +868,31 @@ var PythonAdapter = class {
722
868
  evidence
723
869
  });
724
870
  }
725
- return observations;
726
- }
727
- fileExists(filePath) {
728
- try {
729
- readFileSync2(filePath, "utf-8");
730
- return true;
731
- } catch {
732
- return false;
871
+ if (options.includeTransitive && lockEv) {
872
+ for (const [name, locked] of lockVersions) {
873
+ if (seen.has(name)) continue;
874
+ seen.add(name);
875
+ observations.push({
876
+ id: `dep-${workspace.id}-${name}`,
877
+ workspaceId: workspace.id,
878
+ purl: buildPurl({ type: "python", name, version: locked }),
879
+ ecosystem: "python",
880
+ name,
881
+ sourceType: "registry",
882
+ direct: false,
883
+ scope: "transitive",
884
+ locked,
885
+ status: "current",
886
+ evidence: [lockEv]
887
+ });
888
+ }
733
889
  }
890
+ return observations;
734
891
  }
735
- detectLockfile(workspaceRoot2) {
892
+ async detectLockfile(workspaceRoot2) {
736
893
  for (const file of ["Pipfile.lock", "poetry.lock", "uv.lock"]) {
737
- try {
738
- readFileSync2(join2(workspaceRoot2, file), "utf-8");
739
- return join2(workspaceRoot2, file);
740
- } catch {
741
- }
894
+ const candidate = join2(workspaceRoot2, file);
895
+ if (await fileExistsAsync(candidate)) return candidate;
742
896
  }
743
897
  return void 0;
744
898
  }
@@ -746,13 +900,7 @@ var PythonAdapter = class {
746
900
  var pythonAdapter = new PythonAdapter();
747
901
 
748
902
  // src/adapters/rust.ts
749
- import { readFileSync as readFileSync3 } from "node:fs";
750
- function manifestEvidence3(path) {
751
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
752
- }
753
- function lockfileEvidence3(path) {
754
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
755
- }
903
+ import { readFileSync } from "node:fs";
756
904
  function parseTomlSections2(content) {
757
905
  const sections = [];
758
906
  let currentSection = "__header__";
@@ -772,33 +920,29 @@ function parseTomlSections2(content) {
772
920
  if (currentLines.length > 0) sections.push({ name: currentSection, lines: currentLines });
773
921
  return sections;
774
922
  }
775
- function parseTomlKeyValue(line) {
776
- const trimmed = line.trim();
777
- if (trimmed.startsWith("#")) return void 0;
778
- const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=\s*(.+)$/);
779
- if (!match) return void 0;
780
- return { key: match[1], value: match[2].trim() };
781
- }
782
923
  function extractTomlDeps(sectionLines) {
783
924
  const deps = [];
784
925
  for (const raw of sectionLines) {
785
926
  const line = raw.trim();
786
927
  if (line.startsWith("#") || line === "") continue;
787
- const tableMatch = line.match(/^([a-zA-Z0-9_-]+)\s*=\s*\{\s*(.*?)\s*\}$/);
928
+ const entry = parseTomlKeyValue(line);
929
+ if (!entry) continue;
930
+ const tableMatch = entry.value.match(/^\{\s*(.*?)\s*\}$/);
788
931
  if (tableMatch) {
789
- const name = tableMatch[1];
790
- const inner = tableMatch[2];
932
+ const name = entry.key;
933
+ const inner = tableMatch[1];
934
+ if (inner === void 0) continue;
791
935
  const versionMatch = inner.match(/version\s*=\s*"([^"]+)"/);
792
- deps.push({ name, version: versionMatch ? versionMatch[1] : void 0 });
936
+ const sourceType = /\bgit\s*=/.test(inner) ? "git" : /\bpath\s*=/.test(inner) ? "path" : "registry";
937
+ deps.push({ name, version: versionMatch?.[1], sourceType });
793
938
  continue;
794
939
  }
795
- const simpleMatch = line.match(/^([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"$/);
940
+ const simpleMatch = entry.value.match(/^"([^"]*)"$/);
796
941
  if (simpleMatch) {
797
- deps.push({ name: simpleMatch[1], version: simpleMatch[2] || void 0 });
942
+ deps.push({ name: entry.key, version: simpleMatch[1] || void 0, sourceType: "registry" });
798
943
  continue;
799
944
  }
800
- const partialMatch = parseTomlKeyValue(line);
801
- if (partialMatch && !partialMatch.value.startsWith("{") && !partialMatch.value.startsWith('"')) {
945
+ if (!entry.value.startsWith("{") && !entry.value.startsWith('"')) {
802
946
  }
803
947
  }
804
948
  return deps;
@@ -854,23 +998,23 @@ var RustAdapter = class {
854
998
  const observations = [];
855
999
  const root = workspaceRoot(workspace, options);
856
1000
  const seen = /* @__PURE__ */ new Set();
857
- const cargoTomlPath = workspace.manifests.find((m) => m.includes("Cargo.toml")) || (this.fileExists(resolveIn(root, "Cargo.toml")) ? "Cargo.toml" : void 0);
1001
+ const cargoTomlPath = workspace.manifests.find((m) => m.includes("Cargo.toml")) || (fileExists(resolveIn(root, "Cargo.toml")) ? "Cargo.toml" : void 0);
858
1002
  if (!cargoTomlPath) return [];
859
1003
  const fullManifestPath = resolveIn(root, cargoTomlPath);
860
1004
  let cargoContent;
861
1005
  try {
862
- cargoContent = readFileSync3(fullManifestPath, "utf-8");
1006
+ cargoContent = readFileSync(fullManifestPath, "utf-8");
863
1007
  } catch {
864
1008
  return [];
865
1009
  }
866
- const manifestEv = manifestEvidence3(fullManifestPath);
1010
+ const manifestEv = manifestEvidence(fullManifestPath);
867
1011
  const cargoLockPath = resolveIn(root, "Cargo.lock");
868
1012
  let lockVersions = /* @__PURE__ */ new Map();
869
1013
  let lockEv;
870
1014
  try {
871
- const lockContent = readFileSync3(cargoLockPath, "utf-8");
1015
+ const lockContent = readFileSync(cargoLockPath, "utf-8");
872
1016
  lockVersions = parseCargoLock(lockContent);
873
- lockEv = lockfileEvidence3(cargoLockPath);
1017
+ lockEv = lockfileEvidence(cargoLockPath);
874
1018
  } catch {
875
1019
  }
876
1020
  const sections = parseTomlSections2(cargoContent);
@@ -891,18 +1035,18 @@ var RustAdapter = class {
891
1035
  if (seen.has(dep.name)) continue;
892
1036
  seen.add(dep.name);
893
1037
  const locked = lockVersions.get(dep.name) || dep.version;
894
- const isRegistry = !dep.version || !dep.version.startsWith("path=") && !dep.version.startsWith("git=") && !dep.version.startsWith("../");
1038
+ const isRegistry = dep.sourceType === "registry";
895
1039
  const purl = isRegistry && locked ? buildPurl({ type: "rust", name: dep.name, version: locked }) : isRegistry ? buildPurl({ type: "rust", name: dep.name }) : void 0;
896
1040
  const evidence = [manifestEv];
897
1041
  if (lockEv && locked && lockVersions.has(dep.name)) evidence.push(lockEv);
898
- const status = dep.version && (dep.version.startsWith("path=") || dep.version.startsWith("git=")) ? dep.version.startsWith("git=") ? "git_dependency" : "local_path" : "current";
1042
+ const status = dep.sourceType === "git" ? "git_dependency" : dep.sourceType === "path" ? "local_path" : "current";
899
1043
  observations.push({
900
1044
  id: `dep-${workspace.id}-${dep.name}`,
901
1045
  workspaceId: workspace.id,
902
1046
  ...purl ? { purl } : {},
903
1047
  ecosystem: "rust",
904
1048
  name: dep.name,
905
- sourceType: isRegistry ? "registry" : status === "local_path" ? "path" : "git",
1049
+ sourceType: dep.sourceType,
906
1050
  direct: true,
907
1051
  scope,
908
1052
  ...dep.version ? { requested: dep.version } : {},
@@ -912,27 +1056,32 @@ var RustAdapter = class {
912
1056
  });
913
1057
  }
914
1058
  }
915
- return observations;
916
- }
917
- fileExists(filePath) {
918
- try {
919
- readFileSync3(filePath, "utf-8");
920
- return true;
921
- } catch {
922
- return false;
1059
+ if (options.includeTransitive && lockEv) {
1060
+ for (const [name, locked] of lockVersions) {
1061
+ if (seen.has(name)) continue;
1062
+ seen.add(name);
1063
+ observations.push({
1064
+ id: `dep-${workspace.id}-${name}`,
1065
+ workspaceId: workspace.id,
1066
+ purl: buildPurl({ type: "rust", name, version: locked }),
1067
+ ecosystem: "rust",
1068
+ name,
1069
+ sourceType: "registry",
1070
+ direct: false,
1071
+ scope: "transitive",
1072
+ locked,
1073
+ status: "current",
1074
+ evidence: [lockEv]
1075
+ });
1076
+ }
923
1077
  }
1078
+ return observations;
924
1079
  }
925
1080
  };
926
1081
  var rustAdapter = new RustAdapter();
927
1082
 
928
1083
  // src/adapters/go.ts
929
- import { readFileSync as readFileSync4 } from "node:fs";
930
- function manifestEvidence4(path) {
931
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
932
- }
933
- function lockfileEvidence4(path) {
934
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
935
- }
1084
+ import { readFileSync as readFileSync2 } from "node:fs";
936
1085
  function cleanGoVersion(v) {
937
1086
  return v.replace(/^v/i, "");
938
1087
  }
@@ -968,11 +1117,34 @@ function parseGoMod(content) {
968
1117
  continue;
969
1118
  }
970
1119
  if (line.startsWith("exclude") || line.startsWith("replace") || line.startsWith("retract")) {
971
- continue;
972
1120
  }
973
1121
  }
974
1122
  return deps;
975
1123
  }
1124
+ function parseGoReplacements(content) {
1125
+ const replacements = /* @__PURE__ */ new Map();
1126
+ let inBlock = false;
1127
+ for (const raw of content.split("\n")) {
1128
+ const line = raw.trim();
1129
+ if (line === "replace (") {
1130
+ inBlock = true;
1131
+ continue;
1132
+ }
1133
+ if (inBlock && line === ")") {
1134
+ inBlock = false;
1135
+ continue;
1136
+ }
1137
+ const candidate = inBlock ? line : line.startsWith("replace ") ? line.slice(8).trim() : "";
1138
+ const match = candidate.match(/^(\S+)(?:\s+v\S+)?\s+=>\s+(\S+)/);
1139
+ if (!match) continue;
1140
+ const modulePath = match[1];
1141
+ const target = match[2];
1142
+ if (!modulePath || !target) continue;
1143
+ const local = target.startsWith(".") || target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target);
1144
+ replacements.set(modulePath, local ? "path" : "git");
1145
+ }
1146
+ return replacements;
1147
+ }
976
1148
  function parseGoSum(content) {
977
1149
  const versions = /* @__PURE__ */ new Map();
978
1150
  for (const raw of content.split("\n")) {
@@ -1003,25 +1175,26 @@ var GoAdapter = class {
1003
1175
  const observations = [];
1004
1176
  const root = workspaceRoot(workspace, options);
1005
1177
  const seen = /* @__PURE__ */ new Set();
1006
- const goModPath = workspace.manifests.find((m) => m.includes("go.mod")) || (this.fileExists(resolveIn(root, "go.mod")) ? "go.mod" : void 0);
1178
+ const goModPath = workspace.manifests.find((m) => m.includes("go.mod")) || (fileExists(resolveIn(root, "go.mod")) ? "go.mod" : void 0);
1007
1179
  if (!goModPath) return [];
1008
1180
  const fullManifestPath = resolveIn(root, goModPath);
1009
1181
  let goModContent;
1010
1182
  try {
1011
- goModContent = readFileSync4(fullManifestPath, "utf-8");
1183
+ goModContent = readFileSync2(fullManifestPath, "utf-8");
1012
1184
  } catch {
1013
1185
  return [];
1014
1186
  }
1015
- const manifestEv = manifestEvidence4(fullManifestPath);
1187
+ const manifestEv = manifestEvidence(fullManifestPath);
1016
1188
  const requires = parseGoMod(goModContent);
1189
+ const replacements = parseGoReplacements(goModContent);
1017
1190
  const modName = parseGoModuleName(goModContent);
1018
1191
  const goSumPath = resolveIn(root, "go.sum");
1019
1192
  let lockVersions = /* @__PURE__ */ new Map();
1020
1193
  let lockEv;
1021
1194
  try {
1022
- const sumContent = readFileSync4(goSumPath, "utf-8");
1195
+ const sumContent = readFileSync2(goSumPath, "utf-8");
1023
1196
  lockVersions = parseGoSum(sumContent);
1024
- lockEv = lockfileEvidence4(goSumPath);
1197
+ lockEv = lockfileEvidence(goSumPath);
1025
1198
  } catch {
1026
1199
  }
1027
1200
  for (const req of requires) {
@@ -1031,53 +1204,43 @@ var GoAdapter = class {
1031
1204
  const scope = req.indirect ? "transitive" : "runtime";
1032
1205
  const direct = !req.indirect;
1033
1206
  const locked = lockVersions.get(req.modulePath) || req.version;
1034
- const purl = buildPurl({ type: "go", name: req.modulePath, version: locked });
1207
+ const replacement = replacements.get(req.modulePath);
1208
+ const purl = replacement ? void 0 : buildPurl({ type: "go", name: req.modulePath, version: locked });
1035
1209
  const evidence = [manifestEv];
1036
1210
  if (lockEv && lockVersions.has(req.modulePath)) evidence.push(lockEv);
1037
1211
  observations.push({
1038
1212
  id: `dep-${workspace.id}-${req.modulePath}`,
1039
1213
  workspaceId: workspace.id,
1040
- purl,
1214
+ ...purl ? { purl } : {},
1041
1215
  ecosystem: "go",
1042
1216
  name: req.modulePath,
1043
- sourceType: "registry",
1217
+ sourceType: replacement ?? "registry",
1044
1218
  direct,
1045
1219
  scope,
1046
1220
  requested: req.version,
1047
1221
  ...locked ? { locked } : {},
1048
- status: "current",
1222
+ status: replacement === "path" ? "local_path" : replacement === "git" ? "git_dependency" : "current",
1049
1223
  evidence
1050
1224
  });
1051
1225
  }
1052
1226
  return observations;
1053
1227
  }
1054
- fileExists(filePath) {
1055
- try {
1056
- readFileSync4(filePath, "utf-8");
1057
- return true;
1058
- } catch {
1059
- return false;
1060
- }
1061
- }
1062
1228
  };
1063
1229
  var goAdapter = new GoAdapter();
1064
1230
 
1065
1231
  // src/adapters/dotnet.ts
1066
- import { readFileSync as readFileSync5, readdirSync } from "node:fs";
1232
+ import { readFile as readFile3, readdir } from "node:fs/promises";
1067
1233
  import { join as join3 } from "node:path";
1068
- function manifestEvidence5(path) {
1069
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1070
- }
1071
- function lockfileEvidence5(path) {
1072
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1073
- }
1074
1234
  function parseCsproj(content) {
1075
1235
  const refs = [];
1076
- const regex = /<PackageReference\s+Include\s*=\s*"([^"]+)"\s*(?:Version\s*=\s*"([^"]*)")?\s*\/?\s*>/g;
1077
- let match;
1078
- while ((match = regex.exec(content)) !== null) {
1079
- const name = match[1];
1080
- const version = match[2] || void 0;
1236
+ const regex = /<PackageReference\b([^>]*?)(?:\/>|>([\s\S]*?)<\/PackageReference>)/gi;
1237
+ for (const match of content.matchAll(regex)) {
1238
+ const attributes = match[1] ?? "";
1239
+ const body = match[2] ?? "";
1240
+ const parsedAttributes = parseXmlAttributes(attributes);
1241
+ const name = parsedAttributes.get("Include");
1242
+ if (!name) continue;
1243
+ const version = parsedAttributes.get("Version") ?? xmlTagValue(body, "Version");
1081
1244
  refs.push({ name, version });
1082
1245
  }
1083
1246
  return refs;
@@ -1110,7 +1273,7 @@ var DotNetAdapter = class {
1110
1273
  const seen = /* @__PURE__ */ new Set();
1111
1274
  let csprojPath;
1112
1275
  try {
1113
- const files = readdirSync(root);
1276
+ const files = await readdir(root);
1114
1277
  const csproj = files.find((f) => f.endsWith(".csproj"));
1115
1278
  if (csproj) csprojPath = join3(root, csproj);
1116
1279
  } catch {
@@ -1118,19 +1281,19 @@ var DotNetAdapter = class {
1118
1281
  if (!csprojPath) return [];
1119
1282
  let csprojContent;
1120
1283
  try {
1121
- csprojContent = readFileSync5(csprojPath, "utf-8");
1284
+ csprojContent = await readFile3(csprojPath, "utf-8");
1122
1285
  } catch {
1123
1286
  return [];
1124
1287
  }
1125
- const manifestEv = manifestEvidence5(csprojPath);
1288
+ const manifestEv = manifestEvidence(csprojPath);
1126
1289
  const refs = parseCsproj(csprojContent);
1127
1290
  const assetsPath = join3(root, "project.assets.json");
1128
1291
  let lockVersions = /* @__PURE__ */ new Map();
1129
1292
  let lockEv;
1130
1293
  try {
1131
- const assetsContent = readFileSync5(assetsPath, "utf-8");
1294
+ const assetsContent = await readFile3(assetsPath, "utf-8");
1132
1295
  lockVersions = parseProjectAssetsJson(assetsContent);
1133
- lockEv = lockfileEvidence5(assetsPath);
1296
+ lockEv = lockfileEvidence(assetsPath);
1134
1297
  } catch {
1135
1298
  }
1136
1299
  for (const ref of refs) {
@@ -1161,14 +1324,8 @@ var DotNetAdapter = class {
1161
1324
  var dotNetAdapter = new DotNetAdapter();
1162
1325
 
1163
1326
  // src/adapters/php.ts
1164
- import { readFileSync as readFileSync6 } from "node:fs";
1327
+ import { readFileSync as readFileSync3 } from "node:fs";
1165
1328
  import { join as join4 } from "node:path";
1166
- function manifestEvidence6(path) {
1167
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1168
- }
1169
- function lockfileEvidence6(path) {
1170
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1171
- }
1172
1329
  function parseComposerLock(content) {
1173
1330
  const versions = /* @__PURE__ */ new Map();
1174
1331
  try {
@@ -1196,15 +1353,15 @@ var PhpAdapter = class {
1196
1353
  const observations = [];
1197
1354
  const root = workspaceRoot(workspace, options);
1198
1355
  const seen = /* @__PURE__ */ new Set();
1199
- const composerJsonPath = workspace.manifests.find((m) => m.includes("composer.json")) || (this.fileExists(join4(root, "composer.json")) ? join4(root, "composer.json") : void 0);
1356
+ const composerJsonPath = workspace.manifests.find((m) => m.includes("composer.json")) || (fileExists(join4(root, "composer.json")) ? join4(root, "composer.json") : void 0);
1200
1357
  if (!composerJsonPath) return [];
1201
1358
  let content;
1202
1359
  try {
1203
- content = readFileSync6(composerJsonPath, "utf-8");
1360
+ content = readFileSync3(composerJsonPath, "utf-8");
1204
1361
  } catch {
1205
1362
  return [];
1206
1363
  }
1207
- const manifestEv = manifestEvidence6(composerJsonPath);
1364
+ const manifestEv = manifestEvidence(composerJsonPath);
1208
1365
  let composerJson;
1209
1366
  try {
1210
1367
  composerJson = JSON.parse(content);
@@ -1215,9 +1372,9 @@ var PhpAdapter = class {
1215
1372
  let lockVersions = /* @__PURE__ */ new Map();
1216
1373
  let lockEv;
1217
1374
  try {
1218
- const lockContent = readFileSync6(lockPath, "utf-8");
1375
+ const lockContent = readFileSync3(lockPath, "utf-8");
1219
1376
  lockVersions = parseComposerLock(lockContent);
1220
- lockEv = lockfileEvidence6(lockPath);
1377
+ lockEv = lockfileEvidence(lockPath);
1221
1378
  } catch {
1222
1379
  }
1223
1380
  const sections = [
@@ -1254,26 +1411,12 @@ var PhpAdapter = class {
1254
1411
  }
1255
1412
  return observations;
1256
1413
  }
1257
- fileExists(filePath) {
1258
- try {
1259
- readFileSync6(filePath, "utf-8");
1260
- return true;
1261
- } catch {
1262
- return false;
1263
- }
1264
- }
1265
1414
  };
1266
1415
  var phpAdapter = new PhpAdapter();
1267
1416
 
1268
1417
  // src/adapters/dart.ts
1269
- import { readFileSync as readFileSync7 } from "node:fs";
1418
+ import { readFileSync as readFileSync4 } from "node:fs";
1270
1419
  import { join as join5 } from "node:path";
1271
- function manifestEvidence7(path) {
1272
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1273
- }
1274
- function lockfileEvidence7(path) {
1275
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1276
- }
1277
1420
  function parsePubspecYaml(content) {
1278
1421
  const sections = /* @__PURE__ */ new Map();
1279
1422
  let currentSection;
@@ -1301,6 +1444,14 @@ function parsePubspecYaml(content) {
1301
1444
  }
1302
1445
  const sec = sections.get(currentSection);
1303
1446
  sec.set(currentName, constraint);
1447
+ continue;
1448
+ }
1449
+ if (currentName && line.startsWith(" ")) {
1450
+ const sec = sections.get(currentSection);
1451
+ if (!sec) continue;
1452
+ if (/^sdk:\s*flutter\b/.test(trimmed)) sec.set(currentName, "sdk:flutter");
1453
+ else if (/^git:\s*/.test(trimmed)) sec.set(currentName, `git:${trimmed.slice(4).trim()}`);
1454
+ else if (/^path:\s*/.test(trimmed)) sec.set(currentName, `path:${trimmed.slice(5).trim()}`);
1304
1455
  }
1305
1456
  }
1306
1457
  return sections;
@@ -1339,23 +1490,23 @@ var DartAdapter = class {
1339
1490
  const observations = [];
1340
1491
  const root = workspaceRoot(workspace, options);
1341
1492
  const seen = /* @__PURE__ */ new Set();
1342
- const pubspecPath = workspace.manifests.find((m) => m.includes("pubspec.yaml")) || (this.fileExists(join5(root, "pubspec.yaml")) ? join5(root, "pubspec.yaml") : void 0);
1493
+ const pubspecPath = workspace.manifests.find((m) => m.includes("pubspec.yaml")) || (fileExists(join5(root, "pubspec.yaml")) ? join5(root, "pubspec.yaml") : void 0);
1343
1494
  if (!pubspecPath) return [];
1344
1495
  let content;
1345
1496
  try {
1346
- content = readFileSync7(pubspecPath, "utf-8");
1497
+ content = readFileSync4(pubspecPath, "utf-8");
1347
1498
  } catch {
1348
1499
  return [];
1349
1500
  }
1350
- const manifestEv = manifestEvidence7(pubspecPath);
1501
+ const manifestEv = manifestEvidence(pubspecPath);
1351
1502
  const sections = parsePubspecYaml(content);
1352
1503
  const lockPath = join5(root, "pubspec.lock");
1353
1504
  let lockVersions = /* @__PURE__ */ new Map();
1354
1505
  let lockEv;
1355
1506
  try {
1356
- const lockContent = readFileSync7(lockPath, "utf-8");
1507
+ const lockContent = readFileSync4(lockPath, "utf-8");
1357
1508
  lockVersions = parsePubspecLock(lockContent);
1358
- lockEv = lockfileEvidence7(lockPath);
1509
+ lockEv = lockfileEvidence(lockPath);
1359
1510
  } catch {
1360
1511
  }
1361
1512
  const sectionMapping = [
@@ -1369,7 +1520,7 @@ var DartAdapter = class {
1369
1520
  for (const [name, constraint] of deps) {
1370
1521
  if (seen.has(name)) continue;
1371
1522
  seen.add(name);
1372
- if (constraint === "*" || constraint.startsWith("{")) continue;
1523
+ if (constraint === "*" || constraint === "sdk:flutter" || constraint.startsWith("{")) continue;
1373
1524
  const locked = lockVersions.get(name);
1374
1525
  let status = "current";
1375
1526
  let sourceType = "registry";
@@ -1405,32 +1556,54 @@ var DartAdapter = class {
1405
1556
  }
1406
1557
  return observations;
1407
1558
  }
1408
- fileExists(filePath) {
1409
- try {
1410
- readFileSync7(filePath, "utf-8");
1411
- return true;
1412
- } catch {
1413
- return false;
1414
- }
1415
- }
1416
1559
  };
1417
1560
  var dartAdapter = new DartAdapter();
1418
1561
 
1419
1562
  // src/adapters/maven.ts
1420
- import { readFileSync as readFileSync8 } from "node:fs";
1421
- function manifestEvidence8(path) {
1422
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1423
- }
1563
+ import { readFileSync as readFileSync5 } from "node:fs";
1424
1564
  function parsePomDependencies(xml) {
1425
1565
  const deps = [];
1566
+ const properties = /* @__PURE__ */ new Map();
1567
+ const propertiesBlock = /<properties>([\s\S]*?)<\/properties>/.exec(xml)?.[1] ?? "";
1568
+ const propertyRegex = /<([A-Za-z0-9_.-]+)>\s*([^<]+?)\s*<\/\1>/g;
1569
+ for (const propertyMatch of propertiesBlock.matchAll(propertyRegex)) {
1570
+ const key = propertyMatch[1];
1571
+ const value = propertyMatch[2];
1572
+ if (key && value) properties.set(key, value.trim());
1573
+ }
1574
+ const parentBlock = /<parent>([\s\S]*?)<\/parent>/.exec(xml)?.[1];
1575
+ if (parentBlock) {
1576
+ const parentVersion = xmlTagValue(parentBlock, "version");
1577
+ const parentGroup = xmlTagValue(parentBlock, "groupId");
1578
+ if (parentVersion) {
1579
+ properties.set("parent.version", parentVersion);
1580
+ properties.set("project.parent.version", parentVersion);
1581
+ }
1582
+ if (parentGroup) {
1583
+ properties.set("parent.groupId", parentGroup);
1584
+ properties.set("project.parent.groupId", parentGroup);
1585
+ }
1586
+ }
1587
+ const managed = /* @__PURE__ */ new Map();
1588
+ const managementBlock = /<dependencyManagement>([\s\S]*?)<\/dependencyManagement>/.exec(xml)?.[1] ?? "";
1589
+ const managementRegex = /<dependency>\s*([\s\S]*?)<\/dependency>/g;
1590
+ for (const managementMatch of managementBlock.matchAll(managementRegex)) {
1591
+ const block = managementMatch[1];
1592
+ if (!block) continue;
1593
+ const groupId = xmlTagValue(block, "groupId");
1594
+ const artifactId = xmlTagValue(block, "artifactId");
1595
+ const version = xmlTagValue(block, "version");
1596
+ if (groupId && artifactId && version) managed.set(`${groupId}:${artifactId}`, version);
1597
+ }
1598
+ const directXml = xml.replace(/<dependencyManagement>[\s\S]*?<\/dependencyManagement>/g, "");
1426
1599
  const depRegex = /<dependency>\s*([\s\S]*?)<\/dependency>/g;
1427
- let match;
1428
- while ((match = depRegex.exec(xml)) !== null) {
1600
+ for (const match of directXml.matchAll(depRegex)) {
1429
1601
  const block = match[1];
1430
- const groupId = block.match(/<groupId>([^<]+)<\/groupId>/)?.[1]?.trim();
1431
- const artifactId = block.match(/<artifactId>([^<]+)<\/artifactId>/)?.[1]?.trim();
1432
- const version = block.match(/<version>([^<]+)<\/version>/)?.[1]?.trim();
1433
- const scope = block.match(/<scope>([^<]+)<\/scope>/)?.[1]?.trim();
1602
+ const groupId = xmlTagValue(block, "groupId");
1603
+ const artifactId = xmlTagValue(block, "artifactId");
1604
+ const rawVersion = xmlTagValue(block, "version") ?? (groupId && artifactId ? managed.get(`${groupId}:${artifactId}`) : void 0);
1605
+ const version = rawVersion?.replace(/\$\{([^}]+)\}/g, (_whole, key) => properties.get(key) ?? `\${${key}}`);
1606
+ const scope = xmlTagValue(block, "scope");
1434
1607
  if (groupId && artifactId) {
1435
1608
  deps.push({ groupId, artifactId, version, scope });
1436
1609
  }
@@ -1459,11 +1632,11 @@ var MavenAdapter = class {
1459
1632
  if (!pomPath) return [];
1460
1633
  let content;
1461
1634
  try {
1462
- content = readFileSync8(pomPath, "utf-8");
1635
+ content = readFileSync5(pomPath, "utf-8");
1463
1636
  } catch {
1464
1637
  return [];
1465
1638
  }
1466
- const manifestEv = manifestEvidence8(pomPath);
1639
+ const manifestEv = manifestEvidence(pomPath);
1467
1640
  const deps = parsePomDependencies(content);
1468
1641
  const seen = /* @__PURE__ */ new Set();
1469
1642
  for (const dep of deps) {
@@ -1490,22 +1663,139 @@ var MavenAdapter = class {
1490
1663
  };
1491
1664
  var mavenAdapter = new MavenAdapter();
1492
1665
 
1493
- // src/adapters/ruby.ts
1494
- import { readFileSync as readFileSync9 } from "node:fs";
1495
- function manifestEvidence9(path) {
1496
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1666
+ // src/adapters/gradle.ts
1667
+ import { readFile as readFile4 } from "node:fs/promises";
1668
+ import { join as join6 } from "node:path";
1669
+ function scopeForConfiguration(configuration) {
1670
+ if (/test/i.test(configuration)) return "development";
1671
+ if (/compileOnly|annotationProcessor/i.test(configuration)) return "build";
1672
+ if (/runtimeOnly/i.test(configuration)) return "runtime";
1673
+ return "runtime";
1497
1674
  }
1498
- function lockfileEvidence8(path) {
1499
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1675
+ function parseVersionCatalog(content) {
1676
+ const versions = /* @__PURE__ */ new Map();
1677
+ const libraries = /* @__PURE__ */ new Map();
1678
+ let section = "";
1679
+ for (const raw of content.split("\n")) {
1680
+ const line = raw.replace(/#.*$/, "").trim();
1681
+ const header = /^\[([^\]]+)\]$/.exec(line);
1682
+ if (header) {
1683
+ section = header[1] ?? "";
1684
+ continue;
1685
+ }
1686
+ const entry = /^(?:"([^"]+)"|([\w.-]+))\s*=\s*(.+)$/.exec(line);
1687
+ if (!entry) continue;
1688
+ const key = entry[1] ?? entry[2];
1689
+ const value = entry[3];
1690
+ if (!key || !value) continue;
1691
+ if (section === "versions") {
1692
+ const version = /^["']([^"']+)["']/.exec(value)?.[1];
1693
+ if (version) versions.set(key, version);
1694
+ } else if (section === "libraries") {
1695
+ const module = /\bmodule\s*=\s*["']([^"']+)["']/.exec(value)?.[1] ?? (() => {
1696
+ const group = /\bgroup\s*=\s*["']([^"']+)["']/.exec(value)?.[1];
1697
+ const name = /\bname\s*=\s*["']([^"']+)["']/.exec(value)?.[1];
1698
+ return group && name ? `${group}:${name}` : void 0;
1699
+ })();
1700
+ if (!module) continue;
1701
+ const version = /\bversion\s*=\s*["']([^"']+)["']/.exec(value)?.[1];
1702
+ const ref = /\bversion\.ref\s*=\s*["']([^"']+)["']/.exec(value)?.[1];
1703
+ libraries.set(key.replace(/-/g, "."), `${module}:${version ?? (ref ? versions.get(ref) ?? "" : "")}`.replace(/:$/, ""));
1704
+ }
1705
+ }
1706
+ return libraries;
1707
+ }
1708
+ function parseGradleManifest(content, catalog) {
1709
+ const deps = [];
1710
+ const coordinateRegex = /\b(implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly|annotationProcessor)\s*(?:\(|\s)\s*["']([^"']+)["']/g;
1711
+ for (const match of content.matchAll(coordinateRegex)) {
1712
+ const configuration = match[1];
1713
+ const value = match[2];
1714
+ if (!configuration || !value) continue;
1715
+ const [group, artifact, version] = value.split(":");
1716
+ if (!group || !artifact) continue;
1717
+ deps.push({ name: `${group}:${artifact}`, requested: version, scope: scopeForConfiguration(configuration) });
1718
+ }
1719
+ const aliasRegex = /\b(implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly)\s*\(\s*libs\.([\w.]+)\s*\)/g;
1720
+ for (const match of content.matchAll(aliasRegex)) {
1721
+ const configuration = match[1];
1722
+ const alias = match[2];
1723
+ if (!configuration || !alias) continue;
1724
+ const coordinate = catalog.get(alias);
1725
+ if (!coordinate) continue;
1726
+ const [group, artifact, version] = coordinate.split(":");
1727
+ if (!group || !artifact) continue;
1728
+ deps.push({ name: `${group}:${artifact}`, requested: version, scope: scopeForConfiguration(configuration) });
1729
+ }
1730
+ return deps;
1731
+ }
1732
+ function parseGradleLock(content) {
1733
+ const locked = /* @__PURE__ */ new Map();
1734
+ for (const raw of content.split("\n")) {
1735
+ const line = raw.replace(/#.*/, "").trim();
1736
+ const coordinate = /^([^:\s=]+):([^:\s=]+):([^=\s]+)(?:=.*)?$/.exec(line);
1737
+ if (coordinate?.[1] && coordinate[2] && coordinate[3]) locked.set(`${coordinate[1]}:${coordinate[2]}`, coordinate[3]);
1738
+ }
1739
+ return locked;
1500
1740
  }
1741
+ var GradleAdapter = class {
1742
+ ecosystem = "gradle";
1743
+ async inventory(workspace, options) {
1744
+ const root = workspaceRoot(workspace, options);
1745
+ const manifestPath = workspace.manifests.find((path) => /build\.gradle(?:\.kts)?$/.test(path)) ?? (await fileExistsAsync(join6(root, "build.gradle.kts")) ? join6(root, "build.gradle.kts") : join6(root, "build.gradle"));
1746
+ if (!await fileExistsAsync(resolveIn(root, manifestPath))) return [];
1747
+ const catalogPath = join6(root, "gradle", "libs.versions.toml");
1748
+ const catalog = await fileExistsAsync(catalogPath) ? parseVersionCatalog(await readFile4(catalogPath, "utf8")) : /* @__PURE__ */ new Map();
1749
+ const direct = parseGradleManifest(await readFile4(resolveIn(root, manifestPath), "utf8"), catalog);
1750
+ const lockPath = workspace.lockfiles.find((path) => path.endsWith("gradle.lockfile")) ?? join6(root, "gradle.lockfile");
1751
+ const locked = await fileExistsAsync(resolveIn(root, lockPath)) ? parseGradleLock(await readFile4(resolveIn(root, lockPath), "utf8")) : /* @__PURE__ */ new Map();
1752
+ const manifestEv = manifestEvidence(resolveIn(root, manifestPath));
1753
+ const lockEv = locked.size > 0 ? lockfileEvidence(resolveIn(root, lockPath)) : void 0;
1754
+ const observations = [];
1755
+ const seen = /* @__PURE__ */ new Set();
1756
+ const add = (name, requested, scope, isDirect) => {
1757
+ if (seen.has(name)) return;
1758
+ seen.add(name);
1759
+ const version = locked.get(name) ?? requested;
1760
+ const lockedVersion = locked.get(name);
1761
+ const evidence = isDirect ? [manifestEv] : [];
1762
+ if (lockEv && locked.has(name)) evidence.push(lockEv);
1763
+ observations.push({
1764
+ id: `dep-${workspace.id}-${name}`,
1765
+ workspaceId: workspace.id,
1766
+ purl: buildPurl({ type: "maven", name, ...version ? { version } : {} }),
1767
+ ecosystem: "gradle",
1768
+ name,
1769
+ sourceType: "registry",
1770
+ direct: isDirect,
1771
+ scope: isDirect ? scope : "transitive",
1772
+ ...requested ? { requested } : {},
1773
+ ...lockedVersion ? { locked: lockedVersion } : {},
1774
+ status: "current",
1775
+ evidence
1776
+ });
1777
+ };
1778
+ for (const dep of direct) add(dep.name, dep.requested, dep.scope, true);
1779
+ if (options.includeTransitive) {
1780
+ for (const [name] of locked) add(name, void 0, "transitive", false);
1781
+ }
1782
+ return observations;
1783
+ }
1784
+ };
1785
+ var gradleAdapter = new GradleAdapter();
1786
+
1787
+ // src/adapters/ruby.ts
1788
+ import { readFileSync as readFileSync6 } from "node:fs";
1501
1789
  function parseGemfile(content) {
1502
1790
  const gems = [];
1503
- const gemRegex = /gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/g;
1504
- let match;
1505
- while ((match = gemRegex.exec(content)) !== null) {
1791
+ const gemRegex = /gem\s+['"]([^'"]+)['"]([^\n]*)/g;
1792
+ for (const match of content.matchAll(gemRegex)) {
1506
1793
  const name = match[1];
1507
1794
  if (name === "rails" || name === "ruby") continue;
1508
- gems.push({ name, version: match[2] });
1795
+ const tail = match[2] ?? "";
1796
+ const version = /^\s*,\s*['"]([^'"]+)['"]/.exec(tail)?.[1];
1797
+ const sourceType = /\b(?:git|github):/.test(tail) ? "git" : /\bpath:/.test(tail) ? "path" : "registry";
1798
+ gems.push({ name, version, sourceType });
1509
1799
  }
1510
1800
  return gems;
1511
1801
  }
@@ -1514,7 +1804,11 @@ function parseGemfileLock(content) {
1514
1804
  const lines = content.split("\n");
1515
1805
  let inSpecs = false;
1516
1806
  for (const line of lines) {
1517
- if (line.startsWith("GEM")) {
1807
+ if (/^(?:GEM|GIT|PATH)$/.test(line.trim())) {
1808
+ inSpecs = false;
1809
+ continue;
1810
+ }
1811
+ if (/^\s{2}specs:\s*$/.test(line)) {
1518
1812
  inSpecs = true;
1519
1813
  continue;
1520
1814
  }
@@ -1539,11 +1833,11 @@ var RubyAdapter = class {
1539
1833
  if (!gemfilePath) return [];
1540
1834
  let content;
1541
1835
  try {
1542
- content = readFileSync9(gemfilePath, "utf-8");
1836
+ content = readFileSync6(gemfilePath, "utf-8");
1543
1837
  } catch {
1544
1838
  return [];
1545
1839
  }
1546
- const manifestEv = manifestEvidence9(gemfilePath);
1840
+ const manifestEv = manifestEvidence(gemfilePath);
1547
1841
  const gems = parseGemfile(content);
1548
1842
  const seen = /* @__PURE__ */ new Set();
1549
1843
  const lockfilePath = workspace.lockfiles.find((l) => l.includes("Gemfile.lock"));
@@ -1551,9 +1845,9 @@ var RubyAdapter = class {
1551
1845
  let lockEv;
1552
1846
  if (lockfilePath) {
1553
1847
  try {
1554
- const lockContent = readFileSync9(lockfilePath, "utf-8");
1848
+ const lockContent = readFileSync6(lockfilePath, "utf-8");
1555
1849
  lockVersions = parseGemfileLock(lockContent);
1556
- lockEv = lockfileEvidence8(lockfilePath);
1850
+ lockEv = lockfileEvidence(lockfilePath);
1557
1851
  } catch {
1558
1852
  }
1559
1853
  }
@@ -1562,21 +1856,21 @@ var RubyAdapter = class {
1562
1856
  seen.add(gem.name);
1563
1857
  const locked = lockVersions.get(gem.name);
1564
1858
  const version = locked ?? gem.version;
1565
- const purl = version ? buildPurl({ type: "gem", name: gem.name, version }) : buildPurl({ type: "gem", name: gem.name });
1859
+ const purl = gem.sourceType === "registry" && version ? buildPurl({ type: "gem", name: gem.name, version }) : gem.sourceType === "registry" ? buildPurl({ type: "gem", name: gem.name }) : void 0;
1566
1860
  const evidence = [manifestEv];
1567
1861
  if (lockEv && locked) evidence.push(lockEv);
1568
1862
  observations.push({
1569
1863
  id: `dep-${workspace.id}-${gem.name}`,
1570
1864
  workspaceId: workspace.id,
1571
- purl,
1865
+ ...purl ? { purl } : {},
1572
1866
  ecosystem: "ruby",
1573
1867
  name: gem.name,
1574
- sourceType: "registry",
1868
+ sourceType: gem.sourceType,
1575
1869
  direct: true,
1576
1870
  scope: "runtime",
1577
1871
  ...gem.version ? { requested: gem.version } : {},
1578
1872
  ...locked ? { locked } : {},
1579
- status: "current",
1873
+ status: gem.sourceType === "git" ? "git_dependency" : gem.sourceType === "path" ? "local_path" : "current",
1580
1874
  evidence
1581
1875
  });
1582
1876
  }
@@ -1585,28 +1879,101 @@ var RubyAdapter = class {
1585
1879
  };
1586
1880
  var rubyAdapter = new RubyAdapter();
1587
1881
 
1588
- // src/adapters/elixir.ts
1589
- import { readFileSync as readFileSync10 } from "node:fs";
1590
- function manifestEvidence10(path) {
1591
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1882
+ // src/adapters/swift.ts
1883
+ import { readFileSync as readFileSync7 } from "node:fs";
1884
+ import { basename, join as join7 } from "node:path";
1885
+ function identityFromLocation(location) {
1886
+ return basename(location.replace(/[\\/]$/, "")).replace(/\.git$/i, "").toLowerCase();
1592
1887
  }
1593
- function lockfileEvidence9(path) {
1594
- return { kind: "lockfile", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1888
+ function parsePackageSwift(content) {
1889
+ const deps = [];
1890
+ const packageRegex = /\.package\s*\(\s*(?:name:\s*["'][^"']+["'],\s*)?(url|path):\s*["']([^"']+)["']\s*(?:,\s*(?:from|exact|branch|revision):\s*["']([^"']+)["'])?\s*\)/g;
1891
+ for (const match of content.matchAll(packageRegex)) {
1892
+ const location = match[2];
1893
+ if (!location) continue;
1894
+ deps.push({
1895
+ identity: identityFromLocation(location),
1896
+ requested: match[3],
1897
+ sourceType: match[1] === "path" ? "path" : "git"
1898
+ });
1899
+ }
1900
+ return deps;
1901
+ }
1902
+ function parsePackageResolved(content) {
1903
+ const pins = /* @__PURE__ */ new Map();
1904
+ try {
1905
+ const json = JSON.parse(content);
1906
+ for (const pin of json.pins ?? json.object?.pins ?? []) {
1907
+ const identity = (pin.identity ?? pin.package ?? (pin.location ? identityFromLocation(pin.location) : void 0) ?? (pin.repositoryURL ? identityFromLocation(pin.repositoryURL) : void 0))?.toLowerCase();
1908
+ if (identity) pins.set(identity, { ...pin.state?.version ? { version: pin.state.version } : {}, ...pin.state?.revision ? { revision: pin.state.revision } : {} });
1909
+ }
1910
+ } catch {
1911
+ }
1912
+ return pins;
1595
1913
  }
1914
+ var SwiftAdapter = class {
1915
+ ecosystem = "swift";
1916
+ async inventory(workspace, options) {
1917
+ const root = workspaceRoot(workspace, options);
1918
+ const manifestPath = workspace.manifests.find((path) => path.endsWith("Package.swift")) ?? join7(root, "Package.swift");
1919
+ if (!fileExists(manifestPath)) return [];
1920
+ const direct = parsePackageSwift(readFileSync7(manifestPath, "utf8"));
1921
+ const resolvedPath = workspace.lockfiles.find((path) => path.endsWith("Package.resolved")) ?? join7(root, "Package.resolved");
1922
+ const pins = fileExists(resolvedPath) ? parsePackageResolved(readFileSync7(resolvedPath, "utf8")) : /* @__PURE__ */ new Map();
1923
+ const manifestEv = manifestEvidence(manifestPath);
1924
+ const lockEv = pins.size > 0 ? lockfileEvidence(resolvedPath) : void 0;
1925
+ const observations = [];
1926
+ const seen = /* @__PURE__ */ new Set();
1927
+ const add = (identity, requested, sourceType, isDirect) => {
1928
+ if (seen.has(identity)) return;
1929
+ seen.add(identity);
1930
+ const pin = pins.get(identity);
1931
+ const version = pin?.version ?? pin?.revision ?? requested;
1932
+ const evidence = isDirect ? [manifestEv] : [];
1933
+ if (lockEv && pin) evidence.push(lockEv);
1934
+ observations.push({
1935
+ id: `dep-${workspace.id}-${identity}`,
1936
+ workspaceId: workspace.id,
1937
+ ...sourceType === "git" ? { purl: buildPurl({ type: "swift", name: identity, ...version ? { version } : {} }) } : {},
1938
+ ecosystem: "swift",
1939
+ name: identity,
1940
+ sourceType,
1941
+ direct: isDirect,
1942
+ scope: isDirect ? "runtime" : "transitive",
1943
+ ...requested ? { requested } : {},
1944
+ ...pin?.version ? { locked: pin.version } : pin?.revision ? { locked: pin.revision } : {},
1945
+ status: sourceType === "path" ? "local_path" : "git_dependency",
1946
+ evidence
1947
+ });
1948
+ };
1949
+ for (const dep of direct) add(dep.identity, dep.requested, dep.sourceType, true);
1950
+ if (options.includeTransitive) {
1951
+ for (const [identity] of pins) add(identity, void 0, "git", false);
1952
+ }
1953
+ return observations;
1954
+ }
1955
+ };
1956
+ var swiftAdapter = new SwiftAdapter();
1957
+
1958
+ // src/adapters/elixir.ts
1959
+ import { readFileSync as readFileSync8 } from "node:fs";
1596
1960
  function parseMixExsDeps(content) {
1597
1961
  const deps = [];
1598
- const depRegex = /\{:(\w+),\s*["']([^"']+)["']\}/g;
1599
- let match;
1600
- while ((match = depRegex.exec(content)) !== null) {
1601
- deps.push({ name: match[1], version: match[2] });
1962
+ const depRegex = /\{:(\w+),\s*([^}]+)\}/g;
1963
+ for (const match of content.matchAll(depRegex)) {
1964
+ const name = match[1];
1965
+ const value = match[2];
1966
+ if (!name || !value) continue;
1967
+ const version = /^\s*["']([^"']+)["']/.exec(value)?.[1];
1968
+ const sourceType = /\bgit:/.test(value) ? "git" : /\bpath:/.test(value) ? "path" : "registry";
1969
+ deps.push({ name, version, sourceType });
1602
1970
  }
1603
1971
  return deps;
1604
1972
  }
1605
1973
  function parseMixLock(content) {
1606
1974
  const versions = /* @__PURE__ */ new Map();
1607
- const lockRegex = /\{:"(\w+)",\s*hex: "[^"]*",\s*"([^"]+)"/g;
1608
- let match;
1609
- while ((match = lockRegex.exec(content)) !== null) {
1975
+ const lockRegex = /["']([\w-]+)["']\s*=>\s*\{:hex,\s*:[\w-]+,\s*["']([^"']+)["']/g;
1976
+ for (const match of content.matchAll(lockRegex)) {
1610
1977
  versions.set(match[1], match[2]);
1611
1978
  }
1612
1979
  return versions;
@@ -1619,11 +1986,11 @@ var ElixirAdapter = class {
1619
1986
  if (!mixExsPath) return [];
1620
1987
  let content;
1621
1988
  try {
1622
- content = readFileSync10(mixExsPath, "utf-8");
1989
+ content = readFileSync8(mixExsPath, "utf-8");
1623
1990
  } catch {
1624
1991
  return [];
1625
1992
  }
1626
- const manifestEv = manifestEvidence10(mixExsPath);
1993
+ const manifestEv = manifestEvidence(mixExsPath);
1627
1994
  const deps = parseMixExsDeps(content);
1628
1995
  const seen = /* @__PURE__ */ new Set();
1629
1996
  const lockfilePath = workspace.lockfiles.find((l) => l.includes("mix.lock"));
@@ -1631,9 +1998,9 @@ var ElixirAdapter = class {
1631
1998
  let lockEv;
1632
1999
  if (lockfilePath) {
1633
2000
  try {
1634
- const lockContent = readFileSync10(lockfilePath, "utf-8");
2001
+ const lockContent = readFileSync8(lockfilePath, "utf-8");
1635
2002
  lockVersions = parseMixLock(lockContent);
1636
- lockEv = lockfileEvidence9(lockfilePath);
2003
+ lockEv = lockfileEvidence(lockfilePath);
1637
2004
  } catch {
1638
2005
  }
1639
2006
  }
@@ -1642,21 +2009,21 @@ var ElixirAdapter = class {
1642
2009
  seen.add(dep.name);
1643
2010
  const locked = lockVersions.get(dep.name);
1644
2011
  const version = locked ?? dep.version;
1645
- const purl = version ? buildPurl({ type: "hex", name: dep.name, version }) : buildPurl({ type: "hex", name: dep.name });
2012
+ const purl = dep.sourceType === "registry" && version ? buildPurl({ type: "hex", name: dep.name, version }) : dep.sourceType === "registry" ? buildPurl({ type: "hex", name: dep.name }) : void 0;
1646
2013
  const evidence = [manifestEv];
1647
2014
  if (lockEv && locked) evidence.push(lockEv);
1648
2015
  observations.push({
1649
2016
  id: `dep-${workspace.id}-${dep.name}`,
1650
2017
  workspaceId: workspace.id,
1651
- purl,
2018
+ ...purl ? { purl } : {},
1652
2019
  ecosystem: "elixir",
1653
2020
  name: dep.name,
1654
- sourceType: "registry",
2021
+ sourceType: dep.sourceType,
1655
2022
  direct: true,
1656
2023
  scope: "runtime",
1657
2024
  ...dep.version ? { requested: dep.version } : {},
1658
2025
  ...locked ? { locked } : {},
1659
- status: "current",
2026
+ status: dep.sourceType === "git" ? "git_dependency" : dep.sourceType === "path" ? "local_path" : "current",
1660
2027
  evidence
1661
2028
  });
1662
2029
  }
@@ -1666,10 +2033,7 @@ var ElixirAdapter = class {
1666
2033
  var elixirAdapter = new ElixirAdapter();
1667
2034
 
1668
2035
  // src/adapters/cpp.ts
1669
- import { readFileSync as readFileSync11 } from "node:fs";
1670
- function manifestEvidence11(path) {
1671
- return { kind: "manifest", source: path, retrievedAt: (/* @__PURE__ */ new Date()).toISOString() };
1672
- }
2036
+ import { readFileSync as readFileSync9 } from "node:fs";
1673
2037
  function parseConanTxt(content) {
1674
2038
  const deps = [];
1675
2039
  const requiresMatch = /\[requires\]\s*\n([\s\S]*?)(?:\[|$)/;
@@ -1710,11 +2074,11 @@ var CppAdapter = class {
1710
2074
  for (const manifestPath of workspace.manifests) {
1711
2075
  let content;
1712
2076
  try {
1713
- content = readFileSync11(manifestPath, "utf-8");
2077
+ content = readFileSync9(manifestPath, "utf-8");
1714
2078
  } catch {
1715
2079
  continue;
1716
2080
  }
1717
- const manifestEv = manifestEvidence11(manifestPath);
2081
+ const manifestEv = manifestEvidence(manifestPath);
1718
2082
  let deps = [];
1719
2083
  if (manifestPath.includes("conanfile")) {
1720
2084
  deps = parseConanTxt(content);
@@ -1790,6 +2154,108 @@ function diffSnapshots(oldSnapshot, newSnapshot) {
1790
2154
  return { added, removed, changed };
1791
2155
  }
1792
2156
 
2157
+ // src/trend.ts
2158
+ function median(values) {
2159
+ if (!values.length) return void 0;
2160
+ const sorted = [...values].sort((a, b) => a - b);
2161
+ const middle = Math.floor(sorted.length / 2);
2162
+ const upper = sorted[middle];
2163
+ if (upper === void 0) return void 0;
2164
+ if (sorted.length % 2) return upper;
2165
+ const lower = sorted[middle - 1];
2166
+ return lower === void 0 ? upper : (lower + upper) / 2;
2167
+ }
2168
+ var TrendStore = class {
2169
+ constructor(source) {
2170
+ this.source = source;
2171
+ }
2172
+ source;
2173
+ analyze(projectId, limit = 100) {
2174
+ const snapshots = this.source.listSnapshots(projectId, limit).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
2175
+ const versionHistory = /* @__PURE__ */ new Map();
2176
+ const vulnerabilityDurations = [];
2177
+ const openVulnerabilities = /* @__PURE__ */ new Map();
2178
+ for (const snapshot of snapshots) {
2179
+ const at = Date.parse(snapshot.createdAt);
2180
+ const active = /* @__PURE__ */ new Set();
2181
+ for (const dependency of snapshot.dependencies) {
2182
+ const key = `${dependency.ecosystem}:${dependency.name}`;
2183
+ active.add(key);
2184
+ const vulnerable = dependency.status === "vulnerable";
2185
+ const history = versionHistory.get(key) ?? [];
2186
+ history.push({ at, version: dependency.locked ?? dependency.installed ?? dependency.requested, vulnerable, name: dependency.name, ecosystem: dependency.ecosystem });
2187
+ versionHistory.set(key, history);
2188
+ if (vulnerable && !openVulnerabilities.has(key)) openVulnerabilities.set(key, at);
2189
+ if (!vulnerable && openVulnerabilities.has(key)) {
2190
+ const openedAt = openVulnerabilities.get(key);
2191
+ if (openedAt !== void 0) vulnerabilityDurations.push(Math.max(0, at - openedAt));
2192
+ openVulnerabilities.delete(key);
2193
+ }
2194
+ }
2195
+ for (const key of openVulnerabilities.keys()) {
2196
+ if (!active.has(key)) {
2197
+ const openedAt = openVulnerabilities.get(key);
2198
+ if (openedAt !== void 0) vulnerabilityDurations.push(Math.max(0, at - openedAt));
2199
+ openVulnerabilities.delete(key);
2200
+ }
2201
+ }
2202
+ }
2203
+ const dependencies = [];
2204
+ for (const [key, history] of versionHistory) {
2205
+ const first = history[0];
2206
+ const latest = history.at(-1);
2207
+ if (!first || !latest) continue;
2208
+ let versionChanges = 0;
2209
+ let sameVersionSince = first.at;
2210
+ for (let index = 1; index < history.length; index++) {
2211
+ const current = history[index];
2212
+ const previous = history[index - 1];
2213
+ if (current && previous && current.version !== previous.version) {
2214
+ versionChanges++;
2215
+ sameVersionSince = current.at;
2216
+ }
2217
+ }
2218
+ const spanDays = Math.max(1, (latest.at - first.at) / 864e5);
2219
+ dependencies.push({
2220
+ key,
2221
+ name: latest.name,
2222
+ ecosystem: latest.ecosystem,
2223
+ ...latest.version ? { currentVersion: latest.version } : {},
2224
+ lockedVersionAgeMs: Math.max(0, latest.at - sameVersionSince),
2225
+ versionChanges,
2226
+ upgradeVelocityPerDay: versionChanges / spanDays
2227
+ });
2228
+ }
2229
+ dependencies.sort((a, b) => a.key.localeCompare(b.key));
2230
+ const halfLife = median(vulnerabilityDurations);
2231
+ const firstSnapshot = snapshots[0];
2232
+ const lastSnapshot = snapshots.at(-1);
2233
+ return {
2234
+ projectId,
2235
+ ...firstSnapshot && lastSnapshot ? { from: firstSnapshot.createdAt, to: lastSnapshot.createdAt } : {},
2236
+ snapshots: snapshots.length,
2237
+ dependencies,
2238
+ ...halfLife !== void 0 ? { vulnerabilityHalfLifeMs: halfLife } : {},
2239
+ points: snapshots.map((snapshot) => ({
2240
+ snapshotId: snapshot.id,
2241
+ createdAt: snapshot.createdAt,
2242
+ dependencies: snapshot.dependencies.length,
2243
+ outdated: snapshot.dependencies.filter((dependency) => dependency.status.startsWith("update_available")).length,
2244
+ vulnerable: snapshot.dependencies.filter((dependency) => dependency.status === "vulnerable").length
2245
+ }))
2246
+ };
2247
+ }
2248
+ };
2249
+ function renderTrendMarkdown(report) {
2250
+ const lines = ["# TechStack Trend", "", `**Snapshots:** ${report.snapshots}`];
2251
+ if (report.vulnerabilityHalfLifeMs !== void 0) lines.push(`**Vulnerability half-life:** ${(report.vulnerabilityHalfLifeMs / 864e5).toFixed(1)} days`);
2252
+ lines.push("", "| Dependency | Version | Age (days) | Changes | Velocity/day |", "|---|---|---:|---:|---:|");
2253
+ for (const dependency of report.dependencies) {
2254
+ lines.push(`| ${dependency.ecosystem}:${dependency.name} | ${dependency.currentVersion ?? "\u2014"} | ${(dependency.lockedVersionAgeMs / 864e5).toFixed(1)} | ${dependency.versionChanges} | ${dependency.upgradeVelocityPerDay.toFixed(3)} |`);
2255
+ }
2256
+ return lines.join("\n");
2257
+ }
2258
+
1793
2259
  // src/sbom.ts
1794
2260
  function toSpdx(snapshot) {
1795
2261
  const created = snapshot.createdAt;
@@ -1832,6 +2298,47 @@ function toCycloneDX(snapshot) {
1832
2298
  }
1833
2299
 
1834
2300
  // src/remediation.ts
2301
+ var LANGUAGE_BY_ECOSYSTEM = {
2302
+ python: "python",
2303
+ rust: "rust",
2304
+ go: "go",
2305
+ php: "php",
2306
+ dotnet: "csharp"
2307
+ };
2308
+ var EXECUTABLE_ECOSYSTEMS = /* @__PURE__ */ new Set(["npm", ...Object.keys(LANGUAGE_BY_ECOSYSTEM)]);
2309
+ function versionedPackageName(operation) {
2310
+ const version = operation.targetVersion;
2311
+ if (!version) return operation.dependencyName;
2312
+ switch (operation.ecosystem) {
2313
+ case "python":
2314
+ return `${operation.dependencyName}==${version}`;
2315
+ case "php":
2316
+ return `${operation.dependencyName}:${version}`;
2317
+ default:
2318
+ return `${operation.dependencyName}@${version}`;
2319
+ }
2320
+ }
2321
+ function toLanguagePackageInput(operation, workspace) {
2322
+ if (operation.action === "replace" || operation.action === "investigate") {
2323
+ throw new Error(`${operation.action} requires a manual package choice`);
2324
+ }
2325
+ if (!EXECUTABLE_ECOSYSTEMS.has(operation.ecosystem)) {
2326
+ throw new Error(`Automated remediation is not supported for ${operation.ecosystem}`);
2327
+ }
2328
+ const language = LANGUAGE_BY_ECOSYSTEM[operation.ecosystem];
2329
+ const base = {
2330
+ ...workspace ? { workspace } : {},
2331
+ ...language ? { language } : {},
2332
+ allowScripts: false
2333
+ };
2334
+ if (operation.action === "remove") {
2335
+ if (operation.ecosystem === "go") {
2336
+ return { ...base, operation: "add", names: [`${operation.dependencyName}@none`] };
2337
+ }
2338
+ return { ...base, operation: "remove", names: [operation.dependencyName] };
2339
+ }
2340
+ return { ...base, operation: "add", names: [versionedPackageName(operation)] };
2341
+ }
1835
2342
  function suggestCommand(ecosystem, name, action, targetVersion) {
1836
2343
  const ver = targetVersion ? `@${targetVersion}` : "@latest";
1837
2344
  switch (ecosystem) {
@@ -1951,14 +2458,152 @@ function renderPlanMarkdown(plan) {
1951
2458
  }
1952
2459
  return lines.join("\n");
1953
2460
  }
2461
+ async function applyPlan(plan, options = {}) {
2462
+ const dryRun = options.dryRun !== false;
2463
+ const execute = options.execute;
2464
+ if (!dryRun && !execute) throw new Error("An execute strategy is required when dryRun is false");
2465
+ const results = [];
2466
+ for (const [index, item] of plan.items.entries()) {
2467
+ if (options.signal?.aborted) throw new DOMException("Remediation cancelled", "AbortError");
2468
+ if (dryRun) {
2469
+ results.push({ dependencyName: item.dependencyName, status: "planned" });
2470
+ continue;
2471
+ }
2472
+ const approved = options.approve ? await options.approve(item, index) : false;
2473
+ if (!approved) {
2474
+ results.push({
2475
+ dependencyName: item.dependencyName,
2476
+ status: "skipped",
2477
+ detail: "Not approved"
2478
+ });
2479
+ continue;
2480
+ }
2481
+ try {
2482
+ if (!execute) throw new Error("An execute strategy is required when dryRun is false");
2483
+ const response = await execute({
2484
+ ecosystem: item.ecosystem,
2485
+ workspaceId: item.workspaceId,
2486
+ dependencyName: item.dependencyName,
2487
+ action: item.action,
2488
+ ...item.targetVersion ? { targetVersion: item.targetVersion } : {}
2489
+ });
2490
+ results.push({
2491
+ dependencyName: item.dependencyName,
2492
+ status: "applied",
2493
+ ...response?.detail ? { detail: response.detail } : {}
2494
+ });
2495
+ } catch (error) {
2496
+ results.push({
2497
+ dependencyName: item.dependencyName,
2498
+ status: "failed",
2499
+ detail: error instanceof Error ? error.message : String(error)
2500
+ });
2501
+ }
2502
+ }
2503
+ return { dryRun, items: results };
2504
+ }
2505
+
2506
+ // src/registry/http-fetch.ts
2507
+ import { get as httpGet, request as httpRequest } from "node:http";
2508
+ import { get as httpsGet, request as httpsRequest } from "node:https";
2509
+ var DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
2510
+ function retryAfterMs(headers, now = Date.now()) {
2511
+ const value = Array.isArray(headers["retry-after"]) ? headers["retry-after"][0] : headers["retry-after"];
2512
+ if (!value) return void 0;
2513
+ const seconds = Number(value);
2514
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
2515
+ const date = Date.parse(value);
2516
+ return Number.isFinite(date) ? Math.max(0, date - now) : void 0;
2517
+ }
2518
+ function delay(ms, signal) {
2519
+ if (signal?.aborted) return Promise.reject(new DOMException("Request aborted", "AbortError"));
2520
+ return new Promise((resolve3, reject) => {
2521
+ const onAbort = () => {
2522
+ clearTimeout(timer);
2523
+ reject(new DOMException("Request aborted", "AbortError"));
2524
+ };
2525
+ const timer = setTimeout(() => {
2526
+ signal?.removeEventListener("abort", onAbort);
2527
+ resolve3();
2528
+ }, ms);
2529
+ signal?.addEventListener("abort", onAbort, { once: true });
2530
+ });
2531
+ }
2532
+ function requestOnce(options) {
2533
+ return new Promise((resolve3, reject) => {
2534
+ const method = options.method ?? "GET";
2535
+ const requestOptions = {
2536
+ hostname: options.hostname,
2537
+ path: options.path,
2538
+ method,
2539
+ headers: options.headers,
2540
+ signal: options.signal,
2541
+ timeout: options.timeoutMs ?? 15e3
2542
+ };
2543
+ const isHttp = options.hostname === "localhost" || options.hostname === "127.0.0.1";
2544
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
2545
+ const callback = (response) => {
2546
+ let body = "";
2547
+ response.setEncoding?.("utf8");
2548
+ response.on("data", (chunk) => {
2549
+ body += chunk.toString();
2550
+ if (body.length > maxBodyBytes) {
2551
+ response.destroy();
2552
+ reject(
2553
+ new Error(
2554
+ `Response body exceeded ${maxBodyBytes} bytes for ${options.hostname}${options.path}`
2555
+ )
2556
+ );
2557
+ }
2558
+ });
2559
+ response.on("end", () => resolve3({
2560
+ statusCode: response.statusCode ?? 0,
2561
+ headers: response.headers,
2562
+ body
2563
+ }));
2564
+ };
2565
+ const request = method === "GET" ? (isHttp ? httpGet : httpsGet)(requestOptions, callback) : (isHttp ? httpRequest : httpsRequest)(requestOptions, callback);
2566
+ request.on("error", reject);
2567
+ request.on("timeout", () => {
2568
+ request.destroy();
2569
+ reject(new Error(`Request timeout for ${options.hostname}${options.path}`));
2570
+ });
2571
+ if (options.body) request.write(options.body);
2572
+ request.end();
2573
+ });
2574
+ }
2575
+ async function requestWithRetry(options) {
2576
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
2577
+ let lastError;
2578
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2579
+ try {
2580
+ const response = await requestOnce(options);
2581
+ const retryable = response.statusCode === 429 || response.statusCode >= 500;
2582
+ if (!retryable || attempt === maxAttempts - 1) return response;
2583
+ const serverDelay = retryAfterMs(response.headers);
2584
+ const exponential = (options.baseBackoffMs ?? 1e3) * 2 ** attempt;
2585
+ await delay(serverDelay ?? exponential, options.signal);
2586
+ } catch (error) {
2587
+ if (options.signal?.aborted || error instanceof DOMException && error.name === "AbortError") throw error;
2588
+ lastError = error instanceof Error ? error : new Error(String(error));
2589
+ if (attempt < maxAttempts - 1) {
2590
+ await delay((options.baseBackoffMs ?? 1e3) * 2 ** attempt, options.signal);
2591
+ }
2592
+ }
2593
+ }
2594
+ throw lastError ?? new Error(`Request failed for ${options.hostname}${options.path}`);
2595
+ }
2596
+ function parseJsonResponse(response, source) {
2597
+ try {
2598
+ return JSON.parse(response.body);
2599
+ } catch {
2600
+ throw new Error(`Invalid JSON response from ${source}`);
2601
+ }
2602
+ }
1954
2603
 
1955
2604
  // src/registry/client.ts
1956
- import { get as httpsGet } from "node:https";
1957
- import { get as httpGet } from "node:http";
1958
2605
  var DEFAULT_TTL_MS = 10 * 60 * 1e3;
1959
2606
  var MAX_CONCURRENCY_PER_HOST = 3;
1960
- var MAX_RETRIES = 3;
1961
- var BASE_BACKOFF_MS = 1e3;
1962
2607
  var registryCache = /* @__PURE__ */ new Map();
1963
2608
  var hostConcurrency = /* @__PURE__ */ new Map();
1964
2609
  function getCacheKey(host, path) {
@@ -2006,54 +2651,6 @@ function releaseHostSlot(host) {
2006
2651
  }
2007
2652
  }
2008
2653
  }
2009
- function sleep(ms) {
2010
- return new Promise((resolve3) => setTimeout(resolve3, ms));
2011
- }
2012
- function computeBackoff(attempt, statusCode) {
2013
- const base = statusCode === 429 ? BASE_BACKOFF_MS * 2 : BASE_BACKOFF_MS;
2014
- return base * Math.pow(2, attempt) + Math.random() * 500;
2015
- }
2016
- function httpsFetch(hostname, path, etag, signal) {
2017
- return new Promise((resolve3, reject) => {
2018
- const options = {
2019
- hostname,
2020
- path,
2021
- method: "GET",
2022
- headers: {
2023
- Accept: "application/json",
2024
- "User-Agent": "WrongStack-TechStack/1.0",
2025
- ...etag ? { "If-None-Match": etag } : {}
2026
- },
2027
- signal,
2028
- timeout: 15e3
2029
- };
2030
- const mod = hostname === "localhost" || hostname === "127.0.0.1" ? httpGet : httpsGet;
2031
- const req = mod(options, (res) => {
2032
- const statusCode = res.statusCode ?? 0;
2033
- const responseHeaders = res.headers;
2034
- let body = "";
2035
- res.on("data", (chunk) => {
2036
- body += chunk;
2037
- });
2038
- res.on("end", () => {
2039
- resolve3({
2040
- statusCode,
2041
- headers: responseHeaders,
2042
- body,
2043
- isFromCache: false
2044
- });
2045
- });
2046
- });
2047
- req.on("error", (err) => {
2048
- reject(err);
2049
- });
2050
- req.on("timeout", () => {
2051
- req.destroy();
2052
- reject(new Error(`Request timeout for ${hostname}${path}`));
2053
- });
2054
- req.end();
2055
- });
2056
- }
2057
2654
  function parseNpmPackument(json, name) {
2058
2655
  const latestVersion = json["dist-tags"]?.["latest"];
2059
2656
  let deprecated;
@@ -2208,6 +2805,44 @@ var ECOSYSTEM_FETCHERS = {
2208
2805
  }
2209
2806
  }
2210
2807
  };
2808
+ var RegistryNotFoundError = class extends Error {
2809
+ constructor(statusCode, packageName) {
2810
+ super(`Registry package not found or inaccessible (${statusCode}): ${packageName}`);
2811
+ this.statusCode = statusCode;
2812
+ this.packageName = packageName;
2813
+ this.name = "RegistryNotFoundError";
2814
+ }
2815
+ statusCode;
2816
+ packageName;
2817
+ };
2818
+ var RegistryAuthError = class extends Error {
2819
+ constructor(statusCode, packageName) {
2820
+ super(`Registry authorization failed (${statusCode}): ${packageName}`);
2821
+ this.statusCode = statusCode;
2822
+ this.packageName = packageName;
2823
+ this.name = "RegistryAuthError";
2824
+ }
2825
+ statusCode;
2826
+ packageName;
2827
+ };
2828
+ var RegistryRateLimitError = class extends Error {
2829
+ constructor(statusCode, host) {
2830
+ super(`Registry ${host} returned ${statusCode} after 3 attempts`);
2831
+ this.statusCode = statusCode;
2832
+ this.host = host;
2833
+ this.name = "RegistryRateLimitError";
2834
+ }
2835
+ statusCode;
2836
+ host;
2837
+ };
2838
+ var RegistryNetworkError = class extends Error {
2839
+ constructor(message, cause) {
2840
+ super(message);
2841
+ this.cause = cause;
2842
+ this.name = "RegistryNetworkError";
2843
+ }
2844
+ cause;
2845
+ };
2211
2846
  async function lookupRegistry(ecosystem, name, options = {}) {
2212
2847
  const fetcher = ECOSYSTEM_FETCHERS[ecosystem];
2213
2848
  if (!fetcher) {
@@ -2223,48 +2858,52 @@ async function lookupRegistry(ecosystem, name, options = {}) {
2223
2858
  try {
2224
2859
  const existingEntry = registryCache.get(cacheKey);
2225
2860
  const etag = existingEntry?.etag;
2226
- let lastError;
2227
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
2228
- try {
2229
- const response = await httpsFetch(fetcher.host, path, etag, options.signal);
2230
- if (response.statusCode === 304 && existingEntry) {
2231
- setCache(cacheKey, existingEntry.data, existingEntry.etag, DEFAULT_TTL_MS);
2232
- return existingEntry.data;
2233
- }
2234
- if (response.statusCode === 401 || response.statusCode === 403 || response.statusCode === 404) {
2235
- return void 0;
2236
- }
2237
- if (response.statusCode === 429 || response.statusCode >= 500) {
2238
- if (attempt < MAX_RETRIES - 1) {
2239
- const backoff = computeBackoff(attempt, response.statusCode);
2240
- await sleep(backoff);
2241
- continue;
2242
- }
2243
- throw new Error(`Registry ${fetcher.host} returned ${response.statusCode} after ${MAX_RETRIES} attempts`);
2244
- }
2245
- let json;
2246
- try {
2247
- json = JSON.parse(response.body);
2248
- } catch {
2249
- throw new Error(`Invalid JSON response from ${fetcher.host}${path}`);
2250
- }
2251
- const parsed = fetcher.parser(json, name, ecosystem);
2252
- if (!parsed) {
2253
- return void 0;
2254
- }
2255
- const responseEtag = response.headers["etag"];
2256
- setCache(cacheKey, parsed, responseEtag, DEFAULT_TTL_MS);
2257
- return parsed;
2258
- } catch (err) {
2259
- lastError = err instanceof Error ? err : new Error(String(err));
2260
- if (attempt < MAX_RETRIES - 1) {
2261
- const isRateLimit = err instanceof Error && err.message.includes("429");
2262
- const backoff = computeBackoff(attempt, isRateLimit ? 429 : 500);
2263
- await sleep(backoff);
2264
- }
2265
- }
2861
+ let response;
2862
+ try {
2863
+ response = await requestWithRetry({
2864
+ hostname: fetcher.host,
2865
+ path,
2866
+ headers: {
2867
+ Accept: "application/json",
2868
+ "User-Agent": "WrongStack-TechStack/1.0",
2869
+ ...etag ? { "If-None-Match": etag } : {}
2870
+ },
2871
+ signal: options.signal,
2872
+ timeoutMs: 15e3,
2873
+ maxAttempts: 3
2874
+ });
2875
+ } catch (error) {
2876
+ if (options.signal?.aborted) throw error;
2877
+ const cause = error instanceof Error ? error : new Error(String(error));
2878
+ throw new RegistryNetworkError(cause.message, cause);
2879
+ }
2880
+ if (response.statusCode === 304 && existingEntry) {
2881
+ setCache(cacheKey, existingEntry.data, existingEntry.etag, DEFAULT_TTL_MS);
2882
+ return existingEntry.data;
2266
2883
  }
2267
- throw lastError ?? new Error(`Failed to look up ${ecosystem}:${name}`);
2884
+ if (response.statusCode === 401 || response.statusCode === 404) {
2885
+ if (options.strictErrors) throw new RegistryNotFoundError(response.statusCode, name);
2886
+ return void 0;
2887
+ }
2888
+ if (response.statusCode === 403) {
2889
+ if (options.strictErrors) throw new RegistryAuthError(response.statusCode, name);
2890
+ return void 0;
2891
+ }
2892
+ if (response.statusCode === 429) {
2893
+ throw new RegistryRateLimitError(response.statusCode, fetcher.host);
2894
+ }
2895
+ if (response.statusCode >= 500) {
2896
+ throw new RegistryNetworkError(`Registry ${fetcher.host} returned ${response.statusCode} after 3 attempts`);
2897
+ }
2898
+ if (response.statusCode < 200 || response.statusCode >= 300) {
2899
+ throw new RegistryNetworkError(`Unexpected registry response ${response.statusCode} from ${fetcher.host}`);
2900
+ }
2901
+ const json = parseJsonResponse(response, `${fetcher.host}${path}`);
2902
+ const parsed = fetcher.parser(json, name, ecosystem);
2903
+ if (!parsed) return void 0;
2904
+ const responseEtag = Array.isArray(response.headers.etag) ? response.headers.etag[0] : response.headers.etag;
2905
+ setCache(cacheKey, parsed, responseEtag, DEFAULT_TTL_MS);
2906
+ return parsed;
2268
2907
  } finally {
2269
2908
  releaseHostSlot(fetcher.host);
2270
2909
  }
@@ -2293,14 +2932,21 @@ function clearRegistryCache() {
2293
2932
  registryCache.clear();
2294
2933
  hostConcurrency.clear();
2295
2934
  }
2935
+ function invalidateRegistryCache(ecosystem, names) {
2936
+ const fetcher = ECOSYSTEM_FETCHERS[ecosystem];
2937
+ if (!fetcher) return 0;
2938
+ const keys = names ? names.map((name) => getCacheKey(fetcher.host, fetcher.path(name))) : [...registryCache.keys()].filter((key) => key.startsWith(fetcher.host));
2939
+ let removed = 0;
2940
+ for (const key of keys) {
2941
+ if (registryCache.delete(key)) removed++;
2942
+ }
2943
+ return removed;
2944
+ }
2296
2945
 
2297
2946
  // src/advisory/osv.ts
2298
- import { get as httpsGet2 } from "node:https";
2299
2947
  var OSV_API_BASE = "api.osv.dev";
2300
2948
  var OSV_QUERY_BATCH_PATH = "/v1/querybatch";
2301
2949
  var MAX_BATCH_SIZE = 500;
2302
- var MAX_RETRIES2 = 3;
2303
- var BASE_BACKOFF_MS2 = 1e3;
2304
2950
  function mapSeverity(osvSeverity, databaseSeverity) {
2305
2951
  if (osvSeverity && osvSeverity.length > 0) {
2306
2952
  for (const s of osvSeverity) {
@@ -2322,44 +2968,6 @@ function mapSeverity(osvSeverity, databaseSeverity) {
2322
2968
  }
2323
2969
  return "info";
2324
2970
  }
2325
- function osvPostRequest(body, signal) {
2326
- return new Promise((resolve3, reject) => {
2327
- const options = {
2328
- hostname: OSV_API_BASE,
2329
- path: OSV_QUERY_BATCH_PATH,
2330
- method: "POST",
2331
- headers: {
2332
- "Content-Type": "application/json",
2333
- "Content-Length": Buffer.byteLength(body).toString(),
2334
- "User-Agent": "WrongStack-TechStack/1.0"
2335
- },
2336
- signal,
2337
- timeout: 3e4
2338
- };
2339
- const req = httpsGet2(options, (res) => {
2340
- const statusCode = res.statusCode ?? 0;
2341
- let responseBody = "";
2342
- res.on("data", (chunk) => {
2343
- responseBody += chunk;
2344
- });
2345
- res.on("end", () => {
2346
- resolve3({ statusCode, body: responseBody });
2347
- });
2348
- });
2349
- req.on("error", (err) => {
2350
- reject(err);
2351
- });
2352
- req.on("timeout", () => {
2353
- req.destroy();
2354
- reject(new Error("OSV API request timeout"));
2355
- });
2356
- req.write(body);
2357
- req.end();
2358
- });
2359
- }
2360
- function sleep2(ms) {
2361
- return new Promise((resolve3) => setTimeout(resolve3, ms));
2362
- }
2363
2971
  async function queryOsvBatch(purls, options = {}) {
2364
2972
  const advisories = /* @__PURE__ */ new Map();
2365
2973
  for (const purl of purls) {
@@ -2369,7 +2977,6 @@ async function queryOsvBatch(purls, options = {}) {
2369
2977
  for (let i = 0; i < purls.length; i += MAX_BATCH_SIZE) {
2370
2978
  batches.push(purls.slice(i, i + MAX_BATCH_SIZE));
2371
2979
  }
2372
- let lastError;
2373
2980
  for (const batch of batches) {
2374
2981
  const requestBody = {
2375
2982
  queries: batch.map((purl) => ({
@@ -2377,54 +2984,38 @@ async function queryOsvBatch(purls, options = {}) {
2377
2984
  }))
2378
2985
  };
2379
2986
  const jsonBody = JSON.stringify(requestBody);
2380
- let success = false;
2381
- for (let attempt = 0; attempt < MAX_RETRIES2 && !success; attempt++) {
2382
- try {
2383
- const response = await osvPostRequest(jsonBody, options.signal);
2384
- if (response.statusCode === 200) {
2385
- const result = JSON.parse(response.body);
2386
- if (result.results && Array.isArray(result.results)) {
2387
- for (let i = 0; i < result.results.length; i++) {
2388
- const purl = batch[i];
2389
- if (!purl) continue;
2390
- const vulns = result.results[i]?.vulns;
2391
- if (!vulns || vulns.length === 0) continue;
2392
- const parsed = [];
2393
- for (const vuln of vulns) {
2394
- const dbSpecific = vuln.database_specific;
2395
- const affectedDbSpecific = vuln.affected?.[0]?.database_specific;
2396
- const severitySource = dbSpecific?.severity ?? affectedDbSpecific?.severity;
2397
- parsed.push({
2398
- id: vuln.id,
2399
- summary: vuln.summary ?? vuln.details ?? "No summary available",
2400
- severity: mapSeverity(vuln.severity, severitySource),
2401
- aliases: vuln.aliases ?? []
2402
- });
2403
- }
2404
- advisories.set(purl, parsed);
2405
- }
2406
- }
2407
- success = true;
2408
- } else if (response.statusCode === 429 || response.statusCode >= 500) {
2409
- if (attempt < MAX_RETRIES2 - 1) {
2410
- const backoff = BASE_BACKOFF_MS2 * Math.pow(2, attempt) + Math.random() * 500;
2411
- await sleep2(backoff);
2412
- } else {
2413
- throw new Error(`OSV API returned ${response.statusCode} after ${MAX_RETRIES2} attempts: ${response.body}`);
2414
- }
2415
- } else {
2416
- throw new Error(`OSV API returned ${response.statusCode}: ${response.body}`);
2417
- }
2418
- } catch (err) {
2419
- lastError = err instanceof Error ? err : new Error(String(err));
2420
- if (attempt < MAX_RETRIES2 - 1) {
2421
- const backoff = BASE_BACKOFF_MS2 * Math.pow(2, attempt) + Math.random() * 500;
2422
- await sleep2(backoff);
2423
- }
2424
- }
2425
- }
2426
- if (!success && lastError) {
2427
- throw lastError;
2987
+ const response = await requestWithRetry({
2988
+ hostname: OSV_API_BASE,
2989
+ path: OSV_QUERY_BATCH_PATH,
2990
+ method: "POST",
2991
+ headers: {
2992
+ "Content-Type": "application/json",
2993
+ "Content-Length": Buffer.byteLength(jsonBody).toString(),
2994
+ "User-Agent": "WrongStack-TechStack/1.0"
2995
+ },
2996
+ body: jsonBody,
2997
+ signal: options.signal,
2998
+ timeoutMs: 3e4,
2999
+ maxAttempts: 3
3000
+ });
3001
+ if (response.statusCode !== 200) {
3002
+ throw new Error(`OSV API returned ${response.statusCode}: ${response.body}`);
3003
+ }
3004
+ const result = parseJsonResponse(response, "api.osv.dev/v1/querybatch");
3005
+ for (let i = 0; i < result.results.length; i++) {
3006
+ const purl = batch[i];
3007
+ if (!purl) continue;
3008
+ const vulns = result.results[i]?.vulns;
3009
+ if (!vulns || vulns.length === 0) continue;
3010
+ advisories.set(purl, vulns.map((vuln) => ({
3011
+ id: vuln.id,
3012
+ summary: vuln.summary ?? vuln.details ?? "No summary available",
3013
+ severity: mapSeverity(
3014
+ vuln.severity,
3015
+ vuln.database_specific?.severity ?? vuln.affected?.[0]?.database_specific?.severity
3016
+ ),
3017
+ aliases: vuln.aliases ?? []
3018
+ })));
2428
3019
  }
2429
3020
  }
2430
3021
  const evidence = {
@@ -2441,9 +3032,9 @@ async function queryOsvSingle(purl, options = {}) {
2441
3032
  }
2442
3033
 
2443
3034
  // src/advisory/native-audit.ts
2444
- import { spawnSync } from "node:child_process";
2445
- import { existsSync as existsSync2 } from "node:fs";
2446
- import { join as join6 } from "node:path";
3035
+ import { execFile } from "node:child_process";
3036
+ import { access as access3 } from "node:fs/promises";
3037
+ import { join as join8 } from "node:path";
2447
3038
  function npmSeverity(s) {
2448
3039
  switch (s.toLowerCase()) {
2449
3040
  case "critical":
@@ -2473,8 +3064,8 @@ function cargoSeverity(s) {
2473
3064
  return "info";
2474
3065
  }
2475
3066
  }
2476
- function runNpmAudit(workspaceRoot2) {
2477
- const result = runAuditCommand("npm", ["audit", "--json"], workspaceRoot2);
3067
+ async function runNpmAudit(workspaceRoot2, runner = defaultAuditCommandRunner) {
3068
+ const result = await runner("npm", ["audit", "--json"], workspaceRoot2);
2478
3069
  const advisories = [];
2479
3070
  let detailLines = [];
2480
3071
  if (result.status === 0 || result.status === 1) {
@@ -2523,20 +3114,22 @@ function runNpmAudit(workspaceRoot2) {
2523
3114
  };
2524
3115
  return { advisories, evidence };
2525
3116
  }
2526
- function runPipAudit(workspaceRoot2) {
3117
+ async function runPipAudit(workspaceRoot2, runner = defaultAuditCommandRunner) {
2527
3118
  const reqFiles = ["requirements.txt", "requirements-dev.txt"];
2528
3119
  let reqFlag = "";
2529
3120
  for (const f of reqFiles) {
2530
- if (existsSync2(join6(workspaceRoot2, f))) {
3121
+ try {
3122
+ await access3(join8(workspaceRoot2, f));
2531
3123
  reqFlag = `--requirement ${f}`;
2532
3124
  break;
3125
+ } catch {
2533
3126
  }
2534
3127
  }
2535
3128
  const args = ["audit", "--format", "json"];
2536
3129
  if (reqFlag) {
2537
3130
  args.push(...reqFlag.split(" "));
2538
3131
  }
2539
- const result = runAuditCommand("pip-audit" in process.env ? "pip-audit" : "pip-audit", args, workspaceRoot2);
3132
+ const result = await runner("pip-audit", args, workspaceRoot2);
2540
3133
  return parsePipAuditOutput(result);
2541
3134
  }
2542
3135
  function parsePipAuditOutput(result) {
@@ -2570,8 +3163,8 @@ function parsePipAuditOutput(result) {
2570
3163
  };
2571
3164
  return { advisories, evidence };
2572
3165
  }
2573
- function runCargoAudit(workspaceRoot2) {
2574
- const result = runAuditCommand("cargo", ["audit", "--json"], workspaceRoot2);
3166
+ async function runCargoAudit(workspaceRoot2, runner = defaultAuditCommandRunner) {
3167
+ const result = await runner("cargo", ["audit", "--json"], workspaceRoot2);
2575
3168
  return parseCargoAuditOutput(result);
2576
3169
  }
2577
3170
  function parseCargoAuditOutput(result) {
@@ -2612,8 +3205,8 @@ function parseCargoAuditOutput(result) {
2612
3205
  };
2613
3206
  return { advisories, evidence };
2614
3207
  }
2615
- function runGoVulncheck(workspaceRoot2) {
2616
- const result = runAuditCommand("govulncheck", ["-json"], workspaceRoot2);
3208
+ async function runGoVulncheck(workspaceRoot2, runner = defaultAuditCommandRunner) {
3209
+ const result = await runner("govulncheck", ["-json"], workspaceRoot2);
2617
3210
  const advisories = [];
2618
3211
  let detailLines = [];
2619
3212
  if (result.status === 0 || result.status === 3) {
@@ -2651,8 +3244,8 @@ function runGoVulncheck(workspaceRoot2) {
2651
3244
  };
2652
3245
  return { advisories, evidence };
2653
3246
  }
2654
- function runComposerAudit(workspaceRoot2) {
2655
- const result = runAuditCommand("composer", ["audit", "--format=json"], workspaceRoot2);
3247
+ async function runComposerAudit(workspaceRoot2, runner = defaultAuditCommandRunner) {
3248
+ const result = await runner("composer", ["audit", "--format=json"], workspaceRoot2);
2656
3249
  const advisories = [];
2657
3250
  let detailLines = [];
2658
3251
  if (result.status === 0) {
@@ -2688,8 +3281,8 @@ function runComposerAudit(workspaceRoot2) {
2688
3281
  };
2689
3282
  return { advisories, evidence };
2690
3283
  }
2691
- function runDotnetAudit(workspaceRoot2) {
2692
- const result = runAuditCommand("dotnet", ["package", "audit", "--format", "json"], workspaceRoot2);
3284
+ async function runDotnetAudit(workspaceRoot2, runner = defaultAuditCommandRunner) {
3285
+ const result = await runner("dotnet", ["package", "audit", "--format", "json"], workspaceRoot2);
2693
3286
  const advisories = [];
2694
3287
  let detailLines = [];
2695
3288
  if (result.status === 0) {
@@ -2739,44 +3332,50 @@ function runDotnetAudit(workspaceRoot2) {
2739
3332
  };
2740
3333
  return { advisories, evidence };
2741
3334
  }
2742
- function runAuditCommand(command, args, cwd) {
2743
- try {
2744
- const options = {
3335
+ var defaultAuditCommandRunner = (command, args, cwd) => new Promise((resolve3) => {
3336
+ execFile(
3337
+ command,
3338
+ [...args],
3339
+ {
2745
3340
  cwd,
2746
- encoding: "utf-8",
3341
+ encoding: "utf8",
2747
3342
  timeout: 6e4,
2748
3343
  maxBuffer: 10 * 1024 * 1024,
2749
- // 10MB
2750
- windowsHide: true
2751
- };
2752
- const result = spawnSync(command, args, options);
2753
- return {
2754
- status: result.status,
2755
- stdout: result.stdout?.toString() ?? "",
2756
- stderr: result.stderr?.toString() ?? ""
2757
- };
2758
- } catch (err) {
2759
- return {
2760
- status: null,
2761
- stdout: "",
2762
- stderr: err instanceof Error ? err.message : String(err)
2763
- };
2764
- }
2765
- }
2766
- function runNativeAudit(ecosystem, workspaceRoot2) {
3344
+ windowsHide: true,
3345
+ // On Windows the audit tools are batch shims — `npm`/`composer` resolve
3346
+ // to npm.cmd/composer.cmd, which execFile cannot launch without a shell
3347
+ // (it ignores PATHEXT), so it fails ENOENT and every Windows audit
3348
+ // silently reports "exited with code null" with zero advisories. Route
3349
+ // through the shell there. Safe because every command and argument here
3350
+ // is a static literal (conventional filenames only) no user input
3351
+ // reaches the command line; the dynamic workspace path is passed as cwd.
3352
+ shell: process.platform === "win32"
3353
+ },
3354
+ (error, stdout, stderr) => {
3355
+ const exitCode = error?.code;
3356
+ const status = error ? typeof exitCode === "number" ? exitCode : null : 0;
3357
+ resolve3({
3358
+ status,
3359
+ stdout,
3360
+ stderr: stderr || (status === null && error ? error.message : "")
3361
+ });
3362
+ }
3363
+ );
3364
+ });
3365
+ async function runNativeAudit(ecosystem, workspaceRoot2, runner = defaultAuditCommandRunner) {
2767
3366
  switch (ecosystem) {
2768
3367
  case "npm":
2769
- return runNpmAudit(workspaceRoot2);
3368
+ return runNpmAudit(workspaceRoot2, runner);
2770
3369
  case "python":
2771
- return runPipAudit(workspaceRoot2);
3370
+ return runPipAudit(workspaceRoot2, runner);
2772
3371
  case "rust":
2773
- return runCargoAudit(workspaceRoot2);
3372
+ return runCargoAudit(workspaceRoot2, runner);
2774
3373
  case "go":
2775
- return runGoVulncheck(workspaceRoot2);
3374
+ return runGoVulncheck(workspaceRoot2, runner);
2776
3375
  case "php":
2777
- return runComposerAudit(workspaceRoot2);
3376
+ return runComposerAudit(workspaceRoot2, runner);
2778
3377
  case "dotnet":
2779
- return runDotnetAudit(workspaceRoot2);
3378
+ return runDotnetAudit(workspaceRoot2, runner);
2780
3379
  // dart/pub doesn't have a standard audit command — use OSV instead
2781
3380
  default:
2782
3381
  return {
@@ -2790,14 +3389,41 @@ function runNativeAudit(ecosystem, workspaceRoot2) {
2790
3389
  };
2791
3390
  }
2792
3391
  }
2793
- function isNativeAuditAvailable(ecosystem) {
2794
- const result = runAuditCommand(
3392
+ async function isNativeAuditAvailable(ecosystem, runner = defaultAuditCommandRunner) {
3393
+ const result = await runner(
2795
3394
  ecosystem === "npm" ? "npm" : ecosystem === "python" ? "pip-audit" : ecosystem === "rust" ? "cargo" : ecosystem === "go" ? "govulncheck" : ecosystem === "php" ? "composer" : ecosystem === "dotnet" ? "dotnet" : "",
2796
3395
  ["--version"],
2797
3396
  process.cwd()
2798
3397
  );
2799
3398
  return result.status === 0;
2800
3399
  }
3400
+ function createAuditRunner(commandRunner = defaultAuditCommandRunner) {
3401
+ const commandFor = (ecosystem) => {
3402
+ switch (ecosystem) {
3403
+ case "npm":
3404
+ return "npm";
3405
+ case "python":
3406
+ return "pip-audit";
3407
+ case "rust":
3408
+ return "cargo";
3409
+ case "go":
3410
+ return "govulncheck";
3411
+ case "php":
3412
+ return "composer";
3413
+ case "dotnet":
3414
+ return "dotnet";
3415
+ default:
3416
+ return void 0;
3417
+ }
3418
+ };
3419
+ return {
3420
+ run: (ecosystem, workspaceRoot2) => runNativeAudit(ecosystem, workspaceRoot2, commandRunner),
3421
+ isAvailable: async (ecosystem, cwd = process.cwd()) => {
3422
+ const command = commandFor(ecosystem);
3423
+ return command ? (await commandRunner(command, ["--version"], cwd)).status === 0 : false;
3424
+ }
3425
+ };
3426
+ }
2801
3427
 
2802
3428
  // src/policy/status.ts
2803
3429
  function isValidSemver(version) {
@@ -2940,8 +3566,206 @@ function failedLookupStatus(source, error) {
2940
3566
  };
2941
3567
  }
2942
3568
 
2943
- // src/service.ts
3569
+ // src/service/techstack-engine.ts
3570
+ import { randomUUID as randomUUID2 } from "node:crypto";
3571
+
3572
+ // src/service/finding-factory.ts
3573
+ function computeDependencyFingerprint(dependencies) {
3574
+ const parts = dependencies.map((dependency) => `${dependency.name}@${dependency.locked ?? dependency.requested ?? "unknown"}`).sort().join(",");
3575
+ let hash = 0;
3576
+ for (let index = 0; index < parts.length; index++) {
3577
+ hash = (hash << 5) - hash + parts.charCodeAt(index);
3578
+ hash |= 0;
3579
+ }
3580
+ return `ts-${Math.abs(hash).toString(36)}`;
3581
+ }
3582
+ function createFindingForStatus(dependencyId, status) {
3583
+ const common = { dependencyId, confidence: 1, evidence: [] };
3584
+ switch (status) {
3585
+ case "vulnerable":
3586
+ return { ...common, id: `finding-${dependencyId}-vuln`, type: "vulnerability", severity: "high", action: "upgrade_patch", rationale: "Known security advisory found for this package" };
3587
+ case "deprecated":
3588
+ return { ...common, id: `finding-${dependencyId}-dep`, type: "deprecated", severity: "medium", action: "replace", rationale: "Package is deprecated in the registry" };
3589
+ case "yanked":
3590
+ return { ...common, id: `finding-${dependencyId}-yank`, type: "deprecated", severity: "high", action: "replace", rationale: "Package version has been yanked from the registry" };
3591
+ case "update_available_safe":
3592
+ return { ...common, id: `finding-${dependencyId}-update`, type: "upgrade", severity: "info", action: "upgrade_minor", rationale: "A newer compatible version is available" };
3593
+ case "update_available_breaking":
3594
+ return { ...common, id: `finding-${dependencyId}-major`, type: "upgrade", severity: "low", action: "upgrade_major", rationale: "A newer version is available that may require breaking changes" };
3595
+ default:
3596
+ return { ...common, confidence: 0.5, id: `finding-${dependencyId}-investigate`, type: "investigate", severity: "info", action: "investigate", rationale: `Package status is "${status}" \u2014 may need investigation` };
3597
+ }
3598
+ }
3599
+
3600
+ // src/service/enrich-phase.ts
3601
+ async function runEnrichPhase(snapshot, options = {}) {
3602
+ if (options.online === false || options.signal?.aborted) return snapshot;
3603
+ const grouped = /* @__PURE__ */ new Map();
3604
+ for (const dependency of snapshot.dependencies) {
3605
+ if (!dependency.purl || dependency.sourceType === "path" || dependency.sourceType === "git") continue;
3606
+ grouped.set(dependency.ecosystem, [...grouped.get(dependency.ecosystem) ?? [], dependency]);
3607
+ }
3608
+ const enriched = /* @__PURE__ */ new Map();
3609
+ const findings = [...snapshot.findings];
3610
+ for (const [ecosystem, dependencies] of grouped) {
3611
+ for (const name of new Set(dependencies.map((dependency) => dependency.name))) {
3612
+ if (options.signal?.aborted) throw new DOMException("TechStack job cancelled", "AbortError");
3613
+ let registryEntry;
3614
+ let registryStatus;
3615
+ try {
3616
+ registryEntry = await lookupRegistry(ecosystem, name, {
3617
+ signal: options.signal,
3618
+ force: options.forceRegistryRefresh,
3619
+ strictErrors: true
3620
+ });
3621
+ registryStatus = registryEntry ? {
3622
+ latestStable: registryEntry.latestStable,
3623
+ deprecated: registryEntry.deprecated,
3624
+ yanked: registryEntry.yanked,
3625
+ evidence: [{ kind: "registry", source: registryEntry.source, retrievedAt: registryEntry.retrievedAt, detail: `latestStable: ${registryEntry.latestStable ?? "N/A"}, license: ${registryEntry.license ?? "N/A"}` }]
3626
+ } : { privateOrUnresolved: true };
3627
+ } catch (error) {
3628
+ const unresolved = error instanceof RegistryNotFoundError || error instanceof RegistryAuthError;
3629
+ registryStatus = unresolved ? {
3630
+ privateOrUnresolved: true,
3631
+ evidence: [{ kind: "registry", source: `${ecosystem} registry for ${name}`, retrievedAt: (/* @__PURE__ */ new Date()).toISOString(), detail: error.message }]
3632
+ } : {
3633
+ lookupFailed: true,
3634
+ evidence: [{ kind: "registry", source: `${ecosystem} registry for ${name}`, retrievedAt: (/* @__PURE__ */ new Date()).toISOString(), detail: error instanceof Error ? error.message : "Registry lookup failed" }]
3635
+ };
3636
+ }
3637
+ let advisoryStatus;
3638
+ try {
3639
+ const purls = dependencies.filter((dependency) => dependency.name === name).flatMap((dependency) => dependency.purl ? [dependency.purl] : []);
3640
+ if (purls.length > 0) {
3641
+ const result = await queryOsvBatch(purls, { signal: options.signal });
3642
+ if ([...result.advisories.values()].some((items) => items.length > 0)) {
3643
+ advisoryStatus = { hasAdvisory: true, evidence: [result.evidence] };
3644
+ }
3645
+ }
3646
+ } catch {
3647
+ }
3648
+ for (const dependency of dependencies) {
3649
+ if (dependency.name !== name) continue;
3650
+ const status = classifyStatus(dependency, registryStatus, advisoryStatus);
3651
+ const evidence = [...dependency.evidence, ...registryStatus?.evidence ?? [], ...advisoryStatus?.evidence ?? []];
3652
+ enriched.set(dependency.id, {
3653
+ ...dependency,
3654
+ latestStable: registryEntry?.latestStable ?? dependency.latestStable,
3655
+ license: registryEntry?.license ?? dependency.license,
3656
+ deprecated: registryEntry?.deprecated ?? dependency.deprecated,
3657
+ yanked: registryEntry?.yanked ?? dependency.yanked,
3658
+ status,
3659
+ evidence
3660
+ });
3661
+ if (status !== "current" && status !== "local_path" && status !== "git_dependency") findings.push(createFindingForStatus(dependency.id, status));
3662
+ }
3663
+ }
3664
+ }
3665
+ return { ...snapshot, dependencies: snapshot.dependencies.map((dependency) => enriched.get(dependency.id) ?? dependency), findings };
3666
+ }
3667
+
3668
+ // src/service/inventory-phase.ts
2944
3669
  import { randomUUID } from "node:crypto";
3670
+ var ADAPTER_VERSION = "0.1.0";
3671
+ var ADAPTERS = {
3672
+ npm: npmAdapter,
3673
+ python: pythonAdapter,
3674
+ rust: rustAdapter,
3675
+ go: goAdapter,
3676
+ dotnet: dotNetAdapter,
3677
+ php: phpAdapter,
3678
+ dart: dartAdapter,
3679
+ maven: mavenAdapter,
3680
+ gradle: gradleAdapter,
3681
+ ruby: rubyAdapter,
3682
+ swift: swiftAdapter,
3683
+ elixir: elixirAdapter,
3684
+ cpp: cppAdapter
3685
+ };
3686
+ async function runInventoryPhase(store, projectId, targetRoot, options = {}) {
3687
+ options.onProgress?.("discovering", 0, 1);
3688
+ const workspaces = await discoverWorkspaces(targetRoot, { signal: options.signal });
3689
+ options.onProgress?.("inventorying", 0, workspaces.length);
3690
+ const dependencies = [];
3691
+ let coverage = "full";
3692
+ let completed = 0;
3693
+ for (const workspace of workspaces) {
3694
+ if (options.signal?.aborted) throw new DOMException("TechStack job cancelled", "AbortError");
3695
+ try {
3696
+ dependencies.push(...await ADAPTERS[workspace.ecosystem].inventory(workspace, {
3697
+ projectRoot: targetRoot,
3698
+ includeTransitive: options.includeTransitive,
3699
+ signal: options.signal
3700
+ }));
3701
+ } catch {
3702
+ }
3703
+ if (workspace.coverage === "unsupported") coverage = "partial";
3704
+ completed++;
3705
+ options.onProgress?.("inventorying", completed, workspaces.length);
3706
+ }
3707
+ const snapshot = {
3708
+ id: randomUUID(),
3709
+ projectId,
3710
+ targetRoot,
3711
+ fingerprint: computeDependencyFingerprint(dependencies),
3712
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3713
+ workspaces,
3714
+ dependencies,
3715
+ findings: [],
3716
+ coverage,
3717
+ adapterVersion: ADAPTER_VERSION
3718
+ };
3719
+ store.saveSnapshot(snapshot);
3720
+ return snapshot;
3721
+ }
3722
+
3723
+ // src/service/report-generator.ts
3724
+ function generateReport(snapshot, format = "md") {
3725
+ if (format === "json") return JSON.stringify(snapshot, null, 2);
3726
+ const lines = [
3727
+ "# TechStack Report",
3728
+ "",
3729
+ `**Generated:** ${snapshot.createdAt}`,
3730
+ `**Target:** ${snapshot.targetRoot}`,
3731
+ `**Fingerprint:** ${snapshot.fingerprint}`,
3732
+ `**Workspaces:** ${snapshot.workspaces.length}`,
3733
+ `**Dependencies:** ${snapshot.dependencies.length}`,
3734
+ `**Findings:** ${snapshot.findings.length}`,
3735
+ `**Coverage:** ${snapshot.coverage}`,
3736
+ ""
3737
+ ];
3738
+ if (snapshot.workspaces.length > 0) {
3739
+ lines.push("## Workspaces", "", "| Workspace | Ecosystem | Coverage | Deps |", "|---|---|---|---|");
3740
+ for (const workspace of snapshot.workspaces) {
3741
+ const count = snapshot.dependencies.filter((dependency) => dependency.workspaceId === workspace.id).length;
3742
+ lines.push(`| ${workspace.relativeRoot} | ${workspace.ecosystem} | ${workspace.coverage} | ${count} |`);
3743
+ }
3744
+ lines.push("");
3745
+ }
3746
+ if (snapshot.findings.length > 0) {
3747
+ lines.push("## Findings", "");
3748
+ const bySeverity = /* @__PURE__ */ new Map();
3749
+ for (const finding of snapshot.findings) bySeverity.set(finding.severity, [...bySeverity.get(finding.severity) ?? [], finding]);
3750
+ for (const severity of ["critical", "high", "medium", "low", "info"]) {
3751
+ const findings = bySeverity.get(severity);
3752
+ if (!findings?.length) continue;
3753
+ lines.push(`### ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${findings.length})`, "");
3754
+ for (const finding of findings) {
3755
+ const dependency = snapshot.dependencies.find((candidate) => candidate.id === finding.dependencyId);
3756
+ lines.push(`- **${dependency?.name ?? finding.dependencyId}** \u2014 ${finding.type} \u2014 ${finding.rationale}`);
3757
+ }
3758
+ lines.push("");
3759
+ }
3760
+ }
3761
+ if (snapshot.dependencies.length > 0) {
3762
+ lines.push("## Dependencies", "", "| Name | Ecosystem | Status | Locked | Latest |", "|---|---|---|---|---|");
3763
+ for (const dependency of snapshot.dependencies) {
3764
+ lines.push(`| ${dependency.name} | ${dependency.ecosystem} | ${dependency.status} | ${dependency.locked ?? "\u2014"} | ${dependency.latestStable ?? "\u2014"} |`);
3765
+ }
3766
+ }
3767
+ return lines.join("\n");
3768
+ }
2945
3769
 
2946
3770
  // src/research/triage.ts
2947
3771
  var DEFAULT_TRIAGE_LIMIT = 40;
@@ -3003,285 +3827,45 @@ function clusterCandidates(candidates) {
3003
3827
  return out;
3004
3828
  }
3005
3829
 
3006
- // src/service.ts
3007
- function getAdapter(ecosystem) {
3008
- switch (ecosystem) {
3009
- case "npm":
3010
- return npmAdapter;
3011
- case "python":
3012
- return pythonAdapter;
3013
- case "rust":
3014
- return rustAdapter;
3015
- case "go":
3016
- return goAdapter;
3017
- case "dotnet":
3018
- return dotNetAdapter;
3019
- case "php":
3020
- return phpAdapter;
3021
- case "dart":
3022
- return dartAdapter;
3023
- // Tier B — partial support
3024
- case "maven":
3025
- return mavenAdapter;
3026
- case "gradle":
3027
- return mavenAdapter;
3028
- // reuse Maven adapter (same manifest family)
3029
- case "ruby":
3030
- return rubyAdapter;
3031
- case "swift":
3032
- return void 0;
3033
- // no adapter yet
3034
- case "elixir":
3035
- return elixirAdapter;
3036
- // Tier C — best-effort
3037
- case "cpp":
3038
- return cppAdapter;
3039
- default:
3040
- return void 0;
3830
+ // src/service/research-phase.ts
3831
+ async function runResearchPhase(snapshot, options = {}) {
3832
+ if (!options.researcher || options.signal?.aborted) return snapshot;
3833
+ const candidates = triageCandidates(snapshot.dependencies, { limit: options.researchLimit });
3834
+ if (candidates.length === 0) return snapshot;
3835
+ options.onProgress?.("researching", 0, candidates.length);
3836
+ let findings;
3837
+ try {
3838
+ findings = await options.researcher.research(candidates, {
3839
+ signal: options.signal,
3840
+ onProgress: (completed, total) => options.onProgress?.("researching", completed, total)
3841
+ });
3842
+ } catch {
3843
+ return snapshot;
3041
3844
  }
3845
+ options.onProgress?.("synthesizing", 1, 1);
3846
+ return findings.length ? { ...snapshot, findings: [...snapshot.findings, ...findings] } : snapshot;
3042
3847
  }
3043
- var ADAPTER_VERSION = "0.1.0";
3848
+
3849
+ // src/service/techstack-engine.ts
3044
3850
  var TechStackEngine = class {
3045
- store;
3046
3851
  constructor(store) {
3047
3852
  this.store = store;
3048
3853
  }
3049
- // ── Inventory ─────────────────────────────────────────────────────────
3050
- /**
3051
- * Run an offline inventory: discover workspaces and parse dependencies
3052
- * from manifests and lockfiles. No network calls.
3053
- *
3054
- * Returns a Snapshot with workspace and dependency data but no
3055
- * registry/advisory enrichment.
3056
- */
3057
- async inventory(projectId, targetRoot, _jobId, onProgress) {
3058
- const snapshotId = randomUUID();
3059
- onProgress?.("discovering", 0, 1);
3060
- const rawWorkspaces = await discoverWorkspaces(targetRoot);
3061
- const workspaces = rawWorkspaces.map((w) => ({
3062
- id: w.id,
3063
- relativeRoot: w.relativeRoot,
3064
- ecosystem: w.ecosystem,
3065
- packageManager: w.packageManager,
3066
- manifests: [...w.manifests],
3067
- lockfiles: [...w.lockfiles],
3068
- confidence: w.confidence,
3069
- coverage: w.coverage
3070
- }));
3071
- onProgress?.("inventorying", 0, workspaces.length);
3072
- const allDependencies = [];
3073
- let totalCoverage = "full";
3074
- for (let i = 0; i < workspaces.length; i++) {
3075
- const ws = workspaces[i];
3076
- const adapter = getAdapter(ws.ecosystem);
3077
- let deps = [];
3078
- if (adapter) {
3079
- try {
3080
- deps = await adapter.inventory(ws, { projectRoot: targetRoot });
3081
- } catch {
3082
- deps = [];
3083
- }
3084
- }
3085
- if (ws.coverage === "unsupported") {
3086
- totalCoverage = "partial";
3087
- }
3088
- allDependencies.push(...deps);
3089
- onProgress?.("inventorying", i + 1, workspaces.length);
3090
- }
3091
- const fingerprint = computeFingerprint(allDependencies);
3092
- const snapshot = {
3093
- id: snapshotId,
3094
- projectId,
3095
- targetRoot,
3096
- fingerprint,
3097
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3098
- workspaces,
3099
- dependencies: allDependencies,
3100
- findings: [],
3101
- coverage: totalCoverage,
3102
- adapterVersion: ADAPTER_VERSION
3103
- };
3104
- this.store.saveSnapshot(snapshot);
3105
- return snapshot;
3854
+ store;
3855
+ inventory(projectId, targetRoot, _jobId, onProgress, options = {}) {
3856
+ return runInventoryPhase(this.store, projectId, targetRoot, { ...options, onProgress });
3106
3857
  }
3107
- // ── Enrichment ─────────────────────────────────────────────────────────
3108
- /**
3109
- * Enrich a snapshot with registry metadata and advisory data.
3110
- *
3111
- * This is the online pass: fetches latest versions, licenses, deprecation
3112
- * status from registries, and queries OSV for advisories.
3113
- *
3114
- * Key contracts:
3115
- * - 404/401 → status `private_or_unresolved` (never `dead` or `deprecated`)
3116
- * - Network failure → status `unknown` with evidence detail (never `current`)
3117
- */
3118
- async enrich(snapshot, options = {}) {
3119
- const isOnline = options.online !== false;
3120
- if (!isOnline || options.signal?.aborted) {
3121
- return snapshot;
3122
- }
3123
- const byEcosystem = /* @__PURE__ */ new Map();
3124
- for (const dep of snapshot.dependencies) {
3125
- if (dep.sourceType === "path" || dep.sourceType === "git") continue;
3126
- if (!dep.purl) continue;
3127
- const list = byEcosystem.get(dep.ecosystem);
3128
- if (list) {
3129
- list.push(dep);
3130
- } else {
3131
- byEcosystem.set(dep.ecosystem, [dep]);
3132
- }
3133
- }
3134
- const enrichedDeps = /* @__PURE__ */ new Map();
3135
- const allFindings = [...snapshot.findings];
3136
- for (const [ecosystem, deps] of byEcosystem) {
3137
- const names = [...new Set(deps.map((d) => d.name))];
3138
- for (const name of names) {
3139
- const lookupOpts = {};
3140
- if (options.signal) lookupOpts.signal = options.signal;
3141
- if (options.forceRegistryRefresh) lookupOpts.force = true;
3142
- let registryEntry;
3143
- let registryStatus;
3144
- try {
3145
- registryEntry = await lookupRegistry(ecosystem, name, lookupOpts);
3146
- if (registryEntry) {
3147
- registryStatus = {
3148
- latestStable: registryEntry.latestStable,
3149
- deprecated: registryEntry.deprecated,
3150
- yanked: registryEntry.yanked,
3151
- evidence: [
3152
- {
3153
- kind: "registry",
3154
- source: registryEntry.source,
3155
- retrievedAt: registryEntry.retrievedAt,
3156
- detail: `latestStable: ${registryEntry.latestStable ?? "N/A"}, license: ${registryEntry.license ?? "N/A"}`
3157
- }
3158
- ]
3159
- };
3160
- } else {
3161
- registryStatus = {
3162
- privateOrUnresolved: true,
3163
- evidence: [
3164
- {
3165
- kind: "registry",
3166
- source: `${ecosystem} registry for ${name}`,
3167
- retrievedAt: (/* @__PURE__ */ new Date()).toISOString(),
3168
- detail: "Package returned 404/401 \u2014 private or unresolved"
3169
- }
3170
- ]
3171
- };
3172
- }
3173
- } catch (err) {
3174
- registryStatus = {
3175
- lookupFailed: true,
3176
- evidence: [
3177
- {
3178
- kind: "registry",
3179
- source: `${ecosystem} registry for ${name}`,
3180
- retrievedAt: (/* @__PURE__ */ new Date()).toISOString(),
3181
- detail: err instanceof Error ? err.message : "Registry lookup failed"
3182
- }
3183
- ]
3184
- };
3185
- }
3186
- let advisoryStatus;
3187
- try {
3188
- const depList = deps.filter((d) => d.name === name);
3189
- const purls = depList.map((d) => d.purl).filter((p) => !!p);
3190
- if (purls.length > 0) {
3191
- const osvResult = await queryOsvBatch(purls, { signal: options.signal });
3192
- const hasAdvisory = [...osvResult.advisories.values()].some(
3193
- (advisories) => advisories.length > 0
3194
- );
3195
- if (hasAdvisory) {
3196
- advisoryStatus = {
3197
- hasAdvisory: true
3198
- };
3199
- }
3200
- }
3201
- } catch {
3202
- }
3203
- for (const dep of deps) {
3204
- if (dep.name !== name) continue;
3205
- const newStatus = classifyStatus(dep, registryStatus, advisoryStatus);
3206
- const newEvidence = [
3207
- ...dep.evidence,
3208
- ...registryStatus?.evidence ?? [],
3209
- ...advisoryStatus?.evidence ?? []
3210
- ];
3211
- enrichedDeps.set(dep.id, {
3212
- ...dep,
3213
- latestStable: registryEntry?.latestStable ?? dep.latestStable,
3214
- license: registryEntry?.license ?? dep.license,
3215
- deprecated: registryEntry?.deprecated ?? dep.deprecated,
3216
- yanked: registryEntry?.yanked ?? dep.yanked,
3217
- status: newStatus,
3218
- evidence: newEvidence
3219
- });
3220
- if (newStatus !== "current" && newStatus !== "local_path" && newStatus !== "git_dependency") {
3221
- allFindings.push(
3222
- createFindingForStatus(dep.id, newStatus, registryEntry?.license)
3223
- );
3224
- }
3225
- }
3226
- }
3227
- }
3228
- const finalDependencies = snapshot.dependencies.map(
3229
- (dep) => enrichedDeps.get(dep.id) ?? dep
3230
- );
3231
- return {
3232
- ...snapshot,
3233
- dependencies: finalDependencies,
3234
- findings: allFindings
3235
- };
3858
+ enrich(snapshot, options = {}) {
3859
+ return runEnrichPhase(snapshot, options);
3236
3860
  }
3237
- // ── Research ───────────────────────────────────────────────────────────
3238
- /**
3239
- * Interpret an enriched snapshot with the LLM: triage the problem cases,
3240
- * research them, and append the resulting findings.
3241
- *
3242
- * Additive by construction. Two invariants hold here, and they are the whole
3243
- * reason this is a separate pass rather than part of `enrich()`:
3244
- *
3245
- * 1. **`snapshot.dependencies` is returned untouched.** Version facts come
3246
- * only from registry evidence — the LLM cannot fabricate a `latestStable`
3247
- * because `Finding` has nowhere to put one (SDD §472).
3248
- * 2. **Failure is not fatal.** No researcher, no candidates, a provider
3249
- * outage, a dry web search — every one of them returns the input snapshot
3250
- * unchanged. A deterministic report is the floor, never a casualty of the
3251
- * optional stage above it.
3252
- *
3253
- * @see docs/specs/techstack-sdd.md §31, §472
3254
- */
3255
- async research(snapshot, options = {}) {
3256
- if (!options.researcher || options.signal?.aborted) return snapshot;
3257
- const candidates = triageCandidates(snapshot.dependencies, {
3258
- limit: options.researchLimit
3259
- });
3260
- if (candidates.length === 0) return snapshot;
3261
- options.onProgress?.("researching", 0, candidates.length);
3262
- let findings;
3263
- try {
3264
- findings = await options.researcher.research(candidates, {
3265
- signal: options.signal,
3266
- onProgress: (completed, total) => {
3267
- options.onProgress?.("researching", completed, total);
3268
- }
3269
- });
3270
- } catch {
3271
- return snapshot;
3272
- }
3273
- options.onProgress?.("synthesizing", 1, 1);
3274
- if (findings.length === 0) return snapshot;
3275
- return { ...snapshot, findings: [...snapshot.findings, ...findings] };
3861
+ research(snapshot, options = {}) {
3862
+ return runResearchPhase(snapshot, options);
3863
+ }
3864
+ generateReport(snapshot, format = "md") {
3865
+ return generateReport(snapshot, format);
3276
3866
  }
3277
- // ── Analyze ───────────────────────────────────────────────────────────
3278
- /**
3279
- * Run a full analysis: inventory + enrich + research + persist.
3280
- * This is the main entry point for the analyze job flow.
3281
- */
3282
3867
  async analyze(projectId, options) {
3283
- const jobId = options.jobId ?? randomUUID();
3284
- const requestedBy = options.requestedBy ?? "system";
3868
+ const jobId = options.jobId ?? randomUUID2();
3285
3869
  const job = {
3286
3870
  id: jobId,
3287
3871
  projectId,
@@ -3289,7 +3873,7 @@ var TechStackEngine = class {
3289
3873
  kind: "analyze",
3290
3874
  status: "queued",
3291
3875
  fingerprint: "",
3292
- requestedBy,
3876
+ requestedBy: options.requestedBy ?? "system",
3293
3877
  sessionId: options.sessionId,
3294
3878
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3295
3879
  progress: { phase: "queued", completed: 0, total: 0 }
@@ -3305,199 +3889,32 @@ var TechStackEngine = class {
3305
3889
  try {
3306
3890
  throwIfAborted();
3307
3891
  updateJob("discovering", { phase: "discovering", completed: 0, total: 1 });
3308
- const snapshot = await this.inventory(
3309
- projectId,
3310
- options.targetRoot,
3311
- jobId,
3312
- (phase, completed, total) => {
3313
- throwIfAborted();
3314
- updateJob(phase, { phase, completed, total });
3315
- }
3316
- );
3892
+ let snapshot = await this.inventory(projectId, options.targetRoot, jobId, (phase, completed, total) => {
3893
+ throwIfAborted();
3894
+ updateJob(phase, { phase, completed, total });
3895
+ }, { includeTransitive: options.includeTransitive, signal: options.signal });
3317
3896
  throwIfAborted();
3318
- const isOnline = options.online !== false;
3319
- if (isOnline) {
3897
+ if (options.online !== false) {
3320
3898
  updateJob("enriching", { phase: "enriching", completed: 0, total: 1 });
3321
- const enriched = await this.enrich(snapshot, {
3322
- online: true,
3323
- signal: options.signal
3324
- });
3899
+ snapshot = await this.enrich(snapshot, { online: true, signal: options.signal });
3325
3900
  throwIfAborted();
3326
- const researched = await this.research(enriched, {
3901
+ snapshot = await this.research(snapshot, {
3327
3902
  researcher: options.researcher,
3328
3903
  researchLimit: options.researchLimit,
3329
3904
  signal: options.signal,
3330
- onProgress: (phase, completed, total) => {
3331
- updateJob(phase, { phase, completed, total });
3332
- }
3905
+ onProgress: (phase, completed, total) => updateJob(phase, { phase, completed, total })
3333
3906
  });
3334
3907
  throwIfAborted();
3335
- this.store.saveSnapshot(researched);
3336
- updateJob("completed", { phase: "completed", completed: 1, total: 1 });
3337
- return {
3338
- snapshot: researched,
3339
- job: { ...job, status: "completed", completedAt: (/* @__PURE__ */ new Date()).toISOString() }
3340
- };
3341
3908
  }
3342
3909
  this.store.saveSnapshot(snapshot);
3343
3910
  updateJob("completed", { phase: "completed", completed: 1, total: 1 });
3344
- return {
3345
- snapshot,
3346
- job: { ...job, status: "completed", completedAt: (/* @__PURE__ */ new Date()).toISOString() }
3347
- };
3348
- } catch (err) {
3349
- if (options.signal?.aborted || err instanceof DOMException && err.name === "AbortError") {
3350
- updateJob("cancelled");
3351
- } else {
3352
- updateJob("failed");
3353
- }
3354
- throw err;
3911
+ return { snapshot, job: { ...job, status: "completed", completedAt: (/* @__PURE__ */ new Date()).toISOString() } };
3912
+ } catch (error) {
3913
+ updateJob(options.signal?.aborted || error instanceof DOMException && error.name === "AbortError" ? "cancelled" : "failed");
3914
+ throw error;
3355
3915
  }
3356
3916
  }
3357
- // ── Report generation ─────────────────────────────────────────────────
3358
- /**
3359
- * Generate a human-readable report from a snapshot.
3360
- *
3361
- * @param format 'md' for Markdown, 'json' for raw JSON.
3362
- * @returns The report as a string.
3363
- */
3364
- generateReport(snapshot, format = "md") {
3365
- if (format === "json") return JSON.stringify(snapshot, null, 2);
3366
- const lines = [
3367
- "# TechStack Report",
3368
- "",
3369
- `**Generated:** ${snapshot.createdAt}`,
3370
- `**Target:** ${snapshot.targetRoot}`,
3371
- `**Fingerprint:** ${snapshot.fingerprint}`,
3372
- `**Workspaces:** ${snapshot.workspaces.length}`,
3373
- `**Dependencies:** ${snapshot.dependencies.length}`,
3374
- `**Findings:** ${snapshot.findings.length}`,
3375
- `**Coverage:** ${snapshot.coverage}`,
3376
- ""
3377
- ];
3378
- if (snapshot.workspaces.length > 0) {
3379
- lines.push("## Workspaces", "");
3380
- lines.push("| Workspace | Ecosystem | Coverage | Deps |");
3381
- lines.push("|---|---|---|---|");
3382
- for (const ws of snapshot.workspaces) {
3383
- const depCount = snapshot.dependencies.filter((d) => d.workspaceId === ws.id).length;
3384
- lines.push(`| ${ws.relativeRoot} | ${ws.ecosystem} | ${ws.coverage} | ${depCount} |`);
3385
- }
3386
- lines.push("");
3387
- }
3388
- const findings = snapshot.findings;
3389
- if (findings.length > 0) {
3390
- lines.push("## Findings", "");
3391
- const bySeverity = /* @__PURE__ */ new Map();
3392
- for (const f of findings) {
3393
- const list = bySeverity.get(f.severity) ?? [];
3394
- list.push(f);
3395
- bySeverity.set(f.severity, list);
3396
- }
3397
- for (const sev of ["critical", "high", "medium", "low", "info"]) {
3398
- const items = bySeverity.get(sev);
3399
- if (!items || items.length === 0) continue;
3400
- lines.push(`### ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${items.length})`, "");
3401
- for (const f of items) {
3402
- const dep = snapshot.dependencies.find((d) => d.id === f.dependencyId);
3403
- lines.push(`- **${dep?.name ?? f.dependencyId}** \u2014 ${f.type} \u2014 ${f.rationale}`);
3404
- }
3405
- lines.push("");
3406
- }
3407
- }
3408
- if (snapshot.dependencies.length > 0) {
3409
- lines.push("## Dependencies", "");
3410
- lines.push("| Name | Ecosystem | Status | Locked | Latest |");
3411
- lines.push("|---|---|---|---|---|");
3412
- for (const dep of snapshot.dependencies) {
3413
- lines.push(
3414
- `| ${dep.name} | ${dep.ecosystem} | ${dep.status} | ${dep.locked ?? "\u2014"} | ${dep.latestStable ?? "\u2014"} |`
3415
- );
3416
- }
3417
- }
3418
- return lines.join("\n");
3419
- }
3420
3917
  };
3421
- function computeFingerprint(dependencies) {
3422
- const parts = dependencies.map((d) => `${d.name}@${d.locked ?? d.requested ?? "unknown"}`).sort().join(",");
3423
- let hash = 0;
3424
- for (let i = 0; i < parts.length; i++) {
3425
- const char = parts.charCodeAt(i);
3426
- hash = (hash << 5) - hash + char;
3427
- hash |= 0;
3428
- }
3429
- return `ts-${Math.abs(hash).toString(36)}`;
3430
- }
3431
- function createFindingForStatus(dependencyId, status, _license) {
3432
- switch (status) {
3433
- case "vulnerable":
3434
- return {
3435
- id: `finding-${dependencyId}-vuln`,
3436
- dependencyId,
3437
- type: "vulnerability",
3438
- severity: "high",
3439
- action: "upgrade_patch",
3440
- confidence: 1,
3441
- rationale: "Known security advisory found for this package",
3442
- evidence: []
3443
- };
3444
- case "deprecated":
3445
- return {
3446
- id: `finding-${dependencyId}-dep`,
3447
- dependencyId,
3448
- type: "deprecated",
3449
- severity: "medium",
3450
- action: "replace",
3451
- confidence: 1,
3452
- rationale: "Package is deprecated in the registry",
3453
- evidence: []
3454
- };
3455
- case "yanked":
3456
- return {
3457
- id: `finding-${dependencyId}-yank`,
3458
- dependencyId,
3459
- type: "deprecated",
3460
- severity: "high",
3461
- action: "replace",
3462
- confidence: 1,
3463
- rationale: "Package version has been yanked from the registry",
3464
- evidence: []
3465
- };
3466
- case "update_available_safe":
3467
- return {
3468
- id: `finding-${dependencyId}-update`,
3469
- dependencyId,
3470
- type: "upgrade",
3471
- severity: "info",
3472
- action: "upgrade_minor",
3473
- confidence: 1,
3474
- rationale: "A newer compatible version is available",
3475
- evidence: []
3476
- };
3477
- case "update_available_breaking":
3478
- return {
3479
- id: `finding-${dependencyId}-major`,
3480
- dependencyId,
3481
- type: "upgrade",
3482
- severity: "low",
3483
- action: "upgrade_major",
3484
- confidence: 1,
3485
- rationale: "A newer version is available that may require breaking changes",
3486
- evidence: []
3487
- };
3488
- default:
3489
- return {
3490
- id: `finding-${dependencyId}-investigate`,
3491
- dependencyId,
3492
- type: "investigate",
3493
- severity: "info",
3494
- action: "investigate",
3495
- confidence: 0.5,
3496
- rationale: `Package status is "${status}" \u2014 may need investigation`,
3497
- evidence: []
3498
- };
3499
- }
3500
- }
3501
3918
 
3502
3919
  // src/research/llm.ts
3503
3920
  var DEFAULT_TIMEOUT_MS = 45e3;
@@ -3857,10 +4274,10 @@ function createToolSearch(options = {}) {
3857
4274
  }
3858
4275
 
3859
4276
  // src/store/sqlite.ts
3860
- import { DatabaseSync } from "node:sqlite";
3861
- import { mkdirSync, existsSync as existsSync3 } from "node:fs";
3862
- import { join as join7 } from "node:path";
3863
- import { homedir } from "node:os";
4277
+ import { createRequire } from "node:module";
4278
+ import { mkdirSync, existsSync as existsSync2 } from "node:fs";
4279
+ import { dirname as dirname2, join as join9 } from "node:path";
4280
+ import { wstackGlobalRoot } from "@wrongstack/core/utils";
3864
4281
 
3865
4282
  // src/store/schema.ts
3866
4283
  var SCHEMA_VERSION = 1;
@@ -3930,30 +4347,77 @@ function applySchema(db) {
3930
4347
  }
3931
4348
 
3932
4349
  // src/store/sqlite.ts
4350
+ var DatabaseSyncCtor;
4351
+ var SQLITE_EXPERIMENTAL_WARNING = "SQLite is an experimental feature and might change at any time";
4352
+ function loadDatabaseSync() {
4353
+ if (DatabaseSyncCtor) return DatabaseSyncCtor;
4354
+ const originalEmitWarning = process.emitWarning;
4355
+ const forwardWarning = originalEmitWarning.bind(process);
4356
+ process.emitWarning = ((warning, ...rest) => {
4357
+ const message = typeof warning === "string" ? warning : warning instanceof Error ? warning.message : "";
4358
+ const typeOrOptions = rest[0];
4359
+ const warningType = typeof warning === "string" ? typeof typeOrOptions === "string" ? typeOrOptions : typeof typeOrOptions === "object" && typeOrOptions !== null && "type" in typeOrOptions && typeof typeOrOptions.type === "string" ? typeOrOptions.type : "" : warning instanceof Error ? warning.name : "";
4360
+ const warningCode = typeof warning === "string" ? typeof typeOrOptions === "object" && typeOrOptions !== null && "code" in typeOrOptions && typeof typeOrOptions.code === "string" ? typeOrOptions.code : typeof rest[1] === "string" ? rest[1] : "" : warning instanceof Error && "code" in warning && typeof warning.code === "string" ? warning.code : "";
4361
+ if (message === SQLITE_EXPERIMENTAL_WARNING && (warningType === "ExperimentalWarning" || warningCode === "ExperimentalWarning")) {
4362
+ return;
4363
+ }
4364
+ forwardWarning(warning, ...rest);
4365
+ });
4366
+ try {
4367
+ try {
4368
+ const require2 = createRequire(import.meta.url);
4369
+ const sqliteModule = require2("node:sqlite");
4370
+ if (typeof sqliteModule !== "object" || sqliteModule === null || !("DatabaseSync" in sqliteModule) || typeof sqliteModule.DatabaseSync !== "function") {
4371
+ throw new Error("node:sqlite DatabaseSync unavailable");
4372
+ }
4373
+ DatabaseSyncCtor = sqliteModule.DatabaseSync;
4374
+ return DatabaseSyncCtor;
4375
+ } catch (error) {
4376
+ const message = error instanceof Error ? error.message : String(error);
4377
+ throw new Error(
4378
+ `TechStack SQLite store needs Node's built-in SQLite (node:sqlite), available since Node 22.5. This runtime doesn't provide it: ${message}`,
4379
+ { cause: error }
4380
+ );
4381
+ }
4382
+ } finally {
4383
+ process.emitWarning = originalEmitWarning;
4384
+ }
4385
+ }
3933
4386
  var TechStackStore = class {
3934
4387
  db;
3935
4388
  dbPath;
4389
+ /** Compile-once cache for fixed SQL used on hot job/snapshot paths. */
4390
+ stmtCache = /* @__PURE__ */ new Map();
3936
4391
  constructor(options) {
3937
- this.dbPath = options.dbPath ?? join7(
3938
- homedir(),
3939
- ".wrongstack",
3940
- "projects",
3941
- options.projectSlug,
3942
- "techstack",
3943
- "techstack.db"
3944
- );
3945
- const dir = this.dbPath.slice(0, this.dbPath.lastIndexOf("\\"));
3946
- if (!existsSync3(dir)) {
4392
+ this.dbPath = options.dbPath ?? join9(wstackGlobalRoot(), "projects", options.projectSlug, "techstack", "techstack.db");
4393
+ const dir = dirname2(this.dbPath);
4394
+ if (!existsSync2(dir)) {
3947
4395
  mkdirSync(dir, { recursive: true });
3948
4396
  }
3949
- this.db = new DatabaseSync(this.dbPath);
4397
+ const Database = loadDatabaseSync();
4398
+ this.db = new Database(this.dbPath);
3950
4399
  this.db.exec("PRAGMA journal_mode = WAL;");
4400
+ this.db.exec("PRAGMA synchronous = NORMAL;");
4401
+ this.db.exec("PRAGMA busy_timeout = 10000;");
4402
+ this.db.exec("PRAGMA temp_store = MEMORY;");
4403
+ this.db.exec("PRAGMA cache_size = -32768;");
4404
+ this.db.exec("PRAGMA mmap_size = 134217728;");
3951
4405
  this.db.exec("PRAGMA foreign_keys = ON;");
3952
4406
  applySchema(this.db);
3953
4407
  }
4408
+ /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
4409
+ stmt(sql) {
4410
+ let prepared = this.stmtCache.get(sql);
4411
+ if (prepared === void 0) {
4412
+ prepared = this.db.prepare(sql);
4413
+ this.stmtCache.set(sql, prepared);
4414
+ }
4415
+ return prepared;
4416
+ }
3954
4417
  // ── Lifecycle ───────────────────────────────────────────────────────────
3955
4418
  /** Close the database connection. Idempotent. */
3956
4419
  close() {
4420
+ this.stmtCache.clear();
3957
4421
  try {
3958
4422
  this.db.close();
3959
4423
  } catch {
@@ -3966,7 +4430,7 @@ var TechStackStore = class {
3966
4430
  // ── Snapshots ───────────────────────────────────────────────────────────
3967
4431
  /** Persist a snapshot. */
3968
4432
  saveSnapshot(snapshot) {
3969
- const stmt = this.db.prepare(`
4433
+ const stmt = this.stmt(`
3970
4434
  INSERT OR REPLACE INTO snapshots (id, project_id, target_root, fingerprint, created_at, raw_json, adapter_version)
3971
4435
  VALUES (?, ?, ?, ?, ?, ?, ?)
3972
4436
  `);
@@ -3982,7 +4446,7 @@ var TechStackStore = class {
3982
4446
  }
3983
4447
  /** Get a snapshot by project ID (latest). */
3984
4448
  getSnapshot(projectId) {
3985
- const stmt = this.db.prepare(`
4449
+ const stmt = this.stmt(`
3986
4450
  SELECT raw_json FROM snapshots
3987
4451
  WHERE project_id = ?
3988
4452
  ORDER BY created_at DESC
@@ -3998,7 +4462,7 @@ var TechStackStore = class {
3998
4462
  }
3999
4463
  /** Get a snapshot by ID. */
4000
4464
  getSnapshotById(id) {
4001
- const stmt = this.db.prepare(`
4465
+ const stmt = this.stmt(`
4002
4466
  SELECT raw_json FROM snapshots WHERE id = ?
4003
4467
  `);
4004
4468
  const row = stmt.get(id);
@@ -4011,7 +4475,7 @@ var TechStackStore = class {
4011
4475
  }
4012
4476
  /** List all snapshots for a project (newest first). */
4013
4477
  listSnapshots(projectId, limit = 20) {
4014
- const stmt = this.db.prepare(`
4478
+ const stmt = this.stmt(`
4015
4479
  SELECT raw_json FROM snapshots
4016
4480
  WHERE project_id = ?
4017
4481
  ORDER BY created_at DESC
@@ -4028,7 +4492,7 @@ var TechStackStore = class {
4028
4492
  }
4029
4493
  /** Delete snapshots older than a given timestamp. */
4030
4494
  deleteSnapshotsBefore(projectId, before) {
4031
- const stmt = this.db.prepare(`
4495
+ const stmt = this.stmt(`
4032
4496
  DELETE FROM snapshots WHERE project_id = ? AND created_at < ?
4033
4497
  `);
4034
4498
  const result = stmt.run(projectId, before);
@@ -4037,7 +4501,7 @@ var TechStackStore = class {
4037
4501
  // ── Jobs ────────────────────────────────────────────────────────────────
4038
4502
  /** Persist a job. */
4039
4503
  saveJob(job) {
4040
- const stmt = this.db.prepare(`
4504
+ const stmt = this.stmt(`
4041
4505
  INSERT OR REPLACE INTO jobs
4042
4506
  (id, project_id, target_root, kind, status, fingerprint, requested_by, session_id, created_at, completed_at, error, progress_json)
4043
4507
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -4059,7 +4523,7 @@ var TechStackStore = class {
4059
4523
  }
4060
4524
  /** Get a job by ID. */
4061
4525
  getJob(id) {
4062
- const stmt = this.db.prepare(`
4526
+ const stmt = this.stmt(`
4063
4527
  SELECT * FROM jobs WHERE id = ?
4064
4528
  `);
4065
4529
  const row = stmt.get(id);
@@ -4070,7 +4534,7 @@ var TechStackStore = class {
4070
4534
  updateJobStatus(id, status, progress) {
4071
4535
  const progressJson = progress ? JSON.stringify(progress) : null;
4072
4536
  const completedAt = status === "completed" || status === "failed" || status === "cancelled" ? (/* @__PURE__ */ new Date()).toISOString() : null;
4073
- const stmt = this.db.prepare(`
4537
+ const stmt = this.stmt(`
4074
4538
  UPDATE jobs
4075
4539
  SET status = ?, progress_json = ?, completed_at = COALESCE(?, completed_at)
4076
4540
  WHERE id = ?
@@ -4079,7 +4543,7 @@ var TechStackStore = class {
4079
4543
  }
4080
4544
  /** List jobs for a project (newest first). */
4081
4545
  listJobs(projectId, limit = 50) {
4082
- const stmt = this.db.prepare(`
4546
+ const stmt = this.stmt(`
4083
4547
  SELECT * FROM jobs WHERE project_id = ? ORDER BY created_at DESC LIMIT ?
4084
4548
  `);
4085
4549
  const rows = stmt.all(projectId, limit);
@@ -4088,7 +4552,7 @@ var TechStackStore = class {
4088
4552
  // ── Outbox ──────────────────────────────────────────────────────────────
4089
4553
  /** Create an outbox entry. */
4090
4554
  createOutbox(deliveryId, reportId, sessionId) {
4091
- const stmt = this.db.prepare(`
4555
+ const stmt = this.stmt(`
4092
4556
  INSERT OR IGNORE INTO outbox (delivery_id, report_id, session_id, status, attempts)
4093
4557
  VALUES (?, ?, ?, 'pending', 0)
4094
4558
  `);
@@ -4096,7 +4560,7 @@ var TechStackStore = class {
4096
4560
  }
4097
4561
  /** Claim an outbox entry (atomic CAS). */
4098
4562
  claimOutbox(deliveryId, sessionId) {
4099
- const stmt = this.db.prepare(`
4563
+ const stmt = this.stmt(`
4100
4564
  UPDATE outbox
4101
4565
  SET status = 'claimed', claimed_at = datetime('now'), attempts = attempts + 1
4102
4566
  WHERE delivery_id = ? AND session_id = ? AND status = 'pending'
@@ -4106,7 +4570,7 @@ var TechStackStore = class {
4106
4570
  }
4107
4571
  /** Mark an outbox entry as delivered. */
4108
4572
  deliverOutbox(deliveryId) {
4109
- const stmt = this.db.prepare(`
4573
+ const stmt = this.stmt(`
4110
4574
  UPDATE outbox SET status = 'delivered', delivered_at = datetime('now')
4111
4575
  WHERE delivery_id = ?
4112
4576
  `);
@@ -4114,14 +4578,14 @@ var TechStackStore = class {
4114
4578
  }
4115
4579
  /** Mark an outbox entry as failed. */
4116
4580
  failOutbox(deliveryId) {
4117
- const stmt = this.db.prepare(`
4581
+ const stmt = this.stmt(`
4118
4582
  UPDATE outbox SET status = 'failed' WHERE delivery_id = ?
4119
4583
  `);
4120
4584
  stmt.run(deliveryId);
4121
4585
  }
4122
4586
  /** List outbox entries by status. */
4123
4587
  listOutboxByStatus(status) {
4124
- const stmt = this.db.prepare(`
4588
+ const stmt = this.stmt(`
4125
4589
  SELECT * FROM outbox WHERE status = ?
4126
4590
  `);
4127
4591
  const rows = stmt.all(status);
@@ -4228,14 +4692,22 @@ export {
4228
4692
  DotNetAdapter,
4229
4693
  ElixirAdapter,
4230
4694
  GoAdapter,
4695
+ GradleAdapter,
4231
4696
  MavenAdapter,
4232
4697
  NpmAdapter,
4233
4698
  PhpAdapter,
4234
4699
  PythonAdapter,
4700
+ RegistryAuthError,
4701
+ RegistryNetworkError,
4702
+ RegistryNotFoundError,
4703
+ RegistryRateLimitError,
4235
4704
  RubyAdapter,
4236
4705
  RustAdapter,
4706
+ SwiftAdapter,
4237
4707
  TechStackEngine,
4238
4708
  TechStackStore,
4709
+ TrendStore,
4710
+ applyPlan,
4239
4711
  applySchema,
4240
4712
  attemptDelivery,
4241
4713
  buildPurl,
@@ -4246,6 +4718,7 @@ export {
4246
4718
  constructPurl,
4247
4719
  coverageForEcosystem,
4248
4720
  cppAdapter,
4721
+ createAuditRunner,
4249
4722
  createProviderLlm,
4250
4723
  createResearcher,
4251
4724
  createToolSearch,
@@ -4259,6 +4732,8 @@ export {
4259
4732
  failedLookupStatus,
4260
4733
  generateUpgradePlan,
4261
4734
  goAdapter,
4735
+ gradleAdapter,
4736
+ invalidateRegistryCache,
4262
4737
  isNativeAuditAvailable,
4263
4738
  lookupRegistry,
4264
4739
  lookupRegistryBatch,
@@ -4275,12 +4750,15 @@ export {
4275
4750
  queryOsvBatch,
4276
4751
  queryOsvSingle,
4277
4752
  renderPlanMarkdown,
4753
+ renderTrendMarkdown,
4278
4754
  rubyAdapter,
4279
4755
  runNativeAudit,
4280
4756
  runNpmAudit,
4281
4757
  rustAdapter,
4282
4758
  supportedRegistryEcosystems,
4759
+ swiftAdapter,
4283
4760
  toCycloneDX,
4761
+ toLanguagePackageInput,
4284
4762
  toSpdx,
4285
4763
  triageCandidates
4286
4764
  };