@clear-capabilities/agentic-security-scanner 0.187.0 → 0.189.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/dist/{deabc47d0e27e21bf99b.json → 051c3705cb9cb458cd2f.json} +7 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -4
- package/src/dataflow/CLAUDE.md +13 -0
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +133 -11
- package/src/engine.js +33 -5
- package/src/ir/index.js +6 -5
- package/src/language/CLAUDE.md +9 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +2 -2
- package/src/language/haskell-ir.js +354 -16
- package/src/language/haskell-manifests.js +88 -5
- package/src/language/haskell-models.js +15 -4
- package/src/language/haskell-parser.js +18 -4
- package/src/language/haskell-reachability.js +84 -0
- package/src/language/haskell-security-rules.js +134 -10
- package/src/language/haskell-syntax.js +2 -1
- package/src/language/haskell-trust.js +286 -0
- package/src/language/haskell-web.js +5 -4
- package/src/posture/proof-fabric/container.js +13 -2
- package/src/posture/relevance.js +21 -1
- package/src/sast/cloud-iam.js +3 -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));
|
|
@@ -264,6 +267,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
264
267
|
const hits = [];
|
|
265
268
|
const wildcard = [];
|
|
266
269
|
let unknownReason = null;
|
|
270
|
+
let unknownCandidates = null;
|
|
267
271
|
for (const imp of visibleImports(mod, name, qual)) {
|
|
268
272
|
const target = proj.modules.get(imp.module);
|
|
269
273
|
if (target) {
|
|
@@ -274,7 +278,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
274
278
|
const owner = r.kind === 'def' ? (r.def.kind === 'field' ? (proj.modules.get(r.mod)?.cons.get(r.def.con)?.type) : r.def.kind === 'method' ? r.def.cls : null) : null;
|
|
275
279
|
if (!owner || !(imp.typeAll || []).includes(owner)) continue;
|
|
276
280
|
}
|
|
277
|
-
if (r.kind === 'unknown') unknownReason = r.reason; else hits.push(r);
|
|
281
|
+
if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; } else hits.push(r);
|
|
278
282
|
} else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
|
|
279
283
|
else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
|
|
280
284
|
}
|
|
@@ -282,7 +286,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
282
286
|
const distinct = [...new Map(hits.map((r) => [keyOf(r), r])).values()];
|
|
283
287
|
if (distinct.length === 1) return distinct[0];
|
|
284
288
|
if (distinct.length > 1) return { kind: 'unknown', reason: 'ambiguous-import', candidates: distinct.map(keyOf) };
|
|
285
|
-
if (unknownReason) return { kind: 'unknown', reason: unknownReason };
|
|
289
|
+
if (unknownReason) return unknownCandidates ? { kind: 'unknown', reason: unknownReason, candidates: unknownCandidates } : { kind: 'unknown', reason: unknownReason };
|
|
286
290
|
if (!qual && PRELUDE.has(name)) return { kind: 'builtin', module: 'Prelude', name };
|
|
287
291
|
if (wildcard.length) return { kind: 'external', module: wildcard.length === 1 ? wildcard[0] : null, candidates: wildcard, name, certain: false };
|
|
288
292
|
return { kind: 'unknown', reason: qual ? 'unknown-qualifier' : 'unbound-name' };
|
|
@@ -497,7 +501,7 @@ class FnLowerer {
|
|
|
497
501
|
|
|
498
502
|
// ── expression lowering ────────────────────────────────────────────────────
|
|
499
503
|
lowerExpr(e, line) {
|
|
500
|
-
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' }); }
|
|
501
505
|
try { return this.lowerInner(e, line || 0); } finally { this.depth--; }
|
|
502
506
|
}
|
|
503
507
|
|
|
@@ -549,7 +553,7 @@ class FnLowerer {
|
|
|
549
553
|
for (const r of refs) this.noteRef(r, 'section', ln);
|
|
550
554
|
return { kind: 'array', elements: [this.lowerExpr(e.e, ln)], hs: { section: e.op, partial: { remaining: 1 } } };
|
|
551
555
|
}
|
|
552
|
-
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' });
|
|
553
557
|
}
|
|
554
558
|
}
|
|
555
559
|
|
|
@@ -584,6 +588,7 @@ class FnLowerer {
|
|
|
584
588
|
return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
|
|
585
589
|
}
|
|
586
590
|
if (r.kind === 'param' || r.kind === 'local') return { kind: 'ident', name: e.name };
|
|
591
|
+
if (r.kind === 'unknown' && r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
587
592
|
const lib = libraryName(r, e.name, e.qual);
|
|
588
593
|
const viaReg = lib.viaRegistry ? { viaRegistry: true, certain: false } : {};
|
|
589
594
|
// A modelled SOURCE action named as a value (`getLine >>= f`, `fmap g getLine`) yields untrusted data
|
|
@@ -725,6 +730,10 @@ class FnLowerer {
|
|
|
725
730
|
if (IO_NAMES.has(f.name)) this.sawIO = true;
|
|
726
731
|
} else {
|
|
727
732
|
hs.status = 'unknown'; hs.reason = r.reason || 'unresolved'; hs.effect = 'unknown';
|
|
733
|
+
// An import that two modules both satisfy (the Posix and Windows variants of one module behind a CPP conditional, two re-exports of
|
|
734
|
+
// one name) is a call to ONE of them, which one is not decided here. Each candidate is referenced, so none of them reads as a
|
|
735
|
+
// function nothing in the project uses.
|
|
736
|
+
if (r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
728
737
|
}
|
|
729
738
|
if (hs.status === 'resolved' || hs.status === 'typeclass') {
|
|
730
739
|
const ctxTargets = args.map((a) => argTargets(a));
|
|
@@ -1324,6 +1333,18 @@ class Project {
|
|
|
1324
1333
|
instancesOf(cls, method) { return [...(this.instanceIndex.get(`${cls}.${method}`) || [])]; }
|
|
1325
1334
|
}
|
|
1326
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
|
+
|
|
1327
1348
|
function lowerTopFunction(proj, mod, fun, def) {
|
|
1328
1349
|
const info = { qid: def.qid, name: `${mod.name}.${fun.name}`, line: fun.line, parentName: `${mod.name}.${fun.name}` };
|
|
1329
1350
|
const L = new FnLowerer(proj, mod, info, []);
|
|
@@ -1331,10 +1352,11 @@ function lowerTopFunction(proj, mod, fun, def) {
|
|
|
1331
1352
|
const sig = def.sig;
|
|
1332
1353
|
const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
|
|
1333
1354
|
const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
|
|
1334
|
-
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) });
|
|
1335
1356
|
const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
|
|
1336
1357
|
for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
|
|
1337
1358
|
fn.hs.textParams = ann.map((a) => a.name);
|
|
1359
|
+
fn.hs.paramSources = ann.map((a) => ({ name: a.name, decorator: a.decorator }));
|
|
1338
1360
|
fn.hs.inputParams = inputParams(fn, sig, exported);
|
|
1339
1361
|
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1340
1362
|
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
@@ -1375,15 +1397,44 @@ function irKey(files) {
|
|
|
1375
1397
|
return h.digest('hex');
|
|
1376
1398
|
}
|
|
1377
1399
|
|
|
1378
|
-
|
|
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 = {}) {
|
|
1379
1420
|
const names = Object.keys(fileContents || {}).filter((f) => /\.hs$/i.test(f)).sort();
|
|
1380
1421
|
const keyed = Object.assign(names.slice(), { __src: fileContents });
|
|
1381
1422
|
const key = irKey(keyed);
|
|
1382
1423
|
const hit = IR_CACHE.find((e) => e.key === key);
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
+
}
|
|
1387
1438
|
return ir;
|
|
1388
1439
|
}
|
|
1389
1440
|
|
|
@@ -1452,10 +1503,24 @@ function buildHaskellIRUncached(fileContents) {
|
|
|
1452
1503
|
// follows per call context; treating the parameter as caller-controlled as well would flag a helper that is only ever
|
|
1453
1504
|
// called with constants. Only a function nothing in the project reaches is an entry point whose callers are external.
|
|
1454
1505
|
const reached = new Set();
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
for (const
|
|
1458
|
-
|
|
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); }
|
|
1510
|
+
assignEntryRoles(proj, callGraph);
|
|
1511
|
+
for (const fn of callGraph.functions.values()) {
|
|
1512
|
+
// `reached`: something in the analysed code calls or references this function, so its parameters take their values from THOSE callers
|
|
1513
|
+
// (which the taint engine follows) and not from an unknown outside one. Rules that reason about "caller-supplied" text read it.
|
|
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
|
+
}
|
|
1521
|
+
if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
|
|
1522
|
+
}
|
|
1523
|
+
return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj, haskellReachability: null, _reachSig: null };
|
|
1459
1524
|
}
|
|
1460
1525
|
|
|
1461
1526
|
function incomplete(proj, mod, f, err) {
|
|
@@ -1468,6 +1533,279 @@ function incomplete(proj, mod, f, err) {
|
|
|
1468
1533
|
proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) });
|
|
1469
1534
|
}
|
|
1470
1535
|
|
|
1536
|
+
// ── entry roles ──────────────────────────────────────────────────────────────
|
|
1537
|
+
// Who is on the other end of the program's inputs depends on what kind of program the function belongs to. A function reachable from a
|
|
1538
|
+
// `main` whose reachable modules import no server framework or socket library belongs to a command-line program: whoever runs it supplies its
|
|
1539
|
+
// arguments, environment, standard input and the files it is pointed at. One reachable from a `main` that imports a server framework (a WAI,
|
|
1540
|
+
// Scotty, Servant, Yesod, Snap, Happstack or Spock application, a socket listener) belongs to a service, whose inputs come from its clients.
|
|
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
|
|
1542
|
+
// function the walk does not reach is `library`, never `cli`.
|
|
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
|
+
|
|
1552
|
+
export function assignEntryRoles(proj, callGraph) {
|
|
1553
|
+
const out = new Map();
|
|
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); }
|
|
1555
|
+
const mains = mainFunctions(callGraph);
|
|
1556
|
+
const cli = new Set(); const service = new Set();
|
|
1557
|
+
for (const m of mains) {
|
|
1558
|
+
const seen = new Set([m.qid]);
|
|
1559
|
+
const todo = [m.qid];
|
|
1560
|
+
while (todo.length) { const q = todo.pop(); for (const c of out.get(q) || []) if (!seen.has(c)) { seen.add(c); todo.push(c); } }
|
|
1561
|
+
const mods = new Set();
|
|
1562
|
+
for (const q of seen) { const f = callGraph.functions.get(q); if (f && f.hs && f.hs.module) mods.add(f.hs.module); }
|
|
1563
|
+
let isService = false;
|
|
1564
|
+
for (const name of mods) { const mi = proj.modules.get(name); if (mi && (mi.imports || []).some((i) => SERVER_IMPORT.test(i.module))) { isService = true; break; } }
|
|
1565
|
+
for (const q of seen) (isService ? service : cli).add(q);
|
|
1566
|
+
}
|
|
1567
|
+
for (const fn of callGraph.functions.values()) {
|
|
1568
|
+
if (!fn.hs) continue;
|
|
1569
|
+
fn.hs.entryRole = service.has(fn.qid) ? 'service' : (cli.has(fn.qid) ? 'cli' : 'library');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
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
|
+
|
|
1471
1809
|
// ── call graph ───────────────────────────────────────────────────────────────
|
|
1472
1810
|
function buildHaskellCallGraph(perFile, proj) {
|
|
1473
1811
|
const functions = new Map();
|