@clear-capabilities/agentic-security-scanner 0.188.0 → 0.190.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.
@@ -25,7 +25,8 @@
25
25
 
26
26
  import { createHash } from 'node:crypto';
27
27
  import { parseSyntax, groupDecls, patternVars } from './haskell-syntax.js';
28
- import { preprocessForSyntax } from './haskell-parser.js';
28
+ import { preprocessForSyntaxInfo } from './haskell-parser.js';
29
+ import { exposedModuleIndex } from './haskell-manifests.js';
29
30
  import { deriveCppContext } from './haskell-cpp.js';
30
31
  import { isKnownApi, isSourceApi, qualifyAmbiguous, handlerFrameworkOf } from './haskell-models.js';
31
32
 
@@ -168,11 +169,13 @@ function servantEndpointParams(mod, fn, sig, exported) {
168
169
 
169
170
  // ── module table ─────────────────────────────────────────────────────────────
170
171
  function buildModuleInfo(file, src, cpp) {
171
- const parse = parseSyntax(preprocessForSyntax(src, { file, cpp }));
172
+ const pp = preprocessForSyntaxInfo(src, { file, cpp });
173
+ const parse = parseSyntax(pp.text);
172
174
  const groups = groupDecls(parse.decls);
173
175
  const name = parse.module && parse.module.name ? parse.module.name : 'Main';
174
176
  const mod = {
175
177
  name, file, parse, groups, exports: parse.module ? parse.module.exports : null, imports: parse.imports,
178
+ opaque: pp.opaque, // false: no compile-time-generated code; true / null: a splice or quasi-quote the analysis cannot see through
176
179
  defs: new Map(), cons: new Map(), classes: new Map(), sigs: new Map(),
177
180
  };
178
181
  for (const [n, s] of groups.sigs) mod.sigs.set(n, sigInfo(parse.tokens, s));
@@ -498,7 +501,7 @@ class FnLowerer {
498
501
 
499
502
  // ── expression lowering ────────────────────────────────────────────────────
500
503
  lowerExpr(e, line) {
501
- if (++this.depth > HS_IR_LIMITS.maxExprDepth) { this.depth--; return unknownExpr({ capped: 'expression-depth' }); }
504
+ if (++this.depth > HS_IR_LIMITS.maxExprDepth) { this.depth--; this.diagnostics.push({ kind: 'lowering-gap', detail: 'expression-depth' }); return unknownExpr({ capped: 'expression-depth' }); }
502
505
  try { return this.lowerInner(e, line || 0); } finally { this.depth--; }
503
506
  }
504
507
 
@@ -550,7 +553,7 @@ class FnLowerer {
550
553
  for (const r of refs) this.noteRef(r, 'section', ln);
551
554
  return { kind: 'array', elements: [this.lowerExpr(e.e, ln)], hs: { section: e.op, partial: { remaining: 1 } } };
552
555
  }
553
- default: return unknownExpr({ ast: e.t || 'unknown' });
556
+ default: this.diagnostics.push({ kind: 'lowering-gap', detail: e.t || 'unknown' }); return unknownExpr({ ast: e.t || 'unknown' });
554
557
  }
555
558
  }
556
559
 
@@ -1330,6 +1333,18 @@ class Project {
1330
1333
  instancesOf(cls, method) { return [...(this.instanceIndex.get(`${cls}.${method}`) || [])]; }
1331
1334
  }
1332
1335
 
1336
+ // A function whose declared result is a web framework's handler type (Scotty ActionM, Yesod Handler, Servant Handler / `Server Api`, WAI
1337
+ // Application), in a module that imports that framework: the framework calls it from a route table, a generated dispatcher or a configuration file
1338
+ // the analysed files may not contain. Read from the signature and the imports only.
1339
+ function entryHandlerOf(mod, sig) {
1340
+ if (!sig || !sig.resultText) return null;
1341
+ const mods = mod.imports.map((i) => i.module);
1342
+ const h = handlerFrameworkOf(mods, sig.resultText);
1343
+ if (h) return h.framework;
1344
+ if (/^\s*(?:Server|ServerT)\s+[A-Z]/.test(sig.resultText) && mods.some((m) => /^Servant(?:\.|$)/.test(m))) return 'servant';
1345
+ return null;
1346
+ }
1347
+
1333
1348
  function lowerTopFunction(proj, mod, fun, def) {
1334
1349
  const info = { qid: def.qid, name: `${mod.name}.${fun.name}`, line: fun.line, parentName: `${mod.name}.${fun.name}` };
1335
1350
  const L = new FnLowerer(proj, mod, info, []);
@@ -1337,7 +1352,7 @@ function lowerTopFunction(proj, mod, fun, def) {
1337
1352
  const sig = def.sig;
1338
1353
  const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
1339
1354
  const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
1340
- const fn = L.finishFn({ kind, arity: def.arity, effect: sig ? (sig.returnsIO ? 'io' : 'pure') : (L.sawIO ? 'io' : 'unknown'), sig: sig ? sig.text : null, exported });
1355
+ const fn = L.finishFn({ kind, arity: def.arity, effect: sig ? (sig.returnsIO ? 'io' : 'pure') : (L.sawIO ? 'io' : 'unknown'), sig: sig ? sig.text : null, exported, entryHandler: entryHandlerOf(mod, sig) });
1341
1356
  const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
1342
1357
  for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
1343
1358
  fn.hs.textParams = ann.map((a) => a.name);
@@ -1382,15 +1397,44 @@ function irKey(files) {
1382
1397
  return h.digest('hex');
1383
1398
  }
1384
1399
 
1385
- export function buildHaskellIR(fileContents) {
1400
+ // What the reachability verdict depends on besides the sources: the manifests, whether the file set is the whole project, and the scan's own
1401
+ // gaps. The BUILD stays keyed by the sources alone (several passes ask for the same IR and must share it); the verdict is recomputed in place,
1402
+ // cheaply, whenever a caller states a different context, and a caller that states none gets the default one (everything `unknown`).
1403
+ function reachContext(opts) {
1404
+ const o = opts || {};
1405
+ return { manifests: o.manifests && typeof o.manifests === 'object' ? o.manifests : null, completeScan: o.completeScan === true, scanHoles: Array.isArray(o.scanHoles) ? [...new Set(o.scanHoles.map(String))].sort() : [] };
1406
+ }
1407
+ // Two contexts are the same when this string is: the flag, the holes, and every manifest's path and text (manifests are a few kilobytes each, so the
1408
+ // text itself is compared rather than a digest of it).
1409
+ function reachContextKey(ctx) {
1410
+ return JSON.stringify([ctx.completeScan, ctx.scanHoles, Object.keys(ctx.manifests || {}).sort().map((k) => [k, String(ctx.manifests[k])])]);
1411
+ }
1412
+
1413
+ /**
1414
+ * @param {Record<string,string>} fileContents
1415
+ * @param {{manifests?:Record<string,string>, completeScan?:boolean, scanHoles?:string[]}} [opts] `manifests`: cabal / package.yaml text by path (decides
1416
+ * which modules the package exposes); `completeScan`: `fileContents` is the whole project (a changed-file, pull-request, subdirectory or caller-supplied
1417
+ * scan is not); `scanHoles`: gaps the scan itself knows about (`policy-excluded`, `size-skipped`). Without them every reachability verdict is `unknown`.
1418
+ */
1419
+ export function buildHaskellIR(fileContents, opts = {}) {
1386
1420
  const names = Object.keys(fileContents || {}).filter((f) => /\.hs$/i.test(f)).sort();
1387
1421
  const keyed = Object.assign(names.slice(), { __src: fileContents });
1388
1422
  const key = irKey(keyed);
1389
1423
  const hit = IR_CACHE.find((e) => e.key === key);
1390
- if (hit) return hit.ir;
1391
- const ir = buildHaskellIRUncached(fileContents);
1392
- IR_CACHE.unshift({ key, ir });
1393
- if (IR_CACHE.length > 2) IR_CACHE.length = 2;
1424
+ let ir;
1425
+ if (hit) ir = hit.ir;
1426
+ else {
1427
+ ir = buildHaskellIRUncached(fileContents);
1428
+ IR_CACHE.unshift({ key, ir });
1429
+ if (IR_CACHE.length > 2) IR_CACHE.length = 2;
1430
+ }
1431
+ const ctx = reachContext(opts);
1432
+ const sig = reachContextKey(ctx);
1433
+ if (ir._reachSig !== sig) {
1434
+ ir.haskellReachability = projectReachability(ir.project, ir.callGraph, ctx);
1435
+ ir.callGraph.haskellReachability = ir.haskellReachability;
1436
+ ir._reachSig = sig;
1437
+ }
1394
1438
  return ir;
1395
1439
  }
1396
1440
 
@@ -1459,16 +1503,24 @@ function buildHaskellIRUncached(fileContents) {
1459
1503
  // follows per call context; treating the parameter as caller-controlled as well would flag a helper that is only ever
1460
1504
  // called with constants. Only a function nothing in the project reaches is an entry point whose callers are external.
1461
1505
  const reached = new Set();
1462
- for (const e of callGraph.edges || []) if (e && e.callee) reached.add(e.callee);
1463
- for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) reached.add(r.target);
1506
+ const byCall = new Set();
1507
+ const byRef = new Set();
1508
+ for (const e of callGraph.edges || []) if (e && e.callee) { reached.add(e.callee); (e.kind === 'reference' ? byRef : byCall).add(e.callee); }
1509
+ for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) { reached.add(r.target); byRef.add(r.target); }
1464
1510
  assignEntryRoles(proj, callGraph);
1465
1511
  for (const fn of callGraph.functions.values()) {
1466
1512
  // `reached`: something in the analysed code calls or references this function, so its parameters take their values from THOSE callers
1467
1513
  // (which the taint engine follows) and not from an unknown outside one. Rules that reason about "caller-supplied" text read it.
1468
- if (fn.hs) fn.hs.reached = reached.has(fn.qid);
1514
+ if (fn.hs) {
1515
+ fn.hs.reached = reached.has(fn.qid);
1516
+ // HOW it was reached, and what its parameters were before the removal below, stay on the record. The removal itself is unchanged: the
1517
+ // taint source set (`paramAnnotations`) is byte-identical to what it was, so no recall decision hides in this bookkeeping.
1518
+ fn.hs.reachedBy = byCall.has(fn.qid) ? 'call' : (byRef.has(fn.qid) ? 'reference' : null);
1519
+ if (fn.paramAnnotations) fn.hs.paramEntry = fn.paramAnnotations.map((a) => ({ name: a.name, decorator: a.decorator, reach: !fn.hs.reached ? 'entry' : (fn.hs.reachedBy === 'call' ? 'called-in-project' : 'referenced-only') }));
1520
+ }
1469
1521
  if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
1470
1522
  }
1471
- return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj };
1523
+ return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj, haskellReachability: null, _reachSig: null };
1472
1524
  }
