@cdxgen/cdxgen 13.0.1 → 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 (155) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +125 -4
  4. package/bin/evinse.js +1 -1
  5. package/data/component-tags.json +8 -1
  6. package/data/frameworks-list.json +86 -1
  7. package/data/remediations.json +1144 -0
  8. package/data/rules/asar-archives.yaml +1 -1
  9. package/data/rules/build-fidelity.yaml +782 -0
  10. package/data/rules/package-integrity.yaml +13 -13
  11. package/lib/audit/index.js +48 -4
  12. package/lib/audit/scoring.js +2 -8
  13. package/lib/cli/bomAssembly.js +20 -1
  14. package/lib/cli/cliOptions.js +94 -7
  15. package/lib/cli/index.js +76 -48
  16. package/lib/cli/jsBom.js +32 -3
  17. package/lib/cli/jvmBom.js +1494 -1315
  18. package/lib/cli/managedBom.js +97 -28
  19. package/lib/cli/nativeBom.js +189 -10
  20. package/lib/core/buildLedger.js +799 -0
  21. package/lib/core/deferredExit.js +141 -0
  22. package/lib/core/env.js +69 -1
  23. package/lib/core/fs.js +60 -0
  24. package/lib/core/logger.js +12 -99
  25. package/lib/core/severity.js +16 -0
  26. package/lib/core/syncFileWriter.js +106 -0
  27. package/lib/ecosystems/ecosystems.js +9 -0
  28. package/lib/ecosystems/gradleutils.js +355 -15
  29. package/lib/ecosystems/parsers-js.js +26 -0
  30. package/lib/ecosystems/parsers-jvm.js +9 -0
  31. package/lib/ecosystems/parsers-misc.js +16 -2
  32. package/lib/ecosystems/parsers-python.js +171 -35
  33. package/lib/ecosystems/parsers-rust.js +12 -2
  34. package/lib/ecosystems/pypiClassifiers.js +326 -0
  35. package/lib/ecosystems/pythonutils.js +49 -1
  36. package/lib/ecosystems/rubyutils.js +13 -0
  37. package/lib/evinser/evinser.js +30 -12
  38. package/lib/helpers/remote/dependency-track.js +81 -14
  39. package/lib/inventory/auditCategories.js +9 -0
  40. package/lib/inventory/depsUtils.js +72 -0
  41. package/lib/inventory/dosai.js +431 -22
  42. package/lib/inventory/envcontext.js +206 -18
  43. package/lib/inventory/fetchBatch.js +57 -0
  44. package/lib/inventory/jvmToolEnv.js +593 -0
  45. package/lib/inventory/osinfo.js +1 -0
  46. package/lib/inventory/toolRequirements.js +903 -0
  47. package/lib/managers/binary.js +24 -0
  48. package/lib/managers/docker.js +26 -5
  49. package/lib/server/server.js +1 -1
  50. package/lib/stages/postgen/annotationBuilder.js +97 -0
  51. package/lib/stages/postgen/auditBom.js +76 -79
  52. package/lib/stages/postgen/introspection/annotate.js +320 -0
  53. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  54. package/lib/stages/postgen/introspection/emit.js +192 -0
  55. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  56. package/lib/stages/postgen/introspection/markers.js +227 -0
  57. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  58. package/lib/stages/postgen/introspection/report.js +1208 -0
  59. package/lib/stages/postgen/introspection/score.js +1067 -0
  60. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  61. package/lib/stages/postgen/parentEdge.js +156 -0
  62. package/lib/stages/postgen/postgen.js +115 -2
  63. package/lib/stages/postgen/ruleEngine.js +17 -0
  64. package/lib/stages/pregen/envAudit.js +42 -2
  65. package/lib/stages/pregen/pregen.js +1171 -6
  66. package/lib/validator/bomValidator.js +11 -2
  67. package/package.json +14 -14
  68. package/types/lib/audit/index.d.ts.map +1 -1
  69. package/types/lib/audit/scoring.d.ts +2 -8
  70. package/types/lib/audit/scoring.d.ts.map +1 -1
  71. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  72. package/types/lib/cli/cliOptions.d.ts +2 -1
  73. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  74. package/types/lib/cli/index.d.ts +8 -0
  75. package/types/lib/cli/index.d.ts.map +1 -1
  76. package/types/lib/cli/jsBom.d.ts.map +1 -1
  77. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  78. package/types/lib/cli/managedBom.d.ts.map +1 -1
  79. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  80. package/types/lib/core/buildLedger.d.ts +272 -0
  81. package/types/lib/core/buildLedger.d.ts.map +1 -0
  82. package/types/lib/core/deferredExit.d.ts +84 -0
  83. package/types/lib/core/deferredExit.d.ts.map +1 -0
  84. package/types/lib/core/env.d.ts +22 -1
  85. package/types/lib/core/env.d.ts.map +1 -1
  86. package/types/lib/core/fs.d.ts +21 -0
  87. package/types/lib/core/fs.d.ts.map +1 -1
  88. package/types/lib/core/logger.d.ts +2 -11
  89. package/types/lib/core/logger.d.ts.map +1 -1
  90. package/types/lib/core/severity.d.ts +11 -0
  91. package/types/lib/core/severity.d.ts.map +1 -0
  92. package/types/lib/core/syncFileWriter.d.ts +33 -0
  93. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  94. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  95. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  96. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  97. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  98. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  99. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  102. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  103. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  104. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  105. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  107. package/types/lib/evinser/evinser.d.ts +5 -1
  108. package/types/lib/evinser/evinser.d.ts.map +1 -1
  109. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  110. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  111. package/types/lib/inventory/auditCategories.d.ts +8 -0
  112. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  113. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  114. package/types/lib/inventory/dosai.d.ts +20 -0
  115. package/types/lib/inventory/dosai.d.ts.map +1 -1
  116. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  117. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  118. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  119. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  120. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  121. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  122. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  123. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  124. package/types/lib/managers/binary.d.ts.map +1 -1
  125. package/types/lib/managers/docker.d.ts.map +1 -1
  126. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  127. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  128. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  129. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  130. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  131. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  132. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  133. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  134. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  135. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  136. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  137. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  138. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  139. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  140. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  141. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  142. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  144. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  145. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  146. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  147. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  148. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  149. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  150. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  151. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  152. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  153. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  154. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  155. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,320 @@
