@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.
- package/CHANGELOG.md +19 -0
- package/bin/agentic-security.js +18 -1
- package/dist/{1602.index.js → 2066.index.js} +7 -463
- package/dist/{92f78fd3e2dbe30fb58d.json → 2d84fed7f6258f8552e1.json} +6 -3
- package/dist/8798.index.js +100 -0
- package/dist/9560.index.js +2 -2
- package/dist/9926.index.js +477 -0
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -3
- package/src/dataflow/engine.js +2 -1
- package/src/engine.js +24 -4
- package/src/ir/index.js +6 -5
- package/src/language/CLAUDE.md +10 -3
- package/src/language/context.js +32 -5
- package/src/language/haskell-fix.js +495 -28
- package/src/language/haskell-ir.js +311 -16
- package/src/language/haskell-manifests.js +88 -5
- package/src/language/haskell-parser.js +18 -4
- package/src/language/haskell-reachability.js +84 -0
- package/src/language/haskell-security-rules.js +23 -3
- package/src/language/haskell-syntax.js +2 -1
- package/src/language/haskell-trust.js +24 -6
- package/src/language/haskell-web.js +3 -3
- package/src/language/witness.js +96 -0
- package/src/mcp/tools.js +1 -1
- package/src/posture/proof-fabric/container.js +13 -2
- package/src/posture/relevance.js +21 -1
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
1463
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|