@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,1208 @@
1
+ /**
2
+ * Renderers over a completed reflection document and its scoring document.
3
+ *
4
+ * Every function here is pure: the reflection and scoring documents are the
5
+ * only evidence inputs, nothing reads the filesystem, the ledger, or the BOM
6
+ * again, and nothing writes a file. The caller owns serialization and printing,
7
+ * which is what makes these renderable against a committed fixture without a
8
+ * temporary directory.
9
+ *
10
+ * The markdown report is written for a human reading it in a terminal pager, a
11
+ * CI log, or a pull-request comment: plain GitHub-flavoured markdown, no HTML,
12
+ * no emoji, and a verdict line that carries the right impression on its own.
13
+ * The JSON report is written for the remediation loop: a versioned contract
14
+ * with stable key order and no content that churns between identical runs, so
15
+ * an agent can diff consecutive reports to detect progress. Free text is
16
+ * redacted again here even though the ledger already sanitized it, because
17
+ * this report is the artifact most likely to be pasted into a chat window.
18
+ */
19
+
20
+ import { redactIntrospectionText } from "../../../core/buildLedger.js";
21
+ import { SEVERITY_ORDER } from "../../../core/severity.js";
22
+ import { TIER_LADDER } from "./score.js";
23
+
24
+ /**
25
+ * Version of the JSON report contract. Additive fields bump the minor version;
26
+ * removals or semantic changes bump the major version.
27
+ *
28
+ * @type {string}
29
+ */
30
+ export const INTROSPECTION_REPORT_SCHEMA_VERSION = "1.1";
31
+
32
+ /** Longest free-text span kept whole inside a markdown table cell before an
33
+ * explicit ellipsis marker is appended. */
34
+ const CELL_LIMIT = 80;
35
+
36
+ /** Longest rule-finding prose kept whole in the evidence section. */
37
+ const PROSE_LIMIT = 200;
38
+
39
+ /** Marker appended by the cell and prose truncators. */
40
+ const ELLIPSIS = "…";
41
+
42
+ /**
43
+ * One-line theme for a tier, phrased as what the reader is missing. Rendered
44
+ * in the verdict line, so a reader who stops there still knows what is wrong.
45
+ *
46
+ * @type {Readonly<Record<string, string>>}
47
+ */
48
+ const TIER_THEMES = Object.freeze({
49
+ lockfile:
50
+ "the SBOM for {ecosystems} captures pinned versions but not a fully resolved dependency graph",
51
+ manifest: "the SBOM is missing transitive dependencies for {ecosystems}",
52
+ heuristic:
53
+ "components for {ecosystems} were inferred from build artifacts rather than a manifest",
54
+ absent: "no components were produced for {ecosystems}",
55
+ });
56
+
57
+ /**
58
+ * Degradation-impact phrase used as the "why" of a ledger-derived remediation.
59
+ *
60
+ * @type {Readonly<Record<string, string>>}
61
+ */
62
+ const IMPACT_WHY = Object.freeze({
63
+ "transitive-deps": "transitive dependency evidence was lost",
64
+ versions: "version precision was lost",
65
+ integrity: "integrity evidence was lost",
66
+ components: "component coverage was lost",
67
+ licenses: "license data was lost",
68
+ none: "evidence was degraded without a measurable component cost",
69
+ });
70
+
71
+ /** Action kinds whose command is a real, copy-pasteable shell command. */
72
+ const COMMAND_KINDS = new Set(["install", "build", "container"]);
73
+
74
+ /**
75
+ * Redact a free-text value before it reaches a report. The ledger already
76
+ * sanitized its own fields; this is the report-side assertion of the same
77
+ * guarantee over every string the renderers emit, through the very same
78
+ * redactor the ledger uses — never a second, weaker one.
79
+ *
80
+ * @param {string|undefined} value Free text of untrusted origin.
81
+ * @returns {string|undefined} Redacted text.
82
+ */
83
+ function redacted(value) {
84
+ return redactIntrospectionText(value);
85
+ }
86
+
87
+ /**
88
+ * Sort a copy of a list by one or more string accessors, so no renderer
89
+ * iterates an array whose order depends on how the input was assembled.
90
+ *
91
+ * @param {Object[]} values Array to sort.
92
+ * @param {((value: Object) => string)[]} accessors Ordered sort keys.
93
+ * @returns {Object[]} Sorted copy.
94
+ */
95
+ function sortedBy(values, ...accessors) {
96
+ return [...(Array.isArray(values) ? values : [])].sort((a, b) => {
97
+ for (const accessor of accessors) {
98
+ const order = `${accessor(a) || ""}`.localeCompare(
99
+ `${accessor(b) || ""}`,
100
+ );
101
+ if (order !== 0) {
102
+ return order;
103
+ }
104
+ }
105
+ return 0;
106
+ });
107
+ }
108
+
109
+ /**
110
+ * The final path segment of a POSIX or Windows path, used to shorten marker
111
+ * paths in report prose where the directory prefix adds nothing.
112
+ *
113
+ * @param {string} value Opaque path.
114
+ * @returns {string} Basename.
115
+ */
116
+ function basenameOf(value) {
117
+ const text = `${value ?? ""}`;
118
+ const slashIndex = text.lastIndexOf("/");
119
+ const backslashIndex = text.lastIndexOf("\\");
120
+ return text.slice(Math.max(slashIndex, backslashIndex) + 1);
121
+ }
122
+
123
+ /**
124
+ * Scored rows in ecosystem order, independent of how the scoring document was
125
+ * assembled.
126
+ *
127
+ * @param {Object} scored Scoring document from scoreReflection.
128
+ * @returns {Object[]} Scored ecosystem rows sorted by name.
129
+ */
130
+ function scoredRows(scored) {
131
+ return sortedBy(scored?.ecosystems, (row) => row?.ecosystem);
132
+ }
133
+
134
+ /**
135
+ * Derive the overall tier and confidence labels from the scored rows.
136
+ *
137
+ * The scoring document publishes a number only. Both labels the reports
138
+ * publish are the *worst* among the scored rows: one unhealthy ecosystem must
139
+ * not disappear behind a healthy average, and no report may claim more
140
+ * confidence than its least-corroborated row. A reflection with no scored
141
+ * rows carries no verdict at all, so both labels are null.
142
+ *
143
+ * @param {Object} scored Scoring document from scoreReflection.
144
+ * @returns {{tier: string|null, confidence: string|null}} Worst tier and confidence among the scored rows.
145
+ */
146
+ export function overallAssessment(scored) {
147
+ let tier = null;
148
+ let tierRank = -1;
149
+ let confidence = null;
150
+ let confidenceRank = Number.POSITIVE_INFINITY;
151
+ for (const row of scoredRows(scored)) {
152
+ const rank = TIER_LADDER.indexOf(row?.tier);
153
+ if (rank > tierRank) {
154
+ tierRank = rank;
155
+ tier = row?.tier;
156
+ }
157
+ const rankOfConfidence = SEVERITY_ORDER[row?.confidence];
158
+ if (
159
+ typeof rankOfConfidence === "number" &&
160
+ rankOfConfidence < confidenceRank
161
+ ) {
162
+ confidenceRank = rankOfConfidence;
163
+ confidence = row?.confidence;
164
+ }
165
+ }
166
+ return { tier, confidence };
167
+ }
168
+
169
+ /**
170
+ * Escape and bound a value for use inside a markdown table cell: pipes and
171
+ * the backslash that escapes them are both escaped so the table cannot break
172
+ * and a Windows path renders as written, newlines collapse, and long values end
173
+ * with an explicit ellipsis marker rather than being silently cut.
174
+ *
175
+ * @param {string|undefined} value Cell text.
176
+ * @param {number} [limit] Length kept whole before truncation.
177
+ * @returns {string} Cell-safe text.
178
+ */
179
+ function mdCell(value, limit = CELL_LIMIT) {
180
+ let text = `${value ?? ""}`.replace(/\r?\n/g, " ").replace(/[\\|]/g, "\\$&");
181
+ if (text.length > limit) {
182
+ text = `${text.slice(0, limit - 1)}${ELLIPSIS}`;
183
+ }
184
+ return text;
185
+ }
186
+
187
+ /**
188
+ * Escape and bound prose for a markdown list item. Unlike table cells the
189
+ * pipe needs no escaping, but long prose is cut at the same explicit marker.
190
+ *
191
+ * @param {string|undefined} value Prose text.
192
+ * @param {number} [limit] Length kept whole before truncation.
193
+ * @returns {string} List-safe text.
194
+ */
195
+ function mdProse(value, limit = PROSE_LIMIT) {
196
+ let text = `${value ?? ""}`.replace(/\r?\n/g, " ");
197
+ if (text.length > limit) {
198
+ text = `${text.slice(0, limit - 1)}${ELLIPSIS}`;
199
+ }
200
+ return redacted(text) ?? "";
201
+ }
202
+
203
+ /**
204
+ * One shell-safe command line for a fenced block: newlines collapse and the
205
+ * value is redacted, but the line is never truncated because a cut command is
206
+ * not copy-pasteable.
207
+ *
208
+ * @param {string|undefined} value Command text.
209
+ * @returns {string} Block-safe command line.
210
+ */
211
+ function codeLine(value) {
212
+ return `${redacted(value) ?? ""}`.replace(/\r?\n/g, " ").trim();
213
+ }
214
+
215
+ /**
216
+ * A fenced code block whose fence is longer than any backtick run inside the
217
+ * content, so build output that itself contains fenced blocks cannot close
218
+ * the fence early. Interior newlines survive; the content is redacted as a
219
+ * whole so a multi-line value is treated exactly as the ledger treated it.
220
+ *
221
+ * @param {string} info Fence info string.
222
+ * @param {string|undefined} content Block content.
223
+ * @returns {string[]} The block's lines.
224
+ */
225
+ function fencedBlock(info, content) {
226
+ const text = `${redacted(content) ?? ""}`;
227
+ let longestRun = 0;
228
+ for (const match of text.matchAll(/`+/g)) {
229
+ longestRun = Math.max(longestRun, match[0].length);
230
+ }
231
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
232
+ return [`${fence}${info}`, ...text.split("\n"), fence];
233
+ }
234
+
235
+ /**
236
+ * The evidence lines of one remediation's failure facts: the failed
237
+ * command with its exit code, the recorded cause, and — for a declared
238
+ * command that arrived with a foreign BOM's formulation — the command named
239
+ * as a hypothesis, in phrasing that can never read as something that ran.
240
+ *
241
+ * @param {Object} entry Remediation entry.
242
+ * @returns {string[]} Evidence lines, empty when the entry carries none.
243
+ */
244
+ function evidenceLines(entry) {
245
+ const evidence = entry?.evidence;
246
+ if (!evidence) {
247
+ return [];
248
+ }
249
+ const lines = [];
250
+ if (evidence.failedCommand) {
251
+ const exitCode =
252
+ typeof evidence.exitCode === "number"
253
+ ? ` (exit code ${evidence.exitCode})`
254
+ : "";
255
+ lines.push(`- Failed: \`${codeLine(evidence.failedCommand)}\`${exitCode}`);
256
+ }
257
+ if (evidence.attemptedCommand) {
258
+ lines.push(
259
+ `- Declared by the BOM's formulation, not observed to have run: \`${codeLine(evidence.attemptedCommand)}\``,
260
+ );
261
+ }
262
+ if (evidence.cause) {
263
+ lines.push(`- Cause: ${mdProse(evidence.cause)}`);
264
+ }
265
+ return lines;
266
+ }
267
+
268
+ /**
269
+ * The output excerpt block of one remediation, rendered below the command
270
+ * blocks: reference material a reader diagnoses from, never a step to run.
271
+ *
272
+ * @param {Object} entry Remediation entry.
273
+ * @returns {string[]} Block lines, empty when the run recorded no excerpt.
274
+ */
275
+ function excerptLines(entry) {
276
+ const excerpt = entry?.evidence?.outputExcerpt;
277
+ if (!excerpt) {
278
+ return [];
279
+ }
280
+ return [
281
+ "Failed command output (bounded to its last 2000 characters, redacted):",
282
+ "",
283
+ ...fencedBlock("text", excerpt),
284
+ "",
285
+ ];
286
+ }
287
+
288
+ /**
289
+ * Rebuild the cdxgen invocation a report describes. The same shape backs the
290
+ * Reproduce section and the re-scan commands proposed to the reader, so every
291
+ * command block in the report can be pasted verbatim.
292
+ *
293
+ * @param {Object} reflection Reflection document.
294
+ * @param {Object} options CLI options.
295
+ * @param {boolean} [options.installDeps] Whether the invocation installs dependencies.
296
+ * @param {string} [options.output] BOM output path.
297
+ * @returns {string} The invocation.
298
+ */
299
+ function cdxgenInvocation(reflection, options) {
300
+ const parts = ["cdxgen"];
301
+ const projectTypes = Array.isArray(reflection?.projectTypes)
302
+ ? reflection.projectTypes
303
+ : [];
304
+ if (projectTypes.length) {
305
+ parts.push("-t", projectTypes.join(","));
306
+ }
307
+ parts.push(
308
+ options?.installDeps === false ? "--no-install-deps" : "--install-deps",
309
+ );
310
+ if (options?.output) {
311
+ parts.push("-o", `${options.output}`);
312
+ }
313
+ if (reflection?.projectPath) {
314
+ parts.push(`${reflection.projectPath}`);
315
+ }
316
+ return parts.join(" ");
317
+ }
318
+
319
+ /**
320
+ * The facts of the scanned BOM the report correlates itself with. Absent
321
+ * facts are dropped rather than emitted as nulls, so consumers can detect
322
+ * what the caller actually knew.
323
+ *
324
+ * @param {Object} reflection Reflection document.
325
+ * @param {Object} options CLI options.
326
+ * @returns {Object|undefined} The bom section.
327
+ */
328
+ function bomSection(reflection, options) {
329
+ const section = {};
330
+ if (reflection?.bom?.serialNumber) {
331
+ section.serialNumber = reflection.bom.serialNumber;
332
+ }
333
+ if (Number.isFinite(reflection?.bom?.componentCount)) {
334
+ section.componentCount = reflection.bom.componentCount;
335
+ }
336
+ if (options?.output) {
337
+ section.path = `${options.output}`;
338
+ }
339
+ return Object.keys(section).length ? section : undefined;
340
+ }
341
+
342
+ /**
343
+ * The CI gate result. The gate exists only when the caller configured a
344
+ * threshold; without one there is nothing to publish.
345
+ *
346
+ * @param {Object} scored Scoring document.
347
+ * @param {Object} options CLI options.
348
+ * @returns {Object|undefined} The gate section.
349
+ */
350
+ function gateSection(scored, options) {
351
+ const threshold = options?.introspectFailBelow;
352
+ if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
353
+ return undefined;
354
+ }
355
+ const score =
356
+ typeof scored?.overallScore === "number" ? scored.overallScore : 100;
357
+ return { threshold, passed: score >= threshold };
358
+ }
359
+
360
+ /**
361
+ * Tool fact entries in a fixed key order with their free-text fields redacted:
362
+ * a wanted or found value is whatever the project's own configuration declared,
363
+ * so it reaches the report through the same guarantee as every other string.
364
+ *
365
+ * @param {Object[]} entries Sorted tool entries.
366
+ * @returns {Object[]} The JSON tool entries.
367
+ */
368
+ function toolEntriesJson(entries) {
369
+ return entries.map((entry) => ({
370
+ tool: entry?.tool,
371
+ source: entry?.source,
372
+ wanted: redacted(entry?.wanted),
373
+ found: redacted(entry?.found),
374
+ path: redacted(entry?.path),
375
+ }));
376
+ }
377
+
378
+ /**
379
+ * One JSON ecosystem row: the scoring facts joined with the reflection row's
380
+ * tier reasons and tool facts, built in a fixed key order so consecutive
381
+ * reports diff cleanly.
382
+ *
383
+ * @param {Object} row Scored ecosystem row.
384
+ * @param {Object|undefined} reflectionRow Matching reflection row.
385
+ * @returns {Object} The JSON ecosystem entry.
386
+ */
387
+ function ecosystemJson(row, reflectionRow) {
388
+ const source = reflectionRow || {};
389
+ return {
390
+ ecosystem: row?.ecosystem,
391
+ state: row?.state,
392
+ tier: row?.tier,
393
+ ceilingTier: row?.ceilingTier,
394
+ score: row?.score,
395
+ confidence: row?.confidence,
396
+ componentCount: row?.componentCount,
397
+ dependencyEdgeCount: row?.dependencyEdgeCount,
398
+ tierReasons: sortedBy(
399
+ source?.tierReasons,
400
+ (reason) => (reason?.determining ? "" : "1"),
401
+ (reason) => reason?.source,
402
+ (reason) => reason?.id,
403
+ ).map((reason) => ({
404
+ source: reason?.source,
405
+ id: reason?.id,
406
+ detail: redacted(reason?.detail),
407
+ determining: reason?.determining === true,
408
+ })),
409
+ tools: {
410
+ expected: toolEntriesJson(
411
+ sortedBy(
412
+ source?.toolsExpected,
413
+ (entry) => entry?.tool,
414
+ (entry) => entry?.source,
415
+ (entry) => entry?.wanted,
416
+ ),
417
+ ),
418
+ resolved: toolEntriesJson(
419
+ sortedBy(
420
+ source?.toolsResolved,
421
+ (entry) => entry?.tool,
422
+ (entry) => entry?.source,
423
+ (entry) => entry?.found,
424
+ ),
425
+ ),
426
+ missing: toolEntriesJson(
427
+ sortedBy(
428
+ source?.toolsMissing,
429
+ (entry) => entry?.tool,
430
+ (entry) => entry?.source,
431
+ (entry) => entry?.wanted,
432
+ ),
433
+ ),
434
+ mismatched: toolEntriesJson(
435
+ sortedBy(
436
+ source?.toolsMismatched,
437
+ (entry) => entry?.tool,
438
+ (entry) => entry?.wanted,
439
+ ),
440
+ ),
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * The evidence block of one JSON remediation record: the failure facts the
447
+ * run recorded, rebuilt in a fixed key order with the free text re-redacted.
448
+ * Absent fields are omitted, never null, and the whole block is omitted when
449
+ * the run recorded no failure facts — rule-derived entries carry none.
450
+ *
451
+ * @param {Object|undefined} evidence Evidence facts from the scoring entry.
452
+ * @returns {Object|undefined} The JSON evidence block.
453
+ */
454
+ function evidenceJson(evidence) {
455
+ if (!evidence || typeof evidence !== "object") {
456
+ return undefined;
457
+ }
458
+ const block = {};
459
+ if (evidence.failedCommand) {
460
+ block.failedCommand = redacted(evidence.failedCommand);
461
+ }
462
+ if (evidence.attemptedCommand) {
463
+ block.attemptedCommand = redacted(evidence.attemptedCommand);
464
+ }
465
+ if (evidence.attemptedCommandSource) {
466
+ block.attemptedCommandSource = evidence.attemptedCommandSource;
467
+ }
468
+ if (typeof evidence.exitCode === "number") {
469
+ block.exitCode = evidence.exitCode;
470
+ }
471
+ if (evidence.cause) {
472
+ block.cause = redacted(evidence.cause);
473
+ }
474
+ if (evidence.outputExcerpt) {
475
+ block.outputExcerpt = redacted(evidence.outputExcerpt);
476
+ }
477
+ return Object.keys(block).length ? block : undefined;
478
+ }
479
+
480
+ /**
481
+ * One JSON remediation record: the scoring document's entry rebuilt in a
482
+ * fixed key order with free text re-redacted.
483
+ *
484
+ * @param {Object} entry Ranked remediation entry.
485
+ * @returns {Object} The JSON remediation record.
486
+ */
487
+ function remediationJson(entry) {
488
+ return {
489
+ remediationId: entry?.remediationId,
490
+ source: entry?.source,
491
+ ecosystem: entry?.ecosystem,
492
+ confidence: entry?.confidence,
493
+ severity: entry?.severity,
494
+ summary: redacted(entry?.summary),
495
+ guidance: entry?.guidance ? redacted(entry.guidance) : undefined,
496
+ impact: entry?.impact,
497
+ targetTier: entry?.targetTier,
498
+ currentScore: entry?.currentScore,
499
+ projectedScore: entry?.projectedScore,
500
+ expectedGain: entry?.expectedGain,
501
+ evidenceCount: entry?.evidenceCount,
502
+ evidence: evidenceJson(entry?.evidence),
503
+ subsumes: Array.isArray(entry?.subsumes) ? entry.subsumes : undefined,
504
+ // Actions keep the catalog's authored sequence — install, then build,
505
+ // then re-run — because the order is the recipe the loop follows.
506
+ actions: (Array.isArray(entry?.actions) ? entry.actions : []).map(
507
+ (action) => ({
508
+ kind: action?.kind,
509
+ tool: action?.tool,
510
+ via: action?.via,
511
+ image: action?.image,
512
+ shapedBy: action?.shapedBy ? redacted(action.shapedBy) : undefined,
513
+ versionFrom: action?.versionFrom,
514
+ versionSource: action?.versionSource
515
+ ? redacted(action.versionSource)
516
+ : undefined,
517
+ versionSourceMissing:
518
+ action?.versionSourceMissing === true ? true : undefined,
519
+ command: redacted(action?.command),
520
+ windows: redacted(action?.windows),
521
+ windowsReason: action?.windowsReason
522
+ ? redacted(action.windowsReason)
523
+ : undefined,
524
+ }),
525
+ ),
526
+ verify: entry?.verify
527
+ ? {
528
+ rules: entry.verify.rules
529
+ ? [...entry.verify.rules].sort()
530
+ : undefined,
531
+ expectTier: entry.verify.expectTier,
532
+ eventsCleared: entry.verify.eventsCleared
533
+ ? [...entry.verify.eventsCleared].sort()
534
+ : undefined,
535
+ }
536
+ : undefined,
537
+ docs: entry?.docs,
538
+ blocked: entry?.blocked === true,
539
+ blockedReason: entry?.blockedReason
540
+ ? redacted(entry.blockedReason)
541
+ : undefined,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Build the JSON report document, the remediation loop's contract.
547
+ *
548
+ * @param {Object} reflection Reflection document from reflectOnRun.
549
+ * @param {Object} scored Scoring document from scoreReflection.
550
+ * @param {Object} [options] CLI options that name the run's outputs.
551
+ * @param {string} [options.output] Path the BOM was written to.
552
+ * @param {number} [options.introspectFailBelow] CI gate threshold, when configured.
553
+ * @returns {Object} The report document; serializing it is the caller's job.
554
+ */
555
+ export function buildIntrospectionJson(reflection, scored, options = {}) {
556
+ const assessment = overallAssessment(scored);
557
+ const rowsByEcosystem = new Map(
558
+ (Array.isArray(reflection?.ecosystems) ? reflection.ecosystems : []).map(
559
+ (row) => [row?.ecosystem, row],
560
+ ),
561
+ );
562
+ return {
563
+ schemaVersion: INTROSPECTION_REPORT_SCHEMA_VERSION,
564
+ runId: reflection?.runId,
565
+ inputsFingerprint: reflection?.inputsFingerprint
566
+ ? `sha256:${reflection.inputsFingerprint}`
567
+ : undefined,
568
+ generatedAt: reflection?.generatedAt,
569
+ cdxgen: {
570
+ version: reflection?.cdxgenVersion,
571
+ runtime: {
572
+ name: reflection?.runtime?.name,
573
+ version: reflection?.runtime?.version,
574
+ },
575
+ },
576
+ bom: bomSection(reflection, options),
577
+ ledger: {
578
+ source: reflection?.ledgerSource,
579
+ complete: reflection?.ledgerComplete !== false,
580
+ eventCount: reflection?.ledgerEventCount ?? 0,
581
+ truncated: reflection?.ledgerComplete === false,
582
+ },
583
+ overall: {
584
+ score: scored?.overallScore,
585
+ tier: assessment.tier,
586
+ confidence: assessment.confidence,
587
+ },
588
+ ecosystems: scoredRows(scored).map((row) =>
589
+ ecosystemJson(row, rowsByEcosystem.get(row?.ecosystem)),
590
+ ),
591
+ coverageGaps: sortedBy(scored?.unsupported, (gap) => gap?.ecosystem).map(
592
+ (gap) => ({
593
+ ecosystem: gap?.ecosystem,
594
+ // Marker basenames only: the report carries no absolute paths beyond
595
+ // the BOM path and the resolved tool paths.
596
+ markers: (Array.isArray(gap?.markersOnDisk)
597
+ ? gap.markersOnDisk
598
+ : []
599
+ ).map((marker) => basenameOf(marker)),
600
+ }),
601
+ ),
602
+ remediation: (Array.isArray(scored?.remediations)
603
+ ? scored.remediations
604
+ : []
605
+ ).map(remediationJson),
606
+ observations: sortedBy(
607
+ reflection?.observations,
608
+ (observation) => observation?.kind,
609
+ (observation) => observation?.ecosystem,
610
+ (observation) => observation?.tool,
611
+ (observation) => observation?.remediationId,
612
+ ).map((observation) => ({
613
+ kind: observation?.kind,
614
+ ecosystem: observation?.ecosystem,
615
+ tool: observation?.tool,
616
+ remediationId: observation?.remediationId,
617
+ impact: observation?.impact,
618
+ detail: redacted(observation?.detail),
619
+ command: redacted(observation?.command),
620
+ exitCode:
621
+ typeof observation?.exitCode === "number"
622
+ ? observation.exitCode
623
+ : undefined,
624
+ causeDetail: redacted(observation?.causeDetail),
625
+ outputExcerpt: redacted(observation?.outputExcerpt),
626
+ })),
627
+ gate: gateSection(scored, options),
628
+ };
629
+ }
630
+
631
+ /**
632
+ * The verdict line: badge (tier and score) plus one sentence carrying the
633
+ * impression a reader must take away even if they stop reading there.
634
+ *
635
+ * At-ceiling rows are successes and never name a theme: a manifest-tier helm
636
+ * chart already holds the best fidelity its ecosystem permits, so accusing it
637
+ * of missing transitive dependencies would misinform the one-line reader.
638
+ * Unsupported-only runs put the coverage gap on cdxgen's side of the ledger.
639
+ *
640
+ * @param {Object} _reflection Reflection document (unused; the verdict reads the scoring).
641
+ * @param {Object} scored Scoring document.
642
+ * @returns {string} The verdict line.
643
+ */
644
+ function verdictLine(_reflection, scored) {
645
+ const { tier } = overallAssessment(scored);
646
+ const rows = scoredRows(scored);
647
+ const score = scored?.overallScore ?? 100;
648
+ const remediations = Array.isArray(scored?.remediations)
649
+ ? scored.remediations
650
+ : [];
651
+ const gaps = Array.isArray(scored?.unsupported) ? scored.unsupported : [];
652
+ const concernRows = rows.filter((row) => row?.state !== "at-ceiling");
653
+ const sentence = (() => {
654
+ if (remediations.length) {
655
+ const worstRank = concernRows.reduce(
656
+ (worst, row) =>
657
+ Math.max(worst, TIER_LADDER.indexOf(row?.tier ?? "absent")),
658
+ -1,
659
+ );
660
+ const names = concernRows
661
+ .filter(
662
+ (row) => TIER_LADDER.indexOf(row?.tier ?? "absent") === worstRank,
663
+ )
664
+ .map((row) => row?.ecosystem)
665
+ .filter(Boolean);
666
+ const theme =
667
+ TIER_THEMES[TIER_LADDER[worstRank]] ||
668
+ `the SBOM fidelity tier for {ecosystems} is ${TIER_LADDER[worstRank] || "unknown"}`;
669
+ return `${theme.replace("{ecosystems}", names.join(", "))}; ${remediations.length} remediation(s) proposed`;
670
+ }
671
+ const absentNames = concernRows
672
+ .filter((row) => row?.state === "absent")
673
+ .map((row) => row?.ecosystem)
674
+ .filter(Boolean);
675
+ if (absentNames.length) {
676
+ return TIER_THEMES.absent.replace("{ecosystems}", absentNames.join(", "));
677
+ }
678
+ if (concernRows.length) {
679
+ return "no remediations are pending; the SBOM reflects everything this environment can resolve";
680
+ }
681
+ const gapNames = gaps.map((gap) => gap?.ecosystem).filter(Boolean);
682
+ if (gapNames.length) {
683
+ return `cdxgen cannot parse ${gapNames.join(", ")} yet — a coverage gap in cdxgen, not a defect in your project`;
684
+ }
685
+ return "every scanned ecosystem is at its best achievable fidelity; nothing needs fixing";
686
+ })();
687
+ const tierLabel = tier ?? "ungraded";
688
+ return `Overall: ${tierLabel} (${score}/100) — ${sentence}.`;
689
+ }
690
+
691
+ /**
692
+ * Warnings about the report itself: the conditions under which its own
693
+ * verdicts deserve suspicion. Rendered above the findings, never at the
694
+ * bottom.
695
+ *
696
+ * @param {Object} reflection Reflection document.
697
+ * @param {Object} scored Scoring document.
698
+ * @returns {string[]} Warning sentences.
699
+ */
700
+ function reportWarnings(reflection, scored) {
701
+ const warnings = [];
702
+ if (reflection?.ledgerSource === "none") {
703
+ warnings.push(
704
+ "No build ledger was available (this BOM was audited without a cdxgen run), so verdicts rest on BOM structure alone.",
705
+ );
706
+ }
707
+ if (
708
+ reflection?.formulation?.origin === "foreign" &&
709
+ Array.isArray(reflection?.formulation?.tools) &&
710
+ reflection.formulation.tools.length
711
+ ) {
712
+ warnings.push(
713
+ "The BOM's formulation records the toolchain and CI commands of the machine that generated it, not this one; remediations derived from it describe that environment and its confidence is capped at medium.",
714
+ );
715
+ }
716
+ if (reflection?.ledgerComplete === false) {
717
+ warnings.push(
718
+ "The build ledger was truncated before the run finished, so findings may be incomplete; re-run with CDXGEN_INTROSPECT_LEDGER set to capture every event.",
719
+ );
720
+ }
721
+ const observations = Array.isArray(reflection?.observations)
722
+ ? reflection.observations
723
+ : [];
724
+ if (
725
+ observations.some(
726
+ (observation) => observation?.kind === "ledger.memory-only",
727
+ )
728
+ ) {
729
+ warnings.push(
730
+ "The ledger was read from this thread's memory, so worker-thread events are missing; set CDXGEN_INTROSPECT_LEDGER to capture them in a sidecar.",
731
+ );
732
+ }
733
+ const lowRows = scoredRows(scored).filter((row) => row?.confidence === "low");
734
+ if (
735
+ lowRows.length &&
736
+ reflection?.ledgerSource !== "none" &&
737
+ reflection?.ledgerComplete !== false
738
+ ) {
739
+ const names = lowRows.map((row) => row?.ecosystem).join(", ");
740
+ warnings.push(
741
+ `The verdict for ${names} rests on marker detection alone (low confidence); a ledger sidecar would corroborate it.`,
742
+ );
743
+ }
744
+ return warnings;
745
+ }
746
+
747
+ /**
748
+ * A rendered markdown table from header and row cells.
749
+ *
750
+ * @param {string[]} header Header cells.
751
+ * @param {string[][]} rows Body rows of cells.
752
+ * @returns {string[]} Table lines.
753
+ */
754
+ function markdownTable(header, rows) {
755
+ return [
756
+ `| ${header.join(" | ")} |`,
757
+ `| ${header.map(() => "---").join(" | ")} |`,
758
+ ...rows.map((cells) => `| ${cells.join(" | ")} |`),
759
+ ];
760
+ }
761
+
762
+ /**
763
+ * The per-ecosystem table: ecosystem, tier, score, components, edges,
764
+ * confidence. At-ceiling rows are successes and read as such.
765
+ *
766
+ * @param {Object[]} rows Scored rows.
767
+ * @returns {string[]} Table lines.
768
+ */
769
+ function ecosystemTable(rows) {
770
+ return markdownTable(
771
+ ["ecosystem", "tier", "score", "components", "edges", "confidence"],
772
+ rows.map((row) => [
773
+ mdCell(row?.ecosystem),
774
+ mdCell(row?.tier),
775
+ mdCell(row?.score),
776
+ mdCell(row?.componentCount),
777
+ mdCell(row?.dependencyEdgeCount),
778
+ mdCell(row?.confidence),
779
+ ]),
780
+ );
781
+ }
782
+
783
+ /**
784
+ * The tool-facts table for one ecosystem: one row per tool joining what was
785
+ * expected, resolved, missing and mismatched during the run.
786
+ *
787
+ * @param {Object} reflectionRow Reflection row.
788
+ * @returns {string[]} Table lines, empty when the run recorded no tool facts.
789
+ */
790
+ function toolFactsTable(reflectionRow) {
791
+ const byTool = new Map();
792
+ const rowFor = (tool) => {
793
+ let row = byTool.get(tool);
794
+ if (!row) {
795
+ row = { expected: [], resolved: [], missing: [], mismatched: [] };
796
+ byTool.set(tool, row);
797
+ }
798
+ return row;
799
+ };
800
+ for (const entry of reflectionRow?.toolsExpected || []) {
801
+ rowFor(entry?.tool).expected.push(`${entry?.wanted} (${entry?.source})`);
802
+ }
803
+ for (const entry of reflectionRow?.toolsResolved || []) {
804
+ rowFor(entry?.tool).resolved.push(
805
+ `${entry?.found || "present"} (${entry?.source})`,
806
+ );
807
+ }
808
+ for (const entry of reflectionRow?.toolsMissing || []) {
809
+ rowFor(entry?.tool).missing.push(`yes (${entry?.source})`);
810
+ }
811
+ for (const entry of reflectionRow?.toolsMismatched || []) {
812
+ rowFor(entry?.tool).mismatched.push(
813
+ `wanted ${entry?.wanted}, found ${entry?.found}`,
814
+ );
815
+ }
816
+ if (!byTool.size) {
817
+ return [];
818
+ }
819
+ return markdownTable(
820
+ ["tool", "expected", "resolved", "missing", "mismatched"],
821
+ sortedBy([...byTool.entries()], (entry) => entry[0]).map(
822
+ ([tool, facts]) => [
823
+ mdCell(tool),
824
+ mdCell(facts.expected.sort().join("; ")),
825
+ mdCell(facts.resolved.sort().join("; ")),
826
+ mdCell(facts.missing.sort().join("; ")),
827
+ mdCell(facts.mismatched.sort().join("; ")),
828
+ ],
829
+ ),
830
+ );
831
+ }
832
+
833
+ /**
834
+ * The verification sentence of a remediation, composed from whatever checks
835
+ * its verify clause carries.
836
+ *
837
+ * @param {Object} verify Verify clause.
838
+ * @returns {string} The sentence, or an empty string when nothing is checkable.
839
+ */
840
+ function verifySentence(verify) {
841
+ const parts = [];
842
+ const rules = Array.isArray(verify?.rules) ? verify.rules : [];
843
+ if (rules.length) {
844
+ parts.push(
845
+ `${rules.map((rule) => `\`${rule}\``).join(", ")} no longer fire${rules.length === 1 ? "s" : ""}`,
846
+ );
847
+ }
848
+ if (verify?.expectTier) {
849
+ parts.push(`the tier reaches \`${verify.expectTier}\``);
850
+ }
851
+ const events = Array.isArray(verify?.eventsCleared)
852
+ ? verify.eventsCleared
853
+ : [];
854
+ if (events.length) {
855
+ parts.push(
856
+ `no further \`${events.join("`, `")}\` event${events.length === 1 ? " is" : "s are"} recorded`,
857
+ );
858
+ }
859
+ if (!parts.length) {
860
+ return "";
861
+ }
862
+ return `Confirm ${parts.join(" and ")}.`;
863
+ }
864
+
865
+ /**
866
+ * The command blocks and notes of one remediation. POSIX and Windows variants
867
+ * live in separate fenced blocks; prose-only actions and the re-run reminder
868
+ * become notes, because pasting prose into a shell helps nobody.
869
+ *
870
+ * @param {Object} entry Remediation entry.
871
+ * @param {Object} reflection Reflection document.
872
+ * @param {Object} options CLI options.
873
+ * @returns {string[]} Block and note lines.
874
+ */
875
+ function commandBlocks(entry, reflection, options) {
876
+ const lines = [];
877
+ const actions = Array.isArray(entry?.actions) ? entry.actions : [];
878
+ const posix = [];
879
+ const windows = [];
880
+ const windowsComments = [];
881
+ const notes = [];
882
+ let hasCommands = false;
883
+ for (const action of actions) {
884
+ // The provenance of a substituted version — and the explicit absence of
885
+ // one — belongs next to the command blocks, or an unsubstituted
886
+ // `{{version}}` reads like a template bug rather than a question to ask.
887
+ if (action?.versionSourceMissing) {
888
+ notes.push(
889
+ `no version for \`${action?.tool}\` was recorded by this run, so \`{{version}}\` is left unsubstituted — ask the user which version to install; never invent one`,
890
+ );
891
+ } else if (action?.versionSource) {
892
+ notes.push(
893
+ `${action?.tool} version ${mdProse(action.versionSource)} (${action.versionFrom})`,
894
+ );
895
+ }
896
+ if (action?.shapedBy) {
897
+ notes.push(
898
+ `the command was shaped for this project (${mdProse(action.shapedBy)})`,
899
+ );
900
+ }
901
+ if (!COMMAND_KINDS.has(action?.kind) || !action?.command) {
902
+ if (action?.kind === "rerun") {
903
+ notes.push(
904
+ "Re-run the cdxgen invocation from the Reproduce section to confirm the fix.",
905
+ );
906
+ } else if (action?.command) {
907
+ notes.push(`${action.kind}: ${mdProse(action.command)}`);
908
+ }
909
+ continue;
910
+ }
911
+ hasCommands = true;
912
+ posix.push(codeLine(action.command));
913
+ if (typeof action.windows === "string") {
914
+ windows.push(codeLine(action.windows));
915
+ } else if (action.windows === null) {
916
+ windowsComments.push(
917
+ `# no Windows equivalent: ${mdProse(action.windowsReason || "unspecified")}`,
918
+ );
919
+ windows.push(null);
920
+ } else {
921
+ windows.push(codeLine(action.command));
922
+ }
923
+ }
924
+ if (!hasCommands && entry?.source === "rule") {
925
+ // A rule-derived remediation has no catalog actions; the report proposes
926
+ // the re-scan in which cdxgen itself drives the ecosystem's resolver, the
927
+ // same fix the rule's mitigation names, so the reader always has a
928
+ // pasteable first step.
929
+ const invocation = cdxgenInvocation(reflection, {
930
+ ...options,
931
+ installDeps: true,
932
+ });
933
+ posix.push(codeLine(invocation));
934
+ windows.push(codeLine(invocation));
935
+ hasCommands = true;
936
+ }
937
+ if (hasCommands) {
938
+ lines.push("POSIX:", "", "```sh", ...posix, "```", "");
939
+ const windowsLines = windows.map((line) =>
940
+ line === null ? windowsComments.shift() : line,
941
+ );
942
+ lines.push("Windows:", "", "```bat", ...windowsLines, "```", "");
943
+ }
944
+ if (notes.length) {
945
+ for (const note of notes) {
946
+ lines.push(`- ${note}`);
947
+ }
948
+ lines.push("");
949
+ }
950
+ return lines;
951
+ }
952
+
953
+ /**
954
+ * One numbered remediation section.
955
+ *
956
+ * @param {number} number Section number in ranked order.
957
+ * @param {Object} entry Remediation entry.
958
+ * @param {Object} reflection Reflection document.
959
+ * @param {Object} options CLI options.
960
+ * @returns {string[]} Section lines.
961
+ */
962
+ function remediationSection(number, entry, reflection, options) {
963
+ const lines = [];
964
+ const why = entry?.guidance
965
+ ? mdProse(entry.guidance)
966
+ : `${
967
+ IMPACT_WHY[entry?.impact] ||
968
+ "recorded events degraded the build evidence"
969
+ } (${entry?.evidenceCount || 1} event${(entry?.evidenceCount || 1) === 1 ? "" : "s"}).`;
970
+ lines.push(
971
+ `### ${number}. ${mdProse(entry?.summary || entry?.remediationId)}`,
972
+ "",
973
+ );
974
+ lines.push(
975
+ `- Remediation: \`${entry?.remediationId}\` (source: ${entry?.source}) — ecosystem: \`${entry?.ecosystem}\`, confidence: ${entry?.confidence}`,
976
+ );
977
+ lines.push(`- Why: ${why}`);
978
+ lines.push(...evidenceLines(entry));
979
+ lines.push(
980
+ `- Score: ${entry?.currentScore} → ${entry?.projectedScore} (tier \`${entry?.targetTier}\`); expected overall gain: +${Number(entry?.expectedGain ?? 0).toFixed(2)}`,
981
+ );
982
+ const subsumes = Array.isArray(entry?.subsumes) ? entry.subsumes : [];
983
+ if (subsumes.length) {
984
+ lines.push(
985
+ `- Also resolves: ${subsumes.map((rule) => `\`${rule}\``).join(", ")}`,
986
+ );
987
+ }
988
+ const verify = verifySentence(entry?.verify);
989
+ if (verify) {
990
+ lines.push(`- ${verify}`);
991
+ }
992
+ if (entry?.docs) {
993
+ lines.push(`- Docs: ${entry.docs}`);
994
+ }
995
+ lines.push("");
996
+ lines.push(...commandBlocks(entry, reflection, options));
997
+ lines.push(...excerptLines(entry));
998
+ return lines;
999
+ }
1000
+
1001
+ /**
1002
+ * Render the markdown report.
1003
+ *
1004
+ * @param {Object} reflection Reflection document from reflectOnRun.
1005
+ * @param {Object} scored Scoring document from scoreReflection.
1006
+ * @param {Object} [options] CLI options that name the run's outputs.
1007
+ * @param {string} [options.output] Path the BOM was written to.
1008
+ * @param {boolean} [options.installDeps] Whether the run installs dependencies.
1009
+ * @returns {string} The complete markdown report.
1010
+ */
1011
+ export function renderIntrospectionMarkdown(reflection, scored, options = {}) {
1012
+ const lines = [];
1013
+ const rows = scoredRows(scored);
1014
+ const remediations = Array.isArray(scored?.remediations)
1015
+ ? scored.remediations
1016
+ : [];
1017
+ const actionable = remediations.filter((entry) => entry?.blocked !== true);
1018
+ const blocked = remediations.filter((entry) => entry?.blocked === true);
1019
+ const gaps = sortedBy(scored?.unsupported, (gap) => gap?.ecosystem);
1020
+ const reflectionRows = new Map(
1021
+ (Array.isArray(reflection?.ecosystems) ? reflection.ecosystems : []).map(
1022
+ (row) => [row?.ecosystem, row],
1023
+ ),
1024
+ );
1025
+
1026
+ lines.push("# cdxgen build introspection", "");
1027
+ lines.push(verdictLine(reflection, scored), "");
1028
+
1029
+ const warnings = reportWarnings(reflection, scored);
1030
+ if (warnings.length) {
1031
+ lines.push("## Warnings about this report", "");
1032
+ for (const warning of warnings) {
1033
+ lines.push(`- ${warning}`);
1034
+ }
1035
+ lines.push("");
1036
+ }
1037
+
1038
+ if (rows.length) {
1039
+ lines.push("## Ecosystems", "");
1040
+ lines.push(...ecosystemTable(rows), "");
1041
+ }
1042
+
1043
+ if (gaps.length) {
1044
+ lines.push("## cdxgen coverage gaps", "");
1045
+ lines.push(
1046
+ "The following ecosystems have markers on disk but no cdxgen project type yet, so nothing was parsed. This is a gap in cdxgen's ecosystem coverage, not a problem with your project; it belongs on cdxgen's backlog.",
1047
+ "",
1048
+ );
1049
+ for (const gap of gaps) {
1050
+ const markers = (
1051
+ Array.isArray(gap?.markersOnDisk) ? gap.markersOnDisk : []
1052
+ )
1053
+ .map((marker) => `\`${mdCell(basenameOf(marker))}\``)
1054
+ .join(", ");
1055
+ lines.push(
1056
+ `- \`${mdCell(gap?.ecosystem)}\`${markers ? ` — markers: ${markers}` : ""}`,
1057
+ );
1058
+ }
1059
+ lines.push("");
1060
+ }
1061
+
1062
+ if (actionable.length) {
1063
+ lines.push("## What to fix", "");
1064
+ lines.push("Ranked by expected score gain.", "");
1065
+ for (const [index, entry] of actionable.entries()) {
1066
+ lines.push(...remediationSection(index + 1, entry, reflection, options));
1067
+ }
1068
+ }
1069
+
1070
+ if (blocked.length) {
1071
+ lines.push("## Cannot be fixed from this environment", "");
1072
+ lines.push(
1073
+ "These remediations would help, but a constraint of this run blocks them. Lift the constraint and they become actionable.",
1074
+ "",
1075
+ );
1076
+ for (const [index, entry] of blocked.entries()) {
1077
+ lines.push(
1078
+ ...remediationSection(
1079
+ actionable.length + index + 1,
1080
+ entry,
1081
+ reflection,
1082
+ options,
1083
+ ),
1084
+ );
1085
+ lines.push(
1086
+ `- Blocked: ${mdProse(entry?.blockedReason || "reason not recorded")}`,
1087
+ "",
1088
+ );
1089
+ }
1090
+ }
1091
+
1092
+ const evidenceRows = rows.filter((row) => reflectionRows.get(row?.ecosystem));
1093
+ if (evidenceRows.length || (reflection?.globalFindings || []).length) {
1094
+ lines.push("## Evidence", "");
1095
+ for (const row of evidenceRows) {
1096
+ const reflectionRow = reflectionRows.get(row?.ecosystem);
1097
+ lines.push(`### ${mdCell(row?.ecosystem)}`, "");
1098
+ const toolLines = toolFactsTable(reflectionRow);
1099
+ if (toolLines.length) {
1100
+ lines.push("Tools:", "", ...toolLines, "");
1101
+ }
1102
+ const findings = sortedBy(
1103
+ reflectionRow?.findings,
1104
+ (finding) => finding?.ruleId,
1105
+ (finding) => finding?.message,
1106
+ );
1107
+ if (findings.length) {
1108
+ if (row?.state === "at-ceiling") {
1109
+ lines.push(
1110
+ "Rule findings (informational — this ecosystem already parses at its best achievable fidelity, so no action is proposed):",
1111
+ "",
1112
+ );
1113
+ } else {
1114
+ lines.push("Rule findings:", "");
1115
+ }
1116
+ for (const finding of findings) {
1117
+ lines.push(
1118
+ `- **${mdCell(finding?.ruleId)}** (${mdCell(finding?.severity)}): ${mdProse(finding?.message)}`,
1119
+ );
1120
+ }
1121
+ lines.push("");
1122
+ }
1123
+ }
1124
+ const globalFindings = sortedBy(
1125
+ reflection?.globalFindings,
1126
+ (finding) => finding?.ruleId,
1127
+ (finding) => finding?.message,
1128
+ );
1129
+ if (globalFindings.length) {
1130
+ lines.push("### global", "");
1131
+ lines.push("Findings that could not be attributed to one ecosystem:", "");
1132
+ for (const finding of globalFindings) {
1133
+ lines.push(
1134
+ `- **${mdCell(finding?.ruleId)}** (${mdCell(finding?.severity)}): ${mdProse(finding?.message)}`,
1135
+ );
1136
+ }
1137
+ lines.push("");
1138
+ }
1139
+ }
1140
+
1141
+ lines.push("## Reproduce", "");
1142
+ lines.push(
1143
+ "```sh",
1144
+ codeLine(cdxgenInvocation(reflection, options)),
1145
+ "```",
1146
+ "",
1147
+ );
1148
+ const facts = [];
1149
+ if (reflection?.cdxgenVersion) {
1150
+ facts.push(
1151
+ `cdxgen version: \`${reflection.cdxgenVersion}\`; runtime: ${reflection?.runtime?.name || "unknown"} ${reflection?.runtime?.version || ""}`.trimEnd(),
1152
+ );
1153
+ }
1154
+ if (reflection?.inputsFingerprint) {
1155
+ facts.push(
1156
+ `Inputs fingerprint: \`sha256:${reflection.inputsFingerprint}\``,
1157
+ );
1158
+ }
1159
+ for (const fact of facts) {
1160
+ lines.push(`- ${fact}`);
1161
+ }
1162
+ lines.push("");
1163
+ return lines.join("\n");
1164
+ }
1165
+
1166
+ /**
1167
+ * Render the console summary: a handful of diagnostic lines carrying the
1168
+ * verdict, the remediation counts, and the paths the reports were written
1169
+ * to. The full report never goes to the console.
1170
+ *
1171
+ * @param {Object} scored Scoring document from scoreReflection.
1172
+ * @param {Object} [options] CLI options naming the report outputs.
1173
+ * @param {string} [options.introspectReport] Path of the markdown report.
1174
+ * @param {string} [options.introspectJson] Path of the JSON report.
1175
+ * @returns {string} Newline-terminated lines for the diagnostic stream.
1176
+ */
1177
+ export function renderIntrospectionConsole(scored, options = {}) {
1178
+ const { tier, confidence } = overallAssessment(scored);
1179
+ const remediations = Array.isArray(scored?.remediations)
1180
+ ? scored.remediations
1181
+ : [];
1182
+ const blocked = remediations.filter(
1183
+ (entry) => entry?.blocked === true,
1184
+ ).length;
1185
+ const gaps = Array.isArray(scored?.unsupported)
1186
+ ? scored.unsupported.length
1187
+ : 0;
1188
+ const lines = [
1189
+ `Build introspection: overall ${tier ?? "ungraded"} (${scored?.overallScore ?? 100}/100), confidence ${confidence ?? "n/a"}`,
1190
+ ];
1191
+ const counts = [`${remediations.length} remediation(s) ranked`];
1192
+ if (blocked) {
1193
+ counts.push(`${blocked} blocked`);
1194
+ }
1195
+ if (gaps) {
1196
+ counts.push(`${gaps} coverage gap(s)`);
1197
+ }
1198
+ lines.push(`Build introspection: ${counts.join(", ")}`);
1199
+ if (options?.introspectReport) {
1200
+ lines.push(
1201
+ `Build introspection: markdown report: ${options.introspectReport}`,
1202
+ );
1203
+ }
1204
+ if (options?.introspectJson) {
1205
+ lines.push(`Build introspection: json report: ${options.introspectJson}`);
1206
+ }
1207
+ return `${lines.join("\n")}\n`;
1208
+ }