1
+ /**
2
+ * BOM mutations that carry the introspection verdict inside the document:
3
+ * eight metadata properties and a block of document-level annotations.
4
+ *
5
+ * Sidecar report files get lost; the BOM travels. A consumer who receives
6
+ * only the BOM still learns how much to trust it from the metadata properties,
7
+ * and the annotations carry one entry per remediation the loop should act on.
8
+ * No component or dependency is touched.
9
+ *
10
+ * Both mutations are replace-by-name: a document that was already introspected
11
+ * (a BOM enriched again after an evidence pass) loses its previous
12
+ * introspection state and gains a fresh one, so the verdict always describes
13
+ * the final document.
14
+ */
15
+
16
+ import {
17
+ buildDocumentAnnotation,
18
+ evidenceProperties,
19
+ } from "../annotationBuilder.js";
20
+ import { FORMULATION_RUN_ID_PROPERTY } from "./formulationEvidence.js";
21
+ import { overallAssessment } from "./report.js";
22
+
23
+ /**
24
+ * Version of the introspection BOM surface. Matches the JSON report's
25
+ * schemaVersion so the two stay comparable.
26
+ *
27
+ * @type {string}
28
+ */
29
+ export const INTROSPECTION_SCHEMA_VERSION = "1.1";
30
+
31
+ /**
32
+ * Namespace of every property and annotation entry this module emits.
33
+ *
34
+ * @type {string}
35
+ */
36
+ export const INTROSPECTION_PROPERTY_PREFIX = "cdx:introspection";
37
+
38
+ /**
39
+ * The metadata properties that summarize the verdict, in emission order. The
40
+ * overall tier and confidence are omitted when the run produced no scored row
41
+ * (nothing was graded, so no verdict is claimed); per-ecosystem rows cover the
42
+ * scored ecosystems only, since unsupported ones have neither a tier nor a
43
+ * score.
44
+ *
45
+ * @param {Object} reflection Reflection document from reflectOnRun.
46
+ * @param {Object} scored Scoring document from scoreReflection.
47
+ * @returns {{name: string, value: string}[]} Metadata property entries.
48
+ */
49
+ export function introspectionMetadataProperties(reflection, scored) {
50
+ const assessment = overallAssessment(scored);
51
+ const properties = [
52
+ {
53
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:schemaVersion`,
54
+ value: INTROSPECTION_SCHEMA_VERSION,
55
+ },
56
+ ];
57
+ if (assessment.tier) {
58
+ properties.push({
59
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:tier`,
60
+ value: assessment.tier,
61
+ });
62
+ }
63
+ properties.push({
64
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:score`,
65
+ value: String(scored?.overallScore ?? 100),
66
+ });
67
+ if (assessment.confidence) {
68
+ properties.push({
69
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:confidence`,
70
+ value: assessment.confidence,
71
+ });
72
+ }
73
+ properties.push({
74
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:ledgerComplete`,
75
+ value: String(reflection?.ledgerComplete !== false),
76
+ });
77
+ for (const row of sortedScoredRows(scored)) {
78
+ properties.push({
79
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem:${row.ecosystem}:tier`,
80
+ value: `${row.tier}`,
81
+ });
82
+ properties.push({
83
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem:${row.ecosystem}:score`,
84
+ value: String(row.score),
85
+ });
86
+ }
87
+ properties.push({
88
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:remediationCount`,
89
+ value: String(actionableRemediations(scored).length),
90
+ });
91
+ return properties;
92
+ }
93
+
94
+ /**
95
+ * The annotation describing the run as a whole. Its structured facts are the
96
+ * metadata properties, so the summary is readable without the rest of the
97
+ * document.
98
+ *
99
+ * @param {Object} bomJson CycloneDX BOM the annotation describes.
100
+ * @param {Object} reflection Reflection document from reflectOnRun.
101
+ * @param {Object} scored Scoring document from scoreReflection.
102
+ * @returns {Object|undefined} The summary annotation, or undefined without a cdxgen annotator.
103
+ */
104
+ function summaryAnnotation(bomJson, reflection, scored) {
105
+ const assessment = overallAssessment(scored);
106
+ const remediationCount = (
107
+ Array.isArray(scored?.remediations) ? scored.remediations : []
108
+ ).length;
109
+ // Annotation text is escaped as markdown before it reaches the BOM, so the
110
+ // summary is phrased without brackets or pipes that would survive as
111
+ // backslashes in the rendered document.
112
+ const message =
113
+ `Build introspection: overall tier ${assessment.tier ?? "ungraded"}, ` +
114
+ `score ${scored?.overallScore ?? 100} of 100, ` +
115
+ `confidence ${assessment.confidence ?? "not available"}. ` +
116
+ `${remediationCount} remediation${remediationCount === 1 ? "" : "s"} ranked.`;
117
+ return buildDocumentAnnotation({
118
+ bomJson,
119
+ message,
120
+ properties: introspectionMetadataProperties(reflection, scored),
121
+ });
122
+ }
123
+
124
+ /**
125
+ * One annotation per non-blocked remediation, in ranked order. Blocked
126
+ * entries are constraints of the environment rather than work the loop can
127
+ * do, so they stay in the report files and out of the document. A remediation
128
+ * that subsumes other rules names them, so nothing the run detected goes
129
+ * unreported.
130
+ *
131
+ * @param {Object} bomJson CycloneDX BOM the annotations describe.
132
+ * @param {Object} scored Scoring document from scoreReflection.
133
+ * @returns {Object[]} Remediation annotations.
134
+ */
135
+ function remediationAnnotations(bomJson, scored) {
136
+ const tierByEcosystem = new Map(
137
+ sortedScoredRows(scored).map((row) => [row.ecosystem, row.tier]),
138
+ );
139
+ const annotations = [];
140
+ for (const entry of actionableRemediations(scored)) {
141
+ const properties = [
142
+ {
143
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:remediationId`,
144
+ value: `${entry.remediationId}`,
145
+ },
146
+ {
147
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem`,
148
+ value: `${entry.ecosystem}`,
149
+ },
150
+ {
151
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:currentTier`,
152
+ value: `${tierByEcosystem.get(entry.ecosystem) || entry.targetTier}`,
153
+ },
154
+ {
155
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:targetTier`,
156
+ value: `${entry.targetTier}`,
157
+ },
158
+ {
159
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:expectedGain`,
160
+ value: Number(entry.expectedGain ?? 0).toFixed(2),
161
+ },
162
+ {
163
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:confidence`,
164
+ value: `${entry.confidence}`,
165
+ },
166
+ ];
167
+ const rules = Array.isArray(entry.verify?.rules) ? entry.verify.rules : [];
168
+ if (rules.length) {
169
+ properties.push({
170
+ name: `${INTROSPECTION_PROPERTY_PREFIX}:verify:rules`,
171
+ value: rules.join(","),
172
+ });
173
+ }
174
+ const evidence = { source: entry.source };
175
+ if (entry.impact) {
176
+ evidence.impact = entry.impact;
177
+ }
178
+ if (entry.severity) {
179
+ evidence.severity = entry.severity;
180
+ }
181
+ if (Number.isFinite(entry.evidenceCount)) {
182
+ evidence.events = entry.evidenceCount;
183
+ }
184
+ if (Array.isArray(entry.subsumes) && entry.subsumes.length) {
185
+ evidence.subsumes = entry.subsumes;
186
+ }
187
+ if (
188
+ Array.isArray(entry.verify?.eventsCleared) &&
189
+ entry.verify.eventsCleared.length
190
+ ) {
191
+ evidence.eventsCleared = entry.verify.eventsCleared;
192
+ }
193
+ properties.push(
194
+ ...evidenceProperties(INTROSPECTION_PROPERTY_PREFIX, evidence),
195
+ );
196
+ const annotation = buildDocumentAnnotation({
197
+ bomJson,
198
+ message: `${entry.summary || entry.remediationId}`,
199
+ properties,
200
+ });
201
+ if (annotation) {
202
+ annotations.push(annotation);
203
+ }
204
+ }
205
+ return annotations;
206
+ }
207
+
208
+ /**
209
+ * Attach the introspection verdict to the document: metadata properties plus
210
+ * the summary and remediation annotations. Any introspection state left on the
211
+ * document by a previous pass is removed first, so enrichment flows that run
212
+ * post-processing twice leave exactly one verdict.
213
+ *
214
+ * @param {Object} bomJson CycloneDX BOM, mutated in place.
215
+ * @param {Object} reflection Reflection document from reflectOnRun.
216
+ * @param {Object} scored Scoring document from scoreReflection.
217
+ * @param {Object} [guards] Emission guards.
218
+ * @param {boolean} [guards.annotations] False skips the annotations, for documents at a spec version that predates them.
219
+ * @returns {Object} The mutated BOM.
220
+ */
221
+ export function applyIntrospectionToBom(
222
+ bomJson,
223
+ reflection,
224
+ scored,
225
+ guards = {},
226
+ ) {
227
+ if (!bomJson?.metadata) {
228
+ return bomJson;
229
+ }
230
+ const freshProperties = introspectionMetadataProperties(reflection, scored);
231
+ const retainedProperties = (
232
+ Array.isArray(bomJson.metadata.properties)
233
+ ? bomJson.metadata.properties
234
+ : []
235
+ ).filter(
236
+ (property) =>
237
+ !`${property?.name || ""}`.startsWith(INTROSPECTION_PROPERTY_PREFIX),
238
+ );
239
+ bomJson.metadata.properties = [...retainedProperties, ...freshProperties];
240
+ if (guards.annotations === false) {
241
+ return bomJson;
242
+ }
243
+ const retainedAnnotations = (
244
+ Array.isArray(bomJson.annotations) ? bomJson.annotations : []
245
+ ).filter(
246
+ (annotation) =>
247
+ !`${annotation?.text || ""}`.includes(INTROSPECTION_PROPERTY_PREFIX),
248
+ );
249
+ const freshAnnotations = [
250
+ summaryAnnotation(bomJson, reflection, scored),
251
+ ...remediationAnnotations(bomJson, scored),
252
+ ].filter(Boolean);
253
+ if (freshAnnotations.length) {
254
+ bomJson.annotations = [...retainedAnnotations, ...freshAnnotations];
255
+ }
256
+ return bomJson;
257
+ }
258
+
259
+ /** Scored rows in ecosystem order. */
260
+ function sortedScoredRows(scored) {
261
+ return [...(Array.isArray(scored?.ecosystems) ? scored.ecosystems : [])]
262
+ .filter((row) => row?.ecosystem)
263
+ .sort((a, b) => `${a.ecosystem}`.localeCompare(`${b.ecosystem}`));
264
+ }
265
+
266
+ /** Remediations the loop can act on, in ranked order. */
267
+ function actionableRemediations(scored) {
268
+ return (
269
+ Array.isArray(scored?.remediations) ? scored.remediations : []
270
+ ).filter((entry) => entry?.blocked !== true);
271
+ }
272
+
273
+ /**
274
+ * Stamp every formulation workflow with the id of the run that reflected over
275
+ * the document, so a later consumer of the BOM can tell a formulation record
276
+ * this run generated from one that arrived with a foreign BOM. The stamp is
277
+ * replace-by-name, matching how the rest of the verdict mutates the
278
+ * document: a BOM enriched twice keeps only the latest run's id.
279
+ *
280
+ * @param {Object} bomJson CycloneDX BOM, mutated in place.
281
+ * @param {string} runId Run id of the introspection pass.
282
+ * @returns {Object} The mutated BOM.
283
+ */
284
+ export function applyFormulationRunIdMarker(bomJson, runId) {
285
+ if (!bomJson || !runId) {
286
+ return bomJson;
287
+ }
288
+ for (const entry of Array.isArray(bomJson.formulation)
289
+ ? bomJson.formulation
290
+ : []) {
291
+ for (const workflow of Array.isArray(entry?.workflows)
292
+ ? entry.workflows
293
+ : []) {
294
+ const retained = (
295
+ Array.isArray(workflow.properties) ? workflow.properties : []
296
+ ).filter((property) => property?.name !== FORMULATION_RUN_ID_PROPERTY);
297
+ workflow.properties = [
298
+ ...retained,
299
+ { name: FORMULATION_RUN_ID_PROPERTY, value: runId },
300
+ ];
301
+ }
302
+ }
303
+ return bomJson;
304
+ }
305
+
306
+ /**
307
+ * Whether a BOM already carries an introspection verdict.
308
+ *
309
+ * Evidence collection re-processes a finished BOM through a fresh wrapper, so
310
+ * the document itself is the only marker that survives between passes.
311
+ *
312
+ * @param {Object} bomJson CycloneDX BOM.
313
+ * @returns {boolean} True when the BOM was already introspected.
314
+ */
315
+ export function hasIntrospectionVerdict(bomJson) {
316
+ return (bomJson?.metadata?.properties || []).some(
317
+ (property) =>
318
+ property?.name === `${INTROSPECTION_PROPERTY_PREFIX}:schemaVersion`,
319
+ );
320
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Command-facts collector for command shaping.
3
+ *
4
+ * Gathers, once per run and cached, the project facts the command resolver
5
+ * consumes: which maven and gradle wrapper scripts the project root carries,
6
+ * which Python manager owns its lock files, and which npm client the project
7
+ * names or locks. Detection is filesystem reads only — existence and, for
8
+ * the platform's own shell scripts, the executable bit. Nothing here spawns
9
+ * a process: whether a wrapper actually runs is a question the run itself
10
+ * answers elsewhere, and shaping that cannot know says so through the
11
+ * resolver's fallbacks.
12
+ */
13
+
14
+ import { join } from "node:path";
15
+
16
+ import {
17
+ safeExistsSync,
18
+ safeIsExecutableSync,
19
+ safeReadFileSync,
20
+ } from "../../../core/fs.js";
21
+
22
+ /**
23
+ * The POSIX wrapper scripts whose executable bit is meaningful, mapped to
24
+ * the fact names the resolver reads.
25
+ *
26
+ * @type {ReadonlyArray<[string, string]>}
27
+ */
28
+ const POSIX_WRAPPERS = [
29
+ ["mvnw", "mvnw"],
30
+ ["gradlew", "gradlew"],
31
+ ["composer.phar", "composerPhar"],
32
+ ];
33
+
34
+ /**
35
+ * The Windows wrapper batches whose presence is the practical usability
36
+ * test, mapped to the fact names the resolver reads.
37
+ *
38
+ * @type {ReadonlyArray<[string, string]>}
39
+ */
40
+ const WINDOWS_WRAPPERS = [
41
+ ["mvnw.cmd", "mvnwCmd"],
42
+ ["gradlew.bat", "gradlewBat"],
43
+ ];
44
+
45
+ /**
46
+ * Python managers detected through the lock file that owns them. Poetry is
47
+ * completed by the build backend its pyproject.toml declares, because a
48
+ * stray poetry.lock alone does not make a project poetry-managed.
49
+ *
50
+ * @type {Readonly<Record<string, string>>}
51
+ */
52
+ const MANAGER_LOCK_FILES = Object.freeze({
53
+ "uv.lock": "uv",
54
+ "poetry.lock": "poetry",
55
+ "pdm.lock": "pdm",
56
+ "Pipfile.lock": "pipenv",
57
+ });
58
+
59
+ /**
60
+ * pyproject.toml build backends that settle which competing manager governs
61
+ * the project.
62
+ *
63
+ * @type {Readonly<Record<string, string>>}
64
+ */
65
+ const BACKEND_MANAGERS = Object.freeze({
66
+ "poetry.core.masonry.api": "poetry",
67
+ "pdm.backend": "pdm",
68
+ "pdm.pep517.api": "pdm",
69
+ uv: "uv",
70
+ uv_build: "uv",
71
+ });
72
+
73
+ /** Facts gathered so far, keyed by the project root they describe. */
74
+ const factsCache = new Map();
75
+
76
+ /**
77
+ * Whether a path exists and, on POSIX, carries the executable bit for the
78
+ * current user. On Windows every readable file is runnable, so existence is
79
+ * the whole answer.
80
+ *
81
+ * @param {string} filePath Path to check.
82
+ * @param {boolean} checkExecutable True when the executable bit matters.
83
+ * @returns {boolean} Usability of the path as an executable.
84
+ */
85
+ function isUsableExecutable(filePath, checkExecutable) {
86
+ return checkExecutable
87
+ ? safeIsExecutableSync(filePath)
88
+ : safeExistsSync(filePath);
89
+ }
90
+
91
+ /**
92
+ * Read the build backend a pyproject.toml declares, from its
93
+ * `[build-system]` section only, so a mention elsewhere cannot answer.
94
+ *
95
+ * @param {string} pyprojectPath Path to pyproject.toml.
96
+ * @returns {string|undefined} The declared backend, when present.
97
+ */
98
+ function declaredBuildBackend(pyprojectPath) {
99
+ let content;
100
+ try {
101
+ content = safeReadFileSync(pyprojectPath, "utf-8");
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ let inBuildSystem = false;
106
+ for (const line of content.split(/\r?\n/)) {
107
+ const trimmed = line.trim();
108
+ if (trimmed.startsWith("[")) {
109
+ inBuildSystem = trimmed === "[build-system]";
110
+ continue;
111
+ }
112
+ if (!inBuildSystem || !trimmed.startsWith("build-backend")) {
113
+ continue;
114
+ }
115
+ const eq = trimmed.indexOf("=");
116
+ if (eq === -1) {
117
+ return undefined;
118
+ }
119
+ return trimmed
120
+ .slice(eq + 1)
121
+ .trim()
122
+ .replace(/^["']|["']$/g, "")
123
+ .trim();
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * Whether a pyproject.toml declares a `[tool.<name>]` section, so a
130
+ * manager's own configuration marks the project as its own.
131
+ *
132
+ * @param {string} pyprojectPath Path to pyproject.toml.
133
+ * @param {string} toolName Tool section name, e.g. `poetry`.
134
+ * @returns {boolean} True when the section is present.
135
+ */
136
+ function hasToolSection(pyprojectPath, toolName) {
137
+ let content;
138
+ try {
139
+ content = safeReadFileSync(pyprojectPath, "utf-8");
140
+ } catch {
141
+ return false;
142
+ }
143
+ const section = `[tool.${toolName}]`;
144
+ for (const line of content.split(/\r?\n/)) {
145
+ const trimmed = line.trim();
146
+ if (trimmed === section || trimmed.startsWith(`${section.slice(0, -1)}.`)) {
147
+ return true;
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Detect the Python manager in play at a project root: the manager whose
155
+ * lock file is present, with poetry confirmed by the `[tool.poetry]`
156
+ * section its pyproject.toml declares. Two competing managers are settled
157
+ * by the declared build backend; when it settles nothing, both candidates
158
+ * travel in the facts so the report can ask instead of guessing.
159
+ *
160
+ * @param {string} projectRoot Directory to inspect.
161
+ * @returns {{manager: string|undefined, candidates: string[]}} The manager in play and every candidate detected.
162
+ */
163
+ function detectPythonManager(projectRoot) {
164
+ const pyprojectPath = join(projectRoot, "pyproject.toml");
165
+ const hasPyproject = safeExistsSync(pyprojectPath);
166
+ const candidates = [];
167
+ for (const [lockName, manager] of Object.entries(MANAGER_LOCK_FILES)) {
168
+ if (!safeExistsSync(join(projectRoot, lockName))) {
169
+ continue;
170
+ }
171
+ if (manager === "poetry") {
172
+ if (!hasPyproject || !hasToolSection(pyprojectPath, "poetry")) {
173
+ continue;
174
+ }
175
+ }
176
+ if (!candidates.includes(manager)) {
177
+ candidates.push(manager);
178
+ }
179
+ }
180
+ const ordered = candidates.sort();
181
+ if (ordered.length <= 1) {
182
+ return { manager: ordered[0], candidates: ordered };
183
+ }
184
+ const backend = hasPyproject
185
+ ? declaredBuildBackend(pyprojectPath)
186
+ : undefined;
187
+ const settled = backend ? BACKEND_MANAGERS[backend] : undefined;
188
+ if (settled && ordered.includes(settled)) {
189
+ return { manager: settled, candidates: ordered };
190
+ }
191
+ return { manager: undefined, candidates: ordered };
192
+ }
193
+
194
+ /**
195
+ * Detect the npm client a project root uses: the `packageManager` field of
196
+ * its package.json when the project names one, else the lock file present,
197
+ * else npm.
198
+ *
199
+ * @param {string} projectRoot Directory to inspect.
200
+ * @returns {string} The npm client name.
201
+ */
202
+ function detectNpmClient(projectRoot) {
203
+ const packageJsonPath = join(projectRoot, "package.json");
204
+ if (safeExistsSync(packageJsonPath)) {
205
+ try {
206
+ const packageJson = JSON.parse(
207
+ safeReadFileSync(packageJsonPath, "utf-8"),
208
+ );
209
+ const pinned = `${packageJson?.packageManager || ""}`;
210
+ const at = pinned.indexOf("@");
211
+ const name = (at > 0 ? pinned.slice(0, at) : pinned).trim();
212
+ if (name) {
213
+ return name;
214
+ }
215
+ } catch {
216
+ // An unreadable package.json leaves the lock files to answer.
217
+ }
218
+ }
219
+ const lockClients = [
220
+ ["pnpm-lock.yaml", "pnpm"],
221
+ ["yarn.lock", "yarn"],
222
+ ["bun.lockb", "bun"],
223
+ ["bun.lock", "bun"],
224
+ ["deno.lock", "deno"],
225
+ ["package-lock.json", "npm"],
226
+ ];
227
+ for (const [lockName, client] of lockClients) {
228
+ if (safeExistsSync(join(projectRoot, lockName))) {
229
+ return client;
230
+ }
231
+ }
232
+ return "npm";
233
+ }
234
+
235
+ /**
236
+ * Gather the command-shaping facts for a project root. The result is cached
237
+ * per root, so repeated scoring passes over the same project read the
238
+ * filesystem once.
239
+ *
240
+ * @param {string} projectRoot Directory that was scanned.
241
+ * @param {Object} [options] Test hooks.
242
+ * @param {"posix"|"windows"} [options.platform] Platform override; defaults to the runtime platform.
243
+ * @returns {import("./shapeCommand.js").CommandFacts} Facts for the resolver.
244
+ */
245
+ export function collectCommandFacts(projectRoot, options = {}) {
246
+ const platform =
247
+ options.platform ||
248
+ (globalThis.process?.platform === "win32" ? "windows" : "posix");
249
+ const cacheKey = `${platform}\n${projectRoot || ""}`;
250
+ const cached = factsCache.get(cacheKey);
251
+ if (cached) {
252
+ return cached;
253
+ }
254
+ const facts = {
255
+ projectRoot: projectRoot || "",
256
+ platform,
257
+ wrappers: {},
258
+ pythonManager: undefined,
259
+ pythonManagerCandidates: [],
260
+ npmClient: "npm",
261
+ };
262
+ if (projectRoot && safeExistsSync(projectRoot)) {
263
+ const checkExecutable = platform === "posix";
264
+ for (const [fileName, factName] of POSIX_WRAPPERS) {
265
+ const wrapperPath = join(projectRoot, fileName);
266
+ if (isUsableExecutable(wrapperPath, checkExecutable)) {
267
+ facts.wrappers[factName] = true;
268
+ } else if (safeExistsSync(wrapperPath)) {
269
+ facts.wrappers[`${factName}Inexecutable`] = true;
270
+ }
271
+ }
272
+ for (const [fileName, factName] of WINDOWS_WRAPPERS) {
273
+ if (isUsableExecutable(join(projectRoot, fileName), false)) {
274
+ facts.wrappers[factName] = true;
275
+ }
276
+ }
277
+ const python = detectPythonManager(projectRoot);
278
+ facts.pythonManager = python.manager;
279
+ facts.pythonManagerCandidates = python.candidates;
280
+ facts.npmClient = detectNpmClient(projectRoot);
281
+ }
282
+ factsCache.set(cacheKey, facts);
283
+ return facts;
284
+ }
285
+
286
+ /**
287
+ * Forget the gathered facts. Test hook; a production run gathers once.
288
+ *
289
+ * @returns {void}
290
+ */
291
+ export function clearCommandFactsCache() {
292
+ factsCache.clear();
293
+ }