@cdxgen/cdxgen 13.0.1 → 13.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +30 -16
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +141 -7
  4. package/bin/evinse.js +3 -1
  5. package/data/component-tags.json +8 -1
  6. package/data/crypto-oid.json +88 -12
  7. package/data/frameworks-list.json +86 -1
  8. package/data/remediations.json +1167 -0
  9. package/data/rules/ai-bom.yaml +78 -0
  10. package/data/rules/asar-archives.yaml +1 -1
  11. package/data/rules/build-fidelity.yaml +782 -0
  12. package/data/rules/cargo-rust.yaml +132 -0
  13. package/data/rules/mcp-servers.yaml +122 -0
  14. package/data/rules/package-integrity.yaml +13 -13
  15. package/lib/audit/index.js +48 -4
  16. package/lib/audit/scoring.js +2 -8
  17. package/lib/audit/targets.js +6 -1
  18. package/lib/cli/bomAssembly.js +32 -1
  19. package/lib/cli/cliOptions.js +94 -7
  20. package/lib/cli/index.js +268 -49
  21. package/lib/cli/jsBom.js +91 -5
  22. package/lib/cli/jvmBom.js +1515 -1314
  23. package/lib/cli/managedBom.js +249 -28
  24. package/lib/cli/nativeBom.js +986 -10
  25. package/lib/core/activity.js +120 -2
  26. package/lib/core/buildLedger.js +799 -0
  27. package/lib/core/deferredExit.js +141 -0
  28. package/lib/core/env.js +108 -2
  29. package/lib/core/fs.js +60 -0
  30. package/lib/core/logger.js +12 -99
  31. package/lib/core/severity.js +16 -0
  32. package/lib/core/syncFileWriter.js +106 -0
  33. package/lib/core/userAgent.js +151 -0
  34. package/lib/ecosystems/cargoCache.js +364 -0
  35. package/lib/ecosystems/caxa.js +13 -5
  36. package/lib/ecosystems/cppEvidence.js +40 -2
  37. package/lib/ecosystems/ecosystems.js +250 -91
  38. package/lib/ecosystems/golem.js +11 -1
  39. package/lib/ecosystems/gradleutils.js +498 -17
  40. package/lib/ecosystems/kosi.js +1256 -0
  41. package/lib/ecosystems/parsers-crystal.js +179 -0
  42. package/lib/ecosystems/parsers-dotnet.js +69 -39
  43. package/lib/ecosystems/parsers-elm.js +816 -0
  44. package/lib/ecosystems/parsers-gleam.js +1 -1
  45. package/lib/ecosystems/parsers-js.js +26 -0
  46. package/lib/ecosystems/parsers-julia.js +268 -0
  47. package/lib/ecosystems/parsers-jvm.js +9 -0
  48. package/lib/ecosystems/parsers-lua.js +218 -0
  49. package/lib/ecosystems/parsers-misc.js +188 -2
  50. package/lib/ecosystems/parsers-nim.js +221 -0
  51. package/lib/ecosystems/parsers-opam.js +180 -0
  52. package/lib/ecosystems/parsers-perl.js +277 -0
  53. package/lib/ecosystems/parsers-python.js +172 -36
  54. package/lib/ecosystems/parsers-r.js +148 -0
  55. package/lib/ecosystems/parsers-rebar.js +411 -0
  56. package/lib/ecosystems/parsers-rust.js +944 -19
  57. package/lib/ecosystems/parsers-spack.js +204 -0
  58. package/lib/ecosystems/parsers-stack.js +264 -0
  59. package/lib/ecosystems/parsers-terraform.js +194 -0
  60. package/lib/ecosystems/pypiClassifiers.js +326 -0
  61. package/lib/ecosystems/pythonutils.js +49 -1
  62. package/lib/ecosystems/rubyutils.js +13 -0
  63. package/lib/ecosystems/rusi.js +126 -6
  64. package/lib/evinser/evinser.js +141 -13
  65. package/lib/helpers/agenticToolutils.js +925 -0
  66. package/lib/helpers/remote/dependency-track.js +81 -14
  67. package/lib/inventory/agentFormulationParser.js +57 -4
  68. package/lib/inventory/aiCollector.js +504 -10
  69. package/lib/inventory/aiInventory.js +12 -0
  70. package/lib/inventory/analyzer.js +488 -30
  71. package/lib/inventory/astWorker.js +6 -0
  72. package/lib/inventory/auditCategories.js +17 -0
  73. package/lib/inventory/cbomutils.js +30 -0
  74. package/lib/inventory/cdxrs.js +7 -2
  75. package/lib/inventory/communityAiConfigParser.js +215 -2
  76. package/lib/inventory/depsUtils.js +72 -0
  77. package/lib/inventory/dosai.js +508 -22
  78. package/lib/inventory/envcontext.js +215 -24
  79. package/lib/inventory/evidenceUtils.js +13 -0
  80. package/lib/inventory/fetchBatch.js +131 -2
  81. package/lib/inventory/fetchRate.js +74 -11
  82. package/lib/inventory/forgeEnricher.js +3 -2
  83. package/lib/inventory/jvmToolEnv.js +594 -0
  84. package/lib/inventory/mcpConfigParser.js +166 -2
  85. package/lib/inventory/mcpDiscovery.js +11 -0
  86. package/lib/inventory/mcpServerManifest.js +389 -0
  87. package/lib/inventory/mcpbBundle.js +419 -0
  88. package/lib/inventory/osinfo.js +1 -0
  89. package/lib/inventory/plugins.js +3 -0
  90. package/lib/inventory/protobom.js +54 -88
  91. package/lib/inventory/protobomLoader.js +4 -0
  92. package/lib/inventory/rustFormulationParser.js +1 -1
  93. package/lib/inventory/source.js +10 -4
  94. package/lib/inventory/stylePackageRefs.js +41 -0
  95. package/lib/inventory/svelteUtils.js +524 -0
  96. package/lib/inventory/toolRequirements.js +903 -0
  97. package/lib/managers/binary.js +24 -0
  98. package/lib/managers/docker.js +26 -5
  99. package/lib/parsers/huggingfaceUtils.js +15 -7
  100. package/lib/parsers/onnx.js +294 -0
  101. package/lib/parsers/safetensors.js +114 -0
  102. package/lib/server/server.js +1 -1
  103. package/lib/stages/postgen/annotationBuilder.js +97 -0
  104. package/lib/stages/postgen/auditBom.js +76 -79
  105. package/lib/stages/postgen/introspection/annotate.js +320 -0
  106. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  107. package/lib/stages/postgen/introspection/emit.js +192 -0
  108. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  109. package/lib/stages/postgen/introspection/markers.js +250 -0
  110. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  111. package/lib/stages/postgen/introspection/report.js +1208 -0
  112. package/lib/stages/postgen/introspection/score.js +1067 -0
  113. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  114. package/lib/stages/postgen/parentEdge.js +156 -0
  115. package/lib/stages/postgen/postgen.js +284 -3
  116. package/lib/stages/postgen/ruleEngine.js +17 -0
  117. package/lib/stages/pregen/envAudit.js +42 -2
  118. package/lib/stages/pregen/pregen.js +1171 -6
  119. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
  120. package/lib/validator/bomValidator.js +11 -2
  121. package/package.json +26 -26
  122. package/types/lib/audit/index.d.ts.map +1 -1
  123. package/types/lib/audit/scoring.d.ts +2 -8
  124. package/types/lib/audit/scoring.d.ts.map +1 -1
  125. package/types/lib/audit/targets.d.ts.map +1 -1
  126. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  127. package/types/lib/cli/cliOptions.d.ts +2 -1
  128. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  129. package/types/lib/cli/index.d.ts +10 -2
  130. package/types/lib/cli/index.d.ts.map +1 -1
  131. package/types/lib/cli/jsBom.d.ts +23 -0
  132. package/types/lib/cli/jsBom.d.ts.map +1 -1
  133. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  134. package/types/lib/cli/managedBom.d.ts +33 -0
  135. package/types/lib/cli/managedBom.d.ts.map +1 -1
  136. package/types/lib/cli/nativeBom.d.ts +88 -8
  137. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  138. package/types/lib/core/activity.d.ts.map +1 -1
  139. package/types/lib/core/buildLedger.d.ts +272 -0
  140. package/types/lib/core/buildLedger.d.ts.map +1 -0
  141. package/types/lib/core/deferredExit.d.ts +84 -0
  142. package/types/lib/core/deferredExit.d.ts.map +1 -0
  143. package/types/lib/core/env.d.ts +43 -1
  144. package/types/lib/core/env.d.ts.map +1 -1
  145. package/types/lib/core/fs.d.ts +21 -0
  146. package/types/lib/core/fs.d.ts.map +1 -1
  147. package/types/lib/core/logger.d.ts +2 -11
  148. package/types/lib/core/logger.d.ts.map +1 -1
  149. package/types/lib/core/severity.d.ts +11 -0
  150. package/types/lib/core/severity.d.ts.map +1 -0
  151. package/types/lib/core/syncFileWriter.d.ts +33 -0
  152. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  153. package/types/lib/core/userAgent.d.ts +56 -0
  154. package/types/lib/core/userAgent.d.ts.map +1 -0
  155. package/types/lib/ecosystems/cargoCache.d.ts +75 -0
  156. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
  157. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  159. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  160. package/types/lib/ecosystems/ecosystems.d.ts +15 -1
  161. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  162. package/types/lib/ecosystems/gradleutils.d.ts +53 -0
  163. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  164. package/types/lib/ecosystems/kosi.d.ts +73 -0
  165. package/types/lib/ecosystems/kosi.d.ts.map +1 -0
  166. package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
  167. package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
  168. package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
  169. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  170. package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
  171. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
  172. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  173. package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
  174. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
  175. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  176. package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
  177. package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
  178. package/types/lib/ecosystems/parsers-misc.d.ts +23 -0
  179. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  180. package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
  181. package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
  182. package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
  183. package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
  184. package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
  185. package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
  186. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-r.d.ts +35 -0
  188. package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
  189. package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
  190. package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
  191. package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
  192. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  193. package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
  194. package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
  195. package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
  196. package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
  197. package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
  198. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
  199. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  200. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  201. package/types/lib/ecosystems/pythonutils.d.ts +21 -1
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  205. package/types/lib/evinser/evinser.d.ts +34 -1
  206. package/types/lib/evinser/evinser.d.ts.map +1 -1
  207. package/types/lib/helpers/agenticToolutils.d.ts +87 -0
  208. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
  209. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  210. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  211. package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
  212. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  213. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  214. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  215. package/types/lib/inventory/auditCategories.d.ts +12 -0
  216. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  217. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  218. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  219. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  220. package/types/lib/inventory/dosai.d.ts +43 -0
  221. package/types/lib/inventory/dosai.d.ts.map +1 -1
  222. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  223. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  224. package/types/lib/inventory/fetchBatch.d.ts +43 -0
  225. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  226. package/types/lib/inventory/fetchRate.d.ts +9 -6
  227. package/types/lib/inventory/fetchRate.d.ts.map +1 -1
  228. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  229. package/types/lib/inventory/jvmToolEnv.d.ts +190 -0
  230. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  231. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  232. package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
  233. package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
  234. package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
  235. package/types/lib/inventory/mcpbBundle.d.ts +19 -0
  236. package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
  237. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  238. package/types/lib/inventory/plugins.d.ts.map +1 -1
  239. package/types/lib/inventory/protobom.d.ts +18 -13
  240. package/types/lib/inventory/protobom.d.ts.map +1 -1
  241. package/types/lib/inventory/protobomLoader.d.ts.map +1 -1
  242. package/types/lib/inventory/source.d.ts.map +1 -1
  243. package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
  244. package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
  245. package/types/lib/inventory/svelteUtils.d.ts +124 -0
  246. package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
  247. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  248. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  249. package/types/lib/managers/binary.d.ts.map +1 -1
  250. package/types/lib/managers/docker.d.ts.map +1 -1
  251. package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
  252. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  253. package/types/lib/parsers/onnx.d.ts +28 -0
  254. package/types/lib/parsers/onnx.d.ts.map +1 -0
  255. package/types/lib/parsers/safetensors.d.ts +22 -0
  256. package/types/lib/parsers/safetensors.d.ts.map +1 -0
  257. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  258. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  259. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  260. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  261. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  262. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  263. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  264. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  265. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  266. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  267. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  268. package/types/lib/stages/postgen/introspection/markers.d.ts +58 -0
  269. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  270. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  271. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  272. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  273. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  274. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  275. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  276. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  277. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  278. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  279. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  280. package/types/lib/stages/postgen/postgen.d.ts +35 -2
  281. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  282. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  283. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  284. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  285. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  286. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,1067 @@
