@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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 (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -175,7 +175,23 @@ function exportedLookup(proj, target, name, depth, seen) {
175
175
  return null;
176
176
  }
177
177
 
178
- function resolveGlobal(proj, mod, name, qual, depth = 0, seen = new Set()) {
178
+ // A top-level resolution (depth 0, nothing seen yet) is a pure function of the module table, which is complete before any function
179
+ // is lowered, so its answer is kept per module: the same name is resolved again at every use site, and each resolution walks the
180
+ // module's imports and the re-export chains of every module it imports (hub modules re-export hundreds of names). Resolutions
181
+ // made inside a re-export chain (depth > 0) depend on the path taken, so they are never kept. Callers treat the result as
182
+ // read-only.
183
+ const RESOLVE_MEMO = new WeakMap(); // module info -> (qualifier, name) -> resolution; a module's memo dies with the module
184
+ function resolveGlobal(proj, mod, name, qual, depth = 0, seen = null) {
185
+ if (depth !== 0 || seen) return resolveGlobalUncached(proj, mod, name, qual, depth, seen || new Set());
186
+ let memo = RESOLVE_MEMO.get(mod);
187
+ if (!memo) { memo = new Map(); RESOLVE_MEMO.set(mod, memo); }
188
+ const key = qual ? `${qual}\0${name}` : name;
189
+ let r = memo.get(key);
190
+ if (r === undefined) { r = resolveGlobalUncached(proj, mod, name, qual, 0, new Set()); memo.set(key, r); }
191
+ return r;
192
+ }
193
+
194
+ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
179
195
  if ((!qual || qual === mod.name) && mod.defs.has(name)) return defResult(mod, name);
180
196
  if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
181
197
  const hits = [];
@@ -649,8 +665,7 @@ class FnLowerer {
649
665
  // call to it, not a mere reference: it runs when sequenced.
650
666
  executeRef(v, ln) {
651
667
  if (!v || v.kind !== 'ident' || !v.hs || !v.hs.functionRef || v.hs.closure) return v;
652
- let def = null;
653
- for (const m of this.proj.modules.values()) for (const d of m.defs.values()) if (d.qid === v.hs.functionRef) def = d;
668
+ const def = this.proj.defByQid(v.hs.functionRef);
654
669
  if (!def || def.kind !== 'fun' || def.arity !== 0) return v;
655
670
  return { kind: 'call', callee: v.name, args: [], line: ln, hs: { status: 'resolved', target: v.hs.functionRef, effect: 'io', action: true } };
656
671
  }
@@ -1103,9 +1118,35 @@ class Project {
1103
1118
  this.byFile.get(fn.file).push(fn);
1104
1119
  }
1105
1120
 
1121
+ // The three lookups below used to walk every module on every call (a constructor, a record type, a def by qid), which is
1122
+ // quadratic in project size. They are built once, lazily, on first use: the module table is complete before any function
1123
+ // is lowered, and nothing adds a module, a constructor or a definition afterwards. First-wins / last-wins orders match the
1124
+ // original scans exactly.
1106
1125
  findCon(name) {
1107
- for (const m of this.modules.values()) if (m.cons.has(name)) return m.cons.get(name);
1108
- return null;
1126
+ if (!this._conIndex) {
1127
+ this._conIndex = new Map();
1128
+ for (const m of this.modules.values()) for (const [n, c] of m.cons) if (!this._conIndex.has(n)) this._conIndex.set(n, c);
1129
+ }
1130
+ return this._conIndex.get(name) || null;
1131
+ }
1132
+
1133
+ // Record types declared in the project: type name -> field names (a later declaration of the same type name wins).
1134
+ recordTypes() {
1135
+ if (!this._recTypes) {
1136
+ const recTypes = new Map();
1137
+ for (const m of this.modules.values()) for (const d of (m.parse && m.parse.data) || []) if (d.constructors && d.constructors.some((c) => c.fields && c.fields.length)) recTypes.set(d.name, d.constructors.flatMap((c) => c.fields || []));
1138
+ this._recTypes = recTypes;
1139
+ }
1140
+ return this._recTypes;
1141
+ }
1142
+
1143
+ // A definition by its qid (the last one in module order wins, as the linear scan it replaces did).
1144
+ defByQid(qid) {
1145
+ if (!this._defIndex) {
1146
+ this._defIndex = new Map();
1147
+ for (const m of this.modules.values()) for (const d of m.defs.values()) this._defIndex.set(d.qid, d);
1148
+ }
1149
+ return this._defIndex.get(qid) || null;
1109
1150
  }
1110
1151
 
1111
1152
  instancesOf(cls, method) { return [...(this.instanceIndex.get(`${cls}.${method}`) || [])]; }
@@ -1123,8 +1164,7 @@ function lowerTopFunction(proj, mod, fun, def) {
1123
1164
  fn.hs.textParams = ann.map((a) => a.name);
1124
1165
  // Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
1125
1166
  // parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
1126
- const recTypes = new Map();
1127
- for (const m of proj.modules.values()) for (const d of (m.parse && m.parse.data) || []) if (d.constructors && d.constructors.some((c) => c.fields && c.fields.length)) recTypes.set(d.name, d.constructors.flatMap((c) => c.fields || []));
1167
+ const recTypes = proj.recordTypes();
1128
1168
  fn.hs.recordParams = [];
1129
1169
  if (exported && sig && Array.isArray(sig.argTypes) && Array.isArray(fn.params)) {
1130
1170
  sig.argTypes.forEach((t, i) => { const m = /^\(?\s*([A-Z][A-Za-z0-9_']*)\s*\)?$/.exec(t); if (m && recTypes.has(m[1]) && fn.params[i]) fn.hs.recordParams.push({ name: fn.params[i], type: m[1], fields: [...new Set(recTypes.get(m[1]))] }); });
@@ -32,7 +32,24 @@ export const GHC_BOOT_PACKAGES = Object.freeze(new Set([
32
32
  'process', 'stm', 'text', 'time', 'transformers', 'unix', 'xhtml', 'terminfo', 'Cabal', 'Cabal-syntax',
33
33
  ]));
34
34
 
35
- export const hackagePurl = (name, version) => `pkg:hackage/${encodeURIComponent(name).replace(/%2D/g, '-')}${version ? `@${encodeURIComponent(version)}` : ''}`;
35
+ /**
36
+ * The `base` series each GHC release series ships (the series only: patch releases of one GHC can ship different `base` patch levels, so
37
+ * the answer is a range, never a single version). A release series absent from this table is not guessed.
38
+ */
39
+ export const GHC_BASE_SERIES = Object.freeze({
40
+ '8.8': '4.13', '8.10': '4.14', '9.0': '4.15', '9.2': '4.16', '9.4': '4.17', '9.6': '4.18', '9.8': '4.19', '9.10': '4.20', '9.12': '4.21', '9.14': '4.22',
41
+ });
42
+
43
+ /** `>=4.18 && <4.19` for a GHC version such as "9.6.4", or null when the series is not in the table. */
44
+ export function baseRangeForGhc(ghcVersion) {
45
+ const m = /^(\d+)\.(\d+)(?:\.|$)/.exec(String(ghcVersion || ''));
46
+ const series = m && GHC_BASE_SERIES[`${m[1]}.${m[2]}`];
47
+ if (!series) return null;
48
+ const [a, b] = series.split('.').map(Number);
49
+ return `>=${a}.${b} && <${a}.${b + 1}`;
50
+ }
51
+
52
+ export const hackagePurl =(name, version) => `pkg:hackage/${encodeURIComponent(name).replace(/%2D/g, '-')}${version ? `@${encodeURIComponent(version)}` : ''}`;
36
53
 
37
54
  /** The exact JSON body an OSV `/v1/query` takes for one component. */
38
55
  export function osvWireQuery(name, version) {
@@ -234,17 +251,46 @@ export function matchComponent(aff, comp) {
234
251
  }
235
252
  // `unbounded` = the manifest declared the dependency with NO version bound (`build-depends: aeson`), which in Cabal means "any version".
236
253
  // That is a declared range like any other (it overlaps an advisory, so possibly-affected), not an absent one.
237
- if (comp.declaredRange || comp.unbounded) {
238
- const declared = comp.declaredRange ? rangeToIntervals(comp.declaredRange) : [iv(null, false, null, false)];
254
+ const projectConstraints = Array.isArray(comp.projectConstraints) ? comp.projectConstraints : [];
255
+ if (comp.declaredRange || comp.unbounded || projectConstraints.length) {
256
+ const eff = effectiveDeclared(comp);
257
+ const declared = eff.intervals;
239
258
  if (declared === null) return { status: 'unknown', reason: 'the declared range could not be parsed' };
240
- if (!declared.length) return { status: 'unknown', reason: 'the declared range admits no version' };
259
+ if (!declared.length) return { status: 'unknown', reason: projectConstraints.length ? `the declared range and the project constraints (${projectConstraints.map((c) => c.text).join(', ')}) admit no common version` : 'the declared range admits no version' };
241
260
  const overlap = intervalsOverlap(declared, aff.intervals);
242
- if (!overlap) return { status: 'not-affected', reason: 'no version allowed by the declared range is affected' };
261
+ if (!overlap) return { status: 'not-affected', reason: `no version allowed by the declared range is affected${eff.note}` };
243
262
  const subset = declared.every((d) => aff.intervals.some((a) => !isEmpty(intersectIntervals(d, a)) && containsInterval(a, d)));
244
- return subset ? { status: 'affected', reason: 'every version allowed by the declared range is affected' } : { status: 'possibly-affected', reason: comp.declaredRange ? 'the declared range allows both affected and unaffected versions; no resolved version is known' : 'the dependency is declared with no version bound, so any version may be selected; no resolved version is known' };
263
+ return subset ? { status: 'affected', reason: `every version allowed by the declared range is affected${eff.note}` } : { status: 'possibly-affected', reason: `${comp.declaredRange ? 'the declared range allows both affected and unaffected versions; no resolved version is known' : 'the dependency is declared with no version bound, so any version may be selected; no resolved version is known'}${eff.note}` };
245
264
  }
246
265
  return { status: 'unknown', reason: 'neither a resolved version nor a declared range is known' };
247
266
  }
267
+
268
+ /**
269
+ * The versions a component may be built with: its declared range, widened by `allow-newer` / `allow-older` (which only ever ADD
270
+ * versions: an upper or lower bound dropped), then narrowed by project `constraints:` (hard solver constraints, so they intersect).
271
+ * Widening comes first because a relaxation applies to the package's own bounds and a constraint to whatever remains.
272
+ * @returns {{intervals: object[]|null, note: string}} null intervals = the declared range could not be parsed
273
+ */
274
+ export function effectiveDeclared(comp) {
275
+ let intervals = comp.declaredRange ? rangeToIntervals(comp.declaredRange) : [iv(null, false, null, false)];
276
+ if (intervals === null) return { intervals: null, note: '' };
277
+ const notes = [];
278
+ const rel = Array.isArray(comp.relaxation) && comp.relaxation.length ? new Set(comp.relaxation.map((r) => r.direction)) : null;
279
+ if (rel && comp.declaredRange) {
280
+ intervals = intervals.map((x) => iv(rel.has('older') ? ZERO : x.lo, rel.has('older') ? true : x.loInc, rel.has('newer') ? null : x.hi, rel.has('newer') ? false : x.hiInc));
281
+ notes.push(`widened by ${[...rel].map((d) => `allow-${d}`).join(' and ')}, so the declared bounds are not authoritative`);
282
+ }
283
+ const cons = Array.isArray(comp.projectConstraints) ? comp.projectConstraints : [];
284
+ for (const c of cons) {
285
+ const ci = rangeToIntervals(c.text);
286
+ if (ci === null) continue;
287
+ const out = [];
288
+ for (const a of intervals) for (const b of ci) out.push(intersectIntervals(a, b));
289
+ intervals = normalizeIntervals(out);
290
+ }
291
+ if (cons.length) notes.push(`narrowed by project constraints: ${cons.map((c) => c.text).join(', ')}`);
292
+ return { intervals, note: notes.length ? ` (${notes.join('; ')})` : '' };
293
+ }
248
294
  function containsInterval(outer, inner) {
249
295
  const loOk = outer.lo === null || (inner.lo !== null && (cmp(inner.lo, outer.lo) > 0 || (cmp(inner.lo, outer.lo) === 0 && (outer.loInc || !inner.loInc))));
250
296
  const hiOk = outer.hi === null || (inner.hi !== null && (cmp(inner.hi, outer.hi) < 0 || (cmp(inner.hi, outer.hi) === 0 && (outer.hiInc || !inner.hiInc))));
@@ -291,6 +337,10 @@ export function evaluateComponents(components, db, opts = {}) {
291
337
  ...(ghc ? { remediation: `${comp.name} is provided by the compiler: upgrade GHC (the fix is in ${comp.name} ${aff.fixedIn.join(', ') || '(no fixed version published)'}), not a Cabal dependency bound.` } : { remediation: aff.fixedIn.length ? `Upgrade ${comp.name} to ${aff.fixedIn.join(' or ')}.` : `No fixed version of ${comp.name} is published; remove or replace the dependency.` }),
292
338
  kev: kevHit, epss: epssVals.length ? Math.max(...epssVals) : 'unknown',
293
339
  file: comp.manifest || null, line: comp.line || null, versionSource: comp.versionSource || null,
340
+ carriedBy: (comp.carriers && comp.carriers.length ? comp.carriers : [{ file: comp.manifest || null, line: comp.line || null, target: comp.target || null, componentKind: comp.componentKind || null, declaredRange: comp.declaredRange || null }]),
341
+ ...(comp.projectConstraints && comp.projectConstraints.length ? { projectConstraints: comp.projectConstraints } : {}),
342
+ ...(comp.relaxation && comp.relaxation.length ? { relaxation: comp.relaxation } : {}),
343
+ ...(comp.compilerDerived ? { compilerDerived: comp.compilerDerived } : {}),
294
344
  dataSource: { feed: db.source, generatedAt: db.generatedAt, integrity: db.integrity, feedStatus: feed.status },
295
345
  language: 'haskell', capability: 'sca', analysisKind: 'application', evidenceKind: 'manifest',
296
346
  confidence: m.status === 'affected' ? 0.9 : 0.5,
@@ -299,7 +349,37 @@ export function evaluateComponents(components, db, opts = {}) {
299
349
  }
300
350
  if (!any && !statuses.some((s) => s.name === comp.name && s.status !== 'not-affected')) statuses.push({ ...base, status: 'not-affected' });
301
351
  }
302
- return { findings, statuses, feed };
352
+ return { findings: collapseToWorst(findings), statuses, feed };
353
+ }
354
+
355
+ const STATUS_RANK = { affected: 3, 'possibly-affected': 2, unknown: 1 };
356
+ /**
357
+ * One finding per (package, scope, advisory) carrying the WORST status among the declared uses of that package, with every component that
358
+ * carries that worst status listed in `carriedBy`. Distinct declared ranges used to be collapsed by name and scope with the first one
359
+ * winning, which hid a worse use behind a milder one (found by the live Hackage feed bench). The milder uses are not dropped from the
360
+ * record: `otherUses` lists them, and every use keeps its own row in `statuses`.
361
+ */
362
+ export function collapseToWorst(findings) {
363
+ const groups = new Map();
364
+ for (const f of findings) {
365
+ const k = `${f.name}\u0000${f.scope || ''}\u0000${f.osvId}\u0000${(f.ids && f.ids[0]) || ''}`; // two records for one advisory stay two findings
366
+ if (!groups.has(k)) groups.set(k, []);
367
+ groups.get(k).push(f);
368
+ }
369
+ const out = [];
370
+ for (const members of groups.values()) {
371
+ if (members.length === 1) { out.push(members[0]); continue; }
372
+ const rank = (f) => STATUS_RANK[f.matchStatus] || 0;
373
+ const worst = Math.max(...members.map(rank));
374
+ const top = members.filter((f) => rank(f) === worst);
375
+ const rest = members.filter((f) => rank(f) !== worst);
376
+ const carried = [];
377
+ for (const f of top) for (const c of f.carriedBy || []) if (!carried.some((x) => x.file === c.file && x.line === c.line && x.target === c.target && x.declaredRange === c.declaredRange)) carried.push(c);
378
+ const lead = { ...top[0], carriedBy: carried };
379
+ if (rest.length) lead.otherUses = rest.map((f) => ({ matchStatus: f.matchStatus, declaredRange: f.declaredRange, version: f.version, carriedBy: f.carriedBy }));
380
+ out.push(lead);
381
+ }
382
+ return out;
303
383
  }
304
384
 
305
385
  // ── reachability (imports) ───────────────────────────────────────────────────
@@ -7,7 +7,8 @@ import { join } from 'node:path';
7
7
  import { analyzeHaskellManifests } from './haskell-manifests.js';
8
8
  import { buildHaskellIR } from './haskell-ir.js';
9
9
  import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
10
- import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
10
+ import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES, rangeToIntervals, intervalsContain, baseRangeForGhc } from './haskell-sca.js';
11
+ import { deriveCppContext } from './haskell-cpp.js';
11
12
 
12
13
  const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
13
14
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
@@ -38,12 +39,16 @@ export function hackageComponents(files) {
38
39
  // workspace package, not to Hackage; reporting it would raise advisories about the project's own (usually fixed) version as if it were a
39
40
  // dependency. Found by the live Hackage feed bench (aeson's own test suite depends on aeson).
40
41
  const defined = new Set([...(manifests.packages || []), ...(manifests.hpack || [])].map((p) => p.name).filter(Boolean));
42
+ // Every distinct DECLARED USE is kept (not just the first per package and scope): a second manifest or component with a different range was
43
+ // never evaluated, so a worse status could hide behind a milder kept one. The evaluator reports the worst per package, scope and advisory
44
+ // and lists the components that carry it; `components` below keeps the old one-per-package-and-scope shape for inventories.
41
45
  const seen = new Map();
42
46
  for (const d of manifests.dependencies || []) {
43
47
  if (defined.has(d.name)) continue;
44
48
  const lock = lockedFor(d.name, d.manifest);
45
49
  const resolved = (lock && lock.version) || d.exactPin || null;
46
- const key = `${d.name}@${d.scope || 'runtime'}@${resolved || ''}`; // a covered (resolved) use is not merged into an uncovered one
50
+ // a covered (resolved) use is not merged into an uncovered one; an uncovered use is distinguished by where and how it is declared
51
+ const key = `${d.name}@${d.scope || 'runtime'}@${resolved || ''}@${resolved ? '' : `${d.manifest}|${d.component || ''}|${d.declaredRange || ''}|${d.rangeKind || ''}`}`;
47
52
  if (seen.has(key)) continue;
48
53
  seen.set(key, {
49
54
  ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null, unbounded: !resolved && !d.declaredRange && d.rangeKind === 'unbounded',
@@ -71,7 +76,96 @@ export function hackageComponents(files) {
71
76
  c.unbounded = false;
72
77
  c.rangeInheritedFromSibling = true;
73
78
  }
74
- return { components: [...seen.values()], manifests };
79
+ // Project-level bounds from cabal.project: `constraints:` narrow a declared range (the solver must satisfy both) and `allow-newer` /
80
+ // `allow-older` widen it. Both apply only to a use with no resolved version, from a project file that actually governs the manifest.
81
+ const bounds = projectBounds(manifests);
82
+ for (const c of seen.values()) {
83
+ if (c.version) continue;
84
+ const gov = bounds.governing(c.manifest);
85
+ if (!gov.length) continue;
86
+ const cons = bounds.constraintsFor(c.name, gov);
87
+ const relax = bounds.relaxationsFor(c.name, gov);
88
+ if (relax.length) c.relaxation = relax;
89
+ if (!cons.length) continue;
90
+ // an exact pin inside the declared range IS the resolved version; anything else is an interval to intersect
91
+ const pins = cons.filter((x) => x.pin);
92
+ const distinctPins = [...new Set(pins.map((x) => x.pin))];
93
+ const declaredIv = c.declaredRange ? rangeToIntervals(c.declaredRange) : [{ lo: null, loInc: false, hi: null, hiInc: false }];
94
+ if (distinctPins.length === 1 && declaredIv && (!c.declaredRange || intervalsContain(declaredIv, distinctPins[0])) && cons.every((x) => x.pin || (rangeToIntervals(x.text) && intervalsContain(rangeToIntervals(x.text), distinctPins[0])))) {
95
+ const p = pins[0];
96
+ c.version = distinctPins[0]; c.resolution = 'constraint-pin'; c.versionSource = { file: p.file, line: p.line || null }; c.unbounded = false;
97
+ c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
98
+ } else {
99
+ c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
100
+ }
101
+ }
102
+ // Collapse uses that ended up identical (same package, scope, version, bounds): one entry, every declaring component kept in `carriers`.
103
+ const merged = new Map();
104
+ for (const c of seen.values()) {
105
+ const key = `${c.name}@${c.scope}@${c.version || ''}@${c.version ? '' : `${c.declaredRange || ''}|${c.unbounded}|${(c.projectConstraints || []).map((x) => x.text).join(',')}|${(c.relaxation || []).map((x) => x.direction).join(',')}`}`;
106
+ const carrier = { file: c.manifest, line: c.line, target: c.target, componentKind: c.componentKind, declaredRange: c.declaredRange };
107
+ if (merged.has(key)) { merged.get(key).carriers.push(carrier); continue; }
108
+ merged.set(key, { ...c, carriers: [carrier] });
109
+ }
110
+ const uses = [...merged.values()];
111
+ const first = new Map();
112
+ for (const c of uses) { const k = `${c.name}@${c.scope}@${c.version || ''}`; if (!first.has(k)) first.set(k, c); }
113
+ return { components: [...first.values()], uses, manifests, bounds: { conditionalConstraintsSkipped: bounds.conditionalSkipped } };
114
+ }
115
+
116
+ const isRemotePattern = (p) => /^[a-z][a-z0-9+.-]*:\/\//i.test(p);
117
+ /** Does a cabal.project `packages:` pattern name this manifest (a .cabal, or a package.yaml whose cabal file is generated beside it)? */
118
+ function patternNames(pattern, relManifest) {
119
+ let pat = String(pattern).trim().replace(/^\.\//, '');
120
+ if (pat === '' || pat === '.') pat = '*.cabal';
121
+ else if (pat.endsWith('/')) pat = `${pat}*.cabal`;
122
+ else if (!pat.endsWith('.cabal')) pat = `${pat}/*.cabal`;
123
+ const rel = /(?:^|\/)package\.yaml$/.test(relManifest) ? relManifest.replace(/package\.yaml$/, 'package.cabal') : relManifest;
124
+ const re = new RegExp(`^${pat.split('**').map((seg) => seg.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*')}$`);
125
+ return re.test(rel);
126
+ }
127
+
128
+ /**
129
+ * cabal.project constraints and relaxations, scoped to the manifests a project file governs. A project governs a manifest only when the
130
+ * manifest sits at or below the project's directory AND matches one of its `packages:` patterns (a project with no `packages:` governs
131
+ * the .cabal file in its own directory, which is cabal's default). A constraint under an `if` is not in force everywhere and is not applied
132
+ * (counted in `conditionalSkipped` and disclosed). A freeze file is handled as a resolution, not here.
133
+ */
134
+ function projectBounds(manifests) {
135
+ const dirOf = (p) => (typeof p === 'string' && p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
136
+ const isFreeze = (f) => /\.freeze$|(?:^|\/)cabal\.config$/.test(f || '');
137
+ const projects = (manifests.projects || []).filter((p) => p.kind === 'cabal-project' && !isFreeze(p.file));
138
+ const constraints = [];
139
+ let conditionalSkipped = 0;
140
+ for (const c of manifests.constraints || []) {
141
+ if (c.kind !== 'range' || !c.range || isFreeze(c.manifest)) continue;
142
+ if (c.qualifier && c.qualifier !== 'any') continue;
143
+ if (c.conditions && c.conditions.length) { conditionalSkipped += 1; continue; }
144
+ constraints.push({ name: c.name, text: c.rangeText, file: c.file, line: c.line, project: c.manifest, pin: c.range.t === 'cmp' && c.range.op === '==' && !c.range.wildcard ? c.range.text : null });
145
+ }
146
+ const governs = (project, manifest) => {
147
+ const base = dirOf(project.file);
148
+ let rel;
149
+ if (base === '') rel = manifest;
150
+ else if (manifest.startsWith(`${base}/`)) rel = manifest.slice(base.length + 1);
151
+ else return false;
152
+ const pats = (project.packages || []).map((x) => x.pattern).filter((x) => typeof x === 'string' && !isRemotePattern(x));
153
+ return (pats.length ? pats : ['.']).some((pat) => patternNames(pat, rel));
154
+ };
155
+ const relaxMatches = (target, dep) => {
156
+ const t = String(target || '').trim().replace(/^\^/, '');
157
+ if (t === '' || t === 'all' || t === '*' || t === '*:*') return true;
158
+ const parts = t.split(':');
159
+ const last = parts[parts.length - 1];
160
+ return last === dep || last === '*';
161
+ };
162
+ return {
163
+ conditionalSkipped,
164
+ governing: (manifest) => projects.filter((p) => typeof manifest === 'string' && governs(p, manifest)),
165
+ constraintsFor: (name, gov) => constraints.filter((c) => c.name === name && gov.some((p) => p.file === c.project)),
166
+ // A relaxation naming package `a:dep` is applied to `dep` wherever it is used: widening more than the cabal semantics is the safe direction.
167
+ relaxationsFor: (name, gov) => gov.flatMap((p) => (p.allowRelaxations || []).filter((r) => relaxMatches(r.target, name)).map((r) => ({ direction: r.direction, target: r.target, file: r.file, line: r.line }))),
168
+ };
75
169
  }
76
170
 
77
171
  /**
@@ -130,6 +224,70 @@ export async function prefetchHackageFeed(files, { env = process.env, resolvedCo
130
224
  } catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
131
225
  }
132
226
 
227
+ /**
228
+ * The compiler this project is built with, only when it is stated: `with-compiler`, a Stack `compiler:`, a plan's compiler-id, or a
229
+ * `tested-with` whose every item is the same exact version (a claim by the project, weaker than a pin). Sources that disagree, or a
230
+ * `tested-with` listing several versions, leave it UNKNOWN, never a guess.
231
+ * @returns {{version: string|null, label: string, basis: string|null, reason: string|null}}
232
+ */
233
+ export function projectCompiler(files, manifests, resolved) {
234
+ const pins = [];
235
+ const take = (v, basis) => { if (typeof v === 'string') { const m = /(?:^|[\\/])ghc-(\d+(?:\.\d+)+)$/.exec(v.trim()); if (m) pins.push({ version: m[1], basis }); } };
236
+ for (const p of (manifests && manifests.projects) || []) take(p.withCompiler, 'with-compiler');
237
+ for (const s of (manifests && manifests.stack) || []) take(s.compiler, 'stack compiler');
238
+ const pc = resolved && resolved.compiler;
239
+ if (pc && /^ghc$/i.test(pc.name || 'ghc') && pc.version) pins.push({ version: pc.version, basis: 'plan compiler-id' });
240
+ const distinct = [...new Set(pins.map((p) => p.version))];
241
+ if (distinct.length > 1) return { version: null, label: 'unknown compiler', basis: null, reason: `the project states conflicting compilers: ${pins.map((p) => `${p.basis} ${p.version}`).join(', ')}` };
242
+ if (distinct.length === 1) return { version: distinct[0], label: `GHC ${distinct[0]}`, basis: [...new Set(pins.map((p) => p.basis))].join(' + '), reason: null };
243
+ let tested = null;
244
+ try { tested = deriveCppContext(files).ghc; } catch { /* unreadable manifests state nothing */ }
245
+ if (tested && tested.basis === 'tested-with') {
246
+ const vs = [...new Set(tested.versions.map((v) => v.join('.')))];
247
+ if (vs.length === 1) return { version: vs[0], label: `GHC ${vs[0]}`, basis: 'tested-with (the project\'s own claim, not a pin)', reason: null };
248
+ return { version: null, label: 'unknown compiler', basis: null, reason: `tested-with lists several GHC versions: ${vs.join(', ')}; none is pinned` };
249
+ }
250
+ return { version: null, label: 'unknown compiler', basis: null, reason: 'no compiler is pinned by with-compiler, a Stack compiler or a plan, and no tested-with names exactly one version' };
251
+ }
252
+
253
+ /** A compiler-provided package takes its version from the plan's boot unit, else (for base) the compiler's base series; otherwise it stays as declared. */
254
+ function resolveBootComponent(c, compiler, resolved) {
255
+ if (!c.ghcComponent || c.version) return c;
256
+ const boot = resolved && resolved.bootLibraries && resolved.bootLibraries.find((b) => b.name === c.name && b.version);
257
+ if (boot) return { ...c, version: boot.version, resolution: 'plan-compiler', versionSource: boot.versionSource || null, compilerDerived: { compiler: compiler.label, basis: 'plan boot library' } };
258
+ if (compiler.version && c.name === 'base') {
259
+ const range = baseRangeForGhc(compiler.version);
260
+ if (range) return { ...c, declaredRange: range, unbounded: false, declaredRangeAsWritten: c.declaredRange || null, compilerDerived: { compiler: compiler.label, basis: compiler.basis, rule: `base ${range} is the base series of ${compiler.label}` } };
261
+ }
262
+ return c;
263
+ }
264
+
265
+ const GHC_RANK = { affected: 3, 'possibly-affected': 2, unknown: 1 };
266
+ /** One finding per advisory for the project's compiler: members, the worst status, and a remediation that names the compiler. */
267
+ function groupCompilerFindings(findings, compiler) {
268
+ const byAdvisory = new Map();
269
+ for (const f of findings) { const k = `${f.osvId}\u0000${(f.ids && f.ids[0]) || ''}`; if (!byAdvisory.has(k)) byAdvisory.set(k, []); byAdvisory.get(k).push(f); }
270
+ const out = [];
271
+ for (const members of byAdvisory.values()) {
272
+ const rank = (f) => GHC_RANK[f.matchStatus] || 0;
273
+ const lead = members.slice().sort((a, b) => rank(b) - rank(a))[0];
274
+ const packages = [...new Set(members.map((m) => m.name))].sort();
275
+ const fixed = [...new Set(members.flatMap((m) => m.fixedIn || []))];
276
+ const list = packages.join(', ');
277
+ const fixText = fixed.length ? `a release that ships ${list} ${fixed.join(' or ')} or later` : `a release with a fixed ${list} (none is published yet)`;
278
+ const remediation = compiler.version
279
+ ? `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project builds with ${compiler.label} (${compiler.basis}). Upgrade GHC to ${fixText}; a Cabal bound does not change the compiler's own library.`
280
+ : `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project's compiler is unknown (${compiler.reason}), so which ${list} version applies cannot be decided. Pin one with with-compiler in cabal.project, or supply a Cabal plan, to decide it. The fix is a compiler upgrade: upgrade GHC to ${fixText}.`;
281
+ out.push({
282
+ ...lead, name: list, packages, grouped: 'compiler', ghcComponent: true, compiler: { version: compiler.version, label: compiler.label, basis: compiler.basis, reason: compiler.reason },
283
+ matchStatus: lead.matchStatus, remediation,
284
+ members: members.map((m) => ({ name: m.name, scope: m.scope, version: m.version, declaredRange: m.declaredRange, matchStatus: m.matchStatus, matchReason: m.matchReason, resolution: m.resolution, carriedBy: m.carriedBy, ...(m.compilerDerived ? { compilerDerived: m.compilerDerived } : {}) })),
285
+ memberCount: members.length,
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
133
291
  /** Imports and import-qualified callees across the project's Haskell sources. */
134
292
  export function collectUsage(files) {
135
293
  const hs = {};
@@ -156,9 +314,15 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
156
314
  if (!components.length) return out;
157
315
  let feedReason = null;
158
316
  if (db === undefined) { const c = configuredAdvisoryDb(root, env); db = c.db; feedReason = c.reason; }
159
- const ev = evaluateComponents(components, db, { kev, epss });
160
- out.statuses = ev.statuses;
317
+ const compiler = projectCompiler(files, manifests, resolved);
318
+ out.compiler = compiler;
319
+ // Evaluate every distinct declared use (the evaluator reports the worst per package, scope and advisory), with compiler-provided packages
320
+ // resolved against the project's compiler where that can be decided.
321
+ const evalComponents = mergeResolved(hc.uses, resolved).map((c) => resolveBootComponent(c, compiler, resolved));
322
+ const ev = evaluateComponents(evalComponents, db, { kev, epss });
323
+ out.statuses = ev.statuses.map((s) => (s.ghcComponent ? { ...s, compiler: compiler.label } : s));
161
324
  out.feed = ev.feed;
325
+ if (hc.bounds && hc.bounds.conditionalConstraintsSkipped) out.gaps.push({ kind: 'conditional-constraints-not-applied', detail: `${hc.bounds.conditionalConstraintsSkipped} cabal.project constraint(s) sit under a condition (if os(...), flag, compiler) and were NOT applied to declared ranges; they are not in force everywhere, so a declared range they would narrow is judged as written` });
162
326
  if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
163
327
  else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
164
328
  const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
@@ -173,8 +337,13 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
173
337
  f.reachability = r;
174
338
  f.functionReachable = r.function === 'reachable' ? 'reachable' : (r.function === 'not-imported' ? 'unreachable' : 'unknown');
175
339
  f.reachabilityTier = r.import === 'imported' ? (r.function === 'reachable' ? 'function-reachable' : 'import-reachable') : (r.import === 'not-imported' ? 'not-imported' : 'unknown');
176
- out.supplyChain.push({ ...f, file: f.file, line: f.line });
177
340
  }
341
+ // Compiler-provided packages (GHC boot libraries) are one finding per advisory for the project's compiler, not one per package and scope:
342
+ // the fix is a compiler upgrade whatever the Cabal bounds say. Every member stays listed in the group and every use keeps its status row.
343
+ const ordinary = ev.findings.filter((f) => !f.ghcComponent);
344
+ const groups = groupCompilerFindings(ev.findings.filter((f) => f.ghcComponent), compiler);
345
+ for (const f of [...ordinary, ...groups]) out.supplyChain.push({ ...f, file: f.file, line: f.line });
346
+ out.counts = { dependencyFindings: ordinary.length, compilerAdvisories: groups.length, compilerMemberRows: groups.reduce((n, g) => n + g.memberCount, 0), statusRows: out.statuses.length };
178
347
  // policy
179
348
  const si = sourceIntegrity(manifests || {});
180
349
  for (const s of si.filter((x) => x.finding)) {