1473
1525
 
1474
1526
  function incomplete(proj, mod, f, err) {
@@ -1489,11 +1541,18 @@ function incomplete(proj, mod, f, err) {
1489
1541
  // A function no `main` reaches is `library`: its callers are unknown. The call graph is incomplete (an unresolved call ends a path), so a
1490
1542
  // function the walk does not reach is `library`, never `cli`.
1491
1543
  const SERVER_IMPORT = /^(?:Network\.Wai|Web\.Scotty|Servant|Yesod|Snap|Happstack\.Server|Web\.Spock)(?:\.|$)|^Network\.(?:Socket|Simple\.TCP|Run\.TCP|HTTP\.Server)$/;
1544
+ // Every `Main.main` (and any module's `main`, which also covers `Setup.hs` and test-suite mains: an over-approximation, and therefore the safe
1545
+ // direction for a reachability verdict). One list, shared by the entry roles below and by the reachability roots.
1546
+ export function mainFunctions(callGraph) {
1547
+ const mains = [];
1548
+ for (const fn of callGraph.functions.values()) if (fn.hs && fn.hs.module && fn.name === `${fn.hs.module}.main`) mains.push(fn);
1549
+ return mains;
1550
+ }
1551
+
1492
1552
  export function assignEntryRoles(proj, callGraph) {
1493
1553
  const out = new Map();
1494
1554
  for (const e of callGraph.edges || []) if (e && e.callee && e.caller) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(e.callee); }
1495
- const mains = [];
1496
- for (const fn of callGraph.functions.values()) if (fn.hs && fn.hs.module && fn.name === `${fn.hs.module}.main`) mains.push(fn);
1555
+ const mains = mainFunctions(callGraph);
1497
1556
  const cli = new Set(); const service = new Set();
1498
1557
  for (const m of mains) {
1499
1558
  const seen = new Set([m.qid]);
@@ -1511,6 +1570,242 @@ export function assignEntryRoles(proj, callGraph) {
1511
1570
  }
1512
1571
  }
1513
1572
 
1573
+ // ── project reachability ─────────────────────────────────────────────────────
1574
+ // "Can anything call this function?" asked of the whole project, from its entry points. A negative answer (`unreachable-in-project`) is a claim
1575
+ // about a call graph in a language where typeclass dispatch, re-exports, point-free aliasing and Template Haskell routinely defeat call graphs, so it
1576
+ // is made ONLY when every one of these holds, and degrades to `unknown` (with the reason named) when any fails:
1577
+ //
1578
+ // completeScan && graphComplete && entrypointCount > 0 && exposedModules !== null
1579
+ // && the function's module is not exposed && the function is not an instance method && nothing reachable from a root reaches it
1580
+ //
1581
+ // `completeScan` is the production safety net: under a changed-file, pull-request or subdirectory scan every exported function looks uncalled. A pure
1582
+ // library (no `main`, or no manifest) never gets a negative verdict: an unused library function is a real finding for whoever imports it. Nothing here
1583
+ // reads a comment, a name or a path: the inputs are the call graph, the manifests and the code's structure.
1584
+ //
1585
+ // What makes the graph incomplete, and how each gap is bounded (a gap that can be bounded to the code that could have been affected is bounded to it,
1586
+ // so a single unreadable declaration does not silence a whole project, and one that cannot be bounded is global):
1587
+ // * Template Haskell / quasi-quotes the allow-lists did not accept, a foreign export, a build step that generates modules, a source format the IR does
1588
+ // not read, the function cap: GLOBAL (`opaque-boundary`, `function-cap`). Generated code can call anything.
1589
+ // * a declaration the parser withheld, an expression the lowering could not read, a file that failed to lower (`parse-failure`): the withheld code can
1590
+ // call only what its own module can see, so the module's own functions and the exported functions of every module it imports (with the re-export
1591
+ // closure) become roots of kind `withheld-caller`. Over-approximate, and sound for the code outside that neighbourhood.
1592
+ // * a call through a function VALUE (a parameter, a local binding, a record field, a computed expression, an ambiguous import) is not a gap: the value
1593
+ // can only be a function whose name was mentioned in value position somewhere, and every such mention is a recorded reference that the walk follows.
1594
+ // * a call to a name nothing resolves (`unbound-name`, `unknown-qualifier`, ...) means a definition the IR did not see. If a project function has that
1595
+ // bare name the call is assumed to reach it (over-approximate). Otherwise it is a GLOBAL `unresolved-call` hole, unless the caller's module can see a
1596
+ // module with withheld code (that module's neighbourhood is already roots).
1597
+ // * a class's default method bodies are not lowered at all, so a module that declares one is treated like a module with withheld code.
1598
+ const TOP_KINDS = new Set(['function', 'value', 'io-action']);
1599
+ const ROOT_ORDER = ['main', 'framework-handler', 'foreign-export', 'exposed-api', 'instance-method', 'withheld-caller'];
1600
+ const HOLE_PRIORITY = ['partial-scan', 'policy-excluded', 'size-skipped', 'no-entrypoint', 'no-manifest', 'parse-failure', 'function-cap', 'opaque-boundary', 'unresolved-call'];
1601
+ const GRAPH_HOLES = new Set(['function-cap', 'opaque-boundary', 'unresolved-call']);
1602
+ // An unknown call edge that names a definition the IR could not link. Every other unknown edge is a call through a function VALUE (a parameter, a local
1603
+ // binding, a record field, a computed expression, an ambiguous import whose candidates are referenced), a method with no visible instance, or a call out
1604
+ // of the project, and its callee set is bounded by the references the walk follows.
1605
+ const UNLINKED_UNKNOWN = new Set(['unbound-name', 'unknown-qualifier', 'resolution-depth-cap', 'reexport-cycle']);
1606
+ const bareName = (n) => String(n || '').replace(/^(?:[A-Z][A-Za-z0-9_']*\.)+(?=.)/, '');
1607
+
1608
+ // The modules a module can name things from: those it imports, and any module those re-export (an `import X; module A (module X)`, or an export of a
1609
+ // name A does not define). Over-approximate: a re-exporting module counts every project module it imports, because naming exactly which names flow
1610
+ // through is the resolver's job and a miss here is the unsafe direction.
1611
+ function reexportClosure(proj, start) {
1612
+ const out = new Set();
1613
+ const todo = [];
1614
+ for (const m of start) if (proj.modules.has(m) && !out.has(m)) { out.add(m); todo.push(m); }
1615
+ while (todo.length) {
1616
+ const mod = proj.modules.get(todo.pop());
1617
+ if (!mod || !Array.isArray(mod.exports)) continue;
1618
+ const reexports = mod.exports.some((ex) => (ex.kind === 'module' && ex.name !== mod.name) || (ex.kind === 'var' && (ex.qual || !mod.defs.has(ex.name))));
1619
+ if (!reexports) continue;
1620
+ for (const imp of mod.imports || []) if (proj.modules.has(imp.module) && !out.has(imp.module)) { out.add(imp.module); todo.push(imp.module); }
1621
+ }
1622
+ return out;
1623
+ }
1624
+ const effectiveExposedModules = reexportClosure;
1625
+
1626
+ function isExportedTop(modByFile, fn) {
1627
+ const hs = fn.hs;
1628
+ if (!hs || !TOP_KINDS.has(hs.kind)) return false;
1629
+ if (hs.exported === true) return true;
1630
+ if (hs.exported === false || !Array.isArray(hs.patbind)) return false;
1631
+ const mod = modByFile.get(fn.file);
1632
+ return !!mod && (!mod.exports || mod.exports.some((x) => (x.kind === 'var' && hs.patbind.includes(x.name)) || (x.kind === 'module' && x.name === mod.name)));
1633
+ }
1634
+
1635
+ // Modules in which the IR may have lost code: a declaration it could not parse, an expression it could not lower, a file that failed to lower, a
1636
+ // function past the cap. Keyed by module name.
1637
+ function withheldCodeModules(proj, callGraph) {
1638
+ const out = new Set();
1639
+ const byFile = new Map();
1640
+ for (const [name, m] of proj.modules) byFile.set(m.file, name);
1641
+ for (const [name, m] of proj.modules) if (m.parse && m.parse.errors && m.parse.errors.length) out.add(name);
1642
+ // A class's default method bodies are never lowered (only the signatures become definitions), and a body can call any function its module can see.
1643
+ for (const [name, m] of proj.modules) if (m.parse && (m.parse.classes || []).some((c) => (c.decls || []).some((d) => d.t === 'clause' || d.t === 'patbind'))) out.add(name);
1644
+ for (const d of proj.diagnostics) if (d && d.file && (d.kind === 'ir-parse-failure' || d.kind === 'ir-lowering-failure') && byFile.has(d.file)) out.add(byFile.get(d.file));
1645
+ for (const fn of callGraph.functions.values()) {
1646
+ const hs = fn.hs;
1647
+ if (hs && (hs.incomplete || (hs.diagnostics || []).some((d) => d && d.kind === 'lowering-gap')) && byFile.has(fn.file)) out.add(byFile.get(fn.file));
1648
+ }
1649
+ return out;
1650
+ }
1651
+
1652
+ /**
1653
+ * The functions that start the project's execution from outside: every `main`, every function typed as a web framework handler (a route registered
1654
+ * in a file the IR never sees, such as Yesod's `config/routes`, still calls it), every function named by a `foreign export`, every instance method
1655
+ * (dispatch is by type, from anywhere that can see the type), every exported function of a module the package exposes (`opts.exposedModules`, from the
1656
+ * manifests), and the neighbourhood of any module with withheld code (see above). The exposed-module roots are load-bearing: without them an internal
1657
+ * helper reached only through a public function would look uncalled.
1658
+ * @returns {{roots: Map<string,string>, entrypointCount: number, exposed: Set<string>|null, withheld: Set<string>}} root qid -> kind
1659
+ */
1660
+ export function haskellEntryRoots(proj, callGraph, opts = {}) {
1661
+ const roots = new Map();
1662
+ const add = (q, kind) => { if (q && callGraph.functions.has(q) && !roots.has(q)) roots.set(q, kind); };
1663
+ const mains = mainFunctions(callGraph);
1664
+ for (const m of mains) add(m.qid, 'main');
1665
+ const modByFile = new Map();
1666
+ for (const m of proj.modules.values()) modByFile.set(m.file, m);
1667
+ const exposed = opts.exposedModules ? effectiveExposedModules(proj, opts.exposedModules) : null;
1668
+ const withheld = withheldCodeModules(proj, callGraph);
1669
+ // modules whose functions withheld code may call: the withheld module itself, and everything it can name
1670
+ const neighbourhood = new Set();
1671
+ const ownOnly = new Set();
1672
+ for (const name of withheld) {
1673
+ const mod = proj.modules.get(name);
1674
+ if (!mod) continue;
1675
+ ownOnly.add(name);
1676
+ for (const m of reexportClosure(proj, (mod.imports || []).map((i) => i.module))) neighbourhood.add(m);
1677
+ }
1678
+ for (const fn of callGraph.functions.values()) {
1679
+ const hs = fn.hs;
1680
+ if (!hs) continue;
1681
+ if (hs.entryHandler && TOP_KINDS.has(hs.kind)) add(fn.qid, 'framework-handler');
1682
+ if (hs.kind === 'instance-method') add(fn.qid, 'instance-method');
1683
+ if (exposed && exposed.has(hs.module) && isExportedTop(modByFile, fn)) add(fn.qid, 'exposed-api');
1684
+ if (TOP_KINDS.has(hs.kind) && (ownOnly.has(hs.module) || (neighbourhood.has(hs.module) && isExportedTop(modByFile, fn)))) add(fn.qid, 'withheld-caller');
1685
+ }
1686
+ for (const mod of proj.modules.values()) {
1687
+ for (const b of (mod.parse && mod.parse.boundaries) || []) {
1688
+ if (b && b.kind === 'ffi' && b.direction === 'export' && b.name) { const d = mod.defs.get(b.name); if (d) add(d.qid, 'foreign-export'); }
1689
+ }
1690
+ }
1691
+ return { roots, entrypointCount: mains.length, exposed, withheld };
1692
+ }
1693
+
1694
+ const MANIFEST_HOLE = (reason) => (reason === 'no-manifest' || reason === 'hpack-library-exposes-all-modules' ? 'no-manifest' : (/^manifest-/.test(reason) ? 'parse-failure' : 'opaque-boundary'));
1695
+
1696
+ /**
1697
+ * Walk the call graph from the roots and stamp `fn.hs.reachability = {status, rootKind, holeReason}` on every function (idempotent: recomputed
1698
+ * wholesale for each context). `ctx`: `{manifests, completeScan, scanHoles}`. The returned summary is `ir.haskellReachability`.
1699
+ */
1700
+ export function projectReachability(proj, callGraph, ctx = {}) {
1701
+ const idx = ctx.manifests ? exposedModuleIndex(ctx.manifests) : { modules: null, reason: 'no-manifest' };
1702
+ const { roots, entrypointCount, exposed, withheld } = haskellEntryRoots(proj, callGraph, { exposedModules: idx.modules });
1703
+ const fns = callGraph.functions;
1704
+
1705
+ const out = new Map();
1706
+ const unknownEdges = [];
1707
+ let defsByName = null; // bare name -> qids of the project's top-level functions of that name (built once, on first use)
1708
+ for (const e of callGraph.edges || []) {
1709
+ if (!e || !e.caller) continue;
1710
+ if (e.callee) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(e.callee); }
1711
+ else if (e.status === 'unknown') {
1712
+ // A name the IR could not link: if a project function has that bare name, assume the call reaches it (over-approximate, the safe direction);
1713
+ // if none does, nothing in the project can be the callee and the edge is judged below.
1714
+ if (UNLINKED_UNKNOWN.has(e.reason)) {
1715
+ if (!defsByName) {
1716
+ defsByName = new Map();
1717
+ for (const m of proj.modules.values()) for (const d of m.defs.values()) if (d.kind === 'fun') { if (!defsByName.has(d.name)) defsByName.set(d.name, []); defsByName.get(d.name).push(d.qid); }
1718
+ }
1719
+ const hit = defsByName.get(bareName(e.calleeName));
1720
+ if (hit) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(...hit); continue; }
1721
+ }
1722
+ unknownEdges.push(e);
1723
+ }
1724
+ }
1725
+ // a closure or local function runs when its parent does: the call into it is not always an edge, and what it calls must count as called
1726
+ for (const fn of fns.values()) {
1727
+ const p = fn.hs && fn.hs.parent;
1728
+ if (p && fns.has(p)) { if (!out.has(p)) out.set(p, []); out.get(p).push(fn.qid); }
1729
+ }
1730
+ const unknownFrom = new Map();
1731
+ for (const e of unknownEdges) { if (!unknownFrom.has(e.caller)) unknownFrom.set(e.caller, []); unknownFrom.get(e.caller).push(e); }
1732
+
1733
+ const reachKind = new Map();
1734
+ for (const kind of ROOT_ORDER) {
1735
+ const queue = [];
1736
+ for (const [q, k] of roots) if (k === kind && !reachKind.has(q)) { reachKind.set(q, kind); queue.push(q); }
1737
+ while (queue.length) {
1738
+ const q = queue.pop();
1739
+ for (const t of out.get(q) || []) if (!reachKind.has(t) && fns.has(t)) { reachKind.set(t, kind); queue.push(t); }
1740
+ }
1741
+ }
1742
+
1743
+ // an unresolved name on a reached path, whose caller's module cannot see a module with withheld code, is a definition the IR never saw
1744
+ const modByFile = new Map();
1745
+ for (const m of proj.modules.values()) modByFile.set(m.file, m);
1746
+ const visibleFrom = new Map();
1747
+ const seesWithheld = (modName) => {
1748
+ if (!withheld.size) return false;
1749
+ if (!visibleFrom.has(modName)) {
1750
+ const mod = proj.modules.get(modName);
1751
+ const vis = reexportClosure(proj, [modName, ...((mod && mod.imports) || []).map((i) => i.module)]);
1752
+ visibleFrom.set(modName, [...vis].some((m) => withheld.has(m)));
1753
+ }
1754
+ return visibleFrom.get(modName);
1755
+ };
1756
+ let unresolvedReached = 0;
1757
+ for (const [q, edges] of unknownFrom) {
1758
+ if (!reachKind.has(q)) continue;
1759
+ const f = fns.get(q);
1760
+ const modName = f && f.hs && f.hs.module;
1761
+ for (const e of edges) {
1762
+ if (!UNLINKED_UNKNOWN.has(e.reason)) continue;
1763
+ if (modName && seesWithheld(modName)) continue;
1764
+ unresolvedReached++;
1765
+ }
1766
+ }
1767
+
1768
+ const holes = [];
1769
+ const hole = (reason, detail, count) => holes.push({ reason, ...(detail ? { detail } : {}), ...(count ? { count } : {}) });
1770
+ if (ctx.completeScan !== true) hole('partial-scan', 'the file set is a subset of the project (changed-file, pull-request, subdirectory or caller-supplied scan), so absence of a caller proves nothing');
1771
+ for (const h of ctx.scanHoles || []) hole(h, h === 'policy-excluded' ? 'Haskell sources kept out of the scan by the default path policy could hold callers' : (h === 'size-skipped' ? 'Haskell sources too large to read could hold callers' : 'a source format the IR does not read could hold callers'));
1772
+ if (!entrypointCount) hole('no-entrypoint', 'no `main` in the analysed files');
1773
+ if (idx.modules === null) hole(MANIFEST_HOLE(idx.reason || 'no-manifest'), idx.reason || 'no-manifest');
1774
+ const capped = proj.diagnostics.filter((d) => d.kind === 'function-cap').length;
1775
+ if (capped) hole('function-cap', 'functions past the per-file cap are not in the graph', capped);
1776
+ const opaqueMods = [...proj.modules.values()].filter((m) => m.opaque !== false).length;
1777
+ const foreignExports = [...roots.values()].filter((k) => k === 'foreign-export').length;
1778
+ if (opaqueMods || foreignExports) hole('opaque-boundary', 'Template Haskell or quasi-quoted code, or a foreign export, can call functions the graph does not show', opaqueMods + foreignExports);
1779
+ if (unresolvedReached) hole('unresolved-call', 'a function reachable from an entry point calls a name the IR could not resolve to any definition', unresolvedReached);
1780
+ holes.sort((a, b) => HOLE_PRIORITY.indexOf(a.reason) - HOLE_PRIORITY.indexOf(b.reason));
1781
+ const graphComplete = !holes.some((h) => GRAPH_HOLES.has(h.reason));
1782
+ const blocking = holes.length ? holes[0].reason : null;
1783
+ // bounded, not blocking: recorded so the summary says where code was withheld
1784
+ const bounded = withheld.size ? [{ reason: 'parse-failure', detail: 'code the parser withheld can call only what its module can see; those functions are roots of kind withheld-caller', modules: withheld.size }] : [];
1785
+
1786
+ const topOf = (fn) => { let f = fn; for (let i = 0; i < 32 && f.hs && f.hs.parent && fns.has(f.hs.parent); i++) f = fns.get(f.hs.parent); return f; };
1787
+ const counts = { 'entrypoint-reachable': 0, 'unreachable-in-project': 0, 'external-api': 0, unknown: 0 };
1788
+ for (const fn of fns.values()) {
1789
+ if (!fn.hs || fn.hs.kind === 'module-init') continue;
1790
+ const t = topOf(fn);
1791
+ let status; let rootKind = null; let holeReason = null;
1792
+ if (exposed && t.hs && exposed.has(t.hs.module) && isExportedTop(modByFile, t)) { status = 'external-api'; rootKind = 'exposed-api'; }
1793
+ else if (t.hs && t.hs.kind === 'instance-method') { status = 'unknown'; holeReason = blocking || 'instance-method'; }
1794
+ else if (reachKind.has(fn.qid) || reachKind.has(t.qid)) { status = 'entrypoint-reachable'; rootKind = reachKind.get(fn.qid) || reachKind.get(t.qid); }
1795
+ else if (blocking) { status = 'unknown'; holeReason = blocking; }
1796
+ else { status = 'unreachable-in-project'; }
1797
+ fn.hs.reachability = { status, rootKind, holeReason };
1798
+ counts[status]++;
1799
+ }
1800
+
1801
+ const rootKinds = {};
1802
+ for (const k of roots.values()) rootKinds[k] = (rootKinds[k] || 0) + 1;
1803
+ return {
1804
+ roots: [...roots.keys()].sort(), rootKinds, entrypointCount, graphComplete, holes, bounded, completeScan: ctx.completeScan === true,
1805
+ exposedModules: idx.modules ? [...idx.modules].sort() : null, exposedModulesReason: idx.modules ? null : (idx.reason || null), statusCounts: counts,
1806
+ };
1807
+ }
1808
+
1514
1809
  // ── call graph ───────────────────────────────────────────────────────────────
1515
1810
  function buildHaskellCallGraph(perFile, proj) {
1516
1811
  const functions = new Map();
@@ -371,7 +371,17 @@ function detectGenerated(text) {
371
371
  }
372
372
 
373
373
  function newStanza(kind, name, line, scope) {
374
- return { kind, name, scope, line, deps: [], imports: [], surfaces: [], flagRefs: [], fields: {} };
374
+ return { kind, name, scope, line, deps: [], imports: [], surfaces: [], flagRefs: [], fields: {}, exposedModules: [], otherModules: [], opaque: [] };
375
+ }
376
+
377
+ // What a `ghc-options` value says about the build that the module graph cannot see: a different entry point, a source preprocessor, a compiler plugin.
378
+ // Read from the field text, never from a comment (comment lines are dropped by the line lexer before a field is read).
379
+ function ghcOpaqueKinds(text) {
380
+ const out = [];
381
+ if (/(?:^|\s)-main-is\b/.test(text)) out.push('main-is');
382
+ if (/(?:^|\s)-(?:F|pgmF)\b/.test(text)) out.push('preprocessor');
383
+ if (/(?:^|\s)-f?plugin(?:-package(?:-id)?)?\b/.test(text)) out.push('plugin');
384
+ return out;
375
385
  }
376
386
 
377
387
  function handleCabalField(st, name, node, conds, file) {
@@ -397,7 +407,17 @@ function handleCabalField(st, name, node, conds, file) {
397
407
  case 'import':
398
408
  for (const w of splitWords(val.text)) st.imports.push({ name: w.text, line: locate(val, w.offset).line });
399
409
  break;
410
+ case 'exposed-modules':
411
+ for (const w of splitWords(val.text)) st.exposedModules.push(w.text);
412
+ break;
413
+ case 'other-modules':
414
+ for (const w of splitWords(val.text)) st.otherModules.push(w.text);
415
+ break;
416
+ case 'signatures':
417
+ st.opaque.push('backpack-signatures');
418
+ break;
400
419
  case 'ghc-options': case 'ghc-prof-options': case 'ghc-shared-options':
420
+ for (const k of ghcOpaqueKinds(val.text)) if (!st.opaque.includes(k)) st.opaque.push(k);
401
421
  scanGhcOptions(val.text, null, (kind, detail, idx) => {
402
422
  const loc = locate(val, idx);
403
423
  st.surfaces.push({ kind, detail, file, line: loc.line, column: loc.column });
@@ -511,6 +531,7 @@ export function parseCabalFile(text, opts = {}) {
511
531
  for (const d of c.deps) into.deps.push({ ...d, via: `common:${imp.name}` });
512
532
  for (const s of c.surfaces) into.surfaces.push({ ...s, via: `common:${imp.name}` });
513
533
  for (const r of c.flagRefs) into.flagRefs.push(r);
534
+ for (const k of c.opaque) if (!into.opaque.includes(k)) into.opaque.push(k);
514
535
  expand(c, into, nextSeen, via);
515
536
  }
516
537
  };
@@ -518,7 +539,7 @@ export function parseCabalFile(text, opts = {}) {
518
539
  const declared = new Set(flags.map((f) => f.name));
519
540
  const unknownFlags = [];
520
541
  const components = stanzas.map((st) => {
521
- const own = { deps: st.deps.slice(), surfaces: st.surfaces.slice(), flagRefs: st.flagRefs.slice() };
542
+ const own = { deps: st.deps.slice(), surfaces: st.surfaces.slice(), flagRefs: st.flagRefs.slice(), opaque: [] };
522
543
  expand(st, own, new Set(), null);
523
544
  for (const r of own.flagRefs) {
524
545
  if (!declared.has(r.flag)) {
@@ -535,6 +556,9 @@ export function parseCabalFile(text, opts = {}) {
535
556
  line: st.line,
536
557
  imports: st.imports,
537
558
  fields: st.fields,
559
+ exposedModules: st.exposedModules,
560
+ otherModules: st.otherModules,
561
+ opaque: [...new Set([...st.opaque, ...own.opaque])],
538
562
  dependencies: own.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })),
539
563
  surfaces: own.surfaces,
540
564
  };
@@ -639,8 +663,16 @@ export function parseHpackFile(text, opts = {}) {
639
663
  if (scopeOverride === 'build-tool') surfaces.push({ kind: 'build-tool-depends', detail: `${e.name} ${e.rangeText}`.trim(), file, line, column: d.column, component: st.name });
640
664
  }
641
665
  }
666
+ for (const key of ['exposed-modules', 'other-modules']) {
667
+ if (!(key in sec)) continue;
668
+ const v = Array.isArray(sec[key]) ? sec[key] : [sec[key]];
669
+ for (const m of v) if (typeof m === 'string') (key === 'exposed-modules' ? st.exposedModules : st.otherModules).push(m.trim());
670
+ if (key === 'exposed-modules') st.exposedDeclared = true;
671
+ }
672
+ if ('generated-exposed-modules' in sec || 'generated-other-modules' in sec) st.opaque.push('hpack-generated-modules');
642
673
  if ('ghc-options' in sec) {
643
674
  const opt = Array.isArray(sec['ghc-options']) ? sec['ghc-options'].join(' ') : String(sec['ghc-options']);
675
+ for (const k of ghcOpaqueKinds(opt)) if (!st.opaque.includes(k)) st.opaque.push(k);
644
676
  let li = findLine(lines, new RegExp(`^${ind}(?:-\\s+)?ghc-options\\s*:`), start);
645
677
  if (li < 0) li = start;
646
678
  scanGhcOptions(opt, null, (kind, detail) => surfaces.push({ kind, detail, file, line: li + 1, column: 0, component: st.name }));
@@ -662,7 +694,7 @@ export function parseHpackFile(text, opts = {}) {
662
694
  }
663
695
  };
664
696
 
665
- const mkStanza = (kind, name, scope) => ({ kind, name, scope, deps: [], flagRefs: [] });
697
+ const mkStanza = (kind, name, scope) => ({ kind, name, scope, deps: [], flagRefs: [], exposedModules: [], otherModules: [], opaque: [], exposedDeclared: false });
666
698
  const finish = (st, line) => {
667
699
  for (const r of st.flagRefs) {
668
700
  if (!(doc.flags && isObj(doc.flags) && r.flag in doc.flags)) {
@@ -670,17 +702,19 @@ export function parseHpackFile(text, opts = {}) {
670
702
  diagnostics.push(diag('unknown_flag', `condition references flag '${r.flag}' that package.yaml never declares`, file, r.line, 'warning', { flag: r.flag }));
671
703
  }
672
704
  }
673
- return { kind: st.kind, name: st.name, scope: st.scope, file, line, dependencies: st.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })) };
705
+ return { kind: st.kind, name: st.name, scope: st.scope, file, line, exposedModules: st.exposedModules, otherModules: st.otherModules, exposedDeclared: st.exposedDeclared, opaque: st.opaque, buildTools: st.deps.filter((d) => d.scopeOverride === 'build-tool').map((d) => d.name), dependencies: st.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })) };
674
706
  };
675
707
 
676
708
  const components = [];
677
709
  let packageDependencies = [];
710
+ let topOpaque = [];
678
711
  try {
679
712
  const top = mkStanza('package', pkg.name, 'package');
680
713
  collect(doc, top, [], 0, 0);
681
714
  // top-level `when` and deps apply to every component
682
715
  const topLoc = finish(top, 1);
683
716
  packageDependencies = topLoc.dependencies;
717
+ topOpaque = topLoc.opaque;
684
718
 
685
719
  const section = (key, kind, scope, single) => {
686
720
  if (!(key in doc)) return;
@@ -735,7 +769,7 @@ export function parseHpackFile(text, opts = {}) {
735
769
  return {
736
770
  kind: 'hpack', file, package: pkg, provenance: { generatedBy: null, source: 'hand-written' },
737
771
  components, packageDependencies, flags: flagList, unknownFlags, sourceRepositories,
738
- surfaces, incomplete, diagnostics,
772
+ surfaces, incomplete, diagnostics, topOpaque,
739
773
  };
740
774
  }
741
775
 
@@ -1258,6 +1292,55 @@ export function analyzeHaskellManifests(files, opts = {}) {
1258
1292
  return redactUrlsDeep(out);
1259
1293
  }
1260
1294
 
1295
+ // ---------------------------------------------------------------------------
1296
+ // Exposed modules (who can import this package from outside)
1297
+ // ---------------------------------------------------------------------------
1298
+
1299
+ /** Work counter for the exposed-module index: one parse per cabal / package.yaml, never per function or per module. Read by the scaling test. */
1300
+ export const EXPOSED_INDEX_WORK = { manifestParses: 0, builds: 0 };
1301
+
1302
+ /**
1303
+ * The set of modules a package's manifests EXPOSE to other packages, over every `.cabal` file (and every `package.yaml` that has no `.cabal` beside
1304
+ * it) in `manifests` (path -> text). Returns `{ modules, reason, packages, libraries }`: `modules` is a Set of module names, or `null` when the
1305
+ * manifests cannot say (none present, one failed to parse or hit a budget, an hpack library that exposes every module by default, a build that uses an
1306
+ * entry-point override, a source preprocessor, a plugin, Backpack signatures, a foreign library, or a build tool that generates code outside the
1307
+ * analysed files). `null` is "unknown", never "nothing exposed": an executable-only package yields an EMPTY set, which is a real answer.
1308
+ * Conditional stanzas are unioned (a module exposed under any flag setting is exposed). Nothing here reads a comment, a name or a path.
1309
+ */
1310
+ export function exposedModuleIndex(manifests, opts = {}) {
1311
+ EXPOSED_INDEX_WORK.builds++;
1312
+ const budgets = budgetsOf(opts);
1313
+ const entries = Object.entries(manifests || {}).map(([p, text]) => [String(p).replace(/\\/g, '/'), typeof text === 'string' ? text : '']).filter(([p]) => { const k = classifyManifest(p); return k === 'cabal' || k === 'hpack'; }).sort((a, b) => (a[0] < b[0] ? -1 : 1));
1314
+ if (!entries.length) return { modules: null, reason: 'no-manifest', packages: 0, libraries: 0 };
1315
+ const cabalDirs = new Set(entries.filter(([p]) => classifyManifest(p) === 'cabal').map(([p]) => path.posix.dirname(p)));
1316
+ const modules = new Set();
1317
+ let packages = 0; let libraries = 0; let unknown = null;
1318
+ const fail = (why) => { if (!unknown) unknown = why; };
1319
+ for (const [p, text] of entries) {
1320
+ const kind = classifyManifest(p);
1321
+ if (kind === 'hpack' && cabalDirs.has(path.posix.dirname(p))) continue; // the generated .cabal beside it is what the build reads
1322
+ EXPOSED_INDEX_WORK.manifestParses++;
1323
+ let r;
1324
+ try { r = kind === 'cabal' ? parseCabalFile(text, { file: p, budgets }) : parseHpackFile(text, { file: p, budgets }); } catch { fail('manifest-parse-failure'); continue; }
1325
+ if (r.incomplete) { fail('manifest-incomplete'); continue; }
1326
+ if ((r.diagnostics || []).some((d) => d.code === 'budget_exceeded' || d.kind === 'budget_exceeded')) { fail('manifest-budget'); continue; }
1327
+ packages++;
1328
+ if ((r.topOpaque || []).length) fail(`build-${r.topOpaque[0]}`);
1329
+ for (const c of r.components) {
1330
+ if (c.kind === 'foreign-library') fail('foreign-library');
1331
+ if ((c.opaque || []).length) fail(`build-${c.opaque[0]}`);
1332
+ const generates = (c.surfaces || []).some((s) => s.kind === 'build-tool-depends') || (c.buildTools || []).length > 0;
1333
+ if (generates && c.kind !== 'test-suite' && c.kind !== 'benchmark') fail('build-tool-generated-sources');
1334
+ if (c.kind !== 'library') continue;
1335
+ libraries++;
1336
+ if (kind === 'hpack' && !c.exposedDeclared) fail('hpack-library-exposes-all-modules');
1337
+ for (const m of c.exposedModules || []) modules.add(m);
1338
+ }
1339
+ }
1340
+ if (unknown) return { modules: null, reason: unknown, packages, libraries };
1341
+ return { modules, reason: null, packages, libraries };
1342
+ }
1343
+
1261
1344
  const WALK_SKIP = new Set(['node_modules', '.git', '.hg', '.svn']);
1262
1345
 
1263
1346
  /** Read manifests under `root` (bounded, symlinks never followed) and analyse them. */
@@ -1142,10 +1142,24 @@ const IR_PREPROCESS_GUARD = /^[ \t]*#\s*(?:if|ifdef|ifndef|elif|else|endif|defin
1142
1142
  * Returns the input unchanged when it has nothing to preprocess or cannot be parsed.
1143
1143
  */
1144
1144
  export function preprocessForSyntax(source, opts = {}) {
1145
- if (typeof source !== 'string' || !IR_PREPROCESS_GUARD.test(source)) return source;
1145
+ return preprocessForSyntaxInfo(source, opts).text;
1146
+ }
1147
+
1148
+ /**
1149
+ * The same preprocessing, plus what it learned about the file's opaque boundaries: `opaque` is `false` when the file has none (including every
1150
+ * Template Haskell splice and quasi-quote the closed allow-lists accepted), `true` when a splice, quasi-quote or TH quotation remains whose expansion
1151
+ * is generated at compile time (code the analysis never sees, which can call any function), and `null` when the file mentions Template Haskell but
1152
+ * could not be parsed to find out. Decided from tokens and extensions, never from comment text (the guard only decides whether to look).
1153
+ */
1154
+ export function preprocessForSyntaxInfo(source, opts = {}) {
1155
+ const none = { text: source, opaque: false };
1156
+ if (typeof source !== 'string') return none;
1157
+ const thQuotesOnly = /\bTemplateHaskellQuotes\b/.test(source);
1158
+ if (!IR_PREPROCESS_GUARD.test(source)) return thQuotesOnly ? { text: source, opaque: null } : none;
1146
1159
  let r;
1147
- try { r = parseHaskell(source, { file: opts.file, cpp: opts.cpp }); } catch { return source; }
1148
- if (!r || !r.ok || typeof r._preprocessed !== 'string' || r._preprocessed.length !== source.length) return source;
1160
+ try { r = parseHaskell(source, { file: opts.file, cpp: opts.cpp }); } catch { return { text: source, opaque: null }; }
1161
+ if (!r || !r.ok || typeof r._preprocessed !== 'string' || r._preprocessed.length !== source.length) return { text: source, opaque: /\b(?:TemplateHaskell|TemplateHaskellQuotes|QuasiQuotes)\b/.test(source) ? null : false };
1162
+ const opaque = (r.boundaries || []).some((b) => b && typeof b.kind === 'string' && (b.kind.startsWith('th-') && b.kind !== 'th-safe-splice' || b.kind === 'quasiquote'));
1149
1163
  let out = r._preprocessed;
1150
1164
  for (const b of r.blankRanges || []) {
1151
1165
  const seg = out.slice(b.startOffset, b.endOffset);
@@ -1153,5 +1167,5 @@ export function preprocessForSyntax(source, opts = {}) {
1153
1167
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1154
1168
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1155
1169
  }
1156
- return out;
1170
+ return { text: out, opaque };
1157
1171
  }