1
+ /**
2
+ * Scoring and remediation ranking over a completed reflection document.
3
+ *
4
+ * Every function here is pure: the reflection produced by `reflect.js` is the
5
+ * only evidence input, the remediation catalog is a static lookup passed in by
6
+ * the caller, and nothing reads the ledger, the BOM, or the filesystem. The
7
+ * same reflection therefore always yields the same score, on every runtime.
8
+ *
9
+ * The score is the gradient and the tier is the verdict: an ecosystem at its
10
+ * ceiling scores 100 and emits nothing (a manifest-tier helm chart is a
11
+ * success, not a nagging opportunity), an unsupported ecosystem is excluded
12
+ * from the mean entirely (a missing Elm parser is a coverage gap, not a
13
+ * project defect), and everything else starts from its tier base and loses
14
+ * points for each tool fact and corroborated degradation the reflection
15
+ * recorded. Deductions are floored at the tier base minus 15 so a single
16
+ * ecosystem can never fall out of its tier's band.
17
+ */
18
+
19
+ import { SEVERITY_ORDER } from "../../../core/severity.js";
20
+ import { declaredCommandForEcosystem } from "./formulationEvidence.js";
21
+ import { shapeCommand } from "./shapeCommand.js";
22
+
23
+ /**
24
+ * Base score per fidelity tier, from healthiest to worst.
25
+ *
26
+ * @type {Readonly<Record<string, number>>}
27
+ */
28
+ export const TIER_BASE_SCORES = Object.freeze({
29
+ resolved: 100,
30
+ lockfile: 85,
31
+ manifest: 55,
32
+ heuristic: 25,
33
+ absent: 0,
34
+ });
35
+
36
+ /**
37
+ * The fidelity ladder, healthiest first, shared by the rank arithmetic and the
38
+ * rule-derived target selection.
39
+ *
40
+ * @type {Readonly<string[]>}
41
+ */
42
+ export const TIER_LADDER = Object.freeze([
43
+ "resolved",
44
+ "lockfile",
45
+ "manifest",
46
+ "heuristic",
47
+ "absent",
48
+ ]);
49
+
50
+ /** Ladder position of each tier; higher is worse. */
51
+ const TIER_RANK = new Map(TIER_LADDER.map((tier, index) => [tier, index]));
52
+
53
+ /** Deduction applied for each entry in a row's toolsMissing array. */
54
+ const TOOL_MISSING_DEDUCTION = 8;
55
+ /** Deduction applied for each entry in a row's toolsMismatched array. */
56
+ const TOOL_MISMATCH_DEDUCTION = 5;
57
+ /** Extra deduction for a degradation whose catalog kind is command.failed. */
58
+ const COMMAND_FAILED_DEDUCTION = 6;
59
+ /** Deduction per distinct remediation id, keyed by the degradation's impact. */
60
+ const IMPACT_DEDUCTIONS = Object.freeze({
61
+ "transitive-deps": 10,
62
+ versions: 6,
63
+ integrity: 6,
64
+ components: 8,
65
+ licenses: 3,
66
+ none: 0,
67
+ });
68
+
69
+ /** Widest a score may fall below its tier base. */
70
+ const MAX_DEDUCTION_SPAN = 15;
71
+
72
+ /**
73
+ * Closed vocabulary for catalog action kinds. `container` proposes running
74
+ * cdxgen inside the official image for toolchains that are impractical to
75
+ * provision on the host.
76
+ *
77
+ * @type {Readonly<string[]>}
78
+ */
79
+ export const REMEDIATION_ACTION_KINDS = Object.freeze([
80
+ "install",
81
+ "env",
82
+ "build",
83
+ "config",
84
+ "container",
85
+ "rerun",
86
+ ]);
87
+
88
+ /**
89
+ * Action kinds that need outbound network access, and are therefore blocked
90
+ * when the run was offline or under a command-execution policy.
91
+ *
92
+ * @type {ReadonlySet<string>}
93
+ */
94
+ export const NETWORK_ACTION_KINDS = new Set(["install", "build", "container"]);
95
+
96
+ /**
97
+ * Provisioners a catalog action can depend on. An action whose `via` names one
98
+ * of these is blocked when the run recorded the provisioner as unavailable.
99
+ *
100
+ * @type {ReadonlySet<string>}
101
+ */
102
+ export const PROVISIONER_TOOLS = new Set([
103
+ "sdk",
104
+ "sdkman",
105
+ "nvm",
106
+ "rbenv",
107
+ "winget",
108
+ ]);
109
+
110
+ /** Ledger remediation id whose presence in the observations marks an offline run. */
111
+ const OFFLINE_REMEDIATION_ID = "policy.offline";
112
+
113
+ /** Ledger event kinds that describe a tool or provisioner failing. */
114
+ const UNAVAILABLE_KINDS = new Set([
115
+ "tool.missing",
116
+ "command.failed",
117
+ "evidence.degraded",
118
+ ]);
119
+
120
+ /**
121
+ * Clamp a number into a closed range.
122
+ *
123
+ * @param {number} value Value to clamp.
124
+ * @param {number} min Lower bound.
125
+ * @param {number} max Upper bound.
126
+ * @returns {number} Clamped value.
127
+ */
128
+ function clamp(value, min, max) {
129
+ return Math.min(Math.max(value, min), max);
130
+ }
131
+
132
+ /**
133
+ * Round to two decimal places for stable expected-gain reporting.
134
+ *
135
+ * @param {number} value Value to round.
136
+ * @returns {number} Value rounded to 0.01.
137
+ */
138
+ function round2(value) {
139
+ return Math.round(value * 100) / 100;
140
+ }
141
+
142
+ /**
143
+ * Order a list of rows by ecosystem name so every downstream sum, mean and
144
+ * ranking iterates the same sequence regardless of how the reflection was
145
+ * assembled.
146
+ *
147
+ * @param {Object[]} rows Ecosystem rows from the reflection.
148
+ * @returns {Object[]} Rows sorted by ecosystem name.
149
+ */
150
+ function sortedRows(rows) {
151
+ return [...(rows || [])].sort((a, b) =>
152
+ `${a?.ecosystem || ""}`.localeCompare(`${b?.ecosystem || ""}`),
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Resolve the effective weight of a scored row in the overall mean. Absent
158
+ * ecosystems carry component weight 1 rather than 0 — an ecosystem cdxgen
159
+ * produced nothing for must stay visible in the score, which is the failure
160
+ * the feature exists to catch.
161
+ *
162
+ * @param {Object} row Ecosystem row.
163
+ * @returns {number} Positive weight.
164
+ */
165
+ function rowWeight(row) {
166
+ if (row.state === "absent") {
167
+ return 1;
168
+ }
169
+ const count = Number(row.componentCount);
170
+ return Number.isFinite(count) && count > 0 ? count : 1;
171
+ }
172
+
173
+ /**
174
+ * Confidence that the row's verdict reflects reality, derived from the
175
+ * corroboration between the ledger and the rule pack. `high` needs both
176
+ * sources on the row, `low` covers an absent or incomplete ledger and
177
+ * disk-marker-only verdicts, and everything else is `medium`.
178
+ *
179
+ * A foreign BOM's formulation is the asymmetric case. It substitutes for the
180
+ * absent ledger events as primary evidence, which lifts the verdict off the
181
+ * marker-only floor; because the run was never observed, `medium` is a cap
182
+ * it can never exceed, and a formulation without a toolchain record leaves
183
+ * the row at `low`. On a same-run scan the formulation is the ledger's own
184
+ * evidence reported twice, so it moves nothing in either direction.
185
+ *
186
+ * @param {Object} row Ecosystem row.
187
+ * @param {Object} reflection Reflection document.
188
+ * @returns {"high"|"medium"|"low"} Confidence label.
189
+ */
190
+ export function confidenceFor(row, reflection) {
191
+ const reasons = Array.isArray(row?.tierReasons) ? row.tierReasons : [];
192
+ const determining = reasons.filter((reason) => reason?.determining);
193
+ const markerOnly =
194
+ determining.length > 0 &&
195
+ determining.every((reason) => reason?.source === "disk");
196
+ if (reflection?.formulation?.origin === "foreign") {
197
+ // `medium` is a cap, never a floor: a row whose tier is argued only by a
198
+ // marker on disk stays `low` on a foreign BOM exactly as it does on any
199
+ // other, because a formulation record says nothing about that marker.
200
+ if (markerOnly) {
201
+ return "low";
202
+ }
203
+ // The cap: an unobserved run never supports more than medium, whatever
204
+ // the reasons on the row say. The floor stays low while the formulation
205
+ // carries no toolchain record to substitute for the missing ledger.
206
+ if (
207
+ reflection?.ledgerSource === "none" ||
208
+ reflection?.ledgerComplete === false
209
+ ) {
210
+ return (reflection?.formulation?.tools?.length || 0) > 0
211
+ ? "medium"
212
+ : "low";
213
+ }
214
+ return "medium";
215
+ }
216
+ if (
217
+ reflection?.ledgerSource === "none" ||
218
+ reflection?.ledgerComplete === false ||
219
+ markerOnly
220
+ ) {
221
+ return "low";
222
+ }
223
+ const sources = new Set(reasons.map((reason) => reason?.source));
224
+ if (sources.has("ledger") && sources.has("rule")) {
225
+ return "high";
226
+ }
227
+ return "medium";
228
+ }
229
+
230
+ /**
231
+ * Sort a row's degradations by remediation id and merge duplicates, so the
232
+ * deduction and remediation arithmetic never depends on the order the events
233
+ * arrived in. The representative of a merged group is chosen after sorting,
234
+ * so even its detail text and failure facts are shuffle-stable; a fact the
235
+ * representative lacks is filled from the duplicates it absorbed.
236
+ *
237
+ * @param {Object} row Ecosystem row.
238
+ * @returns {{remediationId: string, impact: string, detail: string, count: number, command: string|undefined, exitCode: number|undefined, causeDetail: string|undefined, outputExcerpt: string|undefined}[]} Deduplicated degradations, sorted by id.
239
+ */
240
+ function mergedDegradations(row) {
241
+ const all = (Array.isArray(row?.degradations) ? row.degradations : []).map(
242
+ (degradation) => ({
243
+ id: `${degradation?.remediationId || ""}`,
244
+ impact: degradation?.impact,
245
+ detail: degradation?.detail,
246
+ command: degradation?.command,
247
+ exitCode:
248
+ typeof degradation?.exitCode === "number"
249
+ ? degradation.exitCode
250
+ : undefined,
251
+ causeDetail: degradation?.causeDetail,
252
+ outputExcerpt: degradation?.outputExcerpt,
253
+ }),
254
+ );
255
+ all.sort(
256
+ (a, b) =>
257
+ a.id.localeCompare(b.id) ||
258
+ `${a.detail || ""}`.localeCompare(`${b.detail || ""}`) ||
259
+ `${a.impact || ""}`.localeCompare(`${b.impact || ""}`),
260
+ );
261
+ const merged = [];
262
+ for (const degradation of all) {
263
+ const last = merged[merged.length - 1];
264
+ if (last && last.remediationId === degradation.id) {
265
+ last.count += 1;
266
+ last.command ||= degradation.command;
267
+ // A successful status is a fact, not a gap: a producer that keys its
268
+ // degradation on the tool's output rather than its status records a
269
+ // zero exit code, which a truthiness test would replace with a later
270
+ // duplicate's.
271
+ if (last.exitCode === undefined) {
272
+ last.exitCode = degradation.exitCode;
273
+ }
274
+ last.causeDetail ||= degradation.causeDetail;
275
+ last.outputExcerpt ||= degradation.outputExcerpt;
276
+ continue;
277
+ }
278
+ merged.push({
279
+ remediationId: degradation.id,
280
+ impact: degradation.impact,
281
+ detail: degradation.detail,
282
+ count: 1,
283
+ command: degradation.command,
284
+ exitCode: degradation.exitCode,
285
+ causeDetail: degradation.causeDetail,
286
+ outputExcerpt: degradation.outputExcerpt,
287
+ });
288
+ }
289
+ return merged;
290
+ }
291
+
292
+ /**
293
+ * Total score deduction for a row: one amount per missing or mismatched tool,
294
+ * plus one impact-keyed amount per distinct corroborated remediation id. The
295
+ * catalog supplies the kind behind each id, so a `command.failed` event keeps
296
+ * its own extra deduction even though the reflection stores degradations by
297
+ * id.
298
+ *
299
+ * @param {Object} row Ecosystem row.
300
+ * @param {Object|null} catalog Remediation catalog keyed by id.
301
+ * @returns {{total: number, byRemediationId: Map<string, number>}} Total deduction and the per-remediation split.
302
+ */
303
+ export function deductionsFor(row, catalog) {
304
+ let total = 0;
305
+ const byRemediationId = new Map();
306
+ const missingCount = Array.isArray(row?.toolsMissing)
307
+ ? row.toolsMissing.length
308
+ : 0;
309
+ const mismatchCount = Array.isArray(row?.toolsMismatched)
310
+ ? row.toolsMismatched.length
311
+ : 0;
312
+ total += missingCount * TOOL_MISSING_DEDUCTION;
313
+ total += mismatchCount * TOOL_MISMATCH_DEDUCTION;
314
+ for (const degradation of mergedDegradations(row)) {
315
+ const entry = catalog?.[degradation.remediationId];
316
+ const amount =
317
+ (IMPACT_DEDUCTIONS[degradation.impact] ?? 0) +
318
+ (entry?.kind === "command.failed" ? COMMAND_FAILED_DEDUCTION : 0);
319
+ byRemediationId.set(degradation.remediationId, amount);
320
+ total += amount;
321
+ }
322
+ return { total, byRemediationId };
323
+ }
324
+
325
+ /**
326
+ * Score one ecosystem row. At-ceiling rows score 100 whatever their tier and
327
+ * unsupported rows score nothing at all — they sit outside the mean.
328
+ *
329
+ * @param {Object} row Ecosystem row.
330
+ * @param {Object|null} catalog Remediation catalog keyed by id.
331
+ * @returns {{score: number, base: number, deduction: number, weight: number}|null} Score facts, or null for unsupported rows.
332
+ */
333
+ export function scoreEcosystemRow(row, catalog) {
334
+ if (!row || row.state === "unsupported") {
335
+ return null;
336
+ }
337
+ const weight = rowWeight(row);
338
+ if (row.state === "at-ceiling") {
339
+ return { score: 100, base: 100, deduction: 0, weight };
340
+ }
341
+ const tier = TIER_RANK.has(row.tier) ? row.tier : "absent";
342
+ const base = TIER_BASE_SCORES[tier];
343
+ const deduction = deductionsFor(row, catalog).total;
344
+ const floor = Math.max(0, base - MAX_DEDUCTION_SPAN);
345
+ const score = clamp(base - deduction, floor, 100);
346
+ return { score, base, deduction, weight };
347
+ }
348
+
349
+ /**
350
+ * Score the ecosystem would reach at a target tier once a given remediation's
351
+ * own deduction is gone. Deductions from other sources stay, so the projection
352
+ * stays honest about what one remediation alone can recover.
353
+ *
354
+ * @param {string} targetTier Tier the remediation aims for.
355
+ * @param {number} totalDeduction Current total deduction for the row.
356
+ * @param {number} removedDeduction Deduction attributable to this remediation.
357
+ * @returns {number} Projected score.
358
+ */
359
+ function projectedScore(targetTier, totalDeduction, removedDeduction) {
360
+ const base = TIER_BASE_SCORES[targetTier] ?? TIER_BASE_SCORES.absent;
361
+ const floor = Math.max(0, base - MAX_DEDUCTION_SPAN);
362
+ return clamp(base - (totalDeduction - removedDeduction), floor, 100);
363
+ }
364
+
365
+ /**
366
+ * Expected-version sources that name a file in which the project itself pins
367
+ * the tool. These outrank everything the user or the environment asked for,
368
+ * because a project that pins a version fails against any other one.
369
+ *
370
+ * @type {ReadonlySet<string>}
371
+ */
372
+ const PIN_FILE_SOURCES = new Set([
373
+ ".java-version",
374
+ ".nvmrc",
375
+ ".python-version",
376
+ ".ruby-version",
377
+ ".sdkmanrc",
378
+ ".tool-versions",
379
+ "Gemfile.lock",
380
+ "build-properties",
381
+ "global.json",
382
+ "go.mod:go",
383
+ "go.mod:toolchain",
384
+ "gradle-wrapper.properties",
385
+ "maven-wrapper.properties",
386
+ "package.json",
387
+ "project/build.properties",
388
+ "pyproject.toml:requires-python",
389
+ "rust-toolchain",
390
+ "rust-toolchain.toml",
391
+ "sdkmanrc",
392
+ "wrapper",
393
+ ]);
394
+
395
+ /**
396
+ * Expected-version sources that record the user themselves naming a version:
397
+ * a CLI project-type pin, an environment override, or an environment hint.
398
+ *
399
+ * @type {ReadonlySet<string>}
400
+ */
401
+ const USER_REQUESTED_SOURCES = new Set([
402
+ "cli",
403
+ "env-hint",
404
+ "override",
405
+ "project-type",
406
+ ]);
407
+
408
+ /**
409
+ * Deterministic order of two expected-version candidates of the same band:
410
+ * the recording source first, then the wanted value.
411
+ *
412
+ * @param {Object} a First candidate.
413
+ * @param {Object} b Second candidate.
414
+ * @returns {number} Negative when a sorts before b, positive after, zero on equality.
415
+ */
416
+ function bySourceThenWanted(a, b) {
417
+ return (
418
+ `${a.source || ""}`.localeCompare(`${b.source || ""}`) ||
419
+ `${a.wanted}`.localeCompare(`${b.wanted}`)
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Version facts one catalog action resolves to: the version that fills the
425
+ * placeholders, the cascade band that answered, and the human-readable
426
+ * provenance for it. `version` is absent when no source answered, in which
427
+ * case `versionSourceMissing` is true and the placeholders survive verbatim.
428
+ *
429
+ * @typedef {Object} ResolvedActionVersion
430
+ * @property {string|undefined} version Declared version, when a source answered.
431
+ * @property {"mismatch"|"pin"|"expected"|"unresolved"} versionFrom Cascade band that answered.
432
+ * @property {string|undefined} versionSource Where the version came from.
433
+ * @property {boolean|undefined} versionSourceMissing True when no source answered.
434
+ */
435
+
436
+ /**
437
+ * Resolve the version a row declares for a tool, walking an ordered cascade
438
+ * with the strongest evidence first: a mismatch the build itself reported
439
+ * (it refused the running version and named the one it needs), then the
440
+ * project's own pin files, then a version the user asked for, then any other
441
+ * recorded expectation. The first band with a candidate wins; inside a band
442
+ * candidates sort by source, then wanted value, so the answer never depends
443
+ * on the order events arrived in.
444
+ *
445
+ * @param {Object} row Ecosystem row.
446
+ * @param {string} tool Tool name from a catalog action.
447
+ * @returns {ResolvedActionVersion} Resolved version facts, unresolved when nothing answered.
448
+ */
449
+ function resolveExpectedVersion(row, tool) {
450
+ const mismatched = (
451
+ Array.isArray(row?.toolsMismatched) ? row.toolsMismatched : []
452
+ )
453
+ .filter((entry) => entry?.tool === tool && entry?.wanted)
454
+ .sort(bySourceThenWanted);
455
+ const mismatch = mismatched[0];
456
+ if (mismatch) {
457
+ return {
458
+ version: `${mismatch.wanted}`,
459
+ versionFrom: "mismatch",
460
+ versionSource: mismatch.detail,
461
+ };
462
+ }
463
+ const candidates = (
464
+ Array.isArray(row?.toolsExpected) ? row.toolsExpected : []
465
+ ).filter((entry) => entry?.tool === tool && entry?.wanted);
466
+ const bands = [
467
+ {
468
+ match: (entry) => PIN_FILE_SOURCES.has(`${entry.source}`),
469
+ versionFrom: "pin",
470
+ describe: (source) => `pinned in ${source}`,
471
+ },
472
+ {
473
+ match: (entry) => USER_REQUESTED_SOURCES.has(`${entry.source}`),
474
+ versionFrom: "expected",
475
+ describe: (source) => `requested via ${source}`,
476
+ },
477
+ {
478
+ match: () => true,
479
+ versionFrom: "expected",
480
+ describe: (source) => `declared by ${source}`,
481
+ },
482
+ ];
483
+ for (const band of bands) {
484
+ const [entry] = candidates
485
+ .filter((candidate) => band.match(candidate))
486
+ .sort(bySourceThenWanted);
487
+ if (entry) {
488
+ return {
489
+ version: `${entry.wanted}`,
490
+ versionFrom: band.versionFrom,
491
+ versionSource: band.describe(`${entry.source}`),
492
+ };
493
+ }
494
+ }
495
+ return { versionFrom: "unresolved", versionSourceMissing: true };
496
+ }
497
+
498
+ /**
499
+ * Fill the `{{version}}` and `{{major}}` placeholders of a command from a
500
+ * resolved version, leaving anything else untouched.
501
+ *
502
+ * @param {string} command Command carrying the placeholders.
503
+ * @param {string} version Resolved version.
504
+ * @returns {string} Command with the placeholders substituted.
505
+ */
506
+ function substituteVersionPlaceholders(command, version) {
507
+ const major = version.split(/[.-]/)[0] || "";
508
+ return command
509
+ .split("{{version}}")
510
+ .join(version)
511
+ .split("{{major}}")
512
+ .join(major);
513
+ }
514
+
515
+ /**
516
+ * Whether a command carries a version placeholder the cascade fills, as
517
+ * opposed to a shaping variable the command facts fill.
518
+ *
519
+ * @param {string} command Command to inspect.
520
+ * @returns {boolean} True when `{{version}}` or `{{major}}` is present.
521
+ */
522
+ function hasVersionPlaceholder(command) {
523
+ return command.includes("{{version}}") || command.includes("{{major}}");
524
+ }
525
+
526
+ /**
527
+ * Resolve one action's placeholders in both its POSIX and Windows commands,
528
+ * and record how each was resolved. Version placeholders (`{{version}}`,
529
+ * `{{major}}`) are filled from the row's declared versions; when no source
530
+ * answered they survive verbatim — the agent must be able to show the user
531
+ * the unsubstituted command — and the action says so through
532
+ * `versionFrom: "unresolved"` and `versionSourceMissing: true`. Command
533
+ * variables (`{{mvn}}`, `{{gradle}}`, `{{pythonManager}}`, `{{npmClient}}`)
534
+ * are filled from the run's command facts, with `shapedBy` naming the
535
+ * detection each substitution relied on; an unresolved `{{pythonManager}}`
536
+ * survives verbatim with a note for the entry summary. An action without
537
+ * version placeholders carries no version facts at all, so a shaped build
538
+ * command never reads as a version answer.
539
+ *
540
+ * @param {Object} action Catalog action.
541
+ * @param {Object} row Ecosystem row.
542
+ * @param {Object} [commandFacts] Facts gathered for the scanned project.
543
+ * @returns {{action: Object, shapedNotes: string[]}} Action copy with placeholders resolved where possible, plus the shaping notes for the summary.
544
+ */
545
+ function resolveActionPlaceholders(action, row, commandFacts) {
546
+ const resolved = { ...action };
547
+ const shapedNotes = [];
548
+ const posixHasPlaceholder =
549
+ typeof action?.command === "string" && action.command.includes("{{");
550
+ const windowsHasPlaceholder =
551
+ typeof action?.windows === "string" && action.windows.includes("{{");
552
+ if (!posixHasPlaceholder && !windowsHasPlaceholder) {
553
+ return { action: resolved, shapedNotes };
554
+ }
555
+ const posixCommand =
556
+ typeof action?.command === "string" ? action.command : "";
557
+ const windowsCommand =
558
+ typeof action?.windows === "string" ? action.windows : "";
559
+ const carriesVersion =
560
+ hasVersionPlaceholder(posixCommand) ||
561
+ hasVersionPlaceholder(windowsCommand);
562
+ if (carriesVersion) {
563
+ const { version, versionFrom, versionSource, versionSourceMissing } =
564
+ resolveExpectedVersion(row, action?.tool);
565
+ if (version) {
566
+ if (posixHasPlaceholder) {
567
+ resolved.command = substituteVersionPlaceholders(
568
+ action.command,
569
+ version,
570
+ );
571
+ }
572
+ if (windowsHasPlaceholder) {
573
+ resolved.windows = substituteVersionPlaceholders(
574
+ action.windows,
575
+ version,
576
+ );
577
+ }
578
+ }
579
+ resolved.versionFrom = versionFrom;
580
+ if (versionSource) {
581
+ resolved.versionSource = versionSource;
582
+ }
583
+ if (versionSourceMissing) {
584
+ resolved.versionSourceMissing = true;
585
+ }
586
+ }
587
+ if (posixHasPlaceholder) {
588
+ const shaped = shapeCommand(resolved.command || "", {
589
+ ...commandFacts,
590
+ platform: "posix",
591
+ });
592
+ resolved.command = shaped.command;
593
+ if (shaped.shapedBy) {
594
+ resolved.shapedBy = shaped.shapedBy;
595
+ }
596
+ if (shaped.shapedNote) {
597
+ shapedNotes.push(shaped.shapedNote);
598
+ }
599
+ }
600
+ if (windowsHasPlaceholder && typeof resolved.windows === "string") {
601
+ const shaped = shapeCommand(resolved.windows, {
602
+ ...commandFacts,
603
+ platform: "windows",
604
+ });
605
+ resolved.windows = shaped.command;
606
+ if (shaped.shapedBy) {
607
+ // One detection per action is enough for the reviewer; the POSIX
608
+ // shaping already names the fact both commands relied on.
609
+ resolved.shapedBy ||= shaped.shapedBy;
610
+ }
611
+ if (shaped.shapedNote) {
612
+ shapedNotes.push(shaped.shapedNote);
613
+ }
614
+ }
615
+ return { action: resolved, shapedNotes };
616
+ }
617
+
618
+ /**
619
+ * Provisioners the run recorded as unavailable, so remediations that depend on
620
+ * one can be blocked instead of sending the loop off to retry an impossible
621
+ * fix.
622
+ *
623
+ * @param {Object} reflection Reflection document.
624
+ * @returns {string[]} Provisioner names recorded as unavailable.
625
+ */
626
+ function unavailableProvisionersFromReflection(reflection) {
627
+ const unavailable = new Set();
628
+ const mark = (name) => {
629
+ if (name && PROVISIONER_TOOLS.has(name)) {
630
+ unavailable.add(name);
631
+ }
632
+ };
633
+ for (const row of sortedRows(reflection?.ecosystems)) {
634
+ for (const entry of Array.isArray(row?.toolsMissing)
635
+ ? row.toolsMissing
636
+ : []) {
637
+ mark(entry?.source);
638
+ mark(entry?.tool);
639
+ }
640
+ }
641
+ for (const observation of Array.isArray(reflection?.observations)
642
+ ? reflection.observations
643
+ : []) {
644
+ if (observation && UNAVAILABLE_KINDS.has(observation.kind)) {
645
+ mark(observation.tool);
646
+ }
647
+ }
648
+ return [...unavailable].sort((a, b) => a.localeCompare(b));
649
+ }
650
+
651
+ /**
652
+ * Reasons a remediation cannot run in this environment. Every action must be
653
+ * runnable for the remediation to proceed; a single blocked action blocks the
654
+ * entry while keeping its expected gain visible to human readers.
655
+ *
656
+ * @param {Object[]} actions Resolved catalog actions.
657
+ * @param {Object} runContext Run facts: secureMode, offline, inContainer, unavailableProvisioners.
658
+ * @returns {string[]} Blocked reasons, one per blocked action.
659
+ */
660
+ function blockedReasonsFor(actions, runContext) {
661
+ const reasons = [];
662
+ for (const action of actions) {
663
+ if (
664
+ NETWORK_ACTION_KINDS.has(action?.kind) &&
665
+ (runContext.secureMode || runContext.offline)
666
+ ) {
667
+ reasons.push(
668
+ `the ${action.kind} action needs network access and this run is offline or under a command-execution policy`,
669
+ );
670
+ continue;
671
+ }
672
+ if (
673
+ action?.via &&
674
+ PROVISIONER_TOOLS.has(action.via) &&
675
+ runContext.unavailableProvisioners.includes(action.via)
676
+ ) {
677
+ reasons.push(
678
+ `the ${action.via} provisioner was recorded as unavailable during the run`,
679
+ );
680
+ continue;
681
+ }
682
+ if (action?.kind === "container" && runContext.inContainer) {
683
+ reasons.push(
684
+ "the action proposes a container run while cdxgen itself is already inside a container",
685
+ );
686
+ }
687
+ }
688
+ return reasons;
689
+ }
690
+
691
+ /**
692
+ * Build the remediation entries for one row: catalog entries from the row's
693
+ * corroborated degradations plus one entry per demoting rule finding. Each
694
+ * entry carries the projection arithmetic it was ranked with, so the report
695
+ * can show its work.
696
+ *
697
+ * @param {Object} row Ecosystem row.
698
+ * @param {Object} scored Row score facts from scoreEcosystemRow.
699
+ * @param {Object} deductionSplit Deductions keyed by remediation id.
700
+ * @param {Object} reflection Reflection document.
701
+ * @param {Object|null} catalog Remediation catalog keyed by id.
702
+ * @param {Object} runContext Run facts used for blocked detection.
703
+ * @param {number} weightsTotal Sum of all scored row weights.
704
+ * @param {Object} [commandFacts] Project facts the command variables resolve from.
705
+ * @returns {Object[]} Unranked remediation entries with positive expected gain.
706
+ */
707
+ function buildRemediationsForRow(
708
+ row,
709
+ scored,
710
+ deductionSplit,
711
+ reflection,
712
+ catalog,
713
+ runContext,
714
+ weightsTotal,
715
+ commandFacts,
716
+ ) {
717
+ const confidence = confidenceFor(row, reflection);
718
+ const ceilingRank = TIER_RANK.has(row.ceilingTier)
719
+ ? TIER_RANK.get(row.ceilingTier)
720
+ : TIER_RANK.get("resolved");
721
+ const entries = [];
722
+ // A declared CI command from a foreign BOM's formulation is the only
723
+ // surviving trace of what its build attempted, so a rule-derived entry on
724
+ // the foreign path names it as a hypothesis to check — never as something
725
+ // that ran. A same-run formulation carries commands the ledger did not
726
+ // observe either, and its entries already name what actually ran.
727
+ const foreignFormulation =
728
+ reflection?.formulation?.origin === "foreign"
729
+ ? reflection.formulation
730
+ : undefined;
731
+
732
+ for (const degradation of mergedDegradations(row)) {
733
+ const catalogEntry = catalog?.[degradation.remediationId];
734
+ if (!catalogEntry) {
735
+ continue;
736
+ }
737
+ const requestedTier = TIER_RANK.has(catalogEntry.targetTier)
738
+ ? catalogEntry.targetTier
739
+ : undefined;
740
+ const currentTier = TIER_RANK.has(row.tier) ? row.tier : "absent";
741
+ // A remediation may not promise a tier better than the ecosystem's
742
+ // ceiling: the loop would chase a score the ecosystem can never reach.
743
+ // A catalog entry that names no tier is tier-neutral — a policy or
744
+ // configuration fix that recovers its own deduction without moving the
745
+ // row up the ladder — so it targets the tier the row already holds and
746
+ // earns whatever its deduction was worth.
747
+ const capped =
748
+ requestedTier && TIER_RANK.get(requestedTier) < ceilingRank
749
+ ? TIER_LADDER[ceilingRank]
750
+ : requestedTier;
751
+ const targetTier = capped || currentTier;
752
+ if (capped && TIER_RANK.get(capped) >= TIER_RANK.get(currentTier)) {
753
+ continue;
754
+ }
755
+ const actions = [];
756
+ const shapedNotes = new Set();
757
+ for (const catalogAction of Array.isArray(catalogEntry.actions)
758
+ ? catalogEntry.actions
759
+ : []) {
760
+ const { action, shapedNotes: notes } = resolveActionPlaceholders(
761
+ catalogAction,
762
+ row,
763
+ commandFacts,
764
+ );
765
+ actions.push(action);
766
+ for (const note of notes) {
767
+ shapedNotes.add(note);
768
+ }
769
+ }
770
+ const blockedReasons = blockedReasonsFor(actions, runContext);
771
+ const projected = projectedScore(
772
+ targetTier,
773
+ scored.deduction,
774
+ deductionSplit.get(degradation.remediationId) || 0,
775
+ );
776
+ // The why behind the entry, verbatim from the run: the failed command,
777
+ // its exit status, the diagnosed cause (or the recorded consequence when
778
+ // no cause was diagnosed), and the bounded output excerpt. Fields the run
779
+ // did not record are omitted, never null.
780
+ const evidence = {};
781
+ if (degradation.command) {
782
+ evidence.failedCommand = degradation.command;
783
+ }
784
+ if (typeof degradation.exitCode === "number") {
785
+ evidence.exitCode = degradation.exitCode;
786
+ }
787
+ const cause = degradation.causeDetail || degradation.detail;
788
+ if (cause) {
789
+ evidence.cause = cause;
790
+ }
791
+ if (degradation.outputExcerpt) {
792
+ evidence.outputExcerpt = degradation.outputExcerpt;
793
+ }
794
+ entries.push({
795
+ remediationId: degradation.remediationId,
796
+ source: "ledger",
797
+ ecosystem: row.ecosystem,
798
+ confidence,
799
+ // A shaping question travels on the summary: an entry whose command
800
+ // could not name its tool without guessing says so where the loop
801
+ // reads first, so the agent asks instead of running a guess.
802
+ summary: shapedNotes.size
803
+ ? `${catalogEntry.title} (${[...shapedNotes].sort().join("; ")})`
804
+ : catalogEntry.title,
805
+ actions,
806
+ verify: catalogEntry.verify,
807
+ docs: catalogEntry.docs,
808
+ impact: degradation.impact,
809
+ targetTier,
810
+ currentScore: scored.score,
811
+ projectedScore: projected,
812
+ expectedGain: round2(
813
+ ((projected - scored.score) * scored.weight) / weightsTotal,
814
+ ),
815
+ evidenceCount: degradation.count,
816
+ evidence: Object.keys(evidence).length ? evidence : undefined,
817
+ blocked: blockedReasons.length > 0,
818
+ blockedReason: [...new Set(blockedReasons)].join("; ") || undefined,
819
+ });
820
+ }
821
+
822
+ const seenRuleIds = new Set();
823
+ // Sorting before the dedupe keeps the surviving representative of a
824
+ // repeated rule id independent of the order the findings arrived in.
825
+ const sortedFindings = [
826
+ ...(Array.isArray(row?.findings) ? row.findings : []),
827
+ ].sort(
828
+ (a, b) =>
829
+ `${a?.ruleId || ""}`.localeCompare(`${b?.ruleId || ""}`) ||
830
+ `${a?.message || ""}`.localeCompare(`${b?.message || ""}`),
831
+ );
832
+ for (const finding of sortedFindings) {
833
+ const ruleId = `${finding?.ruleId || ""}`;
834
+ if (!ruleId || seenRuleIds.has(ruleId)) {
835
+ continue;
836
+ }
837
+ seenRuleIds.add(ruleId);
838
+ const signalRank = TIER_RANK.get(finding?.tierSignal);
839
+ if (signalRank === undefined || signalRank === 0) {
840
+ continue;
841
+ }
842
+ // A rule finding demotes the row to its tier signal; resolving it lifts
843
+ // the row one rung, never past the ecosystem's ceiling.
844
+ let targetTier = TIER_LADDER[signalRank - 1];
845
+ if (TIER_RANK.get(targetTier) < ceilingRank) {
846
+ targetTier = TIER_LADDER[ceilingRank];
847
+ }
848
+ if (
849
+ !targetTier ||
850
+ TIER_RANK.get(targetTier) >= TIER_RANK.get(row.tier || "absent")
851
+ ) {
852
+ continue;
853
+ }
854
+ const projected = projectedScore(targetTier, scored.deduction, 0);
855
+ const attempted = foreignFormulation
856
+ ? declaredCommandForEcosystem(foreignFormulation, row.ecosystem)
857
+ : undefined;
858
+ entries.push({
859
+ remediationId: ruleId,
860
+ source: "rule",
861
+ ecosystem: row.ecosystem,
862
+ confidence,
863
+ summary: finding.message,
864
+ guidance: finding.mitigation,
865
+ actions: [],
866
+ verify: { rules: [ruleId], expectTier: targetTier },
867
+ impact: undefined,
868
+ targetTier,
869
+ severity: finding.severity,
870
+ currentScore: scored.score,
871
+ projectedScore: projected,
872
+ expectedGain: round2(
873
+ ((projected - scored.score) * scored.weight) / weightsTotal,
874
+ ),
875
+ evidenceCount: 1,
876
+ evidence: attempted
877
+ ? {
878
+ attemptedCommand: attempted.commandLine,
879
+ attemptedCommandSource: "formulation",
880
+ }
881
+ : undefined,
882
+ blocked: false,
883
+ blockedReason: undefined,
884
+ });
885
+ }
886
+
887
+ // Filtered on the projection rather than the rounded gain: a single-component
888
+ // ecosystem inside a large monorepo can carry a real recovery worth less than
889
+ // 0.01 of the overall score, and dropping it would hide the very ecosystem
890
+ // the weighting already speaks quietly about.
891
+ // Ranked before folding so a subsumed rule lands on the best fix that clears
892
+ // it, which is the one the loop reads first.
893
+ return subsumeRuleEntries(
894
+ rankRemediations(
895
+ entries.filter((entry) => entry.projectedScore > entry.currentScore),
896
+ ),
897
+ );
898
+ }
899
+
900
+ /**
901
+ * Fold rule-derived entries into the ledger entry that already promises to
902
+ * clear them.
903
+ *
904
+ * A catalog entry's `verify.rules` names the findings its fix resolves, so a
905
+ * separate entry for one of those rules is the same repair counted twice: the
906
+ * loop would be told three things need fixing when doing the first makes the
907
+ * other two disappear. The rule ids move onto the surviving entry as
908
+ * `subsumes`, so nothing the run detected goes unreported.
909
+ *
910
+ * @param {Object[]} entries Remediation entries for one ecosystem.
911
+ * @returns {Object[]} Entries with subsumed rule entries folded in.
912
+ */
913
+ function subsumeRuleEntries(entries) {
914
+ const coveredBy = new Map();
915
+ for (const entry of entries) {
916
+ if (entry.source !== "ledger") {
917
+ continue;
918
+ }
919
+ for (const ruleId of Array.isArray(entry.verify?.rules)
920
+ ? entry.verify.rules
921
+ : []) {
922
+ if (!coveredBy.has(ruleId)) {
923
+ coveredBy.set(ruleId, entry);
924
+ }
925
+ }
926
+ }
927
+ if (!coveredBy.size) {
928
+ return entries;
929
+ }
930
+ const kept = [];
931
+ for (const entry of entries) {
932
+ const cover =
933
+ entry.source === "rule" ? coveredBy.get(entry.remediationId) : undefined;
934
+ if (!cover) {
935
+ kept.push(entry);
936
+ continue;
937
+ }
938
+ cover.subsumes = [
939
+ ...new Set([...(cover.subsumes || []), entry.remediationId]),
940
+ ].sort();
941
+ }
942
+ return kept;
943
+ }
944
+
945
+ /**
946
+ * Order remediations for the loop: the largest expected gain first, then the
947
+ * largest per-ecosystem recovery, then the most confident evidence, then the
948
+ * id — a total order, so the sequence is identical no matter how the input
949
+ * findings were ordered. The per-ecosystem recovery separates entries whose
950
+ * weighted gains round to the same hundredth.
951
+ *
952
+ * @param {Object[]} entries Remediation entries.
953
+ * @returns {Object[]} Ranked copy of the entries.
954
+ */
955
+ export function rankRemediations(entries) {
956
+ return [...(entries || [])].sort(
957
+ (a, b) =>
958
+ b.expectedGain - a.expectedGain ||
959
+ b.projectedScore - b.currentScore - (a.projectedScore - a.currentScore) ||
960
+ (SEVERITY_ORDER[b.confidence] ?? -1) -
961
+ (SEVERITY_ORDER[a.confidence] ?? -1) ||
962
+ `${a.remediationId}`.localeCompare(`${b.remediationId}`),
963
+ );
964
+ }
965
+
966
+ /**
967
+ * Score a completed reflection and rank everything an agent could do next.
968
+ *
969
+ * @param {Object} reflection Reflection document from reflectOnRun.
970
+ * @param {Object|null} catalog Remediation catalog keyed by id (data/remediations.json).
971
+ * @param {Object} [runContext] Run facts the reflection cannot carry.
972
+ * @param {boolean} [runContext.secureMode] The run executed under secure mode or a command policy.
973
+ * @param {boolean} [runContext.inContainer] The run executed inside a container.
974
+ * @param {boolean} [runContext.offline] Overrides the offline fact derived from the ledger observations.
975
+ * @param {string[]} [runContext.unavailableProvisioners] Overrides the provisioners derived from the reflection.
976
+ * @param {Object} [runContext.commandFacts] Project facts the command variables (`{{mvn}}`, `{{gradle}}`, `{{pythonManager}}`, `{{npmClient}}`) resolve from; without them the variables fall back to the plain executables.
977
+ * @returns {Object} Scoring document with the overall score, per-ecosystem scores, coverage gaps and the ranked remediation list.
978
+ */
979
+ export function scoreReflection(reflection, catalog, runContext = {}) {
980
+ const rows = sortedRows(reflection?.ecosystems);
981
+ const observations = Array.isArray(reflection?.observations)
982
+ ? reflection.observations
983
+ : [];
984
+ const runContextResolved = {
985
+ secureMode: runContext.secureMode === true,
986
+ inContainer: runContext.inContainer === true,
987
+ offline:
988
+ runContext.offline ??
989
+ observations.some(
990
+ (observation) => observation?.remediationId === OFFLINE_REMEDIATION_ID,
991
+ ),
992
+ unavailableProvisioners:
993
+ runContext.unavailableProvisioners ||
994
+ unavailableProvisionersFromReflection(reflection),
995
+ commandFacts: runContext.commandFacts,
996
+ };
997
+
998
+ const unsupported = [];
999
+ const pending = [];
1000
+ let weightedTotal = 0;
1001
+ let weightsTotal = 0;
1002
+ for (const row of rows) {
1003
+ if (row?.state === "unsupported") {
1004
+ unsupported.push({
1005
+ ecosystem: row.ecosystem,
1006
+ markersOnDisk: Array.isArray(row.markersOnDisk)
1007
+ ? row.markersOnDisk
1008
+ : [],
1009
+ });
1010
+ continue;
1011
+ }
1012
+ const scored = scoreEcosystemRow(row, catalog);
1013
+ if (!scored) {
1014
+ continue;
1015
+ }
1016
+ weightedTotal += scored.score * scored.weight;
1017
+ weightsTotal += scored.weight;
1018
+ pending.push({ row, scored });
1019
+ }
1020
+ // The gain fraction of a remediation is measured against the full scored
1021
+ // set, so the weights must be complete before any entry is built.
1022
+ const safeWeightsTotal = Math.max(weightsTotal, 1);
1023
+ const scoredRows = [];
1024
+ const allRemediations = [];
1025
+ for (const { row, scored } of pending) {
1026
+ const ecosystemEntry = {
1027
+ ecosystem: row.ecosystem,
1028
+ state: row.state,
1029
+ tier: row.tier,
1030
+ ceilingTier: row.ceilingTier,
1031
+ componentCount: row.componentCount || 0,
1032
+ dependencyEdgeCount: row.dependencyEdgeCount || 0,
1033
+ confidence: confidenceFor(row, reflection),
1034
+ weight: scored.weight,
1035
+ base: scored.base,
1036
+ deduction: scored.deduction,
1037
+ score: scored.score,
1038
+ remediations: [],
1039
+ };
1040
+ scoredRows.push(ecosystemEntry);
1041
+ if (row.state === "at-ceiling") {
1042
+ continue;
1043
+ }
1044
+ const { byRemediationId } = deductionsFor(row, catalog);
1045
+ const remediations = buildRemediationsForRow(
1046
+ row,
1047
+ scored,
1048
+ byRemediationId,
1049
+ reflection,
1050
+ catalog,
1051
+ runContextResolved,
1052
+ safeWeightsTotal,
1053
+ runContextResolved.commandFacts,
1054
+ );
1055
+ ecosystemEntry.remediations = rankRemediations(remediations);
1056
+ allRemediations.push(...remediations);
1057
+ }
1058
+
1059
+ return {
1060
+ overallScore:
1061
+ weightsTotal > 0 ? Math.round(weightedTotal / safeWeightsTotal) : 100,
1062
+ weightsTotal,
1063
+ ecosystems: scoredRows,
1064
+ unsupported,
1065
+ remediations: rankRemediations(allRemediations),
1066
+ };
1067
+ }