@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 (134) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +17 -2
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +125 -14
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,227 @@
1
+ // Structural validation of CycloneDX 1.6 and SPDX 2.3 JSON documents, plus package-URL grammar (X-010).
2
+ //
3
+ // STRENGTH, STATED PLAINLY: this is a hand-written validator of the rules the two specifications state for the parts
4
+ // of a document this project emits (required fields, types, enumerations, identifier formats, uniqueness of
5
+ // identifiers, and that every reference resolves). It is not the official JSON Schema: that file cannot be fetched
6
+ // under the no-network rule and a vendored copy would rot silently, so a document that passes here has been checked
7
+ // against the written rules but not by the specification's own schema tooling. Anything not listed here is not checked.
8
+ //
9
+ // Never throws: a document from anywhere is an expected input. Returns {valid, errors:[{path, message}]}.
10
+
11
+ const err = (errors, path, message) => errors.push({ path, message });
12
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
13
+ const isStr = (v) => typeof v === 'string' && v.length > 0;
14
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
15
+ const HEX = /^[0-9a-fA-F]+$/;
16
+
17
+ // ── package URL ──────────────────────────────────────────────────────────────
18
+ const PURL_TYPE = /^[a-z][a-z0-9.+-]*$/;
19
+ const PCT = /^(?:[A-Za-z0-9._~!$&'()*+,;=:@%-]|%[0-9A-Fa-f]{2})*$/;
20
+ const LOWERCASE_NAMESPACE_TYPES = new Set(['github', 'gitlab', 'bitbucket']);
21
+ const NAMESPACELESS = new Set(['hackage', 'pypi', 'npm']); // npm scopes use a namespace; checked below
22
+ const GENERIC_QUALIFIERS = new Set(['download_url', 'vcs_url', 'checksum', 'repository_url', 'arch', 'distro']);
23
+
24
+ /** Validates a purl against the package-url grammar and the registered-type rules that apply to the types we emit. */
25
+ export function validatePurl(purl) {
26
+ const errors = [];
27
+ if (typeof purl !== 'string' || !purl.startsWith('pkg:')) { errors.push('a purl starts with "pkg:"'); return { valid: false, errors }; }
28
+ let rest = purl.slice(4);
29
+ let subpath = null; let qualifiers = null;
30
+ const hash = rest.indexOf('#'); if (hash >= 0) { subpath = rest.slice(hash + 1); rest = rest.slice(0, hash); }
31
+ const q = rest.indexOf('?'); if (q >= 0) { qualifiers = rest.slice(q + 1); rest = rest.slice(0, q); }
32
+ const slash = rest.indexOf('/');
33
+ if (slash <= 0) { errors.push('a purl needs a type and a name separated by "/"'); return { valid: false, errors }; }
34
+ const type = rest.slice(0, slash);
35
+ if (!PURL_TYPE.test(type)) errors.push(`type "${type}" must be lowercase letters, digits, ".", "+" or "-" and start with a letter`);
36
+ let body = rest.slice(slash + 1);
37
+ let version = null;
38
+ const at = body.lastIndexOf('@'); if (at >= 0) { version = body.slice(at + 1); body = body.slice(0, at); }
39
+ if (version !== null && version === '') errors.push('an empty version after "@" is not allowed');
40
+ const segs = body.split('/');
41
+ const name = segs[segs.length - 1]; const namespace = segs.slice(0, -1);
42
+ if (!name) errors.push('the name is required');
43
+ for (const s of [...namespace, name]) { if (s !== undefined && !PCT.test(s)) errors.push(`segment "${s}" is not correctly percent-encoded`); }
44
+ if (version !== null && !PCT.test(version)) errors.push(`version "${version}" is not correctly percent-encoded`);
45
+ if (LOWERCASE_NAMESPACE_TYPES.has(type)) {
46
+ if (namespace.length !== 1) errors.push(`${type} purls need exactly one namespace (the owner)`);
47
+ for (const s of [...namespace, name]) if (s && s !== s.toLowerCase()) errors.push(`${type} namespace and name are lowercase`);
48
+ }
49
+ if (NAMESPACELESS.has(type) && type !== 'npm' && namespace.length) errors.push(`${type} purls have no namespace`);
50
+ if (type === 'hackage' && name && /[^A-Za-z0-9%-]/.test(name)) errors.push('a hackage package name uses letters, digits and "-"');
51
+ if (qualifiers !== null) {
52
+ const seen = new Set(); let prev = '';
53
+ for (const pair of qualifiers.split('&')) {
54
+ const eq = pair.indexOf('=');
55
+ if (eq <= 0) { errors.push(`qualifier "${pair}" must be key=value`); continue; }
56
+ const k = pair.slice(0, eq); const v = pair.slice(eq + 1);
57
+ if (!/^[a-z][a-z0-9._-]*$/.test(k)) errors.push(`qualifier key "${k}" must be lowercase`);
58
+ if (seen.has(k)) errors.push(`duplicate qualifier "${k}"`); seen.add(k);
59
+ if (k < prev) errors.push('qualifiers must be sorted by key'); prev = k;
60
+ if (v === '') errors.push(`qualifier "${k}" has an empty value (omit it instead)`);
61
+ if (!PCT.test(v)) errors.push(`qualifier "${k}" value is not correctly percent-encoded`);
62
+ if (type === 'generic' && !GENERIC_QUALIFIERS.has(k)) errors.push(`"${k}" is not a recognised qualifier for pkg:generic`);
63
+ }
64
+ }
65
+ if (subpath !== null && subpath.split('/').some((s) => s === '.' || s === '..')) errors.push('a subpath may not contain "." or ".." segments');
66
+ return { valid: errors.length === 0, errors };
67
+ }
68
+
69
+ // ── CycloneDX 1.6 ────────────────────────────────────────────────────────────
70
+ const CDX_COMPONENT_TYPES = new Set(['application', 'framework', 'library', 'container', 'platform', 'operating-system', 'device', 'device-driver', 'firmware', 'file', 'machine-learning-model', 'data', 'cryptographic-asset']);
71
+ const CDX_SCOPES = new Set(['required', 'optional', 'excluded']);
72
+ const CDX_HASH_ALGS = new Set(['MD5', 'SHA-1', 'SHA-256', 'SHA-384', 'SHA-512', 'SHA3-256', 'SHA3-384', 'SHA3-512', 'BLAKE2b-256', 'BLAKE2b-384', 'BLAKE2b-512', 'BLAKE3']);
73
+ const CDX_HASH_LEN = { 'MD5': [32], 'SHA-1': [40], 'SHA-256': [64], 'SHA-384': [96], 'SHA-512': [128], 'SHA3-256': [64], 'SHA3-384': [96], 'SHA3-512': [128], 'BLAKE2b-256': [64], 'BLAKE2b-384': [96], 'BLAKE2b-512': [128], 'BLAKE3': [64] };
74
+ const CDX_EXT_REF_TYPES = new Set(['vcs', 'issue-tracker', 'website', 'advisories', 'bom', 'mailing-list', 'social', 'chat', 'documentation', 'support', 'source-distribution', 'distribution', 'distribution-intake', 'license', 'build-meta', 'build-system', 'release-notes', 'security-contact', 'model-card', 'log', 'configuration', 'evidence', 'formulation', 'attestation', 'threat-model', 'adversary-model', 'risk-assessment', 'vulnerability-assertion', 'exploitability-statement', 'pentest-report', 'static-analysis-report', 'dynamic-analysis-report', 'runtime-analysis-report', 'component-analysis-report', 'maturity-report', 'certification-report', 'quality-metrics', 'codified-infrastructure', 'poam', 'other']);
75
+ const CDX_AGGREGATES = new Set(['complete', 'incomplete', 'incomplete_first_party_only', 'incomplete_first_party_proprietary_only', 'incomplete_first_party_opensource_only', 'incomplete_third_party_only', 'incomplete_third_party_proprietary_only', 'incomplete_third_party_opensource_only', 'unknown', 'not_specified']);
76
+ const CDX_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info', 'none', 'unknown']);
77
+
78
+ function cdxProperties(list, path, errors) {
79
+ if (list === undefined) return;
80
+ if (!Array.isArray(list)) { err(errors, path, 'properties must be an array'); return; }
81
+ list.forEach((p, i) => { if (!isObj(p) || !isStr(p.name)) err(errors, `${path}[${i}]`, 'a property needs a name'); else if (p.value !== undefined && typeof p.value !== 'string') err(errors, `${path}[${i}].value`, 'a property value is a string'); });
82
+ }
83
+
84
+ function cdxComponent(c, path, errors, refs) {
85
+ if (!isObj(c)) { err(errors, path, 'a component must be an object'); return; }
86
+ if (!CDX_COMPONENT_TYPES.has(c.type)) err(errors, `${path}.type`, `type "${c.type}" is not a CycloneDX 1.6 component type`);
87
+ if (!isStr(c.name)) err(errors, `${path}.name`, 'name is required');
88
+ if (c.version !== undefined && typeof c.version !== 'string') err(errors, `${path}.version`, 'version must be a string when present (omit it when unknown)');
89
+ if (c.version === '') err(errors, `${path}.version`, 'an empty version is not a version');
90
+ if (c['bom-ref'] !== undefined) {
91
+ if (!isStr(c['bom-ref'])) err(errors, `${path}.bom-ref`, 'bom-ref must be a non-empty string');
92
+ else if (refs.has(c['bom-ref'])) err(errors, `${path}.bom-ref`, `duplicate bom-ref "${c['bom-ref']}"`);
93
+ else refs.add(c['bom-ref']);
94
+ }
95
+ if (c.purl !== undefined) { const v = validatePurl(c.purl); if (!v.valid) err(errors, `${path}.purl`, `${c.purl}: ${v.errors.join('; ')}`); }
96
+ if (c.scope !== undefined && !CDX_SCOPES.has(c.scope)) err(errors, `${path}.scope`, `scope "${c.scope}" must be required, optional or excluded`);
97
+ if (c.hashes !== undefined) {
98
+ if (!Array.isArray(c.hashes)) err(errors, `${path}.hashes`, 'hashes must be an array');
99
+ else c.hashes.forEach((h, i) => {
100
+ if (!isObj(h) || !CDX_HASH_ALGS.has(h.alg)) { err(errors, `${path}.hashes[${i}].alg`, `unknown hash algorithm "${h && h.alg}"`); return; }
101
+ if (typeof h.content !== 'string' || !HEX.test(h.content) || !CDX_HASH_LEN[h.alg].includes(h.content.length)) err(errors, `${path}.hashes[${i}].content`, `${h.alg} content must be ${CDX_HASH_LEN[h.alg].join('/')} hex characters`);
102
+ });
103
+ }
104
+ if (c.licenses !== undefined && !Array.isArray(c.licenses)) err(errors, `${path}.licenses`, 'licenses must be an array');
105
+ if (c.externalReferences !== undefined) {
106
+ if (!Array.isArray(c.externalReferences)) err(errors, `${path}.externalReferences`, 'externalReferences must be an array');
107
+ else c.externalReferences.forEach((r, i) => { if (!isObj(r) || !isStr(r.url)) err(errors, `${path}.externalReferences[${i}].url`, 'url is required'); if (!isObj(r) || !CDX_EXT_REF_TYPES.has(r.type)) err(errors, `${path}.externalReferences[${i}].type`, `type "${r && r.type}" is not a CycloneDX 1.6 reference type`); });
108
+ }
109
+ cdxProperties(c.properties, `${path}.properties`, errors);
110
+ if (c.components !== undefined) { if (!Array.isArray(c.components)) err(errors, `${path}.components`, 'components must be an array'); else c.components.forEach((x, i) => cdxComponent(x, `${path}.components[${i}]`, errors, refs)); }
111
+ }
112
+
113
+ /** @returns {{valid:boolean, errors:{path:string,message:string}[]}} */
114
+ export function validateCycloneDX16(doc) {
115
+ const errors = [];
116
+ try {
117
+ if (!isObj(doc)) return { valid: false, errors: [{ path: '$', message: 'a CycloneDX document is a JSON object' }] };
118
+ if (doc.bomFormat !== 'CycloneDX') err(errors, '$.bomFormat', 'bomFormat must be "CycloneDX"');
119
+ if (typeof doc.specVersion !== 'string' || !/^1\.[0-9]+$/.test(doc.specVersion)) err(errors, '$.specVersion', 'specVersion must be a string such as "1.6"');
120
+ else if (doc.specVersion !== '1.6') err(errors, '$.specVersion', `this validator checks 1.6, got ${doc.specVersion}`);
121
+ if (doc.serialNumber !== undefined && !/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(doc.serialNumber))) err(errors, '$.serialNumber', 'serialNumber must be a urn:uuid');
122
+ if (doc.version !== undefined && !(Number.isInteger(doc.version) && doc.version >= 1)) err(errors, '$.version', 'version must be an integer of at least 1');
123
+ const refs = new Set();
124
+ const md = doc.metadata;
125
+ if (md !== undefined) {
126
+ if (!isObj(md)) err(errors, '$.metadata', 'metadata must be an object');
127
+ else {
128
+ if (md.timestamp !== undefined && !DATE_TIME.test(String(md.timestamp))) err(errors, '$.metadata.timestamp', 'timestamp must be an ISO 8601 date-time');
129
+ if (md.component !== undefined) cdxComponent(md.component, '$.metadata.component', errors, refs);
130
+ cdxProperties(md.properties, '$.metadata.properties', errors);
131
+ }
132
+ }
133
+ if (doc.components !== undefined) { if (!Array.isArray(doc.components)) err(errors, '$.components', 'components must be an array'); else doc.components.forEach((c, i) => cdxComponent(c, `$.components[${i}]`, errors, refs)); }
134
+ if (doc.dependencies !== undefined) {
135
+ if (!Array.isArray(doc.dependencies)) err(errors, '$.dependencies', 'dependencies must be an array');
136
+ else {
137
+ const seen = new Set();
138
+ doc.dependencies.forEach((d, i) => {
139
+ if (!isObj(d) || !isStr(d.ref)) { err(errors, `$.dependencies[${i}].ref`, 'ref is required'); return; }
140
+ if (!refs.has(d.ref)) err(errors, `$.dependencies[${i}].ref`, `"${d.ref}" is not the bom-ref of any component`);
141
+ if (seen.has(d.ref)) err(errors, `$.dependencies[${i}].ref`, `a second entry for "${d.ref}"`); seen.add(d.ref);
142
+ if (d.dependsOn !== undefined) { if (!Array.isArray(d.dependsOn)) err(errors, `$.dependencies[${i}].dependsOn`, 'dependsOn must be an array'); else d.dependsOn.forEach((r, j) => { if (!refs.has(r)) err(errors, `$.dependencies[${i}].dependsOn[${j}]`, `"${r}" is not the bom-ref of any component`); }); }
143
+ });
144
+ }
145
+ }
146
+ if (doc.compositions !== undefined) {
147
+ if (!Array.isArray(doc.compositions)) err(errors, '$.compositions', 'compositions must be an array');
148
+ else doc.compositions.forEach((c, i) => {
149
+ if (!isObj(c) || !CDX_AGGREGATES.has(c.aggregate)) err(errors, `$.compositions[${i}].aggregate`, `aggregate "${c && c.aggregate}" is not a CycloneDX 1.6 value`);
150
+ for (const k of ['assemblies', 'dependencies']) if (isObj(c) && c[k] !== undefined) { if (!Array.isArray(c[k])) err(errors, `$.compositions[${i}].${k}`, `${k} must be an array`); else c[k].forEach((r, j) => { if (!refs.has(r)) err(errors, `$.compositions[${i}].${k}[${j}]`, `"${r}" is not the bom-ref of any component`); }); }
151
+ });
152
+ }
153
+ if (doc.vulnerabilities !== undefined) {
154
+ if (!Array.isArray(doc.vulnerabilities)) err(errors, '$.vulnerabilities', 'vulnerabilities must be an array');
155
+ else {
156
+ const vrefs = new Set();
157
+ doc.vulnerabilities.forEach((v, i) => {
158
+ if (!isObj(v)) { err(errors, `$.vulnerabilities[${i}]`, 'a vulnerability is an object'); return; }
159
+ if (v['bom-ref'] !== undefined) { if (vrefs.has(v['bom-ref']) || refs.has(v['bom-ref'])) err(errors, `$.vulnerabilities[${i}].bom-ref`, `duplicate bom-ref "${v['bom-ref']}"`); vrefs.add(v['bom-ref']); }
160
+ for (const [j, r] of (v.ratings || []).entries()) if (r.severity !== undefined && !CDX_SEVERITIES.has(r.severity)) err(errors, `$.vulnerabilities[${i}].ratings[${j}].severity`, `severity "${r.severity}" is not a CycloneDX value`);
161
+ for (const [j, a] of (v.affects || []).entries()) if (!isObj(a) || !refs.has(a.ref)) err(errors, `$.vulnerabilities[${i}].affects[${j}].ref`, `"${a && a.ref}" is not the bom-ref of any component`);
162
+ });
163
+ }
164
+ }
165
+ } catch (e) { err(errors, '$', `validator error: ${String((e && e.message) || e)}`); }
166
+ return { valid: errors.length === 0, errors };
167
+ }
168
+
169
+ // ── SPDX 2.3 ─────────────────────────────────────────────────────────────────
170
+ const SPDX_ID = /^SPDXRef-[A-Za-z0-9.-]+$/;
171
+ const SPDX_CHECKSUMS = { SHA1: 40, SHA224: 56, SHA256: 64, SHA384: 96, SHA512: 128, 'SHA3-256': 64, 'SHA3-384': 96, 'SHA3-512': 128, 'BLAKE2b-256': 64, 'BLAKE2b-384': 96, 'BLAKE2b-512': 128, BLAKE3: null, MD2: 32, MD4: 32, MD5: 32, MD6: null, ADLER32: 8 };
172
+ const SPDX_REF_CATEGORIES = new Set(['SECURITY', 'PACKAGE-MANAGER', 'PERSISTENT-ID', 'OTHER']);
173
+ const SPDX_RELATIONSHIPS = new Set(['AMENDS', 'ANCESTOR_OF', 'BUILD_DEPENDENCY_OF', 'BUILD_TOOL_OF', 'CONTAINED_BY', 'CONTAINS', 'COPY_OF', 'DATA_FILE_OF', 'DEPENDENCY_MANIFEST_OF', 'DEPENDENCY_OF', 'DEPENDS_ON', 'DESCENDANT_OF', 'DESCRIBED_BY', 'DESCRIBES', 'DEV_DEPENDENCY_OF', 'DEV_TOOL_OF', 'DISTRIBUTION_ARTIFACT', 'DOCUMENTATION_OF', 'DYNAMIC_LINK', 'EXAMPLE_OF', 'EXPANDED_FROM_ARCHIVE', 'FILE_ADDED', 'FILE_DELETED', 'FILE_MODIFIED', 'GENERATED_FROM', 'GENERATES', 'HAS_PREREQUISITE', 'METAFILE_OF', 'OPTIONAL_COMPONENT_OF', 'OPTIONAL_DEPENDENCY_OF', 'OTHER', 'PACKAGE_OF', 'PATCH_APPLIED', 'PATCH_FOR', 'PREREQUISITE_FOR', 'PROVIDED_DEPENDENCY_OF', 'REQUIREMENT_DESCRIPTION_FOR', 'RUNTIME_DEPENDENCY_OF', 'SPECIFICATION_FOR', 'STATIC_LINK', 'TEST_CASE_OF', 'TEST_DEPENDENCY_OF', 'TEST_OF', 'TEST_TOOL_OF', 'VARIANT_OF']);
174
+
175
+ /** @returns {{valid:boolean, errors:{path:string,message:string}[]}} */
176
+ export function validateSPDX23(doc) {
177
+ const errors = [];
178
+ try {
179
+ if (!isObj(doc)) return { valid: false, errors: [{ path: '$', message: 'an SPDX document is a JSON object' }] };
180
+ if (doc.spdxVersion !== 'SPDX-2.3') err(errors, '$.spdxVersion', 'spdxVersion must be "SPDX-2.3"');
181
+ if (doc.dataLicense !== 'CC0-1.0') err(errors, '$.dataLicense', 'dataLicense must be "CC0-1.0"');
182
+ if (doc.SPDXID !== 'SPDXRef-DOCUMENT') err(errors, '$.SPDXID', 'the document SPDXID is "SPDXRef-DOCUMENT"');
183
+ if (!isStr(doc.name)) err(errors, '$.name', 'name is required');
184
+ if (!isStr(doc.documentNamespace) || !/^[a-z][a-z0-9+.-]*:\/\/[^\s#]+$/i.test(doc.documentNamespace)) err(errors, '$.documentNamespace', 'documentNamespace must be an absolute URI without a fragment');
185
+ const ci = doc.creationInfo;
186
+ if (!isObj(ci)) err(errors, '$.creationInfo', 'creationInfo is required');
187
+ else {
188
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(String(ci.created))) err(errors, '$.creationInfo.created', 'created must be YYYY-MM-DDThh:mm:ssZ');
189
+ if (!Array.isArray(ci.creators) || !ci.creators.length || ci.creators.some((c) => !/^(?:Tool|Organization|Person): .+/.test(String(c)))) err(errors, '$.creationInfo.creators', 'creators must be "Tool|Organization|Person: name"');
190
+ }
191
+ const ids = new Set(['SPDXRef-DOCUMENT']);
192
+ if (doc.packages !== undefined) {
193
+ if (!Array.isArray(doc.packages)) err(errors, '$.packages', 'packages must be an array');
194
+ else doc.packages.forEach((p, i) => {
195
+ const path = `$.packages[${i}]`;
196
+ if (!isObj(p)) { err(errors, path, 'a package is an object'); return; }
197
+ if (!SPDX_ID.test(String(p.SPDXID))) err(errors, `${path}.SPDXID`, 'SPDXID must match SPDXRef-[A-Za-z0-9.-]+');
198
+ else if (ids.has(p.SPDXID)) err(errors, `${path}.SPDXID`, `duplicate SPDXID "${p.SPDXID}"`); else ids.add(p.SPDXID);
199
+ if (!isStr(p.name)) err(errors, `${path}.name`, 'name is required');
200
+ if (!isStr(p.downloadLocation)) err(errors, `${path}.downloadLocation`, 'downloadLocation is required (NOASSERTION when unknown)');
201
+ if (p.versionInfo !== undefined && typeof p.versionInfo !== 'string') err(errors, `${path}.versionInfo`, 'versionInfo must be a string when present (omit it when unknown)');
202
+ if (p.filesAnalyzed !== undefined && typeof p.filesAnalyzed !== 'boolean') err(errors, `${path}.filesAnalyzed`, 'filesAnalyzed must be a boolean');
203
+ for (const k of ['licenseConcluded', 'licenseDeclared', 'copyrightText']) if (p[k] !== undefined && !isStr(p[k])) err(errors, `${path}.${k}`, `${k} must be a string`);
204
+ for (const [j, c] of (p.checksums || []).entries()) {
205
+ if (!isObj(c) || !(c.algorithm in SPDX_CHECKSUMS)) { err(errors, `${path}.checksums[${j}].algorithm`, `unknown checksum algorithm "${c && c.algorithm}"`); continue; }
206
+ const len = SPDX_CHECKSUMS[c.algorithm];
207
+ if (typeof c.checksumValue !== 'string' || !HEX.test(c.checksumValue) || (len && c.checksumValue.length !== len)) err(errors, `${path}.checksums[${j}].checksumValue`, `${c.algorithm} value must be ${len || 'a number of'} hex characters`);
208
+ }
209
+ for (const [j, r] of (p.externalRefs || []).entries()) {
210
+ if (!isObj(r) || !SPDX_REF_CATEGORIES.has(r.referenceCategory)) err(errors, `${path}.externalRefs[${j}].referenceCategory`, `category "${r && r.referenceCategory}" is not an SPDX 2.3 value`);
211
+ if (!isObj(r) || !isStr(r.referenceType) || !isStr(r.referenceLocator)) err(errors, `${path}.externalRefs[${j}]`, 'referenceType and referenceLocator are required');
212
+ else if (r.referenceType === 'purl') { const v = validatePurl(r.referenceLocator); if (!v.valid) err(errors, `${path}.externalRefs[${j}].referenceLocator`, `${r.referenceLocator}: ${v.errors.join('; ')}`); }
213
+ }
214
+ });
215
+ }
216
+ if (doc.relationships !== undefined) {
217
+ if (!Array.isArray(doc.relationships)) err(errors, '$.relationships', 'relationships must be an array');
218
+ else doc.relationships.forEach((r, i) => {
219
+ const path = `$.relationships[${i}]`;
220
+ if (!isObj(r)) { err(errors, path, 'a relationship is an object'); return; }
221
+ if (!SPDX_RELATIONSHIPS.has(r.relationshipType)) err(errors, `${path}.relationshipType`, `relationshipType "${r.relationshipType}" is not an SPDX 2.3 value`);
222
+ for (const k of ['spdxElementId', 'relatedSpdxElement']) if (!(ids.has(r[k]) || r[k] === 'NONE' || r[k] === 'NOASSERTION')) err(errors, `${path}.${k}`, `"${r[k]}" is not the SPDXID of any element`);
223
+ });
224
+ }
225
+ } catch (e) { err(errors, '$', `validator error: ${String((e && e.message) || e)}`); }
226
+ return { valid: errors.length === 0, errors };
227
+ }
@@ -0,0 +1,236 @@
1
+ // Hackage and Nix components, dependency edges and target provenance for the SBOM emitters (X-010).
2
+ //
3
+ // Turns what the language analyses already know into BOM-shaped data WITHOUT inventing any of it:
4
+ //
5
+ // * A version appears only where a resolver or a lock states one. A declared range (`aeson ==2.1.*`) is a property,
6
+ // never a version. A Nix package selector (`pkgs.git`) is not a component at all.
7
+ // * A dependency graph appears only from a fresh resolved plan/export. A manifest or a freeze file gives declared
8
+ // edges at most, and the document says so through `compositions` (`incomplete`), never `complete`.
9
+ // * Resolved builds are never merged away: two Hackage units that differ in flags, or two Nix outputs that share a
10
+ // name and version but not a store hash, stay two components with two bom-refs.
11
+ // * A license appears only when a source states one. Nothing here guesses a license from a name.
12
+ // * Purls follow the registered type rules: `hackage`, `github` for validated upstream hosts, and `generic` (with
13
+ // vcs_url / download_url qualifiers) as the labelled fallback. Nix build provenance travels beside the identity
14
+ // as properties (store path, derivation, system, installable, lock digest), not inside the purl.
15
+
16
+ import { hackageComponents } from './haskell-supply.js';
17
+ import { hackagePurl } from './haskell-sca.js';
18
+ import { buildResolvedGraph } from './haskell-resolved-graph.js';
19
+ import { analyzeNixInputs } from './nix-inventory.js';
20
+
21
+ export const LANGUAGE_BOM_VERSION = 'language-bom/1';
22
+
23
+ const enc = (s) => encodeURIComponent(String(s)).replace(/%2F/gi, '/').replace(/%40/g, '%40');
24
+ const qEnc = (s) => encodeURIComponent(String(s));
25
+ const prop = (name, value) => (value === null || value === undefined || value === '' ? null : { name: `agentic-security:${name}`, value: typeof value === 'string' ? value : JSON.stringify(value) });
26
+ const props = (...xs) => xs.filter(Boolean);
27
+
28
+ /** purl for a generic component, qualifiers sorted by key as the purl spec requires. */
29
+ export function genericPurl(name, version, qualifiers = {}) {
30
+ const q = Object.entries(qualifiers).filter(([, v]) => v !== null && v !== undefined && v !== '').sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${k.toLowerCase()}=${qEnc(v)}`);
31
+ return `pkg:generic/${enc(name)}${version ? `@${qEnc(version)}` : ''}${q.length ? `?${q.join('&')}` : ''}`;
32
+ }
33
+
34
+ /** A purl for a recognised source host; null otherwise (never guessed). */
35
+ export function purlFromGitHost(type, owner, repo, rev) {
36
+ if (!['github', 'gitlab', 'bitbucket'].includes(type) || !owner || !repo) return null;
37
+ return `pkg:${type}/${enc(String(owner).toLowerCase())}/${enc(String(repo).toLowerCase())}${rev ? `@${qEnc(rev)}` : ''}`;
38
+ }
39
+ const GH_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/(?:releases\/download|archive)\//i;
40
+ /** Validated upstream identity from a source URL on a known host. */
41
+ export function purlFromUpstreamUrl(url, version) {
42
+ const m = GH_URL.exec(String(url || ''));
43
+ return m ? purlFromGitHost('github', m[1], m[2], version) : null;
44
+ }
45
+
46
+ const cdxScope = (scopes) => ((scopes || []).includes('runtime') ? 'required' : 'excluded');
47
+
48
+ // ── Haskell ─────────────────────────────────────────────────────────────────
49
+ function haskellBom(files, opts) {
50
+ const out = { components: [], dependencies: [], compositions: [], properties: [], gaps: [], root: null };
51
+ const { components: declared, manifests } = hackageComponents(files);
52
+ if (!manifests) return out;
53
+ const pkg = (manifests.packages || [])[0] || null;
54
+ const rootRef = pkg ? `haskell-root:${pkg.name}@${pkg.version || 'unversioned'}` : 'haskell-root';
55
+ if (pkg) out.root = { type: 'application', 'bom-ref': rootRef, name: pkg.name, ...(pkg.version ? { version: pkg.version } : {}) };
56
+
57
+ const graph = opts.resolved || buildResolvedGraph({ manifests, plan: opts.plan, stackExport: opts.stackExport, expected: opts.expected, rejectStale: opts.rejectStale });
58
+ out.properties.push(prop('haskell:resolution', graph.source), prop('haskell:freshness', graph.freshness && graph.freshness.status), prop('haskell:closure', graph.closure));
59
+ if (graph.compiler) out.properties.push(prop('haskell:compiler', graph.compiler.id));
60
+ out.gaps.push(...(graph.gaps || []).map((g) => ({ ...g, source: 'haskell' })));
61
+
62
+ const refOf = new Map(); // unit id -> bom-ref
63
+ if (graph.graphAvailable && graph.units) {
64
+ for (const u of graph.units) {
65
+ if (u.origin === 'local') { refOf.set(u.id, rootRef); continue; }
66
+ const ref = `hackage:${u.id}`;
67
+ refOf.set(u.id, ref);
68
+ let purl = null;
69
+ if (u.origin === 'hackage' || u.origin === 'compiler') purl = hackagePurl(u.name, u.version || null);
70
+ else if (u.origin === 'vcs') purl = genericPurl(u.name, u.version, { vcs_url: u.repo && u.repo.uri });
71
+ else if (u.origin === 'url') purl = genericPurl(u.name, u.version, { download_url: u.repo && u.repo.uri });
72
+ out.components.push({
73
+ ecosystem: 'hackage', name: u.name, version: u.version || null, purl, bomRef: ref, filePath: (files && Object.keys(files).find((f) => /\.cabal$|cabal\.project$/.test(f))) || undefined, cdxScope: cdxScope(u.scopes), scopes: u.scopes || [],
74
+ // identity across versions: the package, plus its flag assignment when it has one (two builds stay two components)
75
+ identityKey: `hackage:${u.name}${u.flags && Object.keys(u.flags).length ? `?${JSON.stringify(u.flags)}` : ''}${u.sublibrary ? `:${u.sublibrary}` : ''}`,
76
+ ...(u.sha256 ? { hashes: [{ alg: 'SHA-256', content: u.sha256 }] } : {}),
77
+ properties: props(prop('haskell:unitId', u.id), prop('haskell:origin', u.origin), prop('haskell:scopes', u.scopes), prop('haskell:flags', u.flags && Object.keys(u.flags).length ? u.flags : null), prop('haskell:boot', u.boot ? true : null), prop('haskell:component', u.component && u.component.kind), prop('haskell:originEvidence', u.originEvidence)),
78
+ });
79
+ }
80
+ const dep = new Map();
81
+ for (const e of graph.edges || []) {
82
+ const from = refOf.get(e.from); const to = refOf.get(e.to);
83
+ if (!from || !to || from === to) continue;
84
+ if (!dep.has(from)) dep.set(from, new Map());
85
+ const toUnit = (graph.units || []).find((x) => x.id === e.to);
86
+ dep.get(from).set(to, (toUnit && toUnit.scopes && toUnit.scopes.includes('runtime')) ? 'runtime' : ((toUnit && toUnit.scopes && toUnit.scopes.includes('test')) ? 'test' : 'build'));
87
+ }
88
+ for (const [ref, m] of [...dep.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out.dependencies.push({ ref, dependsOn: [...m.keys()].sort(), kinds: Object.fromEntries(m) });
89
+ out.compositions.push({ aggregate: graph.closure && graph.closure.complete ? 'complete' : 'incomplete', dependencies: [...new Set([...refOf.values()])].sort() });
90
+ } else {
91
+ // Declared inventory only: names and (where a freeze or exact pin states one) versions. Not a closure.
92
+ const seen = new Map();
93
+ for (const d of declared) {
94
+ const key = `${d.name}@${d.version || ''}`;
95
+ const ref = d.version ? `hackage:${d.name}@${d.version}` : `hackage:${d.name}`;
96
+ if (seen.has(key)) { const c = seen.get(key); if (!c.scopes.includes(d.scope)) c.scopes.push(d.scope); c.cdxScope = cdxScope(c.scopes); continue; }
97
+ const c = {
98
+ ecosystem: 'hackage', name: d.name, version: d.version || null, purl: hackagePurl(d.name, d.version || null), bomRef: ref, filePath: d.manifest || undefined, scopes: [d.scope], cdxScope: cdxScope([d.scope]), identityKey: `hackage:${d.name}`,
99
+ properties: props(prop('haskell:declaredRange', d.declaredRange), prop('haskell:resolution', d.resolution), prop('haskell:boot', d.ghcComponent ? true : null), prop('haskell:manifest', d.manifest)),
100
+ };
101
+ seen.set(key, c); out.components.push(c);
102
+ }
103
+ if (declared.length) {
104
+ out.dependencies.push({ ref: rootRef, dependsOn: out.components.map((c) => c.bomRef).sort(), kinds: Object.fromEntries(out.components.map((c) => [c.bomRef, (c.scopes || []).includes('runtime') ? 'runtime' : 'build'])) });
105
+ out.compositions.push({ aggregate: 'incomplete', dependencies: [rootRef] });
106
+ }
107
+ out.gaps.push({ kind: 'declared-inventory-only', source: 'haskell', detail: 'no fresh plan or Stack export: components are the declared dependencies, versions only where a freeze or an exact pin states one; transitive packages are not listed' });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // ── Nix ─────────────────────────────────────────────────────────────────────
113
+ function nixInputBom(files) {
114
+ const out = { components: [], dependencies: [], compositions: [], properties: [], gaps: [], root: null };
115
+ const inv = analyzeNixInputs({ files });
116
+ out.gaps.push(...inv.gaps.map((g) => ({ ...g, source: 'nix' })));
117
+ const refOf = new Map();
118
+ for (const f of inv.flakes) {
119
+ const nodes = (f.graph && f.graph.nodes) || [];
120
+ for (const n of nodes) {
121
+ if (n.root || !n.reachable) continue;
122
+ const key = `${f.file}:${n.key}`;
123
+ const ref = `nix-input:${key}`;
124
+ refOf.set(`${f.file}|${n.key}`, ref);
125
+ const lk = n.locked || null;
126
+ const rev = lk && (lk.rev || null);
127
+ let purl = null; let url = null;
128
+ if (lk && lk.type) {
129
+ purl = purlFromGitHost(lk.type, lk.owner, lk.repo, rev);
130
+ if (!purl && lk.url) purl = genericPurl(n.name, rev, lk.type === 'tarball' ? { download_url: lk.url } : { vcs_url: lk.url });
131
+ if (lk.type === 'github') url = `https://github.com/${lk.owner}/${lk.repo}`;
132
+ else if (lk.url) url = lk.url;
133
+ }
134
+ if (!purl) purl = genericPurl(n.name, null);
135
+ out.components.push({
136
+ ecosystem: 'nix', name: n.name, version: rev, purl, bomRef: ref, filePath: f.lock && f.lock.file ? f.lock.file : f.file, cdxScope: 'required', scopes: ['source'], identityKey: `nix-input:${f.file}:${n.key}`,
137
+ ...(url ? { externalReferences: [{ type: 'vcs', url }] } : {}),
138
+ properties: props(prop('nix:class', 'flake-input'), prop('nix:lockNode', n.key), prop('nix:flakeFile', f.file), prop('nix:fetchType', lk && lk.type), prop('nix:narHash', lk && lk.narHash), prop('nix:locked', lk ? true : false), prop('nix:purlBasis', purl.startsWith('pkg:generic') ? 'generic-fallback' : 'validated-upstream-host')),
139
+ });
140
+ }
141
+ const edges = (f.graph && f.graph.edges) || [];
142
+ const by = new Map();
143
+ for (const e of edges) { const a = refOf.get(`${f.file}|${e.from}`); const b = e.to ? refOf.get(`${f.file}|${e.to}`) : null; if (a && b && a !== b) { if (!by.has(a)) by.set(a, new Set()); by.get(a).add(b); } }
144
+ for (const [ref, set] of [...by.entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1))) out.dependencies.push({ ref, dependsOn: [...set].sort(), kinds: Object.fromEntries([...set].map((s) => [s, 'runtime'])) });
145
+ }
146
+ if (out.components.length) out.compositions.push({ aggregate: 'incomplete', dependencies: out.components.map((c) => c.bomRef).sort() });
147
+ out.gaps.push({ kind: 'inputs-are-not-a-closure', source: 'nix', detail: 'flake.lock lists source inputs; it is not the build or runtime closure, so the Nix inputs are reported as incomplete' });
148
+ return out;
149
+ }
150
+
151
+ function nixClosureBom(closure) {
152
+ const out = { components: [], dependencies: [], compositions: [], properties: [], gaps: [], root: null };
153
+ if (!closure || closure.status !== 'ok') { if (closure) out.gaps.push({ kind: 'closure-not-usable', source: 'nix', detail: `closure import status ${closure.status}` }); return out; }
154
+ const prov = (closure.provenance || [])[0] || {};
155
+ const target = prov.target || {};
156
+ out.properties.push(prop('nix:target.system', target.system), prop('nix:target.installable', target.installable), prop('nix:toolVersion', prov.toolVersion), prop('nix:exactRuntimeClosure', closure.claims && closure.claims.exactRuntimeClosure), prop('nix:exactBuildGraph', closure.claims && closure.claims.exactBuildGraph));
157
+ const refOf = new Map();
158
+ for (const n of closure.nodes) {
159
+ if (n.kind !== 'output' && n.kind !== 'source') continue;
160
+ if (!n.pname && !n.storeName) continue;
161
+ const name = n.pname || n.storeName;
162
+ const ref = `nix-store:${n.hash}-${n.storeName}`;
163
+ refOf.set(n.id, ref);
164
+ const urls = (n.upstream && n.upstream.urls) || [];
165
+ let purl = null; let basis = 'generic-fallback';
166
+ for (const u of urls) { const p = purlFromUpstreamUrl(u, n.version); if (p) { purl = p; basis = 'validated-upstream-host'; break; } }
167
+ if (!purl) purl = genericPurl(name, n.version || null);
168
+ const scopes = n.scopes || [];
169
+ out.components.push({
170
+ ecosystem: 'nix', name, version: n.version || null, purl, bomRef: ref, cdxScope: cdxScope(scopes), scopes,
171
+ identityKey: `nix:${name}:${n.system || ''}:${n.outputName || 'out'}${n.sameNameDifferentBuild ? `:${n.hash}` : ''}`,
172
+ properties: props(prop('nix:class', n.kind === 'source' ? 'source' : 'store-output'), prop('nix:storePath', n.id), prop('nix:system', n.system), prop('nix:output', n.outputName), prop('nix:scopes', scopes), prop('nix:versionAuthority', n.versionAuthority), prop('nix:identity', n.identity), prop('nix:purlBasis', basis), prop('nix:sameNameDifferentBuild', n.sameNameDifferentBuild ? true : null), prop('nix:patches', (n.patches || []).map((p) => p.name)), prop('nix:installable', target.installable)),
173
+ });
174
+ }
175
+ const by = new Map();
176
+ for (const e of closure.edges) {
177
+ if (e.kind !== 'reference') continue;
178
+ const a = refOf.get(e.from); const b = refOf.get(e.to);
179
+ if (!a || !b || a === b) continue;
180
+ if (!by.has(a)) by.set(a, new Set()); by.get(a).add(b);
181
+ }
182
+ for (const [ref, set] of [...by.entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1))) out.dependencies.push({ ref, dependsOn: [...set].sort(), kinds: Object.fromEntries([...set].map((s) => [s, 'runtime'])) });
183
+ const runtimeRefs = closure.nodes.filter((n) => n.kind === 'output' && (n.scopes || []).includes('runtime')).map((n) => refOf.get(n.id)).filter(Boolean).sort();
184
+ if (runtimeRefs.length) out.compositions.push({ aggregate: closure.claims && closure.claims.exactRuntimeClosure ? 'complete' : 'incomplete', dependencies: runtimeRefs });
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * @param {Record<string,string>} files rel path -> content (sources and manifests)
190
+ * @param {{closure?: object, resolved?: object, plan?: object, stackExport?: object, expected?: object, rejectStale?: boolean}} [opts]
191
+ * `closure` is an importNixClosure() result (an explicit, provenance-carrying export); nothing is run or fetched.
192
+ */
193
+ export function languageBom(files, opts = {}) {
194
+ const parts = [haskellBom(files || {}, opts), nixInputBom(files || {}), nixClosureBom(opts.closure)];
195
+ const merged = { version: LANGUAGE_BOM_VERSION, components: [], dependencies: [], compositions: [], properties: [], gaps: [], root: null };
196
+ const byRef = new Map();
197
+ for (const p of parts) {
198
+ for (const c of p.components) { if (!byRef.has(c.bomRef)) { byRef.set(c.bomRef, c); merged.components.push(c); } }
199
+ for (const d of p.dependencies) merged.dependencies.push(d);
200
+ merged.compositions.push(...p.compositions);
201
+ merged.properties.push(...p.properties);
202
+ merged.gaps.push(...p.gaps);
203
+ if (!merged.root && p.root) merged.root = p.root;
204
+ }
205
+ // a dependency edge may only name refs that exist (the root ref is allowed)
206
+ const known = new Set([...byRef.keys(), ...(merged.root ? [merged.root['bom-ref']] : [])]);
207
+ merged.dependencies = merged.dependencies.map((d) => ({ ...d, dependsOn: d.dependsOn.filter((r) => known.has(r)) })).filter((d) => known.has(d.ref));
208
+ merged.components.sort((a, b) => (a.bomRef < b.bomRef ? -1 : 1));
209
+ return merged;
210
+ }
211
+
212
+ /**
213
+ * The language half of a Pipeline Bill of Materials: what is fetched and how it is pinned before anything is built.
214
+ * Static facts from the manifests and lock files only; nothing is fetched or evaluated.
215
+ */
216
+ export function languagePbom(files) {
217
+ const out = { version: LANGUAGE_BOM_VERSION, haskell: null, nix: null };
218
+ const { manifests } = hackageComponents(files || {});
219
+ if (manifests) {
220
+ out.haskell = {
221
+ packages: (manifests.packages || []).map((p) => ({ name: p.name, version: p.version || null, buildType: p.buildType || null, file: p.file })),
222
+ customSetup: (manifests.packages || []).some((p) => String(p.buildType || '').toLowerCase() === 'custom'),
223
+ sourceRepositories: (manifests.sourceRepositories || []).map((r) => ({ location: r.location, tag: r.tag || null, pinned: /^[0-9a-f]{40}$/i.test(r.tag || ''), file: r.file, line: r.line })),
224
+ compiler: (manifests.projects || []).map((p) => p.withCompiler).find(Boolean) || null,
225
+ };
226
+ }
227
+ const inv = analyzeNixInputs({ files: files || {} });
228
+ if (inv.flakes.length || inv.legacy.length) {
229
+ out.nix = {
230
+ inputs: inv.flakes.flatMap((f) => (f.declared || []).map((d) => ({ flake: f.file, name: d.name, url: d.url || null, status: d.status, fetchType: d.lockedFetch ? d.lockedFetch.type : null, rev: d.lockedFetch ? d.lockedFetch.rev || null : null, narHash: d.lockedFetch ? d.lockedFetch.narHash || null : null, locked: !!d.locked }))),
231
+ legacy: inv.legacy.map((l) => ({ kind: l.kind, file: l.file, line: l.line || null, resolution: l.resolution || null })),
232
+ closure: { complete: false, reason: 'inputs and fetchers only; a build or runtime closure needs an explicit export' },
233
+ };
234
+ }
235
+ return out;
236
+ }