@cdxgen/cdxgen 13.0.0 → 13.1.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 (170) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +140 -8
  4. package/bin/convert.js +7 -0
  5. package/bin/evinse.js +1 -1
  6. package/bin/hbom.js +13 -4
  7. package/bin/tracebom.js +9 -1
  8. package/data/component-tags.json +8 -1
  9. package/data/frameworks-list.json +86 -1
  10. package/data/remediations.json +1144 -0
  11. package/data/rules/asar-archives.yaml +1 -1
  12. package/data/rules/build-fidelity.yaml +782 -0
  13. package/data/rules/package-integrity.yaml +13 -13
  14. package/lib/audit/index.js +48 -4
  15. package/lib/audit/scoring.js +2 -8
  16. package/lib/cli/bomAssembly.js +83 -15
  17. package/lib/cli/cliOptions.js +124 -7
  18. package/lib/cli/index.js +76 -48
  19. package/lib/cli/jsBom.js +32 -3
  20. package/lib/cli/jvmBom.js +1494 -1315
  21. package/lib/cli/managedBom.js +97 -28
  22. package/lib/cli/nativeBom.js +189 -10
  23. package/lib/core/buildLedger.js +799 -0
  24. package/lib/core/deferredExit.js +141 -0
  25. package/lib/core/env.js +69 -1
  26. package/lib/core/fs.js +60 -0
  27. package/lib/core/logger.js +76 -25
  28. package/lib/core/severity.js +16 -0
  29. package/lib/core/syncFileWriter.js +106 -0
  30. package/lib/core/ui.js +46 -2
  31. package/lib/ecosystems/ecosystems.js +9 -0
  32. package/lib/ecosystems/gradleutils.js +355 -15
  33. package/lib/ecosystems/parsers-js.js +26 -0
  34. package/lib/ecosystems/parsers-jvm.js +9 -0
  35. package/lib/ecosystems/parsers-misc.js +16 -2
  36. package/lib/ecosystems/parsers-python.js +171 -35
  37. package/lib/ecosystems/parsers-rust.js +12 -2
  38. package/lib/ecosystems/pypiClassifiers.js +326 -0
  39. package/lib/ecosystems/pythonutils.js +49 -1
  40. package/lib/ecosystems/rubyutils.js +13 -0
  41. package/lib/ecosystems/utils.js +9 -1
  42. package/lib/evinser/evinser.js +30 -12
  43. package/lib/helpers/remote/dependency-track.js +81 -14
  44. package/lib/inventory/auditCategories.js +9 -0
  45. package/lib/inventory/depsUtils.js +72 -0
  46. package/lib/inventory/dosai.js +431 -22
  47. package/lib/inventory/envcontext.js +211 -19
  48. package/lib/inventory/fetchBatch.js +57 -0
  49. package/lib/inventory/jvmToolEnv.js +593 -0
  50. package/lib/inventory/osinfo.js +123 -16
  51. package/lib/inventory/table.js +9 -5
  52. package/lib/inventory/toolRequirements.js +903 -0
  53. package/lib/managers/binary.js +216 -40
  54. package/lib/managers/docker.js +26 -5
  55. package/lib/server/server.js +1 -1
  56. package/lib/stages/postgen/annotationBuilder.js +97 -0
  57. package/lib/stages/postgen/auditBom.js +76 -79
  58. package/lib/stages/postgen/introspection/annotate.js +320 -0
  59. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  60. package/lib/stages/postgen/introspection/emit.js +192 -0
  61. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  62. package/lib/stages/postgen/introspection/markers.js +227 -0
  63. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  64. package/lib/stages/postgen/introspection/report.js +1208 -0
  65. package/lib/stages/postgen/introspection/score.js +1067 -0
  66. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  67. package/lib/stages/postgen/parentEdge.js +156 -0
  68. package/lib/stages/postgen/postgen.js +115 -2
  69. package/lib/stages/postgen/ruleEngine.js +17 -0
  70. package/lib/stages/pregen/envAudit.js +42 -2
  71. package/lib/stages/pregen/pregen.js +1171 -6
  72. package/lib/validator/bomValidator.js +11 -2
  73. package/package.json +14 -14
  74. package/types/lib/audit/index.d.ts.map +1 -1
  75. package/types/lib/audit/scoring.d.ts +2 -8
  76. package/types/lib/audit/scoring.d.ts.map +1 -1
  77. package/types/lib/cli/bomAssembly.d.ts +14 -0
  78. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  79. package/types/lib/cli/cliOptions.d.ts +2 -1
  80. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  81. package/types/lib/cli/index.d.ts +8 -0
  82. package/types/lib/cli/index.d.ts.map +1 -1
  83. package/types/lib/cli/jsBom.d.ts.map +1 -1
  84. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  85. package/types/lib/cli/managedBom.d.ts.map +1 -1
  86. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  87. package/types/lib/core/buildLedger.d.ts +272 -0
  88. package/types/lib/core/buildLedger.d.ts.map +1 -0
  89. package/types/lib/core/deferredExit.d.ts +84 -0
  90. package/types/lib/core/deferredExit.d.ts.map +1 -0
  91. package/types/lib/core/env.d.ts +22 -1
  92. package/types/lib/core/env.d.ts.map +1 -1
  93. package/types/lib/core/fs.d.ts +21 -0
  94. package/types/lib/core/fs.d.ts.map +1 -1
  95. package/types/lib/core/logger.d.ts +14 -1
  96. package/types/lib/core/logger.d.ts.map +1 -1
  97. package/types/lib/core/severity.d.ts +11 -0
  98. package/types/lib/core/severity.d.ts.map +1 -0
  99. package/types/lib/core/syncFileWriter.d.ts +33 -0
  100. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  101. package/types/lib/core/ui.d.ts +1 -1
  102. package/types/lib/core/ui.d.ts.map +1 -1
  103. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  104. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  105. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  109. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  110. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  111. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  112. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  113. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  114. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  115. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  116. package/types/lib/ecosystems/utils.d.ts +4 -4
  117. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  118. package/types/lib/evinser/evinser.d.ts +5 -1
  119. package/types/lib/evinser/evinser.d.ts.map +1 -1
  120. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  121. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  122. package/types/lib/inventory/auditCategories.d.ts +8 -0
  123. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  125. package/types/lib/inventory/dosai.d.ts +20 -0
  126. package/types/lib/inventory/dosai.d.ts.map +1 -1
  127. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  128. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  129. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  130. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  131. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  132. package/types/lib/inventory/osinfo.d.ts +83 -2
  133. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  134. package/types/lib/inventory/table.d.ts +4 -1
  135. package/types/lib/inventory/table.d.ts.map +1 -1
  136. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  137. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  138. package/types/lib/managers/binary.d.ts +71 -0
  139. package/types/lib/managers/binary.d.ts.map +1 -1
  140. package/types/lib/managers/docker.d.ts.map +1 -1
  141. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  142. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  144. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  145. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  146. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  147. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  148. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  149. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  150. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  151. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  152. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  153. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  154. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  155. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  156. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  157. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  158. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  159. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  160. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  161. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  162. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  163. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  164. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  165. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  166. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  167. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  168. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  169. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  170. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,1214 @@
