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