@clear-capabilities/agentic-security-scanner 0.153.1 → 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 (136) hide show
  1. package/CHANGELOG.md +67 -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 +18 -3
  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 +140 -19
  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/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,414 @@
1
+ // Resolved Haskell dependency graph (HS-008).
2
+ //
3
+ // Reads an EXPLICIT cabal install plan (dist-newstyle/cache/plan.json) or a Stack
4
+ // resolved export (`stack ls dependencies json`) and joins it with the declared
5
+ // manifest inventory from haskell-manifests.js. Static only: no solver, no
6
+ // `cabal`/`stack` process, no network, no snapshot fetch.
7
+ //
8
+ // The rules this module exists to enforce:
9
+ //
10
+ // * a resolved graph is a CLAIM about one input state. It is checked against the
11
+ // manifests, compiler, flags and targets it is meant to describe; a mismatch
12
+ // marks it `stale` and DOWNGRADES it (no versions are copied into the declared
13
+ // inventory, no complete closure is claimed);
14
+ // * a freeze file or a stack.yaml.lock is a set of pins, not a closure. Alone, it
15
+ // never yields `closure.complete`;
16
+ // * missing resolved data leaves `units`/`edges` as null (unknown), never [] (none),
17
+ // and every declared dependency keeps `resolvedVersion: null` with a reason;
18
+ // * a local package is labelled `local`, never `hackage`; compiler-shipped packages
19
+ // are `compiler` with `boot:true`; units are keyed by unit id so two builds of one
20
+ // package under different flags stay distinct.
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { versionSatisfies, analyzeHaskellManifestsInDir } from './haskell-manifests.js';
24
+
25
+ export const RESOLVED_SCHEMA_VERSION = 1;
26
+ export const RESOLVED_BUDGETS = Object.freeze({ maxBytes: 32 * 1024 * 1024, maxUnits: 100000, maxEdges: 1000000 });
27
+
28
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
29
+ const gap = (kind, message, extra = {}) => ({ kind, message, ...extra });
30
+
31
+ // Packages that ship with GHC. Used ONLY as a labelled fallback when a Stack export
32
+ // carries no location data; a cabal plan states `pre-existing` itself.
33
+ const BOOT_FALLBACK = new Set(['base', 'ghc-prim', 'ghc-bignum', 'integer-gmp', 'template-haskell', 'ghc', 'rts', 'ghc-boot', 'ghc-boot-th', 'ghc-heap', 'ghci', 'array', 'bytestring', 'containers', 'deepseq', 'directory', 'exceptions', 'filepath', 'haskeline', 'hpc', 'mtl', 'parsec', 'pretty', 'process', 'stm', 'text', 'time', 'transformers', 'unix', 'Win32', 'binary', 'Cabal', 'Cabal-syntax', 'terminfo', 'xhtml', 'semaphore-compat', 'system-cxx-std-lib']);
34
+
35
+ function componentScope(componentName) {
36
+ const c = typeof componentName === 'string' ? componentName : 'lib';
37
+ const kind = c.split(':')[0];
38
+ const name = c.includes(':') ? c.slice(c.indexOf(':') + 1) : null;
39
+ const scope = { lib: 'runtime', exe: 'runtime', flib: 'runtime', test: 'test', bench: 'benchmark', setup: 'setup' }[kind] || 'runtime';
40
+ return { kind, name, scope, sublibrary: kind === 'lib' && name ? name : null };
41
+ }
42
+
43
+ function planOrigin(u, localNames) {
44
+ if (u.type === 'pre-existing') return { origin: 'compiler', boot: true, evidence: 'plan-type:pre-existing' };
45
+ const src = isObj(u['pkg-src']) ? u['pkg-src'] : null;
46
+ const st = src && typeof src.type === 'string' ? src.type : null;
47
+ if (u.style === 'local' || st === 'local') return { origin: 'local', boot: false, evidence: u.style === 'local' ? 'style:local' : 'pkg-src:local' };
48
+ if (st === 'repo-tar') return { origin: 'hackage', boot: false, evidence: `pkg-src:repo-tar${src.repo && src.repo.type ? `/${src.repo.type}` : ''}` };
49
+ if (st === 'source-repo') return { origin: 'vcs', boot: false, evidence: 'pkg-src:source-repo' };
50
+ if (st === 'remote-tarball') return { origin: 'url', boot: false, evidence: 'pkg-src:remote-tarball' };
51
+ if (localNames.has(u['pkg-name']) && !st) return { origin: 'unknown', boot: false, evidence: 'name matches a local package but plan does not say local' };
52
+ return { origin: 'unknown', boot: false, evidence: st ? `pkg-src:${st}` : 'no pkg-src' };
53
+ }
54
+
55
+ function depIds(u) {
56
+ const dep = []; const exe = [];
57
+ const take = (arr, into) => { if (Array.isArray(arr)) for (const x of arr) if (typeof x === 'string') into.push(x); };
58
+ take(u.depends, dep); take(u['exe-depends'], exe);
59
+ if (isObj(u.components)) for (const c of Object.values(u.components)) if (isObj(c)) { take(c.depends, dep); take(c['exe-depends'], exe); }
60
+ return { dep: [...new Set(dep)], exe: [...new Set(exe)] };
61
+ }
62
+
63
+ /** Local package facts derived from the declared manifest analysis. */
64
+ function manifestFacts(manifests) {
65
+ const m = manifests || {};
66
+ const locals = new Map(); // name -> {version}
67
+ for (const p of m.packages || []) if (p.name) locals.set(p.name, { version: p.version || null });
68
+ for (const p of m.hpack || []) if (p.name && !locals.has(p.name)) locals.set(p.name, { version: p.version || null });
69
+ const withCompiler = (m.projects || []).map((p) => p.withCompiler).find((c) => typeof c === 'string' && /^ghc-\d/.test(c)) || null;
70
+ const stackFile = (m.stack || [])[0] || null;
71
+ let stackCompiler = null;
72
+ if (stackFile) {
73
+ if (typeof stackFile.compiler === 'string') stackCompiler = stackFile.compiler;
74
+ else if (stackFile.snapshot && stackFile.snapshot.kind === 'compiler') stackCompiler = stackFile.snapshot.selector;
75
+ }
76
+ return {
77
+ locals, compiler: withCompiler || stackCompiler, stackFile,
78
+ flagSettings: m.flagSettings || [], flags: m.flags || [], dependencies: m.dependencies || [],
79
+ relaxed: (m.projects || []).some((p) => (p.allowRelaxations || []).length > 0),
80
+ };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Cabal plan.json
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function propagateScopes(units, edges) {
88
+ const byId = new Map(units.map((u) => [u.id, u]));
89
+ const out = new Map();
90
+ for (const e of edges) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); }
91
+ const seen = new Map(units.map((u) => [u.id, new Set()]));
92
+ const queue = [];
93
+ for (const u of units) if (u.origin === 'local') { seen.get(u.id).add(u.ownScope); queue.push([u.id, u.ownScope]); }
94
+ while (queue.length) {
95
+ const [id, scope] = queue.shift();
96
+ for (const e of out.get(id) || []) {
97
+ const s = e.kind === 'exe-depends' ? 'build-tool' : scope;
98
+ const set = seen.get(e.to);
99
+ if (set && !set.has(s)) { set.add(s); queue.push([e.to, s]); }
100
+ }
101
+ }
102
+ for (const [id, set] of seen) byId.get(id).scopes = [...set].sort();
103
+ }
104
+
105
+ function parsePlan(plan, facts, budgets) {
106
+ const gaps = [];
107
+ if (!isObj(plan) || !Array.isArray(plan['install-plan'])) {
108
+ return { units: null, edges: null, gaps: [gap('malformed_plan', 'plan.json has no `install-plan` array; no graph could be read')], compiler: null, platform: null, malformed: true };
109
+ }
110
+ const raw = plan['install-plan'];
111
+ let truncated = false;
112
+ const list = raw.length > budgets.maxUnits ? (truncated = true, raw.slice(0, budgets.maxUnits)) : raw;
113
+ if (truncated) gaps.push(gap('budget_exceeded', `plan has ${raw.length} units; only ${budgets.maxUnits} were read`));
114
+ const localNames = new Set(facts.locals.keys());
115
+ const units = [];
116
+ const ids = new Set();
117
+ for (const [i, u] of list.entries()) {
118
+ if (!isObj(u) || typeof u.id !== 'string' || typeof u['pkg-name'] !== 'string') {
119
+ gaps.push(gap('malformed_unit', `install-plan entry ${i} lacks id or pkg-name; skipped`, { index: i }));
120
+ continue;
121
+ }
122
+ if (ids.has(u.id)) { gaps.push(gap('duplicate_unit_id', `unit id ${u.id} appears twice; first kept`, { unit: u.id })); continue; }
123
+ ids.add(u.id);
124
+ const o = planOrigin(u, localNames);
125
+ const comp = componentScope(u['component-name']);
126
+ units.push({
127
+ id: u.id, name: u['pkg-name'], version: typeof u['pkg-version'] === 'string' ? u['pkg-version'] : null,
128
+ origin: o.origin, boot: o.boot, originEvidence: o.evidence, planType: u.type || null, style: u.style || null,
129
+ component: { kind: comp.kind, name: comp.name }, sublibrary: comp.sublibrary, ownScope: comp.scope, scopes: [],
130
+ flags: isObj(u.flags) ? { ...u.flags } : {},
131
+ sha256: typeof u['pkg-src-sha256'] === 'string' ? u['pkg-src-sha256'] : null,
132
+ repo: isObj(u['pkg-src']) && isObj(u['pkg-src'].repo) ? { type: u['pkg-src'].repo.type || null, uri: u['pkg-src'].repo.uri || null } : null,
133
+ _deps: depIds(u),
134
+ });
135
+ }
136
+ const edges = [];
137
+ for (const u of units) {
138
+ for (const [kind, arr] of [['depends', u._deps.dep], ['exe-depends', u._deps.exe]]) {
139
+ for (const to of arr) {
140
+ if (!ids.has(to)) { gaps.push(gap('dangling_edge', `unit ${u.id} depends on ${to}, which the plan does not contain`, { from: u.id, to })); continue; }
141
+ if (edges.length >= budgets.maxEdges) { gaps.push(gap('budget_exceeded', 'edge budget reached')); break; }
142
+ edges.push({ from: u.id, to, kind });
143
+ }
144
+ }
145
+ delete u._deps;
146
+ }
147
+ propagateScopes(units, edges);
148
+ for (const u of units) delete u.ownScope;
149
+ const cid = typeof plan['compiler-id'] === 'string' ? plan['compiler-id'] : null;
150
+ return {
151
+ units, edges, gaps, malformed: false,
152
+ compiler: cid ? { id: cid, name: cid.replace(/-\d.*$/, ''), version: (/-(\d+(?:\.\d+)*)$/.exec(cid) || [])[1] || null } : null,
153
+ platform: { os: plan.os || null, arch: plan.arch || null },
154
+ };
155
+ }
156
+
157
+ function flagMap(manifestFlagSettings, flagDecls, pkgName) {
158
+ // expected explicit settings for one local package, plus manual-flag defaults
159
+ const exp = new Map();
160
+ for (const f of flagDecls) if (f.package === pkgName && f.manual && typeof f.default === 'boolean') exp.set(f.name, { value: f.default, from: 'manual-default' });
161
+ for (const s of manifestFlagSettings) {
162
+ if (s.scope === 'stack' || typeof s.value !== 'boolean') continue;
163
+ if (s.package === pkgName || s.package === null) exp.set(s.name, { value: s.value, from: `${s.scope}-setting` });
164
+ }
165
+ return exp;
166
+ }
167
+
168
+ function checkPlanFreshness(parsed, facts, expected) {
169
+ const reasons = [];
170
+ const stale = (kind, message, extra = {}) => reasons.push({ kind, message, ...extra });
171
+ const wantCompiler = expected.compiler || facts.compiler;
172
+ if (wantCompiler && /^ghc-\d/.test(wantCompiler)) {
173
+ if (!parsed.compiler) stale('compiler_unknown', 'plan records no compiler-id; compiler match cannot be shown', { severity: 'unverified' });
174
+ else if (parsed.compiler.id !== wantCompiler) stale('compiler_mismatch', `plan was generated for ${parsed.compiler.id} but the project targets ${wantCompiler}`);
175
+ }
176
+ if (expected.os && parsed.platform && parsed.platform.os && parsed.platform.os !== expected.os) stale('platform_mismatch', `plan os ${parsed.platform.os} differs from expected ${expected.os}`);
177
+ if (expected.arch && parsed.platform && parsed.platform.arch && parsed.platform.arch !== expected.arch) stale('platform_mismatch', `plan arch ${parsed.platform.arch} differs from expected ${expected.arch}`);
178
+ if (Number.isFinite(expected.planMtimeMs) && Number.isFinite(expected.manifestMtimeMs) && expected.planMtimeMs < expected.manifestMtimeMs) stale('plan_older_than_manifest', 'plan.json is older than the newest manifest; it predates the current declarations');
179
+
180
+ const units = parsed.units || [];
181
+ const localUnits = units.filter((u) => u.origin === 'local');
182
+ const planLocalNames = new Set(localUnits.map((u) => u.name));
183
+ for (const [name, info] of facts.locals) {
184
+ if (!planLocalNames.has(name)) stale('local_package_missing', `manifest declares local package ${name} but the plan has no local unit for it`, { package: name });
185
+ else if (info.version) {
186
+ const vs = localUnits.filter((u) => u.name === name).map((u) => u.version);
187
+ if (!vs.includes(info.version)) stale('local_version_mismatch', `manifest says ${name} ${info.version} but the plan built ${vs.join(', ')}`, { package: name });
188
+ }
189
+ }
190
+ if (facts.locals.size) for (const n of planLocalNames) if (!facts.locals.has(n)) stale('unknown_local_package', `plan builds local package ${n} that no manifest declares`, { package: n });
191
+
192
+ for (const u of localUnits) {
193
+ for (const [flag, e] of flagMap(facts.flagSettings, facts.flags, u.name)) {
194
+ if (flag in u.flags && u.flags[flag] !== e.value) stale('flags_mismatch', `plan built ${u.name} with ${flag}=${u.flags[flag]} but the manifest/project sets ${e.value} (${e.from})`, { package: u.name, flag });
195
+ }
196
+ }
197
+ if (Array.isArray(expected.targets)) {
198
+ const have = new Set(units.map((u) => (u.origin === 'local' ? `${u.name}:${u.component.kind}${u.component.name ? `:${u.component.name}` : ''}` : null)).filter(Boolean));
199
+ for (const t of expected.targets) if (!have.has(t)) stale('target_missing', `expected target ${t} is not in the plan`, { target: t });
200
+ }
201
+ // Declared, unconditional runtime dependencies must be explainable by the plan.
202
+ const names = new Set(units.map((u) => u.name));
203
+ const byName = new Map();
204
+ for (const u of units) { if (!byName.has(u.name)) byName.set(u.name, []); byName.get(u.name).push(u); }
205
+ for (const d of facts.dependencies) {
206
+ if (d.sublibrary || !d.package || !facts.locals.has(d.package) || (d.conditions || []).length) continue;
207
+ if (d.manifestType === 'cabal' && d.shadowedBy) continue;
208
+ if (d.scope !== 'runtime') continue;
209
+ if (!names.has(d.name)) { stale('declared_dependency_missing', `${d.package} declares ${d.name} but the plan contains no such package`, { package: d.package, dependency: d.name }); continue; }
210
+ if (!facts.relaxed && d.rangeKind !== 'unparsed' && d.declaredRange && d.rangeError == null) {
211
+ const ok = byName.get(d.name).some((u) => { try { return u.version && versionSatisfies(d.declaredRange, u.version); } catch { return true; } });
212
+ if (!ok) stale('bounds_violated', `plan has ${d.name} ${byName.get(d.name).map((u) => u.version).join(', ')}, outside declared bound ${d.declaredRange} of ${d.package}`, { package: d.package, dependency: d.name });
213
+ }
214
+ }
215
+ return reasons;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Stack export
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function parseStackExport(doc, facts, budgets) {
223
+ const envelope = isObj(doc) && Array.isArray(doc.packages) ? doc : null;
224
+ const list = Array.isArray(doc) ? doc : (envelope ? envelope.packages : null);
225
+ if (!Array.isArray(list)) return { units: null, edges: null, gaps: [gap('malformed_stack_export', 'stack export is neither an array nor an object with `packages`; no graph could be read')], malformed: true, envelope: null };
226
+ const gaps = [];
227
+ const scope = envelope && typeof envelope.scope === 'string' ? envelope.scope : null;
228
+ const units = [];
229
+ const idOf = new Map();
230
+ for (const [i, e] of list.slice(0, budgets.maxUnits).entries()) {
231
+ if (!isObj(e) || typeof e.name !== 'string') { gaps.push(gap('malformed_unit', `stack export entry ${i} has no name; skipped`, { index: i })); continue; }
232
+ const version = typeof e.version === 'string' ? e.version : null;
233
+ const id = `${e.name}-${version || '?'}`;
234
+ if (idOf.has(e.name)) { gaps.push(gap('duplicate_unit_id', `package ${e.name} appears twice; first kept`, { unit: id })); continue; }
235
+ idOf.set(e.name, id);
236
+ const loc = isObj(e.location) ? e.location : null;
237
+ let origin; let boot = false; let evidence;
238
+ if (facts.locals.has(e.name)) { origin = 'local'; evidence = 'name matches a local manifest package'; }
239
+ else if (loc && loc.type === 'hackage') { origin = 'hackage'; evidence = 'location:hackage'; }
240
+ else if (loc && typeof loc.type === 'string') { origin = ['git', 'hg', 'github'].includes(loc.type) ? 'vcs' : (loc.type === 'archive' || loc.type === 'url' ? 'url' : 'unknown'); evidence = `location:${loc.type}`; }
241
+ else if (BOOT_FALLBACK.has(e.name)) { origin = 'compiler'; boot = true; evidence = 'boot-name-fallback (export carries no location)'; }
242
+ else { origin = 'unknown'; evidence = 'export carries no location'; }
243
+ units.push({
244
+ id, name: e.name, version, origin, boot, originEvidence: evidence, planType: null, style: null,
245
+ component: { kind: null, name: null }, sublibrary: null, scopes: [scope || 'unspecified'], flags: {}, sha256: null, repo: null,
246
+ _names: Array.isArray(e.dependencies) ? e.dependencies.filter((x) => typeof x === 'string') : [],
247
+ });
248
+ }
249
+ const edges = [];
250
+ for (const u of units) {
251
+ for (const n of u._names) {
252
+ const to = idOf.get(n);
253
+ if (!to) { gaps.push(gap('dangling_edge', `${u.id} depends on ${n}, which the stack export does not contain`, { from: u.id, to: n })); continue; }
254
+ if (edges.length < budgets.maxEdges) edges.push({ from: u.id, to, kind: 'depends' });
255
+ }
256
+ delete u._names;
257
+ }
258
+ return { units, edges, gaps, malformed: false, envelope };
259
+ }
260
+
261
+ function checkStackFreshness(parsed, facts, expected) {
262
+ const reasons = [];
263
+ const env = parsed.envelope;
264
+ const stale = (kind, message, extra = {}) => reasons.push({ kind, message, ...extra });
265
+ if (!env) { stale('stack_export_unlabelled', 'a bare stack export carries no snapshot or compiler, so it cannot be matched to stack.yaml', { severity: 'unverified' }); }
266
+ else {
267
+ const sf = facts.stackFile;
268
+ const snap = typeof env.snapshot === 'string' ? env.snapshot : (typeof env.resolver === 'string' ? env.resolver : null);
269
+ const wantSnap = expected.snapshot || (sf && sf.snapshot ? sf.snapshot.selector : null);
270
+ if (wantSnap) { if (!snap) stale('snapshot_unknown', 'export records no snapshot', { severity: 'unverified' }); else if (snap !== wantSnap) stale('snapshot_mismatch', `export was made for snapshot ${snap} but stack.yaml selects ${wantSnap}`); }
271
+ const wantCompiler = expected.compiler || facts.compiler;
272
+ const comp = typeof env.compiler === 'string' ? env.compiler : null;
273
+ if (wantCompiler && /^ghc-\d/.test(wantCompiler) && comp && comp !== wantCompiler) stale('compiler_mismatch', `export compiler ${comp} differs from ${wantCompiler}`);
274
+ if (!snap && !comp) stale('stack_export_unlabelled', 'export envelope carries neither snapshot nor compiler', { severity: 'unverified' });
275
+ }
276
+ const units = parsed.units || [];
277
+ const names = new Set(units.map((u) => u.name));
278
+ for (const n of facts.locals.keys()) if (!names.has(n)) stale('local_package_missing', `manifest declares local package ${n} but the stack export lacks it`, { package: n });
279
+ const extra = facts.stackFile ? (facts.stackFile.extraDeps || []).filter((e) => e.kind === 'hackage' && e.version) : [];
280
+ for (const e of extra) {
281
+ const u = units.find((x) => x.name === e.name);
282
+ if (u && u.version && u.version !== e.version) stale('extra_dep_mismatch', `stack.yaml pins ${e.name}-${e.version} but the export has ${u.version}`, { package: e.name });
283
+ }
284
+ return reasons;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Declared inventory join
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function joinDeclared(manifests, graph) {
292
+ const byName = new Map();
293
+ for (const u of graph.units || []) { if (!byName.has(u.name)) byName.set(u.name, []); byName.get(u.name).push(u); }
294
+ const locked = new Map();
295
+ for (const l of (manifests && manifests.lockedPackages) || []) if (l.version && !locked.has(l.name)) locked.set(l.name, l.version);
296
+ const usable = graph.graphAvailable && graph.freshness.status === 'fresh';
297
+ const why = !graph.graphAvailable ? 'no_resolved_data' : (graph.freshness.status === 'stale' ? 'stale_resolved_data' : 'unverified_resolved_data');
298
+ return ((manifests && manifests.dependencies) || []).map((d) => {
299
+ const base = { ...d, resolvedVersion: null, resolution: { status: 'unresolved', reason: why, unitIds: [] } };
300
+ if (locked.has(d.name)) base.lockedVersion = locked.get(d.name);
301
+ if (!usable) return base;
302
+ const cands = (byName.get(d.name) || []).filter((u) => !d.sublibrary || u.sublibrary === d.sublibrary || u.sublibrary === null);
303
+ if (!cands.length) return { ...base, resolution: { status: 'unresolved', reason: 'not_in_resolved_graph', unitIds: [] } };
304
+ const versions = [...new Set(cands.map((u) => u.version))];
305
+ if (versions.length > 1) return { ...base, resolution: { status: 'ambiguous', reason: 'multiple_versions_in_graph', unitIds: cands.map((u) => u.id) } };
306
+ return { ...base, resolvedVersion: versions[0], resolution: { status: 'resolved', reason: null, unitIds: cands.map((u) => u.id), origin: cands[0].origin } };
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Build the resolved graph.
312
+ * input: { manifests (analyzeHaskellManifests output), plan (parsed plan.json),
313
+ * stackExport (parsed JSON), expected: {compiler, os, arch, targets, snapshot,
314
+ * planMtimeMs, manifestMtimeMs}, rejectStale, budgets }
315
+ */
316
+ export function buildResolvedGraph(input = {}) {
317
+ const budgets = { ...RESOLVED_BUDGETS, ...(input.budgets || {}) };
318
+ const expected = input.expected || {};
319
+ const facts = manifestFacts(input.manifests);
320
+ const result = {
321
+ schemaVersion: RESOLVED_SCHEMA_VERSION, source: 'none', graphAvailable: false,
322
+ freshness: { status: 'unverified', reasons: [] }, compiler: null,
323
+ units: null, edges: null, gaps: [], closure: { complete: false, reason: 'no_resolved_data' }, declared: [],
324
+ };
325
+ let parsed = null;
326
+ let reasons = [];
327
+ if (input.plan !== undefined && input.plan !== null) {
328
+ result.source = 'cabal-plan';
329
+ parsed = parsePlan(input.plan, facts, budgets);
330
+ result.compiler = parsed.compiler || null;
331
+ if (!parsed.malformed) reasons = checkPlanFreshness(parsed, facts, expected);
332
+ } else if (input.stackExport !== undefined && input.stackExport !== null) {
333
+ result.source = 'stack-export';
334
+ parsed = parseStackExport(input.stackExport, facts, budgets);
335
+ if (!parsed.malformed) reasons = checkStackFreshness(parsed, facts, expected);
336
+ }
337
+
338
+ const lockOnly = !parsed && (((input.manifests || {}).lockedPackages || []).length > 0);
339
+ if (!parsed) {
340
+ result.gaps.push(gap('no_resolved_data', 'no plan.json or stack export was supplied; the dependency graph is unknown (not empty). Nothing was fetched.'));
341
+ if (lockOnly) {
342
+ result.closure = { complete: false, reason: 'lock_only' };
343
+ result.gaps.push(gap('lock_is_not_closure', 'freeze/lock pins name versions but not edges or the full transitive set; a closure is not claimed'));
344
+ }
345
+ } else {
346
+ result.gaps.push(...parsed.gaps);
347
+ if (parsed.malformed) result.closure = { complete: false, reason: 'malformed_resolved_data' };
348
+ else {
349
+ const hardStale = reasons.filter((r) => r.severity !== 'unverified');
350
+ result.freshness = { status: hardStale.length ? 'stale' : (reasons.length ? 'unverified' : 'fresh'), reasons };
351
+ for (const r of reasons) result.gaps.push(gap(r.severity === 'unverified' ? 'freshness_unverified' : 'stale_resolved_data', r.message, { reason: r.kind }));
352
+ if (result.freshness.status === 'stale' && input.rejectStale) {
353
+ result.closure = { complete: false, reason: 'rejected_stale' };
354
+ } else {
355
+ result.graphAvailable = true;
356
+ result.units = parsed.units.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
357
+ result.edges = parsed.edges.sort((a, b) => `${a.from}\0${a.to}\0${a.kind}`.localeCompare(`${b.from}\0${b.to}\0${b.kind}`));
358
+ const dangling = parsed.gaps.some((g) => ['dangling_edge', 'malformed_unit', 'budget_exceeded', 'duplicate_unit_id'].includes(g.kind));
359
+ if (result.freshness.status === 'fresh' && !dangling) result.closure = { complete: true, reason: null };
360
+ else result.closure = { complete: false, reason: result.freshness.status === 'fresh' ? 'graph_gaps' : `${result.freshness.status}_resolved_data` };
361
+ }
362
+ }
363
+ }
364
+ result.declared = joinDeclared(input.manifests, result);
365
+ return result;
366
+ }
367
+
368
+ /**
369
+ * Link imported module names to packages. A link is `verified` only when `moduleIndex`
370
+ * (unit id or package name -> exposed modules, from package metadata) lists the module
371
+ * AND that package is in the graph. Anything else is reported unlinked, never guessed.
372
+ */
373
+ export function linkModulesToPackages(graph, moduleIndex, imports) {
374
+ const idx = isObj(moduleIndex) ? moduleIndex : {};
375
+ return (imports || []).map((mod) => {
376
+ if (!graph || !graph.graphAvailable) return { module: mod, status: 'unlinked', reason: 'no_resolved_graph' };
377
+ const hits = graph.units.filter((u) => (idx[u.id] || idx[u.name] || []).includes(mod));
378
+ if (!hits.length) return { module: mod, status: 'unlinked', reason: 'no_metadata_for_module' };
379
+ return { module: mod, status: hits.length === 1 ? 'verified' : 'ambiguous', units: hits.map((u) => u.id), packages: [...new Set(hits.map((u) => u.name))] };
380
+ });
381
+ }
382
+
383
+ function readBounded(root, rel, budgets) {
384
+ const abs = path.resolve(root, rel);
385
+ const base = path.resolve(root);
386
+ if (abs !== base && !abs.startsWith(base + path.sep)) return { error: gap('path_outside_root', `${rel} escapes the project root; not read`) };
387
+ let st;
388
+ try { st = fs.statSync(abs); } catch { return { error: gap('resolved_file_missing', `${rel} does not exist`) }; }
389
+ if (!st.isFile()) return { error: gap('resolved_file_missing', `${rel} is not a regular file`) };
390
+ if (st.size > budgets.maxBytes) return { error: gap('budget_exceeded', `${rel} exceeds ${budgets.maxBytes} bytes; not read`) };
391
+ try { return { text: fs.readFileSync(abs, 'utf8'), mtimeMs: st.mtimeMs }; } catch (e) { return { error: gap('read_failed', String(e && e.message).slice(0, 120)) }; }
392
+ }
393
+
394
+ /**
395
+ * Directory entry point. Plan and stack export are read ONLY from the explicit
396
+ * relative paths given (`planPath`, `stackExportPath`); nothing is discovered or run.
397
+ */
398
+ export function analyzeResolvedHaskell(root, opts = {}) {
399
+ const budgets = { ...RESOLVED_BUDGETS, ...(opts.budgets || {}) };
400
+ const manifests = analyzeHaskellManifestsInDir(root, opts);
401
+ const extraGaps = [];
402
+ const input = { manifests, expected: { ...(opts.expected || {}) }, rejectStale: opts.rejectStale, budgets };
403
+ const load = (rel, key) => {
404
+ const r = readBounded(root, rel, budgets);
405
+ if (r.error) { extraGaps.push(r.error); return null; }
406
+ try { input[key] = JSON.parse(r.text); } catch (e) { extraGaps.push(gap(key === 'plan' ? 'malformed_plan' : 'malformed_stack_export', `${rel} is not valid JSON: ${String(e && e.message).slice(0, 100)}`)); return null; }
407
+ return r;
408
+ };
409
+ if (opts.planPath) { const r = load(opts.planPath, 'plan'); if (r && input.expected.planMtimeMs === undefined) input.expected.planMtimeMs = r.mtimeMs; }
410
+ if (opts.stackExportPath && input.plan === undefined) load(opts.stackExportPath, 'stackExport');
411
+ const g = buildResolvedGraph(input);
412
+ g.gaps.unshift(...extraGaps);
413
+ return { manifests, graph: g };
414
+ }