1
+ /**
2
+ * Reflection: join what cdxgen did (the build ledger) with what it produced
3
+ * (the BOM, through the build-fidelity rule pack) and commit to a per-
4
+ * ecosystem fidelity verdict.
5
+ *
6
+ * The two evidence sources are kept independent on purpose. A finding backed
7
+ * by both the ledger and a BOM-side rule is high confidence; either alone is
8
+ * lower confidence. A ledger event with no BOM-side corroboration is reported
9
+ * as an observation, never as a defect: an event says the run degraded
10
+ * somewhere, but only the BOM shows whether the output paid for it. The
11
+ * rule-only path is a first-class path — reflecting over a foreign BOM with
12
+ * no ledger at all still produces a complete verdict.
13
+ *
14
+ * Every ecosystem present in the scan is assigned exactly one tier from the
15
+ * ladder `resolved > lockfile > manifest > heuristic > absent`, or excluded
16
+ * from scoring entirely as `unsupported` when its markers name an ecosystem
17
+ * cdxgen has no project type for. Ties break toward the worse tier: a report
18
+ * that overstates fidelity stops the remediation loop early, while one that
19
+ * understates it merely costs an extra iteration.
20
+ */
21
+
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { basename, join } from "node:path";
24
+
25
+ import { Purl } from "@cdxgen/cdx-purl";
26
+
27
+ import { readEnvironmentVariable } from "../../../core/activity.js";
28
+ import {
29
+ getLedgerEvents,
30
+ LEDGER_EVENT_IMPACTS,
31
+ LEDGER_EVENT_KINDS,
32
+ LEDGER_TOOL_ECOSYSTEM,
33
+ loadLedgerFile,
34
+ } from "../../../core/buildLedger.js";
35
+ import {
36
+ isVersionedJvmToolProjectType,
37
+ PROJECT_TYPE_ALIASES,
38
+ } from "../../../core/env.js";
39
+ import { dirNameStr } from "../../../core/paths.js";
40
+ import { CDXGEN_VERSION } from "../../../core/state.js";
41
+ import { evaluateRules, loadRules } from "../ruleEngine.js";
42
+ import {
43
+ findRuntimeToolMismatch,
44
+ readFormulationEvidence,
45
+ TOOL_NAME_ECOSYSTEMS,
46
+ } from "./formulationEvidence.js";
47
+ import { detectEcosystemMarkers, MARKERS_BY_NAME } from "./markers.js";
48
+
49
+ /**
50
+ * The fidelity tier ladder, from healthiest to worst.
51
+ *
52
+ * @type {string[]}
53
+ */
54
+ export const FIDELITY_TIERS = [
55
+ "resolved",
56
+ "lockfile",
57
+ "manifest",
58
+ "heuristic",
59
+ "absent",
60
+ ];
61
+
62
+ /** Ladder position of each tier; higher is worse. */
63
+ const TIER_SEVERITY = new Map([
64
+ ["resolved", 0],
65
+ ["lockfile", 1],
66
+ ["manifest", 2],
67
+ ["heuristic", 3],
68
+ ["absent", 4],
69
+ ]);
70
+
71
+ /**
72
+ * Ecosystem states. `graded` rows carry a tier below their ceiling and are
73
+ * the remediation loop's business; `at-ceiling` rows already parse as
74
+ * completely as their ecosystem permits; `absent` rows produced zero
75
+ * components for a supported ecosystem and are real failures; `unsupported`
76
+ * rows are coverage gaps in cdxgen itself, excluded from the score.
77
+ *
78
+ * @type {string[]}
79
+ */
80
+ export const ECOSYSTEM_STATES = [
81
+ "graded",
82
+ "at-ceiling",
83
+ "absent",
84
+ "unsupported",
85
+ ];
86
+
87
+ /**
88
+ * Ecosystems whose best achievable tier is below `resolved`, because the
89
+ * ecosystem has no resolved graph for cdxgen to read. Any ecosystem not
90
+ * listed here defaults to a ceiling of `resolved`, so a newly supported
91
+ * ecosystem fails safe by demanding the most. Every entry below is backed by
92
+ * a measurement recorded in the deliverable handoff; none is added from
93
+ * reading a table.
94
+ *
95
+ * @type {Readonly<Record<string, string>>}
96
+ */
97
+ export const ECOSYSTEM_CEILINGS = Object.freeze({
98
+ // Helm charts carry pinned dependencies in Chart.yaml with no resolver to
99
+ // run; measured 52 components / 0 dependency nodes on helm-charts.
100
+ helm: "manifest",
101
+ // deps.edn and project.clj pin exact versions; measured 23 components /
102
+ // 0 dependency nodes on babashka.
103
+ clojure: "manifest",
104
+ // cdxgen parses Package.resolved but never builds a Swift dependency
105
+ // graph; measured 3 components / 0 dependency nodes with a byte-identical
106
+ // BOM whether or not the swift toolchain was available.
107
+ swift: "lockfile",
108
+ // pubspec.lock records only a direct/transitive classification per package
109
+ // and no cdxgen code path invokes a dart toolchain, so no environment can
110
+ // add the missing graph; measured 344 components / 1 dependency node on
111
+ // spotube, the parser's maximum for the format.
112
+ dart: "manifest",
113
+ // The npm, rust and python parsers derive every BOM they emit from a
114
+ // lockfile or an equivalent pinned source, so the components always carry
115
+ // the lockfile-only evidence the checks below key on - npm integrity
116
+ // strings, Cargo.lock checksums, cdx:pypi provenance properties - whether
117
+ // or not the dependencies were installed. `resolved` is therefore not a
118
+ // state any environment can produce evidence for in these ecosystems; the
119
+ // graph itself is still guarded by the build-fidelity rules, which demote
120
+ // to `manifest` the moment coverage drops.
121
+ npm: "lockfile",
122
+ python: "lockfile",
123
+ rust: "lockfile",
124
+ });
125
+
126
+ /**
127
+ * Canonical ecosystem names used by the ledger's producers and by this
128
+ * module's rows, mapped from the PROJECT_TYPE_ALIASES keys that describe the
129
+ * same ecosystem. Ecosystems absent from this map (containers, osquery,
130
+ * universal and similar non-package types) never get a fidelity row.
131
+ *
132
+ * @type {Readonly<Record<string, string>>}
133
+ */
134
+ const ECOSYSTEM_ALIAS_KEYS = Object.freeze({
135
+ java: "java",
136
+ js: "npm",
137
+ py: "python",
138
+ go: "go",
139
+ rust: "rust",
140
+ php: "php",
141
+ ruby: "ruby",
142
+ csharp: "csharp",
143
+ dart: "dart",
144
+ helm: "helm",
145
+ clojure: "clojure",
146
+ swift: "swift",
147
+ cocoa: "cocoa",
148
+ elixir: "elixir",
149
+ c: "c",
150
+ haskell: "haskell",
151
+ });
152
+
153
+ /** Alias or project type → canonical ecosystem. The first assignment of an
154
+ * alias wins, so an alias claimed by two ecosystems (e.g. `swift` under both
155
+ * swift and cocoa) keeps the more specific ecosystem. */
156
+ const ALIAS_TO_ECOSYSTEM = new Map();
157
+ for (const [key, canonical] of Object.entries(ECOSYSTEM_ALIAS_KEYS)) {
158
+ for (const alias of [key, ...(PROJECT_TYPE_ALIASES[key] || [])]) {
159
+ const normalizedAlias = alias.toLowerCase();
160
+ if (!ALIAS_TO_ECOSYSTEM.has(normalizedAlias)) {
161
+ ALIAS_TO_ECOSYSTEM.set(normalizedAlias, canonical);
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Every project type cdxgen recognizes, keys and aliases alike. An ecosystem
168
+ * outside {@link ECOSYSTEM_ALIAS_KEYS} but present here — containers, os,
169
+ * universal, github, binary — is supported and simply has no package-fidelity
170
+ * ladder, so it gets no row at all. Only a name cdxgen cannot dispatch on is
171
+ * a coverage gap.
172
+ *
173
+ * @type {Set<string>}
174
+ */
175
+ const KNOWN_PROJECT_TYPES = new Set();
176
+ for (const [key, aliases] of Object.entries(PROJECT_TYPE_ALIASES)) {
177
+ KNOWN_PROJECT_TYPES.add(key.toLowerCase());
178
+ for (const alias of aliases) {
179
+ KNOWN_PROJECT_TYPES.add(alias.toLowerCase());
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Purl type → canonical ecosystem for every type the build-fidelity pack and
185
+ * the ledger's producers name. Components with other purl types never form a
186
+ * fidelity row.
187
+ *
188
+ * @type {Readonly<Record<string, string>>}
189
+ */
190
+ const PURL_TYPE_ECOSYSTEMS = Object.freeze({
191
+ maven: "java",
192
+ npm: "npm",
193
+ pypi: "python",
194
+ golang: "go",
195
+ cargo: "rust",
196
+ gem: "ruby",
197
+ nuget: "csharp",
198
+ composer: "php",
199
+ pub: "dart",
200
+ helm: "helm",
201
+ clojars: "clojure",
202
+ swift: "swift",
203
+ cocoapods: "cocoa",
204
+ hex: "elixir",
205
+ conan: "c",
206
+ hackage: "haskell",
207
+ });
208
+
209
+ /** Canonical ecosystem → the purl types that belong to it. */
210
+ const ECOSYSTEM_PURL_TYPES = new Map();
211
+ for (const [purlType, ecosystem] of Object.entries(PURL_TYPE_ECOSYSTEMS)) {
212
+ const types = ECOSYSTEM_PURL_TYPES.get(ecosystem) || [];
213
+ types.push(purlType);
214
+ ECOSYSTEM_PURL_TYPES.set(ecosystem, types);
215
+ }
216
+
217
+ /**
218
+ * Ledger event kinds whose event says the run degraded. `tool.missing` and
219
+ * `tool.mismatch` are excluded: they are tool facts first and live in the
220
+ * row's toolsMissing/toolsMismatched arrays.
221
+ *
222
+ * @type {Set<string>}
223
+ */
224
+ const DEGRADATION_KINDS = new Set([
225
+ LEDGER_EVENT_KINDS.FALLBACK_ENGAGED,
226
+ LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED,
227
+ LEDGER_EVENT_KINDS.COMMAND_FAILED,
228
+ ]);
229
+
230
+ /**
231
+ * Per-ecosystem checks for lockfile evidence: BOM features that only a
232
+ * lockfile parse produces. The tier only reaches `lockfile` when no demoting
233
+ * rule or event fired, so a lockfile marker on an unhealthy BOM never hides
234
+ * a demotion. For the ecosystems whose ceiling is `lockfile` this evidence is
235
+ * what carries a healthy row onto its ceiling.
236
+ *
237
+ * @type {Readonly<Record<string, (bomJson: Object, facts: Object) => boolean>>}
238
+ */
239
+ const LOCKFILE_EVIDENCE_CHECKS = Object.freeze({
240
+ // npm lockfiles are the only npm source that carries integrity strings.
241
+ npm: (_bomJson, facts) => facts.hashesByEcosystem.get("npm") === true,
242
+ // Cargo.lock checksums are the only cargo source that carries hashes.
243
+ rust: (_bomJson, facts) => facts.hashesByEcosystem.get("rust") === true,
244
+ // Python parses record per-package provenance properties only when a
245
+ // requirements file or a lockfile was read.
246
+ python: (_bomJson, facts) =>
247
+ facts.provenanceByEcosystem.get("python") === true,
248
+ });
249
+
250
+ /** Degradation kinds eligible to become remediation entries, joined with the
251
+ * rule findings that corroborate them. */
252
+ const REMEDIABLE_KINDS = DEGRADATION_KINDS;
253
+
254
+ /** Fidelity rules from data/rules/build-fidelity.yaml, loaded once. */
255
+ let fidelityRulesPromise;
256
+
257
+ /**
258
+ * True when introspection is enabled for a run: the CLI option is set or the
259
+ * documented environment opt-in is present. Everything in this module sits
260
+ * behind this check, so a disabled run pays one boolean test.
261
+ *
262
+ * @param {Object} options CLI options.
263
+ * @returns {boolean} True when the reflection should run.
264
+ */
265
+ export { isIntrospectionEnabled } from "../../../core/buildLedger.js";
266
+
267
+ /**
268
+ * Resolve the fidelity rule pack, loading it once per process.
269
+ *
270
+ * @returns {Promise<Object[]>} Rules in the build-fidelity category.
271
+ */
272
+ async function getFidelityRules() {
273
+ if (!fidelityRulesPromise) {
274
+ fidelityRulesPromise = loadRules(join(dirNameStr, "data", "rules")).then(
275
+ (rules) => rules.filter((rule) => rule.category === "build-fidelity"),
276
+ );
277
+ }
278
+ return fidelityRulesPromise;
279
+ }
280
+
281
+ /**
282
+ * Extract the purl type from a purl string, matching the segment the
283
+ * build-fidelity conditions gate on. Falls back to the same substring split
284
+ * the rule conditions use when the purl cannot be parsed.
285
+ *
286
+ * @param {string|undefined} purl Package URL.
287
+ * @returns {string|undefined} Purl type, or undefined when absent.
288
+ */
289
+ function purlTypeOf(purl) {
290
+ if (!purl || typeof purl !== "string") {
291
+ return undefined;
292
+ }
293
+ try {
294
+ return Purl.parse(purl)?.type || undefined;
295
+ } catch {
296
+ const schemeIndex = purl.indexOf("pkg:");
297
+ if (schemeIndex < 0) {
298
+ return undefined;
299
+ }
300
+ return purl.slice(schemeIndex + 4).split("/")[0] || undefined;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Collect the BOM facts the verdict reads: components and dependency edges
306
+ * per canonical ecosystem, lockfile evidence markers, and the parent
307
+ * component's purl type.
308
+ *
309
+ * @param {Object} bomJson CycloneDX BOM.
310
+ * @returns {Object} Aggregated facts.
311
+ */
312
+ function collectBomFacts(bomJson) {
313
+ const components = Array.isArray(bomJson?.components)
314
+ ? bomJson.components
315
+ : [];
316
+ const dependencies = Array.isArray(bomJson?.dependencies)
317
+ ? bomJson.dependencies
318
+ : [];
319
+ const componentCountByEcosystem = new Map();
320
+ const hashesByEcosystem = new Map();
321
+ const provenanceByEcosystem = new Map();
322
+ const refEcosystem = new Map();
323
+ for (const component of components) {
324
+ const purlType = purlTypeOf(component.purl);
325
+ const ecosystem = PURL_TYPE_ECOSYSTEMS[purlType];
326
+ if (!ecosystem) {
327
+ continue;
328
+ }
329
+ componentCountByEcosystem.set(
330
+ ecosystem,
331
+ (componentCountByEcosystem.get(ecosystem) || 0) + 1,
332
+ );
333
+ if (component["bom-ref"]) {
334
+ refEcosystem.set(component["bom-ref"], ecosystem);
335
+ }
336
+ if (Array.isArray(component.hashes) && component.hashes.length) {
337
+ hashesByEcosystem.set(ecosystem, true);
338
+ }
339
+ if (
340
+ Array.isArray(component.properties) &&
341
+ component.properties.some((property) =>
342
+ property?.name?.startsWith(`cdx:${purlType}:`),
343
+ )
344
+ ) {
345
+ provenanceByEcosystem.set(ecosystem, true);
346
+ }
347
+ }
348
+ const edgeCountByEcosystem = new Map();
349
+ let totalEdgeCount = 0;
350
+ for (const dependency of dependencies) {
351
+ const edgeCount = Array.isArray(dependency.dependsOn)
352
+ ? dependency.dependsOn.length
353
+ : 0;
354
+ totalEdgeCount += edgeCount;
355
+ const ecosystem = refEcosystem.get(dependency.ref);
356
+ if (ecosystem) {
357
+ edgeCountByEcosystem.set(
358
+ ecosystem,
359
+ (edgeCountByEcosystem.get(ecosystem) || 0) + edgeCount,
360
+ );
361
+ }
362
+ }
363
+ return {
364
+ componentCountByEcosystem,
365
+ edgeCountByEcosystem,
366
+ hashesByEcosystem,
367
+ provenanceByEcosystem,
368
+ refEcosystem,
369
+ totalEdgeCount,
370
+ dependencyCount: dependencies.length,
371
+ parentPurlType: purlTypeOf(bomJson?.metadata?.component?.purl),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Resolve the events the reflection joins against: the caller's override, the
377
+ * JSONL sidecar when configured, or the in-memory buffer. Prefer the sidecar
378
+ * whenever it exists — worker threads append there, so it is the only
379
+ * complete record of a multi-worker run.
380
+ *
381
+ * @param {Object} context Reflection overrides.
382
+ * @returns {{events: Object[], source: string}} Events and their origin.
383
+ */
384
+ function resolveLedgerEvents(context) {
385
+ if (Array.isArray(context.ledgerEvents)) {
386
+ return {
387
+ events: context.ledgerEvents,
388
+ source: context.ledgerEvents.length ? "provided" : "none",
389
+ };
390
+ }
391
+ const sidecarPath = readEnvironmentVariable("CDXGEN_INTROSPECT_LEDGER");
392
+ if (sidecarPath) {
393
+ return { events: loadLedgerFile(sidecarPath), source: "sidecar" };
394
+ }
395
+ const events = getLedgerEvents();
396
+ return { events, source: events.length ? "memory" : "none" };
397
+ }
398
+
399
+ /**
400
+ * Resolve the host runtime from the ledger's runtime event when present,
401
+ * falling back to direct runtime detection. The ledger event is preferred
402
+ * because it is what the run itself recorded, and reading it avoids re-running
403
+ * the environment probes.
404
+ *
405
+ * @param {Object[]} events Ledger events.
406
+ * @returns {{name: string, version: string}} Runtime name and version.
407
+ */
408
+ function resolveRuntime(events) {
409
+ const runtimeEvent = events.find(
410
+ (event) =>
411
+ event.kind === LEDGER_EVENT_KINDS.TOOL_RESOLVED &&
412
+ event.ecosystem === "generic" &&
413
+ event.source === "runtime",
414
+ );
415
+ if (runtimeEvent) {
416
+ const names = { node: "Node.js", deno: "Deno", bun: "Bun" };
417
+ return {
418
+ name: names[runtimeEvent.tool] || runtimeEvent.tool || "Unknown",
419
+ version: runtimeEvent.found || "N/A",
420
+ };
421
+ }
422
+ if (globalThis.Deno?.version?.deno !== undefined) {
423
+ return { name: "Deno", version: globalThis.Deno.version.deno };
424
+ }
425
+ if (globalThis.Bun?.version !== undefined) {
426
+ return { name: "Bun", version: globalThis.Bun.version };
427
+ }
428
+ if (globalThis.process?.versions?.node !== undefined) {
429
+ return { name: "Node.js", version: globalThis.process.versions.node };
430
+ }
431
+ return { name: "Unknown", version: "N/A" };
432
+ }
433
+
434
+ /**
435
+ * Drop a trailing version pin such as the `3.9.9` of `maven3.9.9`, scanning
436
+ * backwards over digits and dots so the cost stays linear in the length of
437
+ * the project type.
438
+ *
439
+ * @param {string} projectType Lower-cased project type.
440
+ * @returns {string} The type without its trailing version characters.
441
+ */
442
+ function withoutVersionSuffix(projectType) {
443
+ let end = projectType.length;
444
+ while (end > 0) {
445
+ const char = projectType[end - 1];
446
+ if (char !== "." && (char < "0" || char > "9")) {
447
+ break;
448
+ }
449
+ end -= 1;
450
+ }
451
+ return projectType.slice(0, end);
452
+ }
453
+
454
+ /**
455
+ * Canonical ecosystem for a CLI project type, resolving versioned JVM tool
456
+ * pins such as `maven3.9.9` to their base ecosystem.
457
+ *
458
+ * @param {string} projectType Project type from the CLI options.
459
+ * @returns {string|undefined} Canonical ecosystem, or undefined for types outside the fidelity scope.
460
+ */
461
+ function canonicalEcosystemFor(projectType) {
462
+ const normalizedType = `${projectType || ""}`.toLowerCase();
463
+ return (
464
+ ALIAS_TO_ECOSYSTEM.get(normalizedType) ||
465
+ (isVersionedJvmToolProjectType(normalizedType)
466
+ ? ALIAS_TO_ECOSYSTEM.get(withoutVersionSuffix(normalizedType))
467
+ : undefined)
468
+ );
469
+ }
470
+
471
+ /**
472
+ * Requested project types from the CLI options, accepting the string and
473
+ * array forms and comma-separated members.
474
+ *
475
+ * @param {Object} options CLI options.
476
+ * @returns {string[]} Requested project types.
477
+ */
478
+ function requestedProjectTypes(options) {
479
+ const rawTypes = Array.isArray(options?.projectType)
480
+ ? options.projectType
481
+ : options?.projectType
482
+ ? [options.projectType]
483
+ : [];
484
+ return rawTypes
485
+ .flatMap((type) => `${type}`.split(","))
486
+ .map((type) => type.trim())
487
+ .filter(Boolean);
488
+ }
489
+
490
+ /**
491
+ * Attribute a rule finding to one ecosystem. Scoped rules are attributed to
492
+ * the ecosystem owning the most components among the purl types the rule
493
+ * applies to; the empty-component rule is attributed through the parent purl
494
+ * type it reports; purely structural rules stay global.
495
+ *
496
+ * @param {Object} finding Rule finding.
497
+ * @param {Object} ruleById Rule definitions keyed by id.
498
+ * @param {Object} facts Aggregated BOM facts.
499
+ * @returns {string|undefined} Canonical ecosystem, or undefined when the finding is global.
500
+ */
501
+ function attributeFinding(finding, ruleById, facts) {
502
+ const rule = ruleById.get(finding.ruleId);
503
+ const appliesTo = Array.isArray(rule?.["applies-to"])
504
+ ? rule["applies-to"]
505
+ : [];
506
+ if (appliesTo.length) {
507
+ const present = appliesTo
508
+ .map((purlType) => PURL_TYPE_ECOSYSTEMS[purlType])
509
+ .filter((ecosystem) => facts.componentCountByEcosystem.has(ecosystem))
510
+ .sort(
511
+ (a, b) =>
512
+ (facts.componentCountByEcosystem.get(b) || 0) -
513
+ (facts.componentCountByEcosystem.get(a) || 0) || a.localeCompare(b),
514
+ );
515
+ return present[0];
516
+ }
517
+ const reportedPurlType = finding._match?.parentPurlType;
518
+ if (reportedPurlType && PURL_TYPE_ECOSYSTEMS[reportedPurlType]) {
519
+ return PURL_TYPE_ECOSYSTEMS[reportedPurlType];
520
+ }
521
+ return undefined;
522
+ }
523
+
524
+ /**
525
+ * Stable JSON serialization for fingerprints and dedup keys: object keys are
526
+ * sorted recursively so the result depends on values, never on insertion
527
+ * order.
528
+ *
529
+ * @param {*} value Value to serialize.
530
+ * @returns {string} Deterministic JSON text.
531
+ */
532
+ function stableStringify(value) {
533
+ if (Array.isArray(value)) {
534
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
535
+ }
536
+ if (value && typeof value === "object") {
537
+ const keys = Object.keys(value).sort();
538
+ return `{${keys
539
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
540
+ .join(",")}}`;
541
+ }
542
+ return JSON.stringify(value);
543
+ }
544
+
545
+ /**
546
+ * Hash the inputs that must change before a re-run can improve the verdict:
547
+ * the scanned path, the requested project types, the *resolved* tool versions
548
+ * the run observed, and the cdxgen version. Expected versions are excluded on
549
+ * purpose — the fingerprint exists so an agent loop can detect that an
550
+ * install took effect, and only found versions move when it did.
551
+ *
552
+ * @param {Object} params Fingerprint inputs.
553
+ * @param {string} params.projectPath Scanned directory.
554
+ * @param {string[]} params.projectTypes Requested project types.
555
+ * @param {Object[]} params.events Ledger events.
556
+ * @returns {string} Hex sha256 of the canonical inputs.
557
+ */
558
+ function computeInputsFingerprint({ projectPath, projectTypes, events }) {
559
+ const resolvedTools = [];
560
+ const seenTools = new Set();
561
+ for (const event of events) {
562
+ if (
563
+ event.kind !== LEDGER_EVENT_KINDS.TOOL_RESOLVED ||
564
+ !event.tool ||
565
+ event.found === undefined ||
566
+ event.ecosystem === LEDGER_TOOL_ECOSYSTEM
567
+ ) {
568
+ continue;
569
+ }
570
+ const key = `${event.ecosystem}|${event.tool}|${event.found}`;
571
+ if (seenTools.has(key)) {
572
+ continue;
573
+ }
574
+ seenTools.add(key);
575
+ resolvedTools.push({
576
+ ecosystem: event.ecosystem,
577
+ tool: event.tool,
578
+ found: event.found,
579
+ });
580
+ }
581
+ resolvedTools.sort((a, b) =>
582
+ `${a.ecosystem}|${a.tool}|${a.found}`.localeCompare(
583
+ `${b.ecosystem}|${b.tool}|${b.found}`,
584
+ ),
585
+ );
586
+ const payload = stableStringify({
587
+ projectPath: projectPath || "",
588
+ projectTypes: [...projectTypes].sort(),
589
+ resolvedTools,
590
+ cdxgenVersion: CDXGEN_VERSION,
591
+ });
592
+ return createHash("sha256").update(payload).digest("hex");
593
+ }
594
+
595
+ /**
596
+ * Deduplicate row tool facts by their full tuple so repeated probes of the
597
+ * same tool collapse into one entry.
598
+ *
599
+ * @param {Object[]} entries Tool fact entries.
600
+ * @returns {Object[]} Deduplicated entries in first-seen order.
601
+ */
602
+ function dedupeToolEntries(entries) {
603
+ const seen = new Set();
604
+ const deduped = [];
605
+ for (const entry of entries) {
606
+ const key = stableStringify(entry);
607
+ if (seen.has(key)) {
608
+ continue;
609
+ }
610
+ seen.add(key);
611
+ deduped.push(entry);
612
+ }
613
+ return deduped;
614
+ }
615
+
616
+ /**
617
+ * Tier signal a finding's rule declares, resolved through the rule pack
618
+ * because findings do not carry pack metadata.
619
+ *
620
+ * @param {Object} finding Rule finding.
621
+ * @param {Object} ruleById Rule definitions keyed by id.
622
+ * @returns {string|undefined} The rule's tier-signal, when declared.
623
+ */
624
+ function tierSignal(finding, ruleById) {
625
+ return ruleById.get(finding.ruleId)?.["tier-signal"];
626
+ }
627
+
628
+ /**
629
+ * The evidence source a finding's tier reason is attributed to. Rules whose
630
+ * findings read the BOM's formulation carry `reason-source: formulation`;
631
+ * everything else reasons from the rule pack's BOM-structural match.
632
+ *
633
+ * @param {Object} finding Rule finding.
634
+ * @param {Object} ruleById Rule definitions keyed by id.
635
+ * @returns {string} The reason's source label.
636
+ */
637
+ function reasonSourceOf(finding, ruleById) {
638
+ return ruleById.get(finding.ruleId)?.["reason-source"] || "rule";
639
+ }
640
+
641
+ /**
642
+ * Compact observation record for an event that is reported but never scored.
643
+ * The failure facts a remediation would need — the command, its exit code, a
644
+ * bounded output excerpt and the diagnosed cause — travel with the record, so
645
+ * an observation explains itself as well as a remediation does.
646
+ *
647
+ * @param {Object} event Ledger event.
648
+ * @returns {Object} Observation.
649
+ */
650
+ function observationFromEvent(event) {
651
+ return {
652
+ kind: event.kind,
653
+ ecosystem: event.ecosystem,
654
+ tool: event.tool,
655
+ remediationId: event.remediationId,
656
+ impact: event.impact,
657
+ detail: event.detail,
658
+ command: event.command,
659
+ exitCode: typeof event.exitCode === "number" ? event.exitCode : undefined,
660
+ causeDetail: event.causeDetail,
661
+ outputExcerpt: event.outputExcerpt,
662
+ };
663
+ }
664
+
665
+ /**
666
+ * The failure facts one degradation record carries alongside its remediation
667
+ * id and impact, taken from the event that reported it. Every field is
668
+ * optional: a producer that had no output in hand records none.
669
+ *
670
+ * @param {Object} event Ledger event.
671
+ * @returns {Object} Failure facts, with absent fields left undefined.
672
+ */
673
+ function failureFactsFromEvent(event) {
674
+ return {
675
+ command: event.command,
676
+ exitCode: typeof event.exitCode === "number" ? event.exitCode : undefined,
677
+ causeDetail: event.causeDetail,
678
+ outputExcerpt: event.outputExcerpt,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Build one ecosystem's assessment: tool facts, corroborated degradations,
684
+ * findings, and the tier assigned by the deterministic ladder. Every
685
+ * contributing reason is recorded, and the reasons behind the winning tier
686
+ * are marked determining.
687
+ *
688
+ * @param {Object} row Row inputs.
689
+ * @param {string} row.ecosystem Canonical ecosystem.
690
+ * @param {boolean} row.unsupported True when cdxgen has no project type for the ecosystem.
691
+ * @param {Object[]} row.events Ledger events for this ecosystem.
692
+ * @param {Object[]} row.findings Rule findings attributed to this ecosystem.
693
+ * @param {Object} row.ruleById Rule definitions keyed by id.
694
+ * @param {string[]} row.markersOnDisk Marker paths found on disk or in ledger event paths.
695
+ * @param {number} row.componentCount Components of this ecosystem in the BOM.
696
+ * @param {number} row.dependencyEdgeCount Dependency edges anchored in this ecosystem.
697
+ * @param {Object[]} [row.formulationTools] Tool facts the BOM's formulation records for this ecosystem, used only when no ledger observed the run.
698
+ * @param {Object} bomJson CycloneDX BOM.
699
+ * @param {Object} facts Aggregated BOM facts.
700
+ * @returns {{assessment: Object, observed: Object[]}} The assessment and the events routed to observations.
701
+ */
702
+ function assessEcosystem(row, bomJson, facts) {
703
+ const observed = [];
704
+ const toolsExpected = [];
705
+ const toolsResolved = [];
706
+ const toolsMissing = [];
707
+ const toolsMismatched = [];
708
+ for (const event of row.events) {
709
+ switch (event.kind) {
710
+ case LEDGER_EVENT_KINDS.TOOL_EXPECTED:
711
+ toolsExpected.push({
712
+ tool: event.tool,
713
+ wanted: event.wanted,
714
+ source: event.source,
715
+ });
716
+ break;
717
+ case LEDGER_EVENT_KINDS.TOOL_RESOLVED:
718
+ toolsResolved.push({
719
+ tool: event.tool,
720
+ found: event.found,
721
+ source: event.source,
722
+ path: event.path,
723
+ });
724
+ break;
725
+ case LEDGER_EVENT_KINDS.TOOL_MISSING:
726
+ toolsMissing.push({
727
+ tool: event.tool,
728
+ wanted: event.wanted,
729
+ source: event.source,
730
+ });
731
+ break;
732
+ case LEDGER_EVENT_KINDS.TOOL_MISMATCH:
733
+ toolsMismatched.push({
734
+ tool: event.tool,
735
+ wanted: event.wanted,
736
+ found: event.found,
737
+ source: event.source,
738
+ detail: event.detail,
739
+ });
740
+ break;
741
+ default:
742
+ break;
743
+ }
744
+ }
745
+ // On a foreign BOM no ledger observed the run, so the formulation's
746
+ // platform record is the primary tool evidence and substitutes for the
747
+ // absent `tool.resolved` events. On a same-run scan the probes were
748
+ // already counted once as ledger events, and adding them here would
749
+ // report one probe twice.
750
+ for (const tool of Array.isArray(row.formulationTools)
751
+ ? row.formulationTools
752
+ : []) {
753
+ toolsResolved.push(tool);
754
+ }
755
+
756
+ if (row.unsupported) {
757
+ return {
758
+ assessment: {
759
+ ecosystem: row.ecosystem,
760
+ state: "unsupported",
761
+ tier: null,
762
+ ceilingTier: null,
763
+ tierReasons: [
764
+ {
765
+ source: "disk",
766
+ id: "unsupported-ecosystem",
767
+ detail:
768
+ "cdxgen has no project type for this ecosystem, so nothing was parsed even though its manifest is on disk; this is a cdxgen coverage gap, not a project defect.",
769
+ determining: true,
770
+ },
771
+ ],
772
+ componentCount: 0,
773
+ dependencyEdgeCount: 0,
774
+ markersOnDisk: row.markersOnDisk,
775
+ toolsExpected: [],
776
+ toolsResolved: [],
777
+ toolsMissing: [],
778
+ toolsMismatched: [],
779
+ degradations: [],
780
+ findings: [],
781
+ },
782
+ observed,
783
+ };
784
+ }
785
+
786
+ const hasFindings = row.findings.length > 0;
787
+ // A ledger event corroborates when the BOM shows the damage it explains:
788
+ // a finding fired for this ecosystem, or the ecosystem contributed no
789
+ // dependency graph at all — no components, or components with no edge
790
+ // anchored in them. That second shape is the one an umbrella rule cannot
791
+ // see in a polyglot BOM, where another ecosystem's graph keeps the global
792
+ // rule quiet. Uncorroborated events stay observations — they describe a
793
+ // degraded step that did not cost the output anything measurable.
794
+ const corroborated = hasFindings || row.dependencyEdgeCount === 0;
795
+
796
+ // Gather every candidate reason with the tier it argues for, then pick the
797
+ // worst tier argued: the ladder steps are first-match-wins in the plan's
798
+ // order, which is exactly a tie-break toward the worse tier.
799
+ const candidates = [];
800
+ if (row.markersOnDisk.length > 0 && row.componentCount === 0) {
801
+ candidates.push({
802
+ tier: "absent",
803
+ reason: {
804
+ source: "disk",
805
+ id: "markers",
806
+ detail: `${row.markersOnDisk.length} marker file(s) on disk and no components produced for this ecosystem.`,
807
+ },
808
+ });
809
+ }
810
+ for (const finding of row.findings) {
811
+ const signal = tierSignal(finding, row.ruleById);
812
+ if (signal && TIER_SEVERITY.has(signal)) {
813
+ candidates.push({
814
+ tier: signal,
815
+ reason: {
816
+ source: reasonSourceOf(finding, row.ruleById),
817
+ id: finding.ruleId,
818
+ detail: finding.message,
819
+ },
820
+ });
821
+ }
822
+ }
823
+ const fallbackWithComponentCost = row.events.find(
824
+ (event) =>
825
+ corroborated &&
826
+ event.kind === LEDGER_EVENT_KINDS.FALLBACK_ENGAGED &&
827
+ (event.impact === LEDGER_EVENT_IMPACTS.VERSIONS ||
828
+ event.impact === LEDGER_EVENT_IMPACTS.COMPONENTS),
829
+ );
830
+ if (fallbackWithComponentCost) {
831
+ candidates.push({
832
+ tier: "heuristic",
833
+ reason: {
834
+ source: "ledger",
835
+ id:
836
+ fallbackWithComponentCost.remediationId ||
837
+ fallbackWithComponentCost.kind,
838
+ detail:
839
+ fallbackWithComponentCost.detail ||
840
+ "A fallback produced components from artifacts instead of a manifest.",
841
+ },
842
+ });
843
+ }
844
+ const fallbackWithTransitiveCost = row.events.find(
845
+ (event) =>
846
+ corroborated &&
847
+ (event.kind === LEDGER_EVENT_KINDS.FALLBACK_ENGAGED ||
848
+ event.kind === LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED) &&
849
+ event.impact === LEDGER_EVENT_IMPACTS.TRANSITIVE_DEPS,
850
+ );
851
+ if (fallbackWithTransitiveCost) {
852
+ candidates.push({
853
+ tier: "manifest",
854
+ reason: {
855
+ source: "ledger",
856
+ id:
857
+ fallbackWithTransitiveCost.remediationId ||
858
+ fallbackWithTransitiveCost.kind,
859
+ detail:
860
+ fallbackWithTransitiveCost.detail ||
861
+ "Transitive dependency resolution was skipped or failed.",
862
+ },
863
+ });
864
+ }
865
+ const lockfileEvidence = LOCKFILE_EVIDENCE_CHECKS[row.ecosystem];
866
+ if (lockfileEvidence?.(bomJson, facts)) {
867
+ candidates.push({
868
+ tier: "lockfile",
869
+ reason: {
870
+ source: "bom",
871
+ id: "lockfile-evidence",
872
+ detail:
873
+ "Components carry lockfile-only evidence such as integrity hashes or per-package provenance properties.",
874
+ },
875
+ });
876
+ }
877
+
878
+ let tier = "resolved";
879
+ let determiningReasons = [];
880
+ if (candidates.length) {
881
+ const worstTier = candidates.reduce(
882
+ (worst, candidate) =>
883
+ TIER_SEVERITY.get(candidate.tier) > TIER_SEVERITY.get(worst)
884
+ ? candidate.tier
885
+ : worst,
886
+ "resolved",
887
+ );
888
+ tier = worstTier;
889
+ determiningReasons = candidates
890
+ .filter((candidate) => candidate.tier === worstTier)
891
+ .map((candidate) => candidate.reason);
892
+ } else {
893
+ determiningReasons = [
894
+ {
895
+ source: "bom",
896
+ id: "no-demoting-signal",
897
+ detail:
898
+ "No fidelity finding fired and no corroborated degradation was recorded for this ecosystem.",
899
+ },
900
+ ];
901
+ }
902
+ const tierReasons = [
903
+ ...determiningReasons.map((reason) => ({ ...reason, determining: true })),
904
+ ...candidates
905
+ .filter((candidate) => candidate.tier !== tier)
906
+ .map((candidate) => ({ ...candidate.reason, determining: false })),
907
+ ];
908
+
909
+ const ceilingTier = ECOSYSTEM_CEILINGS[row.ecosystem] || "resolved";
910
+ const state =
911
+ tier === "absent"
912
+ ? "absent"
913
+ : tier === ceilingTier
914
+ ? "at-ceiling"
915
+ : "graded";
916
+
917
+ // Degradation events become remediation entries only when the BOM and the
918
+ // ledger agree on the damage and the ecosystem has room to improve. On an
919
+ // at-ceiling row the same events are expected noise, so they route to
920
+ // observations instead. The failure facts ride with the degradation record
921
+ // so the remediation entry, not only the observation, can answer "why".
922
+ const degradations = [];
923
+ const seenDegradations = new Map();
924
+ for (const event of row.events) {
925
+ if (!REMEDIABLE_KINDS.has(event.kind)) {
926
+ continue;
927
+ }
928
+ if (event.remediationId && corroborated && state !== "at-ceiling") {
929
+ const existing = seenDegradations.get(event.remediationId);
930
+ if (!existing) {
931
+ const degradation = {
932
+ remediationId: event.remediationId,
933
+ impact: event.impact,
934
+ detail: event.detail,
935
+ ...failureFactsFromEvent(event),
936
+ };
937
+ seenDegradations.set(event.remediationId, degradation);
938
+ degradations.push(degradation);
939
+ } else if (!existing.outputExcerpt && event.outputExcerpt) {
940
+ // The first event for an id owns the record, but the excerpt is the
941
+ // one fact later duplicates can supply better.
942
+ existing.outputExcerpt = event.outputExcerpt;
943
+ if (existing.exitCode === undefined && event.exitCode !== undefined) {
944
+ existing.exitCode = event.exitCode;
945
+ }
946
+ }
947
+ } else {
948
+ observed.push(observationFromEvent(event));
949
+ }
950
+ }
951
+
952
+ return {
953
+ assessment: {
954
+ ecosystem: row.ecosystem,
955
+ state,
956
+ tier,
957
+ ceilingTier,
958
+ tierReasons,
959
+ componentCount: row.componentCount,
960
+ dependencyEdgeCount: row.dependencyEdgeCount,
961
+ markersOnDisk: row.markersOnDisk,
962
+ toolsExpected: dedupeToolEntries(toolsExpected),
963
+ toolsResolved: dedupeToolEntries(toolsResolved),
964
+ toolsMissing: dedupeToolEntries(toolsMissing),
965
+ toolsMismatched: dedupeToolEntries(toolsMismatched),
966
+ degradations,
967
+ findings: row.findings.map((finding) => {
968
+ const signal = tierSignal(finding, row.ruleById);
969
+ return signal ? { ...finding, tierSignal: signal } : finding;
970
+ }),
971
+ },
972
+ observed,
973
+ };
974
+ }
975
+
976
+ /**
977
+ * Review a completed run and produce a per-ecosystem fidelity assessment.
978
+ *
979
+ * @param {Object} bomJson Generated CycloneDX BOM.
980
+ * @param {Object} options CLI options.
981
+ * @param {Object} [context] Overrides for testing.
982
+ * @param {Object[]} [context.ledgerEvents] Events, defaulting to the sidecar or in-memory ledger.
983
+ * @param {string} [context.ledgerSource] Force the reported ledger source.
984
+ * @param {boolean} [context.ledgerComplete] Force the reported ledger completeness.
985
+ * @param {string} [context.projectPath] Directory scanned, for marker detection.
986
+ * @param {Object} [context.markerHooks] Directory-lister overrides for marker detection tests.
987
+ * @returns {Promise<Object>} The reflection document.
988
+ */
989
+ export async function reflectOnRun(bomJson, options = {}, context = {}) {
990
+ const { events, source } = resolveLedgerEvents(context);
991
+ let ledgerComplete = context.ledgerComplete !== false;
992
+ const projectPath =
993
+ context.projectPath !== undefined
994
+ ? context.projectPath
995
+ : options.filePath || "";
996
+ const projectTypes = requestedProjectTypes(options);
997
+ const facts = collectBomFacts(bomJson);
998
+ const runId = randomUUID();
999
+ // The formulation section is read before the rules fire so the rules can
1000
+ // gate on its origin; on a same-run scan its platform components are the
1001
+ // ledger's own probes reported twice, and only the foreign case may act as
1002
+ // primary evidence.
1003
+ const formulation = readFormulationEvidence(bomJson, {
1004
+ runId,
1005
+ ledgerEventCount: events.length,
1006
+ });
1007
+ const runtime = resolveRuntime(events);
1008
+ const formulationRuntimeMismatch =
1009
+ formulation.origin === "foreign"
1010
+ ? findRuntimeToolMismatch(formulation, runtime)
1011
+ : undefined;
1012
+ const rules = await getFidelityRules();
1013
+ const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
1014
+ const findings = await evaluateRules(rules, bomJson, {
1015
+ formulationEvidence: {
1016
+ origin: formulation.origin,
1017
+ runtimeMismatch: formulationRuntimeMismatch,
1018
+ },
1019
+ });
1020
+
1021
+ const observations = [];
1022
+ const seenObservations = new Set();
1023
+ const pushObservation = (observation) => {
1024
+ const key = stableStringify(observation);
1025
+ if (!seenObservations.has(key)) {
1026
+ seenObservations.add(key);
1027
+ observations.push(observation);
1028
+ }
1029
+ };
1030
+
1031
+ // Events describing the recorder itself are never an ecosystem: the
1032
+ // truncation marker in particular means the ledger is incomplete and must
1033
+ // be reported as such instead of being scored as a finding against
1034
+ // whichever ecosystem happened to hit the cap.
1035
+ const recorderEvents = [];
1036
+ const projectEvents = [];
1037
+ for (const event of events) {
1038
+ if (event.ecosystem === LEDGER_TOOL_ECOSYSTEM) {
1039
+ recorderEvents.push(event);
1040
+ if (event.kind === LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED) {
1041
+ ledgerComplete = false;
1042
+ }
1043
+ } else {
1044
+ projectEvents.push(event);
1045
+ }
1046
+ }
1047
+ for (const event of recorderEvents) {
1048
+ pushObservation(observationFromEvent(event));
1049
+ }
1050
+ if (source === "memory" && events.length > 0) {
1051
+ pushObservation({
1052
+ kind: "ledger.memory-only",
1053
+ ecosystem: LEDGER_TOOL_ECOSYSTEM,
1054
+ detail:
1055
+ "The ledger was read from this thread's memory; set CDXGEN_INTROSPECT_LEDGER to capture worker-thread events in a sidecar.",
1056
+ });
1057
+ }
1058
+
1059
+ // Markers found on disk, bounded to the scanned directory and one level of
1060
+ // subdirectories. Ledger event paths that name a marker file extend the
1061
+ // picture at no extra I/O cost.
1062
+ const { markersByEcosystem } = detectEcosystemMarkers(
1063
+ projectPath,
1064
+ context.markerHooks || {},
1065
+ );
1066
+ for (const event of projectEvents) {
1067
+ const ecosystem = ALIAS_TO_ECOSYSTEM.get(
1068
+ `${event.ecosystem}`.toLowerCase(),
1069
+ );
1070
+ if (ecosystem && event.path && MARKERS_BY_NAME.has(basename(event.path))) {
1071
+ const paths = new Set(markersByEcosystem.get(ecosystem) || []);
1072
+ paths.add(event.path);
1073
+ markersByEcosystem.set(ecosystem, [...paths].sort());
1074
+ }
1075
+ }
1076
+
1077
+ // The row universe: requested project types, ecosystems present in the
1078
+ // BOM, and ecosystems a finding was attributed to. Ledger events alone
1079
+ // never create a row — probes and declared pins routinely touch
1080
+ // ecosystems the run was not asked to scan.
1081
+ const rowEcosystems = new Set();
1082
+ const unsupportedCandidates = new Set();
1083
+ for (const projectType of projectTypes) {
1084
+ const canonical = canonicalEcosystemFor(projectType);
1085
+ const normalizedType = `${projectType}`.toLowerCase();
1086
+ if (canonical) {
1087
+ rowEcosystems.add(canonical);
1088
+ } else if (!KNOWN_PROJECT_TYPES.has(normalizedType)) {
1089
+ unsupportedCandidates.add(normalizedType);
1090
+ }
1091
+ }
1092
+ for (const ecosystem of facts.componentCountByEcosystem.keys()) {
1093
+ rowEcosystems.add(ecosystem);
1094
+ }
1095
+ // Keyed by the finding itself, not its rule id: one rule fires once per
1096
+ // matched item, and two matches of the same rule can belong to different
1097
+ // ecosystems.
1098
+ const attributedFindings = new Map();
1099
+ for (const finding of findings) {
1100
+ const ecosystem = attributeFinding(finding, ruleById, facts);
1101
+ attributedFindings.set(finding, ecosystem);
1102
+ if (ecosystem) {
1103
+ rowEcosystems.add(ecosystem);
1104
+ }
1105
+ }
1106
+ // Markers for ecosystems cdxgen cannot parse are coverage gaps, never
1107
+ // scored rows.
1108
+ for (const ecosystem of markersByEcosystem.keys()) {
1109
+ if (
1110
+ !ALIAS_TO_ECOSYSTEM.has(ecosystem) &&
1111
+ !KNOWN_PROJECT_TYPES.has(ecosystem)
1112
+ ) {
1113
+ unsupportedCandidates.add(ecosystem);
1114
+ }
1115
+ }
1116
+ for (const ecosystem of unsupportedCandidates) {
1117
+ rowEcosystems.delete(ecosystem);
1118
+ }
1119
+
1120
+ const eventsByEcosystem = new Map();
1121
+ for (const event of projectEvents) {
1122
+ const ecosystem = ALIAS_TO_ECOSYSTEM.get(
1123
+ `${event.ecosystem}`.toLowerCase(),
1124
+ );
1125
+ if (!ecosystem || !rowEcosystems.has(ecosystem)) {
1126
+ // Events for ecosystems outside the scan scope are observations when
1127
+ // they report a degradation, and pure context otherwise.
1128
+ if (DEGRADATION_KINDS.has(event.kind)) {
1129
+ pushObservation(observationFromEvent(event));
1130
+ }
1131
+ continue;
1132
+ }
1133
+ const ecosystemEvents = eventsByEcosystem.get(ecosystem) || [];
1134
+ ecosystemEvents.push(event);
1135
+ eventsByEcosystem.set(ecosystem, ecosystemEvents);
1136
+ }
1137
+
1138
+ const ecosystems = [];
1139
+ const rowNames = [
1140
+ ...new Set([...rowEcosystems, ...unsupportedCandidates]),
1141
+ ].sort((a, b) => a.localeCompare(b));
1142
+ // Formulation tool facts join a row only when no ledger observed the run;
1143
+ // on a same-run scan each probe is already recorded as a `tool.resolved`
1144
+ // event and must not be reported twice.
1145
+ const formulationToolsByEcosystem = new Map();
1146
+ if (formulation.origin === "foreign") {
1147
+ for (const tool of formulation.tools) {
1148
+ const ecosystem = TOOL_NAME_ECOSYSTEMS[`${tool?.name}`];
1149
+ if (!ecosystem || !rowEcosystems.has(ecosystem)) {
1150
+ continue;
1151
+ }
1152
+ const entries = formulationToolsByEcosystem.get(ecosystem) || [];
1153
+ entries.push({
1154
+ tool: tool.name,
1155
+ found: tool.version,
1156
+ source: "formulation",
1157
+ });
1158
+ formulationToolsByEcosystem.set(ecosystem, entries);
1159
+ }
1160
+ }
1161
+ for (const ecosystem of rowNames) {
1162
+ const unsupported = unsupportedCandidates.has(ecosystem);
1163
+ const markers = new Set(markersByEcosystem.get(ecosystem) || []);
1164
+ const rowFindings = findings.filter(
1165
+ (finding) => attributedFindings.get(finding) === ecosystem,
1166
+ );
1167
+ const { assessment, observed } = assessEcosystem(
1168
+ {
1169
+ ecosystem,
1170
+ unsupported,
1171
+ events: eventsByEcosystem.get(ecosystem) || [],
1172
+ findings: rowFindings,
1173
+ ruleById,
1174
+ markersOnDisk: [...markers].sort(),
1175
+ componentCount: unsupported
1176
+ ? 0
1177
+ : facts.componentCountByEcosystem.get(ecosystem) || 0,
1178
+ dependencyEdgeCount: unsupported
1179
+ ? 0
1180
+ : facts.edgeCountByEcosystem.get(ecosystem) || 0,
1181
+ formulationTools: formulationToolsByEcosystem.get(ecosystem) || [],
1182
+ },
1183
+ bomJson,
1184
+ facts,
1185
+ );
1186
+ ecosystems.push(assessment);
1187
+ for (const observation of observed) {
1188
+ pushObservation(observation);
1189
+ }
1190
+ }
1191
+
1192
+ return {
1193
+ runId,
1194
+ inputsFingerprint: computeInputsFingerprint({
1195
+ projectPath,
1196
+ projectTypes,
1197
+ events: projectEvents,
1198
+ }),
1199
+ generatedAt: new Date().toISOString(),
1200
+ cdxgenVersion: CDXGEN_VERSION,
1201
+ runtime,
1202
+ ledgerSource: context.ledgerSource || source,
1203
+ ledgerComplete,
1204
+ ledgerEventCount: events.length,
1205
+ projectPath,
1206
+ projectTypes: [...projectTypes].sort(),
1207
+ ecosystems,
1208
+ globalFindings: findings.filter(
1209
+ (finding) => attributedFindings.get(finding) === undefined,
1210
+ ),
1211
+ observations,
1212
+ formulation,
1213
+ };
1214
+ }