@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
// Functional semantic IR and call graph for Haskell (HS-002).
|
|
2
|
+
//
|
|
3
|
+
// Lowers the expression AST from haskell-syntax.js into the EXISTING dataflow IR
|
|
4
|
+
// contract (src/ir/CLAUDE.md): `{file, functions[{qid, name, line, params, file,
|
|
5
|
+
// cfg, calls}], topLevel}`. No node or expression kind is added, so every IR
|
|
6
|
+
// consumer (SSA, path feasibility, the taint engine, buildCallGraph) keeps
|
|
7
|
+
// working unchanged. Haskell specifics ride in an optional `hs` side object on
|
|
8
|
+
// functions, nodes and call expressions.
|
|
9
|
+
//
|
|
10
|
+
// Semantics that cannot be faked with mutable assignments:
|
|
11
|
+
// * Immutable, lazy bindings. A `let`/`where` binding that is never referenced
|
|
12
|
+
// produces NO executed node (it is recorded under `fn.hs.deferred`). One that
|
|
13
|
+
// is demanded only conditionally keeps its flow but is marked
|
|
14
|
+
// `demand: 'potential'` with `uncertainty: 'potentially-demanded'`.
|
|
15
|
+
// * Effects. IO actions are executed where they are sequenced (do statements),
|
|
16
|
+
// not where they are bound. `hs.effect` is `io` / `pure` / `unknown`.
|
|
17
|
+
// * Partial application and composition are values, not calls.
|
|
18
|
+
// * `$`, `.`, `&`, `>>=`, `=<<`, `fmap`, `<$>`, `<*>` normalise to ordinary
|
|
19
|
+
// applications; map/traverse/mapM_/forM_/foldr/filter/sequence are bounded
|
|
20
|
+
// summaries over an element placeholder.
|
|
21
|
+
//
|
|
22
|
+
// Honest boundaries: no type checking and no typeclass resolution beyond the
|
|
23
|
+
// instances visible in the analysed files. An unresolved target is recorded as
|
|
24
|
+
// `status: 'unknown'` and is NEVER treated as unreachable (see `reachability`).
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { parseSyntax, groupDecls, patternVars } from './haskell-syntax.js';
|
|
28
|
+
import { isKnownApi, isSourceApi, qualifyAmbiguous } from './haskell-models.js';
|
|
29
|
+
|
|
30
|
+
export const HS_IR_LIMITS = Object.freeze({
|
|
31
|
+
maxExprDepth: 200,
|
|
32
|
+
maxResolveDepth: 16,
|
|
33
|
+
maxFunctionsPerFile: 5000,
|
|
34
|
+
maxSummaryIterations: 64,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const PRELUDE = new Set(['map', 'fmap', 'filter', 'foldr', 'foldl', 'mapM', 'mapM_', 'sequence', 'sequence_', 'concatMap', 'return', 'pure', 'lookup', 'putStrLn', 'putStr', 'print', 'getLine', 'getContents', 'readFile', 'writeFile', 'appendFile', 'interact', 'show', 'read', 'id', 'const', 'flip', 'length', 'head', 'tail', 'null', 'not', 'fst', 'snd', 'otherwise', 'div', 'mod', 'elem', 'concat', 'zip', 'unwords', 'words', 'lines', 'unlines', 'reverse', 'take', 'drop', 'error', 'maybe', 'either', 'traverse', 'sequenceA', 'readLn', 'sum', 'product', 'and', 'or', 'any', 'all', 'replicate', 'lookup', 'curry', 'uncurry', 'seq']);
|
|
38
|
+
|
|
39
|
+
const IO_NAMES = new Set(['putStr', 'putStrLn', 'print', 'getLine', 'getContents', 'readFile', 'writeFile', 'appendFile', 'readLn', 'interact', 'callCommand', 'callProcess', 'system', 'rawSystem', 'readProcess', 'readProcessWithExitCode', 'readCreateProcess', 'createProcess', 'spawnCommand', 'spawnProcess', 'getArgs', 'getEnv', 'lookupEnv', 'hPutStrLn', 'hPutStr', 'hGetLine', 'hGetContents', 'execute', 'execute_', 'query', 'query_', 'httpLBS', 'httpBS', 'unsafePerformIO']);
|
|
40
|
+
|
|
41
|
+
// name -> positions of the function, the collection and (folds) the seed.
|
|
42
|
+
const BODY_SETTER = /^Network\.HTTP\.(?:Simple|Conduit|Client)\.setRequestBody(?:JSON|LBS|BS|URLEncoded|File)$/;
|
|
43
|
+
const HOF = {
|
|
44
|
+
map: { fn: 0, coll: 1, kind: 'map' }, fmap: { fn: 0, coll: 1, kind: 'map' }, concatMap: { fn: 0, coll: 1, kind: 'map' },
|
|
45
|
+
traverse: { fn: 0, coll: 1, kind: 'traverse' }, mapM: { fn: 0, coll: 1, kind: 'traverse' },
|
|
46
|
+
forM: { fn: 1, coll: 0, kind: 'traverse' }, for: { fn: 1, coll: 0, kind: 'traverse' },
|
|
47
|
+
traverse_: { fn: 0, coll: 1, kind: 'traverse_' }, mapM_: { fn: 0, coll: 1, kind: 'traverse_' },
|
|
48
|
+
forM_: { fn: 1, coll: 0, kind: 'traverse_' }, for_: { fn: 1, coll: 0, kind: 'traverse_' },
|
|
49
|
+
filter: { fn: 0, coll: 1, kind: 'filter' },
|
|
50
|
+
foldr: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, foldl: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, "foldl'": { fn: 0, seed: 1, coll: 2, kind: 'fold' },
|
|
51
|
+
sequence: { coll: 0, kind: 'sequence' }, sequenceA: { coll: 0, kind: 'sequence' }, sequence_: { coll: 0, kind: 'sequence' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ARITH = new Set(['+', '-', '*', '/', '^', '**', '==', '/=', '<', '<=', '>', '>=', '!!', '%']);
|
|
55
|
+
|
|
56
|
+
const unknownExpr = (hs) => (hs ? { kind: 'unknown', hs } : { kind: 'unknown' });
|
|
57
|
+
const mkUnion = (a, b) => (!a ? b : !b ? a : { kind: 'union', branches: [a, b] });
|
|
58
|
+
|
|
59
|
+
// ── signatures ───────────────────────────────────────────────────────────────
|
|
60
|
+
function sigInfo(tokens, sig) {
|
|
61
|
+
let depth = 0;
|
|
62
|
+
let arity = 0;
|
|
63
|
+
let resStart = sig.ta;
|
|
64
|
+
for (let i = sig.ta; i < sig.tb; i++) {
|
|
65
|
+
const t = tokens[i];
|
|
66
|
+
if (t.t === 'sp') {
|
|
67
|
+
if (t.v === '(' || t.v === '[' || t.v === '{') depth++;
|
|
68
|
+
else if (t.v === ')' || t.v === ']' || t.v === '}') depth--;
|
|
69
|
+
} else if (depth === 0 && t.t === 'rop' && t.v === '->') { arity++; resStart = i + 1; }
|
|
70
|
+
else if (depth === 0 && t.t === 'rop' && t.v === '=>') { arity = 0; resStart = i + 1; }
|
|
71
|
+
}
|
|
72
|
+
const first = tokens[resStart];
|
|
73
|
+
// The text of each argument type, in order (the part before the final result): used to decide which parameters carry
|
|
74
|
+
// caller-supplied text.
|
|
75
|
+
const argTypes = [];
|
|
76
|
+
if (arity > 0) {
|
|
77
|
+
let start = sig.ta; depth = 0;
|
|
78
|
+
for (let i = sig.ta; i < resStart; i++) {
|
|
79
|
+
const t = tokens[i];
|
|
80
|
+
if (t.t === 'sp') { if (t.v === '(' || t.v === '[' || t.v === '{') depth++; else if (t.v === ')' || t.v === ']' || t.v === '}') depth--; }
|
|
81
|
+
else if (depth === 0 && t.t === 'rop' && t.v === '=>') start = i + 1;
|
|
82
|
+
else if (depth === 0 && t.t === 'rop' && t.v === '->') { argTypes.push(tokens.slice(start, i).map((x) => x.v).join(' ')); start = i + 1; }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { arity, returnsIO: !!first && first.t === 'con' && first.v === 'IO', text: tokens.slice(sig.ta, sig.tb).map((t) => t.v).join(' '), argTypes };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parameters of an EXPORTED function whose declared type is text-like: the caller supplies that text, and nothing in the
|
|
89
|
+
// module proves it trusted. They are modelled as a source of kind "caller-controlled parameter" (a weaker claim than a
|
|
90
|
+
// request or stdin read: the finding says so). A function without a signature, a non-exported function and a parameter
|
|
91
|
+
// of any other type (a connection, a number, a record) are never sources.
|
|
92
|
+
const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
|
|
93
|
+
function callerControlledParams(fn, sig, exported) {
|
|
94
|
+
if (!exported || !sig || !Array.isArray(sig.argTypes) || !Array.isArray(fn.params)) return [];
|
|
95
|
+
const out = [];
|
|
96
|
+
sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && TEXT_LIKE.test(t)) out.push({ index: i, name, decorator: 'hs:caller-controlled' }); });
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── module table ─────────────────────────────────────────────────────────────
|
|
101
|
+
function buildModuleInfo(file, src) {
|
|
102
|
+
const parse = parseSyntax(src);
|
|
103
|
+
const groups = groupDecls(parse.decls);
|
|
104
|
+
const name = parse.module && parse.module.name ? parse.module.name : 'Main';
|
|
105
|
+
const mod = {
|
|
106
|
+
name, file, parse, groups, exports: parse.module ? parse.module.exports : null, imports: parse.imports,
|
|
107
|
+
defs: new Map(), cons: new Map(), classes: new Map(), sigs: new Map(),
|
|
108
|
+
};
|
|
109
|
+
for (const [n, s] of groups.sigs) mod.sigs.set(n, sigInfo(parse.tokens, s));
|
|
110
|
+
const addDef = (n, def) => { if (!mod.defs.has(n)) mod.defs.set(n, def); };
|
|
111
|
+
for (const f of groups.funs) {
|
|
112
|
+
const arity = Math.max(0, ...f.clauses.map((c) => c.pats.length));
|
|
113
|
+
addDef(f.name, { kind: 'fun', qid: `${file}::${name}.${f.name}@${f.line}`, name: f.name, arity, line: f.line, sig: mod.sigs.get(f.name) || null });
|
|
114
|
+
}
|
|
115
|
+
for (const pb of groups.patbinds) {
|
|
116
|
+
for (const v of patternVars(pb.pat)) addDef(v, { kind: 'fun', qid: `${file}::${name}.${v}@${pb.line}`, name: v, arity: 0, line: pb.line, sig: mod.sigs.get(v) || null, patbind: true });
|
|
117
|
+
}
|
|
118
|
+
for (const d of parse.data) {
|
|
119
|
+
for (const c of d.constructors) {
|
|
120
|
+
mod.cons.set(c.name, { fields: c.fields, arity: c.arity, type: d.name });
|
|
121
|
+
for (const fld of c.fields || []) addDef(fld, { kind: 'field', qid: `${file}::${name}.${fld}@${d.line}`, name: fld, con: c.name, line: d.line });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const bnd of parse.boundaries || []) {
|
|
125
|
+
if (bnd.kind === 'ffi' && bnd.name) addDef(bnd.name, { kind: 'ffi', qid: `${file}::${name}.${bnd.name}@${bnd.line}`, name: bnd.name, line: bnd.line });
|
|
126
|
+
}
|
|
127
|
+
for (const c of parse.classes) {
|
|
128
|
+
const methods = new Set();
|
|
129
|
+
for (const d of c.decls) if (d.t === 'sig') for (const m of d.names) { methods.add(m); addDef(m, { kind: 'method', cls: c.name, qid: `${file}::${name}.${m}@${d.line}`, name: m, arity: 0, line: d.line }); }
|
|
130
|
+
mod.classes.set(c.name, methods);
|
|
131
|
+
}
|
|
132
|
+
return mod;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── name resolution ──────────────────────────────────────────────────────────
|
|
136
|
+
function visibleImports(mod, name, qual) {
|
|
137
|
+
return mod.imports.filter((imp) => {
|
|
138
|
+
if (qual) return imp.as ? imp.as === qual : imp.module === qual;
|
|
139
|
+
if (imp.qualified) return false;
|
|
140
|
+
if (imp.items) return imp.hiding ? !imp.items.includes(name) : (imp.items.includes(name) || (imp.typeAll && imp.typeAll.length > 0));
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function defResult(mod, name) {
|
|
146
|
+
const def = mod.defs.get(name);
|
|
147
|
+
return { kind: 'def', qid: def.qid, mod: mod.name, name, def };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function exportedLookup(proj, target, name, depth, seen) {
|
|
151
|
+
const key = `${target.name}.${name}`;
|
|
152
|
+
if (seen.has(key)) return { kind: 'unknown', reason: 'reexport-cycle' };
|
|
153
|
+
if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
|
|
154
|
+
const next = new Set(seen); next.add(key);
|
|
155
|
+
if (!target.exports) return target.defs.has(name) ? defResult(target, name) : null;
|
|
156
|
+
for (const ex of target.exports) {
|
|
157
|
+
if (ex.kind === 'var' && ex.name === name) return resolveGlobal(proj, target, name, ex.qual || null, depth + 1, next);
|
|
158
|
+
if (ex.kind === 'type' && ex.all) {
|
|
159
|
+
const d = target.defs.get(name);
|
|
160
|
+
if (d && d.kind === 'field') return defResult(target, name);
|
|
161
|
+
}
|
|
162
|
+
if (ex.kind === 'module') {
|
|
163
|
+
if (ex.name === target.name) { if (target.defs.has(name)) return defResult(target, name); continue; }
|
|
164
|
+
for (const imp of target.imports) {
|
|
165
|
+
if (imp.qualified || (imp.as !== ex.name && imp.module !== ex.name)) continue;
|
|
166
|
+
if (imp.items && (imp.hiding ? imp.items.includes(name) : !imp.items.includes(name))) continue;
|
|
167
|
+
const t2 = proj.modules.get(imp.module);
|
|
168
|
+
const r = t2 ? exportedLookup(proj, t2, name, depth + 1, next) : { kind: 'external', module: imp.module, name, certain: !!(imp.items && !imp.hiding && imp.items.includes(name)) };
|
|
169
|
+
if (r) return r;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveGlobal(proj, mod, name, qual, depth = 0, seen = new Set()) {
|
|
177
|
+
if ((!qual || qual === mod.name) && mod.defs.has(name)) return defResult(mod, name);
|
|
178
|
+
if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
|
|
179
|
+
const hits = [];
|
|
180
|
+
const wildcard = [];
|
|
181
|
+
let unknownReason = null;
|
|
182
|
+
for (const imp of visibleImports(mod, name, qual)) {
|
|
183
|
+
const target = proj.modules.get(imp.module);
|
|
184
|
+
if (target) {
|
|
185
|
+
const r = exportedLookup(proj, target, name, depth + 1, seen);
|
|
186
|
+
if (!r) continue;
|
|
187
|
+
if (!qual && !imp.hiding && imp.items && !imp.items.includes(name)) {
|
|
188
|
+
// Visible only through `T(..)`: it must really be a field/method of one of those types.
|
|
189
|
+
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;
|
|
190
|
+
if (!owner || !(imp.typeAll || []).includes(owner)) continue;
|
|
191
|
+
}
|
|
192
|
+
if (r.kind === 'unknown') unknownReason = r.reason; else hits.push(r);
|
|
193
|
+
} else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
|
|
194
|
+
else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
|
|
195
|
+
}
|
|
196
|
+
const keyOf = (r) => (r.kind === 'def' ? r.qid : `${r.module}.${r.name}`);
|
|
197
|
+
const distinct = [...new Map(hits.map((r) => [keyOf(r), r])).values()];
|
|
198
|
+
if (distinct.length === 1) return distinct[0];
|
|
199
|
+
if (distinct.length > 1) return { kind: 'unknown', reason: 'ambiguous-import', candidates: distinct.map(keyOf) };
|
|
200
|
+
if (unknownReason) return { kind: 'unknown', reason: unknownReason };
|
|
201
|
+
if (!qual && PRELUDE.has(name)) return { kind: 'builtin', module: 'Prelude', name };
|
|
202
|
+
if (wildcard.length) return { kind: 'external', module: wildcard.length === 1 ? wildcard[0] : null, candidates: wildcard, name, certain: false };
|
|
203
|
+
return { kind: 'unknown', reason: qual ? 'unknown-qualifier' : 'unbound-name' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The import-qualified name of a library reference. External: its module (or, when several wildcard
|
|
207
|
+
// imports compete, the one module the model registry knows for that name). Builtin: `Prelude.<name>`
|
|
208
|
+
// only for names the registry models, so user-visible names of unmodelled Prelude functions are unchanged.
|
|
209
|
+
function libraryName(r, name, qual) {
|
|
210
|
+
if (r.kind === 'external') {
|
|
211
|
+
if (r.module) return { name: `${r.module}.${name}` };
|
|
212
|
+
const qa = qualifyAmbiguous(name, r.candidates);
|
|
213
|
+
if (qa) return { name: `${qa.module}.${name}`, viaRegistry: true };
|
|
214
|
+
return { name: qual ? `${qual}.${name}` : name };
|
|
215
|
+
}
|
|
216
|
+
if (r.kind === 'builtin' && isKnownApi('Prelude', name)) return { name: `Prelude.${name}` };
|
|
217
|
+
return { name: qual ? `${qual}.${name}` : name };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// An IO action named in an executing position (`x <- getLine`, a statement) runs when sequenced: it is a call.
|
|
221
|
+
function asExecutedAction(v, ln) {
|
|
222
|
+
if (v && v.kind === 'ident' && v.hs && (v.hs.resolution === 'builtin' || v.hs.resolution === 'external')) {
|
|
223
|
+
return { kind: 'call', callee: v.name, args: [], line: ln, hs: { status: v.hs.resolution, effect: 'io', action: true, ...(v.hs.viaRegistry ? { viaRegistry: true, certain: false } : {}) } };
|
|
224
|
+
}
|
|
225
|
+
return v;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── CFG builder ──────────────────────────────────────────────────────────────
|
|
229
|
+
class Cfg {
|
|
230
|
+
constructor(line) {
|
|
231
|
+
this.nodes = {};
|
|
232
|
+
this.n = 0;
|
|
233
|
+
this.entry = this.mk('entry', line);
|
|
234
|
+
this.exit = null;
|
|
235
|
+
this.front = [this.entry];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
mk(kind, line, extra) {
|
|
239
|
+
const id = `n${this.n++}`;
|
|
240
|
+
this.nodes[id] = { kind, line: line || 0, succ: [], pred: [], ...(extra || {}) };
|
|
241
|
+
return id;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
link(a, b) {
|
|
245
|
+
if (!this.nodes[a].succ.includes(b)) { this.nodes[a].succ.push(b); this.nodes[b].pred.push(a); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
add(kind, line, extra) {
|
|
249
|
+
const id = this.mk(kind, line, extra);
|
|
250
|
+
for (const f of this.front) this.link(f, id);
|
|
251
|
+
this.front = [id];
|
|
252
|
+
return id;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
finish(line) {
|
|
256
|
+
this.exit = this.mk('exit', line);
|
|
257
|
+
for (const f of this.front) this.link(f, this.exit);
|
|
258
|
+
this.front = [];
|
|
259
|
+
return { entry: this.entry, exit: this.exit, nodes: this.nodes };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── AST helpers ──────────────────────────────────────────────────────────────
|
|
264
|
+
function countOcc(name, ast) {
|
|
265
|
+
let n = 0;
|
|
266
|
+
const seen = new WeakSet();
|
|
267
|
+
const walk = (x) => {
|
|
268
|
+
if (!x || typeof x !== 'object' || seen.has(x)) return;
|
|
269
|
+
seen.add(x);
|
|
270
|
+
if (Array.isArray(x)) { for (const y of x) walk(y); return; }
|
|
271
|
+
if (x.t === 'var' && !x.qual && x.name === name) n++;
|
|
272
|
+
for (const k of Object.keys(x)) { const v = x[k]; if (v && typeof v === 'object') walk(v); }
|
|
273
|
+
};
|
|
274
|
+
walk(ast);
|
|
275
|
+
return n;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const unparen = (e) => { let x = e; while (x && x.t === 'paren') x = x.e; return x; };
|
|
279
|
+
|
|
280
|
+
function headVar(e) {
|
|
281
|
+
const x = unparen(e);
|
|
282
|
+
if (!x) return null;
|
|
283
|
+
if (x.t === 'var' && !x.qual) return x.name;
|
|
284
|
+
if (x.t === 'app') return headVar(x.f);
|
|
285
|
+
if (x.t === 'op' && ['$', '>>=', '>>', '<$>', '*>', '<*', '<*>', '$!'].includes(x.op)) return headVar(x.l);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Names the body demands unconditionally: an executed statement head, or the
|
|
290
|
+
// scrutinee/condition of the top-level branch. Anything else is "potential".
|
|
291
|
+
function strictNames(items, out = new Set()) {
|
|
292
|
+
for (const it of items) {
|
|
293
|
+
if (!it) continue;
|
|
294
|
+
if (it.t === 'sexpr' || it.t === 'sbind') { const h = headVar(it.e); if (h) out.add(h); continue; }
|
|
295
|
+
if (it.t === 'slet') continue;
|
|
296
|
+
const x = unparen(it);
|
|
297
|
+
if (!x) continue;
|
|
298
|
+
if (x.t === 'do') strictNames(x.stmts, out);
|
|
299
|
+
else if (x.t === 'let') strictNames([x.body], out);
|
|
300
|
+
else if (x.t === 'if') { const h = headVar(x.c); if (h) out.add(h); }
|
|
301
|
+
else if (x.t === 'case') { const h = headVar(x.scrut); if (h) out.add(h); }
|
|
302
|
+
else { const h = headVar(x); if (h) out.add(h); }
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── bounded inlining of small pure helpers ───────────────────────────────────
|
|
308
|
+
// A call to a user function whose whole body is a small pure expression (a record builder, a wrapper,
|
|
309
|
+
// an operator chain) is lowered by substituting the already-lowered arguments into that body. That is
|
|
310
|
+
// what keeps record fields apart across a function return (`mkCfg l` builds {cmd: l, label: "fixed"},
|
|
311
|
+
// so `label c` stays clean) and what analyses the helper once per call context. The helper is still
|
|
312
|
+
// lowered and analysed on its own; the call is kept as a reference edge in the call graph.
|
|
313
|
+
const INLINE_MAX_NODES = 60;
|
|
314
|
+
const INLINE_MAX_DEPTH = 4;
|
|
315
|
+
const INLINE_KINDS = new Set(['var', 'con', 'lit', 'app', 'op', 'paren', 'tuple', 'list', 'rec', 'neg', 'lowered']);
|
|
316
|
+
|
|
317
|
+
function inlineScan(node, params, selfName, acc = { size: 0, ok: true }, headPos = false) {
|
|
318
|
+
if (!acc.ok || !node || typeof node !== 'object') return acc;
|
|
319
|
+
if (Array.isArray(node)) { for (const x of node) inlineScan(x, params, selfName, acc, false); return acc; }
|
|
320
|
+
if (typeof node.t === 'string') {
|
|
321
|
+
if (!INLINE_KINDS.has(node.t)) { acc.ok = false; return acc; }
|
|
322
|
+
if (++acc.size > INLINE_MAX_NODES) { acc.ok = false; return acc; }
|
|
323
|
+
if (node.t === 'var' && !node.qual) {
|
|
324
|
+
if (node.name === selfName) { acc.ok = false; return acc; } // recursion
|
|
325
|
+
if (headPos && params.has(node.name)) { acc.ok = false; return acc; } // a parameter applied as a function
|
|
326
|
+
}
|
|
327
|
+
if (node.t === 'app') { inlineScan(node.f, params, selfName, acc, true); inlineScan(node.args, params, selfName, acc, false); return acc; }
|
|
328
|
+
if (node.t === 'op') { inlineScan(node.l, params, selfName, acc, false); inlineScan(node.r, params, selfName, acc, false); return acc; }
|
|
329
|
+
}
|
|
330
|
+
for (const k of Object.keys(node)) {
|
|
331
|
+
if (k === 't' || k === 'line') continue;
|
|
332
|
+
const v = node[k];
|
|
333
|
+
if (v && typeof v === 'object') inlineScan(v, params, selfName, acc, false);
|
|
334
|
+
}
|
|
335
|
+
return acc;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function inlinableBody(fnAst) {
|
|
339
|
+
if (!fnAst || fnAst.clauses.length !== 1) return null;
|
|
340
|
+
const c = fnAst.clauses[0];
|
|
341
|
+
if (!c.pats.every((p) => p && p.t === 'pvar')) return null;
|
|
342
|
+
if (!c.rhs || (c.rhs.guards && c.rhs.guards.length) || (c.rhs.where && c.rhs.where.length) || !c.rhs.body) return null;
|
|
343
|
+
const params = new Set(c.pats.map((p) => p.name));
|
|
344
|
+
const acc = inlineScan(c.rhs.body, params, fnAst.name);
|
|
345
|
+
return acc.ok ? { params: c.pats.map((p) => p.name), body: c.rhs.body } : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── per-function lowering ────────────────────────────────────────────────────
|
|
349
|
+
class FnLowerer {
|
|
350
|
+
constructor(proj, mod, fnInfo, scopes) {
|
|
351
|
+
this.proj = proj;
|
|
352
|
+
this.mod = mod;
|
|
353
|
+
this.info = fnInfo; // {qid, name, line, parentName}
|
|
354
|
+
this.scopes = scopes ? [...scopes] : [];
|
|
355
|
+
this.base = this.scopes.length;
|
|
356
|
+
this.cfg = new Cfg(fnInfo.line);
|
|
357
|
+
this.depth = 0;
|
|
358
|
+
this.tmp = 0;
|
|
359
|
+
this.lam = 0;
|
|
360
|
+
this.refs = [];
|
|
361
|
+
this.deferred = [];
|
|
362
|
+
this.captures = new Set();
|
|
363
|
+
this.diagnostics = [];
|
|
364
|
+
this.params = [];
|
|
365
|
+
this.sawIO = false;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// scope handling
|
|
369
|
+
push() { const m = new Map(); this.scopes.push(m); return m; }
|
|
370
|
+
pop() { this.scopes.pop(); }
|
|
371
|
+
declare(name, entry) { this.scopes[this.scopes.length - 1].set(name, entry); }
|
|
372
|
+
lookupLocal(name) {
|
|
373
|
+
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
|
374
|
+
if (!this.scopes[i].has(name)) continue;
|
|
375
|
+
const entry = this.scopes[i].get(name);
|
|
376
|
+
// A parameter of an enclosing function is a captured variable here, not
|
|
377
|
+
// one of this function's own positional parameters.
|
|
378
|
+
if (i < this.base && entry.kind === 'param') { this.captures.add(name); return { kind: 'local', captured: true }; }
|
|
379
|
+
return entry;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
resolve(name, qual) {
|
|
385
|
+
if (!qual) {
|
|
386
|
+
const l = this.lookupLocal(name);
|
|
387
|
+
if (l) return { kind: l.kind, ...l, name };
|
|
388
|
+
}
|
|
389
|
+
return resolveGlobal(this.proj, this.mod, name, qual || null);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
noteRef(qid, via, line) { if (qid) this.refs.push({ target: qid, via, line }); }
|
|
393
|
+
|
|
394
|
+
// ── expression lowering ────────────────────────────────────────────────────
|
|
395
|
+
lowerExpr(e, line) {
|
|
396
|
+
if (++this.depth > HS_IR_LIMITS.maxExprDepth) { this.depth--; return unknownExpr({ capped: 'expression-depth' }); }
|
|
397
|
+
try { return this.lowerInner(e, line || 0); } finally { this.depth--; }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lowerInner(e, line) {
|
|
401
|
+
if (!e) return unknownExpr();
|
|
402
|
+
// While an inlined body is lowered, every node reports the CALL SITE's line: the callee's own line
|
|
403
|
+
// belongs to a different place (often a different file).
|
|
404
|
+
const ln = this.lineOverride || e.line || line;
|
|
405
|
+
switch (e.t) {
|
|
406
|
+
case 'lowered': return e.expr;
|
|
407
|
+
case 'paren': return this.lowerExpr(e.e, ln);
|
|
408
|
+
case 'lit': return { kind: 'literal', value: e.kind === 'int' || e.kind === 'float' ? Number(String(e.v).replace(/_/g, '')) : e.v };
|
|
409
|
+
case 'con': return { kind: 'literal', value: e.name, hs: { con: e.name } };
|
|
410
|
+
case 'var': return this.lowerVarValue(e, ln);
|
|
411
|
+
case 'app': return this.lowerApp(e.f, e.args, ln);
|
|
412
|
+
case 'op': return this.lowerOp(e, ln);
|
|
413
|
+
case 'neg': return { kind: 'binary', op: '-', left: { kind: 'literal', value: 0 }, right: this.lowerExpr(e.e, ln) };
|
|
414
|
+
case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
|
|
415
|
+
case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
|
|
416
|
+
case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
|
|
417
|
+
case 'listcomp': return this.lowerListComp(e, ln);
|
|
418
|
+
case 'rec': return this.lowerRecord(e, ln);
|
|
419
|
+
case 'recupd': {
|
|
420
|
+
const base = this.lowerExpr(e.base, ln);
|
|
421
|
+
return mkUnion(base, { kind: 'object', props: e.fields.map((f) => ({ key: f.name, value: this.lowerExpr(f.e, ln) })), hs: { update: true } });
|
|
422
|
+
}
|
|
423
|
+
case 'if': {
|
|
424
|
+
const c = this.lowerExpr(e.c, ln);
|
|
425
|
+
return this.branch(c, ln, () => this.lowerExpr(e.a, ln), () => this.lowerExpr(e.b, ln));
|
|
426
|
+
}
|
|
427
|
+
case 'multiif': return this.lowerGuards(e.guards, ln);
|
|
428
|
+
case 'case': return this.lowerCase(e, ln);
|
|
429
|
+
case 'lamcase': return this.liftClosure({ params: [{ t: 'pvar', name: '$lc' }], body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: e.alts, line: ln }, line: ln }, ln);
|
|
430
|
+
case 'lam': return this.liftClosure(e, ln);
|
|
431
|
+
case 'let': {
|
|
432
|
+
this.push();
|
|
433
|
+
try {
|
|
434
|
+
this.processDecls(e.decls, [e.body], ln);
|
|
435
|
+
return this.lowerExpr(e.body, ln);
|
|
436
|
+
} finally { this.pop(); }
|
|
437
|
+
}
|
|
438
|
+
case 'do': return this.lowerDo(e.stmts, ln);
|
|
439
|
+
case 'lsec': case 'rsec': {
|
|
440
|
+
const refs = this.sectionTargets(e);
|
|
441
|
+
for (const r of refs) this.noteRef(r, 'section', ln);
|
|
442
|
+
return { kind: 'array', elements: [this.lowerExpr(e.e, ln)], hs: { section: e.op, partial: { remaining: 1 } } };
|
|
443
|
+
}
|
|
444
|
+
default: return unknownExpr({ ast: e.t || 'unknown' });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
sectionTargets() { return []; }
|
|
449
|
+
|
|
450
|
+
lowerRecord(e, ln) {
|
|
451
|
+
const props = [];
|
|
452
|
+
const cons = this.mod.cons.get(e.con.name) || this.proj.findCon(e.con.name);
|
|
453
|
+
for (const f of e.fields) {
|
|
454
|
+
if (f.wildcard) {
|
|
455
|
+
for (const fld of (cons && cons.fields) || []) if (!e.fields.some((g) => g.name === fld)) props.push({ key: fld, value: { kind: 'ident', name: fld } });
|
|
456
|
+
} else props.push({ key: f.name, value: this.lowerExpr(f.e, ln) });
|
|
457
|
+
}
|
|
458
|
+
return { kind: 'object', props, hs: { con: e.con.name } };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
lowerListComp(e, ln) {
|
|
462
|
+
for (const q of e.quals) {
|
|
463
|
+
if (q.t === 'qbind') this.bindPattern(q.pat, { kind: 'member', object: this.lowerExpr(q.e, ln), prop: '[]' }, ln);
|
|
464
|
+
else if (q.t === 'qlet') this.processDecls(q.decls, [e.head], ln);
|
|
465
|
+
}
|
|
466
|
+
return { kind: 'array', elements: [this.lowerExpr(e.head, ln)], hs: { comprehension: true } };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// A function name used as a value (not applied).
|
|
470
|
+
lowerVarValue(e, ln) {
|
|
471
|
+
const r = this.resolve(e.name, e.qual);
|
|
472
|
+
if (r.kind === 'alias') return r.expr;
|
|
473
|
+
if (r.kind === 'def' && r.def.kind === 'field') return { kind: 'ident', name: e.name, hs: { fieldSelector: r.name } };
|
|
474
|
+
if (r.kind === 'def' || r.kind === 'localfn') {
|
|
475
|
+
this.noteRef(r.qid, 'reference', ln);
|
|
476
|
+
return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
|
|
477
|
+
}
|
|
478
|
+
if (r.kind === 'param' || r.kind === 'local') return { kind: 'ident', name: e.name };
|
|
479
|
+
const lib = libraryName(r, e.name, e.qual);
|
|
480
|
+
const viaReg = lib.viaRegistry ? { viaRegistry: true, certain: false } : {};
|
|
481
|
+
// A modelled SOURCE action named as a value (`getLine >>= f`, `fmap g getLine`) yields untrusted data
|
|
482
|
+
// wherever it is bound, so it lowers to the call that produces it.
|
|
483
|
+
if ((r.kind === 'external' || r.kind === 'builtin') && isSourceApi(lib.name)) {
|
|
484
|
+
return this.sourceValue({ kind: 'call', callee: lib.name, args: [], line: ln, hs: { status: r.kind, effect: 'io', action: true, ...viaReg } }, ln);
|
|
485
|
+
}
|
|
486
|
+
return { kind: 'ident', name: lib.name, hs: { resolution: r.kind, ...viaReg } };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
canonical(r) {
|
|
490
|
+
if (r.kind === 'def') return `${r.mod}.${r.name}`;
|
|
491
|
+
if (r.kind === 'localfn') return r.canonical;
|
|
492
|
+
return r.name;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── application ────────────────────────────────────────────────────────────
|
|
496
|
+
lowerApp(fAst, argAsts, ln, via) {
|
|
497
|
+
const f = unparen(fAst);
|
|
498
|
+
if (!f) return unknownExpr();
|
|
499
|
+
if (f.t === 'app') return this.lowerApp(f.f, [...f.args, ...argAsts], ln, via);
|
|
500
|
+
if (!argAsts.length) return this.lowerExpr(f, ln);
|
|
501
|
+
if (f.t === 'op') {
|
|
502
|
+
if (f.op === '.') {
|
|
503
|
+
const [a0, ...rest] = argAsts;
|
|
504
|
+
return this.lowerApp(f.l, [{ t: 'app', f: f.r, args: [a0], line: ln }, ...rest], ln, via);
|
|
505
|
+
}
|
|
506
|
+
if (f.op === '$' || f.op === '$!') return this.lowerApp(f.l, [f.r, ...argAsts], ln, via);
|
|
507
|
+
if (f.op === '&') return this.lowerApp(f.r, [f.l, ...argAsts], ln, via);
|
|
508
|
+
}
|
|
509
|
+
if (f.t === 'rsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: argAsts[0], r: f.e, line: ln }, ln);
|
|
510
|
+
if (f.t === 'lsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: f.e, r: argAsts[0], line: ln }, ln);
|
|
511
|
+
if (f.t === 'lam') return this.betaReduce(f, argAsts, ln);
|
|
512
|
+
if (f.t === 'var') return this.applyVar(f, argAsts, ln, via);
|
|
513
|
+
if (f.t === 'con') {
|
|
514
|
+
const cons = this.mod.cons.get(f.name) || this.proj.findCon(f.name);
|
|
515
|
+
const elements = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
516
|
+
if (cons && cons.fields) return { kind: 'object', props: cons.fields.map((k, i) => ({ key: k, value: elements[i] || unknownExpr() })), hs: { con: f.name } };
|
|
517
|
+
return { kind: 'array', elements, hs: { con: f.name } };
|
|
518
|
+
}
|
|
519
|
+
// Function-valued conditional/let/case etc.: the target is not statically known.
|
|
520
|
+
const fv = this.lowerExpr(f, ln);
|
|
521
|
+
return { kind: 'call', callee: '<indirect>', args: [fv, ...argAsts.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'computed-function', via } };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
betaReduce(lam, argAsts, ln) {
|
|
525
|
+
const n = Math.min(lam.params.length, argAsts.length);
|
|
526
|
+
this.push();
|
|
527
|
+
try {
|
|
528
|
+
for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln);
|
|
529
|
+
if (lam.params.length > argAsts.length) return { kind: 'array', elements: argAsts.map((a) => this.lowerExpr(a, ln)), hs: { partial: { remaining: lam.params.length - argAsts.length }, closure: true } };
|
|
530
|
+
const body = this.lowerExpr(lam.body, ln);
|
|
531
|
+
if (argAsts.length > n) return { kind: 'call', callee: '<indirect>', args: [body, ...argAsts.slice(n).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'result-applied' } };
|
|
532
|
+
return body;
|
|
533
|
+
} finally { this.pop(); }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
applyVar(f, argAsts, ln, via) {
|
|
537
|
+
const r = this.resolve(f.name, f.qual);
|
|
538
|
+
// Bounded collection summaries only apply to library names, never a user definition.
|
|
539
|
+
if (HOF[f.name] && (r.kind === 'builtin' || r.kind === 'external')) {
|
|
540
|
+
const out = this.lowerHof(f.name, argAsts, ln);
|
|
541
|
+
if (out) return out;
|
|
542
|
+
}
|
|
543
|
+
if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
|
|
544
|
+
const obj = this.lowerExpr(argAsts[0], ln);
|
|
545
|
+
const mem = { kind: 'member', object: obj, prop: r.name };
|
|
546
|
+
return argAsts.length === 1 ? mem : { kind: 'call', callee: '<indirect>', args: [mem, ...argAsts.slice(1).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'field-applied' } };
|
|
547
|
+
}
|
|
548
|
+
const args = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
549
|
+
const hs = { via };
|
|
550
|
+
let callee = f.qual ? `${f.qual}.${f.name}` : f.name;
|
|
551
|
+
const arity = (r.kind === 'def' && r.def.kind === 'fun') ? r.def.arity : (r.kind === 'localfn' ? r.arity : -1);
|
|
552
|
+
if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
|
|
553
|
+
callee = this.canonical(r);
|
|
554
|
+
hs.status = 'resolved'; hs.target = r.qid;
|
|
555
|
+
if (r.kind === 'def' && arity === args.length && arity > 0 && (this.inlineDepth || 0) < INLINE_MAX_DEPTH) {
|
|
556
|
+
const inl = this.tryInline(r, args, ln, via);
|
|
557
|
+
if (inl) return inl;
|
|
558
|
+
}
|
|
559
|
+
if (arity > args.length) {
|
|
560
|
+
this.noteRef(r.qid, 'partial', ln);
|
|
561
|
+
return { kind: 'array', elements: args, hs: { partial: { target: r.qid, name: callee, remaining: arity - args.length } } };
|
|
562
|
+
}
|
|
563
|
+
const sig = r.kind === 'def' ? r.def.sig : null;
|
|
564
|
+
hs.effect = sig ? (sig.returnsIO ? 'io' : 'pure') : 'unknown';
|
|
565
|
+
} else if (r.kind === 'def' && r.def.kind === 'ffi') {
|
|
566
|
+
// A foreign function: the code behind it is not analysed, so the call is an opaque boundary.
|
|
567
|
+
callee = `${r.mod}.${r.name}`;
|
|
568
|
+
hs.status = 'ffi'; hs.reason = 'foreign-function'; hs.effect = 'io';
|
|
569
|
+
} else if (r.kind === 'def' && r.def.kind === 'method') {
|
|
570
|
+
callee = `${r.mod}.${r.name}`;
|
|
571
|
+
hs.status = 'typeclass'; hs.cls = r.def.cls; hs.method = r.name; hs.target = r.qid;
|
|
572
|
+
hs.candidates = this.proj.instancesOf(r.def.cls, r.name);
|
|
573
|
+
hs.effect = 'unknown';
|
|
574
|
+
} else if (r.kind === 'param') {
|
|
575
|
+
hs.status = 'param'; hs.paramIndex = r.index; hs.effect = 'unknown';
|
|
576
|
+
} else if (r.kind === 'local') {
|
|
577
|
+
hs.status = 'unknown'; hs.reason = 'local-function-value'; hs.effect = 'unknown';
|
|
578
|
+
} else if (r.kind === 'external') {
|
|
579
|
+
const lib = libraryName(r, f.name, f.qual);
|
|
580
|
+
callee = lib.name;
|
|
581
|
+
hs.status = 'external'; hs.certain = lib.viaRegistry ? false : r.certain; if (lib.viaRegistry) hs.viaRegistry = true; hs.effect = IO_NAMES.has(f.name) ? 'io' : 'unknown';
|
|
582
|
+
if (IO_NAMES.has(f.name)) this.sawIO = true;
|
|
583
|
+
} else if (r.kind === 'builtin') {
|
|
584
|
+
callee = libraryName(r, f.name, f.qual).name;
|
|
585
|
+
hs.status = 'builtin'; hs.effect = IO_NAMES.has(f.name) ? 'io' : 'pure';
|
|
586
|
+
if (IO_NAMES.has(f.name)) this.sawIO = true;
|
|
587
|
+
} else {
|
|
588
|
+
hs.status = 'unknown'; hs.reason = r.reason || 'unresolved'; hs.effect = 'unknown';
|
|
589
|
+
}
|
|
590
|
+
if (hs.status === 'resolved' || hs.status === 'typeclass') {
|
|
591
|
+
const ctxTargets = args.map((a) => argTargets(a));
|
|
592
|
+
if (ctxTargets.some((t) => t.length)) hs.argTargets = ctxTargets;
|
|
593
|
+
}
|
|
594
|
+
const call = { kind: 'call', callee, args, line: ln, hs };
|
|
595
|
+
// `setRequestBodyJSON body req` returns the REQUEST with a body: the body's taint is a property of the body, and
|
|
596
|
+
// must not make the request's URL tainted. The call still gets its own node so a sink on the body argument fires.
|
|
597
|
+
if ((hs.status === 'external' || hs.status === 'builtin') && BODY_SETTER.test(callee) && args.length >= 2) {
|
|
598
|
+
const t = `$body${this.tmp++}`;
|
|
599
|
+
this.cfg.add('assign', ln, { target: t, source: call, hs: { demand: 'executed', bodySetter: true } });
|
|
600
|
+
return args[args.length - 1];
|
|
601
|
+
}
|
|
602
|
+
return (hs.status === 'external' || hs.status === 'builtin') && isSourceApi(callee) ? this.sourceValue(call, ln) : call;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// A modelled source read gets its own assignment node, so the evidence chain names the exact line the
|
|
606
|
+
// untrusted data entered, even when the read sits inside a larger expression (`f =<< getLine`).
|
|
607
|
+
sourceValue(call, ln) {
|
|
608
|
+
const t = `$src${this.tmp++}`;
|
|
609
|
+
this.cfg.add('assign', call.line || ln, { target: t, source: call, hs: { demand: 'executed', source: true } });
|
|
610
|
+
return { kind: 'ident', name: t, hs: { sourceTemp: call.callee } };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Substitute lowered arguments into a small pure callee body. Returns null when the callee is not inlinable.
|
|
614
|
+
tryInline(r, loweredArgs, ln, via) {
|
|
615
|
+
const calleeMod = this.proj.modules.get(r.mod);
|
|
616
|
+
if (!calleeMod) return null;
|
|
617
|
+
const fnAst = calleeMod.groups.funs.find((f) => f.name === r.name);
|
|
618
|
+
const inl = inlinableBody(fnAst);
|
|
619
|
+
if (!inl || inl.params.length !== loweredArgs.length) return null;
|
|
620
|
+
const savedMod = this.mod;
|
|
621
|
+
const savedOverride = this.lineOverride;
|
|
622
|
+
this.lineOverride = this.lineOverride || ln;
|
|
623
|
+
this.inlineDepth = (this.inlineDepth || 0) + 1;
|
|
624
|
+
this.push();
|
|
625
|
+
try {
|
|
626
|
+
inl.params.forEach((name, i) => this.declare(name, { kind: 'alias', expr: loweredArgs[i] }));
|
|
627
|
+
this.mod = calleeMod;
|
|
628
|
+
(this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: [], line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
|
|
629
|
+
const out = this.lowerExpr(inl.body, ln);
|
|
630
|
+
return out && typeof out === 'object' ? { ...out, hs: { ...(out.hs || {}), inlinedFrom: r.qid } } : out;
|
|
631
|
+
} finally {
|
|
632
|
+
this.mod = savedMod;
|
|
633
|
+
this.lineOverride = savedOverride;
|
|
634
|
+
this.pop();
|
|
635
|
+
this.inlineDepth -= 1;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// A project-defined zero-argument action executed by a bind or a statement (`u <- requireUser`) is a
|
|
640
|
+
// call to it, not a mere reference: it runs when sequenced.
|
|
641
|
+
executeRef(v, ln) {
|
|
642
|
+
if (!v || v.kind !== 'ident' || !v.hs || !v.hs.functionRef || v.hs.closure) return v;
|
|
643
|
+
let def = null;
|
|
644
|
+
for (const m of this.proj.modules.values()) for (const d of m.defs.values()) if (d.qid === v.hs.functionRef) def = d;
|
|
645
|
+
if (!def || def.kind !== 'fun' || def.arity !== 0) return v;
|
|
646
|
+
return { kind: 'call', callee: v.name, args: [], line: ln, hs: { status: 'resolved', target: v.hs.functionRef, effect: 'io', action: true } };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Bounded collection/traversal summaries. Returns null to fall back to a plain call.
|
|
650
|
+
lowerHof(name, argAsts, ln) {
|
|
651
|
+
const spec = HOF[name];
|
|
652
|
+
const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll) + 1;
|
|
653
|
+
if (argAsts.length < need || (spec.seed !== undefined && argAsts.length < 3)) return null;
|
|
654
|
+
const coll = this.lowerExpr(argAsts[spec.coll], ln);
|
|
655
|
+
const elem = { kind: 'member', object: coll, prop: '[]' };
|
|
656
|
+
if (spec.kind === 'sequence') { this.sawIO = true; return coll; }
|
|
657
|
+
const fnAst = argAsts[spec.fn];
|
|
658
|
+
const el = { t: 'lowered', expr: elem };
|
|
659
|
+
if (spec.kind === 'fold') {
|
|
660
|
+
const seed = this.lowerExpr(argAsts[spec.seed], ln);
|
|
661
|
+
const step = this.lowerApp(fnAst, [spec.fn === 0 && (name === 'foldr') ? el : { t: 'lowered', expr: seed }, name === 'foldr' ? { t: 'lowered', expr: seed } : el], ln, name);
|
|
662
|
+
return mkUnion(seed, step);
|
|
663
|
+
}
|
|
664
|
+
const res = this.lowerApp(fnAst, [el], ln, name);
|
|
665
|
+
if (spec.kind === 'filter') {
|
|
666
|
+
this.cfg.add('if', ln, { cond: res, hs: { via: 'filter' } });
|
|
667
|
+
return coll;
|
|
668
|
+
}
|
|
669
|
+
if (spec.kind === 'traverse_') {
|
|
670
|
+
this.addCallNode(res, ln);
|
|
671
|
+
return { kind: 'literal', value: '()' };
|
|
672
|
+
}
|
|
673
|
+
return { kind: 'array', elements: [res], hs: { via: name, effect: spec.kind === 'traverse' ? 'unknown' : 'pure' } };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── operators ──────────────────────────────────────────────────────────────
|
|
677
|
+
lowerOp(e, ln) {
|
|
678
|
+
const op = e.op;
|
|
679
|
+
switch (op) {
|
|
680
|
+
case '$': case '$!': return this.lowerApp(e.l, [e.r], ln);
|
|
681
|
+
case '&': return this.lowerApp(e.r, [e.l], ln);
|
|
682
|
+
case '<&>': return this.lowerApp(e.r, [e.l], ln, 'fmap');
|
|
683
|
+
case '>>=': return this.lowerApp(e.r, [e.l], ln, 'bind');
|
|
684
|
+
case '=<<': return this.lowerApp(e.l, [e.r], ln, 'bind');
|
|
685
|
+
case '<$>': case '<$!>': return this.lowerApp(e.l, [e.r], ln, 'fmap');
|
|
686
|
+
case '<$': { const v = this.lowerExpr(e.l, ln); this.addCallNode(this.lowerExpr(e.r, ln), ln); return v; }
|
|
687
|
+
case '>>': case '*>': { this.addCallNode(this.lowerExpr(e.l, ln), ln); return this.lowerExpr(e.r, ln); }
|
|
688
|
+
case '<*': { const v = this.lowerExpr(e.l, ln); this.addCallNode(this.lowerExpr(e.r, ln), ln); return v; }
|
|
689
|
+
case '<*>': return this.lowerApplicative(e, ln);
|
|
690
|
+
case '<|>': return mkUnion(this.lowerExpr(e.l, ln), this.lowerExpr(e.r, ln));
|
|
691
|
+
case '.': case '>=>': case '<=<': {
|
|
692
|
+
const l = this.lowerExpr(e.l, ln);
|
|
693
|
+
const r = this.lowerExpr(e.r, ln);
|
|
694
|
+
return { kind: 'array', elements: [l, r], hs: { compose: true, targets: [...argTargets(l), ...argTargets(r)] } };
|
|
695
|
+
}
|
|
696
|
+
case '++': case '<>': return { kind: 'binary', op: '+', left: this.lowerExpr(e.l, ln), right: this.lowerExpr(e.r, ln), hs: { concat: op } };
|
|
697
|
+
case '&&': return { kind: 'logical', op: 'and', left: this.lowerExpr(e.l, ln), right: this.lowerExpr(e.r, ln) };
|
|
698
|
+
case '||': return { kind: 'logical', op: 'or', left: this.lowerExpr(e.l, ln), right: this.lowerExpr(e.r, ln) };
|
|
699
|
+
case ':': return { kind: 'array', elements: [this.lowerExpr(e.l, ln), this.lowerExpr(e.r, ln)], hs: { cons: true } };
|
|
700
|
+
default: break;
|
|
701
|
+
}
|
|
702
|
+
if (ARITH.has(op)) return { kind: 'binary', op, left: this.lowerExpr(e.l, ln), right: this.lowerExpr(e.r, ln) };
|
|
703
|
+
if (op[0] === ':') return { kind: 'array', elements: [this.lowerExpr(e.l, ln), this.lowerExpr(e.r, ln)], hs: { con: op } };
|
|
704
|
+
return this.lowerApp({ t: 'var', name: op, qual: e.qual || null, line: ln }, [e.l, e.r], ln);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// f <$> a <*> b <*> c ==> f a b c ; `pure f <*> a` likewise.
|
|
708
|
+
lowerApplicative(e, ln) {
|
|
709
|
+
const rest = [];
|
|
710
|
+
let cur = e;
|
|
711
|
+
while (cur.t === 'op' && cur.op === '<*>') { rest.unshift(cur.r); cur = unparen(cur.l); if (!cur) break; }
|
|
712
|
+
if (cur && cur.t === 'op' && (cur.op === '<$>' || cur.op === '<$!>')) return this.lowerApp(cur.l, [cur.r, ...rest], ln, 'applicative');
|
|
713
|
+
const pure = cur && cur.t === 'app' && unparen(cur.f) && unparen(cur.f).t === 'var' && ['pure', 'return'].includes(unparen(cur.f).name) && cur.args.length === 1;
|
|
714
|
+
if (pure) return this.lowerApp(cur.args[0], rest, ln, 'applicative');
|
|
715
|
+
const base = cur ? this.lowerExpr(cur, ln) : unknownExpr();
|
|
716
|
+
return { kind: 'call', callee: '<ap>', args: [base, ...rest.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'applicative-function-unknown', via: 'applicative' } };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── closures ───────────────────────────────────────────────────────────────
|
|
720
|
+
liftClosure(lam, ln) {
|
|
721
|
+
this.lam++;
|
|
722
|
+
const name = `${this.info.name}.\\lambda${this.lam}`;
|
|
723
|
+
const qid = `${this.mod.file}::${this.info.parentName || this.info.name}.\\lambda${this.lam}@${ln}`;
|
|
724
|
+
const sub = new FnLowerer(this.proj, this.mod, { qid, name, line: ln, parentName: this.info.parentName || this.info.name }, this.scopes);
|
|
725
|
+
sub.push();
|
|
726
|
+
const params = lam.params.map((p, i) => (p.t === 'pvar' ? p.name : `$arg${i}`));
|
|
727
|
+
params.forEach((p, i) => sub.declare(p, { kind: 'param', index: i }));
|
|
728
|
+
sub.params = params;
|
|
729
|
+
lam.params.forEach((p, i) => { if (p.t !== 'pvar') sub.bindPattern(p, { kind: 'ident', name: `$arg${i}` }, ln); });
|
|
730
|
+
const body = sub.lowerExpr(lam.body, ln);
|
|
731
|
+
sub.cfg.add('return', (body && body.kind === 'call' && body.line) || ln, { value: body });
|
|
732
|
+
this.proj.addFn(sub.finishFn({ kind: 'closure', arity: params.length, parent: this.info.qid, captures: [...sub.captures] }));
|
|
733
|
+
this.noteRef(qid, 'closure', ln);
|
|
734
|
+
return { kind: 'ident', name: qid, hs: { functionRef: qid, closure: true } };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── statements / control flow ──────────────────────────────────────────────
|
|
738
|
+
addCallNode(expr, ln) {
|
|
739
|
+
if (!expr) return;
|
|
740
|
+
if (expr.kind === 'call') this.cfg.add('call', ln, { callee: expr.callee, args: expr.args, hs: expr.hs });
|
|
741
|
+
else if (expr.kind !== 'literal' && expr.kind !== 'ident') this.cfg.add('assign', ln, { target: `$e${this.tmp++}`, source: expr, hs: { demand: 'executed' } });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
branch(cond, ln, thenF, elseF) {
|
|
745
|
+
const c = this.cfg;
|
|
746
|
+
const ifId = c.add('if', ln, { cond });
|
|
747
|
+
c.front = [ifId];
|
|
748
|
+
const a = thenF();
|
|
749
|
+
const fa = c.front;
|
|
750
|
+
const thenEdgesAfterThen = [...c.nodes[ifId].succ];
|
|
751
|
+
c.front = [ifId];
|
|
752
|
+
const b = elseF();
|
|
753
|
+
const fb = c.front;
|
|
754
|
+
const join = c.mk('noop', ln);
|
|
755
|
+
// Which successor is which: a branch that adds no node falls straight through to the join, so the
|
|
756
|
+
// order of `succ` is NOT then/else. Record the real entries for guard analysis.
|
|
757
|
+
const thenEdges = thenEdgesAfterThen;
|
|
758
|
+
const elseEdges = c.nodes[ifId].succ.filter((x) => !thenEdges.includes(x));
|
|
759
|
+
c.nodes[ifId].thenEntry = thenEdges[0] || join;
|
|
760
|
+
c.nodes[ifId].elseEntry = elseEdges[0] || join;
|
|
761
|
+
for (const f of [...fa, ...fb]) c.link(f, join);
|
|
762
|
+
c.front = [join];
|
|
763
|
+
const u = mkUnion(a, b);
|
|
764
|
+
// Record which condition selects which branch, so a guard can be judged structurally: branch 0 runs
|
|
765
|
+
// when the condition holds, branch 1 when it does not. (A branch that yields nothing collapses the
|
|
766
|
+
// union and the fact is simply not recorded: fewer guards are recognised, never more.)
|
|
767
|
+
if (u && u.kind === 'union' && u.branches.length === 2 && u.branches[0] === a && u.branches[1] === b) {
|
|
768
|
+
u.hs = { ...(u.hs || {}), branchConds: [{ cond, when: true }, { cond, when: false }] };
|
|
769
|
+
}
|
|
770
|
+
return u;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// items: [{test():expr|null, body():expr|null}], chained as nested branches.
|
|
774
|
+
alternatives(items, i, ln) {
|
|
775
|
+
if (i >= items.length) return null;
|
|
776
|
+
const it = items[i];
|
|
777
|
+
const test = it.test();
|
|
778
|
+
if (test === null) return it.body();
|
|
779
|
+
return this.branch(test, ln, () => it.body(), () => this.alternatives(items, i + 1, ln));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
lowerGuards(guards, ln) {
|
|
783
|
+
const items = guards.map((g) => ({
|
|
784
|
+
test: () => this.guardCond(g.quals, ln),
|
|
785
|
+
body: () => this.lowerExpr(g.body, ln),
|
|
786
|
+
}));
|
|
787
|
+
return this.alternatives(items, 0, ln);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
guardCond(quals, ln) {
|
|
791
|
+
let cond = null;
|
|
792
|
+
for (const q of quals) {
|
|
793
|
+
let c = null;
|
|
794
|
+
if (q.t === 'qbool') {
|
|
795
|
+
const x = unparen(q.e);
|
|
796
|
+
if (x && x.t === 'var' && (x.name === 'otherwise' || x.name === 'True')) continue;
|
|
797
|
+
c = this.lowerExpr(q.e, ln);
|
|
798
|
+
} else if (q.t === 'qbind') {
|
|
799
|
+
const scrut = this.lowerExpr(q.e, ln);
|
|
800
|
+
this.bindPattern(q.pat, scrut, ln);
|
|
801
|
+
c = { kind: 'call', callee: '<match>', args: [scrut], hs: { status: 'builtin', effect: 'pure', pattern: true } };
|
|
802
|
+
} else if (q.t === 'qlet') this.processDecls(q.decls, [], ln);
|
|
803
|
+
if (c) cond = cond ? { kind: 'logical', op: 'and', left: cond, right: c } : c;
|
|
804
|
+
}
|
|
805
|
+
return cond;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
lowerRhs(rhs, ln) {
|
|
809
|
+
this.push();
|
|
810
|
+
try {
|
|
811
|
+
this.processDecls(rhs.where || [], [rhs.body, ...(rhs.guards || []).flatMap((g) => [g.body, ...g.quals])], ln);
|
|
812
|
+
if (rhs.guards) return this.lowerGuards(rhs.guards, ln);
|
|
813
|
+
return this.lowerExpr(rhs.body, ln);
|
|
814
|
+
} finally { this.pop(); }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
lowerCase(e, ln) {
|
|
818
|
+
const scrut = this.lowerExpr(e.scrut, ln);
|
|
819
|
+
const s = `$s${this.tmp++}`;
|
|
820
|
+
this.cfg.add('assign', ln, { target: s, source: scrut, hs: { demand: 'demanded' } });
|
|
821
|
+
const sv = { kind: 'ident', name: s };
|
|
822
|
+
const items = e.alts.map((alt) => ({
|
|
823
|
+
test: () => (isRefutable(alt.pat) ? { kind: 'call', callee: '<match>', args: [sv], hs: { status: 'builtin', effect: 'pure', pattern: true } } : null),
|
|
824
|
+
body: () => { this.push(); try { this.bindPattern(alt.pat, sv, ln); return this.lowerRhs(alt.rhs, alt.line || ln); } finally { this.pop(); } },
|
|
825
|
+
}));
|
|
826
|
+
return this.alternatives(items, 0, ln);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
lowerDo(stmts, ln) {
|
|
830
|
+
this.push();
|
|
831
|
+
try {
|
|
832
|
+
let last = null;
|
|
833
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
834
|
+
const st = stmts[i];
|
|
835
|
+
const isLast = i === stmts.length - 1;
|
|
836
|
+
const sl = st.line || ln;
|
|
837
|
+
if (st.t === 'sbind') {
|
|
838
|
+
const v = this.executeRef(asExecutedAction(this.lowerExpr(st.e, sl), sl), sl);
|
|
839
|
+
this.bindPattern(st.pat, v, sl, { executed: true });
|
|
840
|
+
} else if (st.t === 'slet') {
|
|
841
|
+
this.processDecls(st.decls, stmts.slice(i + 1), sl);
|
|
842
|
+
} else if (st.t === 'sexpr') {
|
|
843
|
+
const v = this.executeRef(asExecutedAction(this.lowerExpr(st.e, sl), sl), sl);
|
|
844
|
+
if (isLast) last = v; else this.addCallNode(v, sl);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return last || { kind: 'literal', value: '()' };
|
|
848
|
+
} finally { this.pop(); }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── patterns ───────────────────────────────────────────────────────────────
|
|
852
|
+
bindPattern(p, src, ln, opts) {
|
|
853
|
+
if (p && p.t !== 'pvar' && src && src.kind !== 'ident') {
|
|
854
|
+
// Evaluate the scrutinee once; every component then reads the temporary.
|
|
855
|
+
if (!patternVars(p).length) { if (opts && opts.executed) this.addCallNode(src, ln); return; }
|
|
856
|
+
const t = `$p${this.tmp++}`;
|
|
857
|
+
this.cfg.add('assign', ln, { target: t, source: src, hs: { demand: opts && opts.executed ? 'executed' : 'demanded', pattern: true } });
|
|
858
|
+
src = { kind: 'ident', name: t };
|
|
859
|
+
} else if (p && p.t === 'pwild' && opts && opts.executed) { this.addCallNode(src, ln); return; }
|
|
860
|
+
const emit = (name, source) => {
|
|
861
|
+
this.declare(name, { kind: 'local' });
|
|
862
|
+
// `x <- getLine`: bind the read DIRECTLY to `x` (retarget the temp assignment just emitted) so the
|
|
863
|
+
// evidence chain stays attached to the variable the program actually uses.
|
|
864
|
+
if (source && source.kind === 'ident' && source.hs && source.hs.sourceTemp && this.cfg.front.length === 1) {
|
|
865
|
+
const last = this.cfg.nodes[this.cfg.front[0]];
|
|
866
|
+
if (last && last.kind === 'assign' && last.target === source.name) { last.target = name; last.hs = { ...last.hs, pattern: true }; return; }
|
|
867
|
+
}
|
|
868
|
+
this.addAssign(ln, name, source, { demand: opts && opts.executed ? 'executed' : 'demanded', pattern: true });
|
|
869
|
+
};
|
|
870
|
+
const walk = (pat, s) => {
|
|
871
|
+
if (!pat) return;
|
|
872
|
+
switch (pat.t) {
|
|
873
|
+
case 'pvar': emit(pat.name, s); break;
|
|
874
|
+
case 'pas': emit(pat.name, s); walk(pat.p, s); break;
|
|
875
|
+
case 'ptuple': pat.items.forEach((x, i) => walk(x, { kind: 'member', object: s, prop: `[${i}]` })); break;
|
|
876
|
+
case 'plist': pat.items.forEach((x) => walk(x, { kind: 'member', object: s, prop: '[]' })); break;
|
|
877
|
+
case 'pcon': {
|
|
878
|
+
if (pat.con === ':' && pat.args.length === 2) { walk(pat.args[0], { kind: 'member', object: s, prop: '[0]' }); walk(pat.args[1], { kind: 'member', object: s, prop: '[]' }); break; }
|
|
879
|
+
const info = this.mod.cons.get(pat.con) || this.proj.findCon(pat.con);
|
|
880
|
+
pat.args.forEach((x, i) => walk(x, { kind: 'member', object: s, prop: info && info.fields && info.fields[i] ? info.fields[i] : `${pat.con}.${i}` }));
|
|
881
|
+
for (const f of pat.fields) {
|
|
882
|
+
if (f.wildcard) { for (const fld of (info && info.fields) || []) if (!pat.fields.some((g) => g.name === fld)) emit(fld, { kind: 'member', object: s, prop: fld }); }
|
|
883
|
+
else walk(f.pat, { kind: 'member', object: s, prop: f.name });
|
|
884
|
+
}
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
default: break;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
walk(p, src);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── let / where declarations: lazy, immutable bindings ─────────────────────
|
|
894
|
+
processDecls(decls, bodyAsts, ln) {
|
|
895
|
+
if (!decls.length) return;
|
|
896
|
+
const g = groupDecls(decls);
|
|
897
|
+
const scope = this.scopes[this.scopes.length - 1];
|
|
898
|
+
const parent = this.info.parentName || this.info.name;
|
|
899
|
+
for (const f of g.funs) {
|
|
900
|
+
const arity = Math.max(0, ...f.clauses.map((c) => c.pats.length));
|
|
901
|
+
if (arity > 0) {
|
|
902
|
+
const canonical = `${this.info.name}.${f.name}`;
|
|
903
|
+
scope.set(f.name, { kind: 'localfn', qid: `${this.mod.file}::${parent}.${f.name}@${f.line}`, arity, canonical });
|
|
904
|
+
} else scope.set(f.name, { kind: 'local' });
|
|
905
|
+
}
|
|
906
|
+
for (const pb of g.patbinds) for (const v of patternVars(pb.pat)) scope.set(v, { kind: 'local' });
|
|
907
|
+
const strict = strictNames(bodyAsts.map((b) => b));
|
|
908
|
+
const siblingAsts = decls.map((d) => (d.t === 'clause' ? d.clause.rhs : d.t === 'patbind' ? d.rhs : null));
|
|
909
|
+
|
|
910
|
+
for (const f of g.funs) {
|
|
911
|
+
const arity = Math.max(0, ...f.clauses.map((c) => c.pats.length));
|
|
912
|
+
const entry = scope.get(f.name);
|
|
913
|
+
if (arity > 0) { this.liftLocalFunction(f, entry); continue; }
|
|
914
|
+
const rhsAsts = f.clauses.map((c) => c.rhs);
|
|
915
|
+
const others = siblingAsts.filter((a) => a && !rhsAsts.includes(a));
|
|
916
|
+
this.bindLazy([f.name], () => this.lowerRhs(f.clauses[0].rhs, f.line || ln), bodyAsts, others, strict, f.line || ln);
|
|
917
|
+
}
|
|
918
|
+
for (const pb of g.patbinds) {
|
|
919
|
+
const names = patternVars(pb.pat);
|
|
920
|
+
const others = siblingAsts.filter((a) => a && a !== pb.rhs);
|
|
921
|
+
this.bindLazy(names, () => this.lowerRhs(pb.rhs, pb.line || ln), bodyAsts, others, strict, pb.line || ln, pb.pat);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Assign `source` to `target`. A record construction is stored FIELD BY FIELD (`c.cmd = ..`,
|
|
926
|
+
// `c.label = ..`), so the engine's access-path lattice keeps a clean sibling field clean. A record
|
|
927
|
+
// update (`r { f = v }`) is not split: it also carries the base record, which must stay whole.
|
|
928
|
+
addAssign(ln, target, source, hs) {
|
|
929
|
+
const rv = (this.recordVars ||= new Map());
|
|
930
|
+
const emptyRecord = () => this.cfg.add('assign', ln, { target, source: { kind: 'object', props: [], hs: { record: true } }, hs: { ...hs, record: true } });
|
|
931
|
+
// a fresh record: one assignment per field
|
|
932
|
+
if (source && source.kind === 'object' && Array.isArray(source.props) && source.props.length && !(source.hs && source.hs.update)) {
|
|
933
|
+
emptyRecord();
|
|
934
|
+
const keys = [];
|
|
935
|
+
for (const pr of source.props) {
|
|
936
|
+
if (!pr || typeof pr.key !== 'string') continue;
|
|
937
|
+
keys.push(pr.key);
|
|
938
|
+
this.addAssign(ln, `${target}.${pr.key}`, pr.value, { ...hs, recordField: pr.key });
|
|
939
|
+
}
|
|
940
|
+
if (!target.includes('.')) rv.set(target, keys);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
// a copy of a known record: copy it field by field, so one field's taint does not become the whole record's
|
|
944
|
+
if (source && source.kind === 'ident' && rv.has(source.name) && !target.includes('.')) {
|
|
945
|
+
const keys = rv.get(source.name);
|
|
946
|
+
emptyRecord();
|
|
947
|
+
for (const k of keys) this.addAssign(ln, `${target}.${k}`, { kind: 'member', object: { kind: 'ident', name: source.name }, prop: k }, { ...hs, recordField: k });
|
|
948
|
+
rv.set(target, keys);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// an update of a known record: the base's fields, with the updated ones replaced
|
|
952
|
+
if (source && source.kind === 'union' && source.branches.length === 2 && source.branches[1] && source.branches[1].hs && source.branches[1].hs.update
|
|
953
|
+
&& source.branches[0] && source.branches[0].kind === 'ident' && rv.has(source.branches[0].name) && !target.includes('.')) {
|
|
954
|
+
const base = source.branches[0].name;
|
|
955
|
+
const upd = new Map((source.branches[1].props || []).filter((p) => p && typeof p.key === 'string').map((p) => [p.key, p.value]));
|
|
956
|
+
const keys = [...new Set([...rv.get(base), ...upd.keys()])];
|
|
957
|
+
emptyRecord();
|
|
958
|
+
for (const k of keys) this.addAssign(ln, `${target}.${k}`, upd.has(k) ? upd.get(k) : { kind: 'member', object: { kind: 'ident', name: base }, prop: k }, { ...hs, recordField: k });
|
|
959
|
+
rv.set(target, keys);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (!target.includes('.')) rv.delete(target);
|
|
963
|
+
this.cfg.add('assign', ln, { target, source, hs });
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
bindLazy(names, lowerRhsFn, bodyAsts, otherAsts, strict, ln, pat) {
|
|
967
|
+
const used = names.reduce((n, nm) => n + bodyAsts.reduce((s, b) => s + countOcc(nm, b), 0) + otherAsts.reduce((s, b) => s + countOcc(nm, b), 0), 0);
|
|
968
|
+
if (used === 0) {
|
|
969
|
+
// Never demanded: lower into a scratch graph so it is disclosed but not executed.
|
|
970
|
+
const keep = this.cfg;
|
|
971
|
+
this.cfg = new Cfg(ln);
|
|
972
|
+
let src;
|
|
973
|
+
try { src = lowerRhsFn(); } finally { this.cfg = keep; }
|
|
974
|
+
const callees = [];
|
|
975
|
+
collectCallsInExpr(src, callees);
|
|
976
|
+
this.deferred.push({ names, line: ln, demand: 'unused', reason: 'binding is never referenced; lazy evaluation never demands it', callees: callees.map((c) => c.callee) });
|
|
977
|
+
this.cfg.add('noop', ln, { hs: { deferred: { names, demand: 'unused' } } });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const demanded = names.some((n) => strict.has(n));
|
|
981
|
+
const hs = demanded ? { demand: 'demanded', lazy: true } : { demand: 'potential', uncertainty: 'potentially-demanded', lazy: true };
|
|
982
|
+
const src = lowerRhsFn();
|
|
983
|
+
if (pat && pat.t !== 'pvar') this.bindPattern(pat, src, ln);
|
|
984
|
+
else this.addAssign(ln, names[0], src, hs);
|
|
985
|
+
if (!pat || pat.t === 'pvar') for (const n of names) this.declare(n, { kind: 'local' });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
liftLocalFunction(f, entry) {
|
|
989
|
+
const name = entry.canonical;
|
|
990
|
+
const sub = new FnLowerer(this.proj, this.mod, { qid: entry.qid, name, line: f.line, parentName: this.info.parentName || this.info.name }, this.scopes);
|
|
991
|
+
sub.lowerClauses(f.clauses, f.line);
|
|
992
|
+
this.proj.addFn(sub.finishFn({ kind: 'local-function', arity: entry.arity, parent: this.info.qid }));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Multi-clause function body: pattern clauses chained as alternatives.
|
|
996
|
+
lowerClauses(clauses, ln) {
|
|
997
|
+
this.push();
|
|
998
|
+
const arity = Math.max(0, ...clauses.map((c) => c.pats.length));
|
|
999
|
+
const params = [];
|
|
1000
|
+
for (let i = 0; i < arity; i++) {
|
|
1001
|
+
const names = clauses.map((c) => (c.pats[i] && c.pats[i].t === 'pvar' ? c.pats[i].name : null));
|
|
1002
|
+
params.push(names[0] && names.every((n) => n === names[0]) ? names[0] : `$arg${i}`);
|
|
1003
|
+
}
|
|
1004
|
+
this.params = params;
|
|
1005
|
+
params.forEach((p, i) => this.declare(p, { kind: 'param', index: i }));
|
|
1006
|
+
const items = clauses.map((c) => ({
|
|
1007
|
+
test: () => (c.pats.some((p) => isRefutable(p)) ? { kind: 'call', callee: '<match>', args: params.map((p) => ({ kind: 'ident', name: p })), hs: { status: 'builtin', effect: 'pure', pattern: true } } : null),
|
|
1008
|
+
body: () => {
|
|
1009
|
+
this.push();
|
|
1010
|
+
try {
|
|
1011
|
+
c.pats.forEach((p, i) => { if (!(p.t === 'pvar' && p.name === params[i])) this.bindPattern(p, { kind: 'ident', name: params[i] }, c.line || ln); });
|
|
1012
|
+
return this.lowerRhs(c.rhs, c.line || ln);
|
|
1013
|
+
} finally { this.pop(); }
|
|
1014
|
+
},
|
|
1015
|
+
}));
|
|
1016
|
+
const value = this.alternatives(items, 0, ln);
|
|
1017
|
+
this.cfg.add('return', (value && value.kind === 'call' && value.line) || ln, { value: value || unknownExpr({ reason: 'non-exhaustive' }) });
|
|
1018
|
+
this.pop();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
finishFn(hs) {
|
|
1022
|
+
const cfg = this.cfg.finish(this.info.line);
|
|
1023
|
+
const fn = {
|
|
1024
|
+
qid: this.info.qid, name: this.info.name, line: this.info.line, params: this.params, file: this.mod.file, cfg,
|
|
1025
|
+
calls: [],
|
|
1026
|
+
hs: { module: this.mod.name, ...hs, refs: this.refs, deferred: this.deferred, diagnostics: this.diagnostics, effect: hs.effect || (this.sawIO ? 'io' : 'unknown') },
|
|
1027
|
+
};
|
|
1028
|
+
fn.calls = collectCalls(cfg);
|
|
1029
|
+
// Inlining substitutes a callee's body at the call site, but the call itself still happened:
|
|
1030
|
+
// keep it as a resolved edge so the call graph and reachability see it.
|
|
1031
|
+
if (this.inlinedCalls) fn.calls.push(...this.inlinedCalls);
|
|
1032
|
+
return fn;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function isRefutable(p) {
|
|
1037
|
+
if (!p) return false;
|
|
1038
|
+
if (p.t === 'pcon' || p.t === 'plit' || p.t === 'plist') return true;
|
|
1039
|
+
if (p.t === 'pas') return isRefutable(p.p);
|
|
1040
|
+
if (p.t === 'ptuple') return p.items.some(isRefutable);
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Targets an argument expression may call when applied later.
|
|
1045
|
+
function argTargets(expr) {
|
|
1046
|
+
if (!expr || !expr.hs) return [];
|
|
1047
|
+
const out = [];
|
|
1048
|
+
if (expr.hs.functionRef) out.push(expr.hs.functionRef);
|
|
1049
|
+
if (expr.hs.partial && expr.hs.partial.target) out.push(expr.hs.partial.target);
|
|
1050
|
+
if (expr.hs.compose && expr.hs.targets) out.push(...expr.hs.targets);
|
|
1051
|
+
return out;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function collectCallsInExpr(expr, out) {
|
|
1055
|
+
if (!expr || typeof expr !== 'object') return;
|
|
1056
|
+
if (expr.kind === 'call') out.push(expr);
|
|
1057
|
+
for (const k of ['args', 'parts', 'branches', 'elements']) if (Array.isArray(expr[k])) for (const x of expr[k]) collectCallsInExpr(x, out);
|
|
1058
|
+
if (Array.isArray(expr.props)) for (const p of expr.props) collectCallsInExpr(p && p.value, out);
|
|
1059
|
+
for (const k of ['left', 'right', 'object']) if (expr[k]) collectCallsInExpr(expr[k], out);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// `fn.calls` in the shared contract shape, plus the Haskell resolution facts.
|
|
1063
|
+
function collectCalls(cfg) {
|
|
1064
|
+
const sites = [];
|
|
1065
|
+
for (const [id, node] of Object.entries(cfg.nodes)) {
|
|
1066
|
+
const found = [];
|
|
1067
|
+
if (node.kind === 'call') {
|
|
1068
|
+
found.push({ kind: 'call', callee: node.callee, args: node.args, hs: node.hs });
|
|
1069
|
+
for (const a of node.args || []) collectCallsInExpr(a, found);
|
|
1070
|
+
} else if (node.kind === 'assign') collectCallsInExpr(node.source, found);
|
|
1071
|
+
else if (node.kind === 'return') collectCallsInExpr(node.value, found);
|
|
1072
|
+
else if (node.kind === 'if') collectCallsInExpr(node.cond, found);
|
|
1073
|
+
for (const c of found) {
|
|
1074
|
+
if (c.callee === '<match>') continue;
|
|
1075
|
+
sites.push({ site: id, callee: c.callee, args: c.args, line: node.line, hs: c.hs || {} });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return sites;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ── project ──────────────────────────────────────────────────────────────────
|
|
1082
|
+
class Project {
|
|
1083
|
+
constructor() {
|
|
1084
|
+
this.modules = new Map();
|
|
1085
|
+
this.fns = [];
|
|
1086
|
+
this.byFile = new Map();
|
|
1087
|
+
this.instanceIndex = new Map(); // `${cls}.${method}` -> qids
|
|
1088
|
+
this.diagnostics = [];
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
addFn(fn) {
|
|
1092
|
+
this.fns.push(fn);
|
|
1093
|
+
if (!this.byFile.has(fn.file)) this.byFile.set(fn.file, []);
|
|
1094
|
+
this.byFile.get(fn.file).push(fn);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
findCon(name) {
|
|
1098
|
+
for (const m of this.modules.values()) if (m.cons.has(name)) return m.cons.get(name);
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
instancesOf(cls, method) { return [...(this.instanceIndex.get(`${cls}.${method}`) || [])]; }
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function lowerTopFunction(proj, mod, fun, def) {
|
|
1106
|
+
const info = { qid: def.qid, name: `${mod.name}.${fun.name}`, line: fun.line, parentName: `${mod.name}.${fun.name}` };
|
|
1107
|
+
const L = new FnLowerer(proj, mod, info, []);
|
|
1108
|
+
L.lowerClauses(fun.clauses, fun.line);
|
|
1109
|
+
const sig = def.sig;
|
|
1110
|
+
const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
|
|
1111
|
+
const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
|
|
1112
|
+
const fn = L.finishFn({ kind, arity: def.arity, effect: sig ? (sig.returnsIO ? 'io' : 'pure') : (L.sawIO ? 'io' : 'unknown'), sig: sig ? sig.text : null, exported });
|
|
1113
|
+
const ann = callerControlledParams(fn, sig, exported);
|
|
1114
|
+
fn.hs.textParams = ann.map((a) => a.name);
|
|
1115
|
+
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1116
|
+
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
1117
|
+
const recTypes = new Map();
|
|
1118
|
+
for (const m of proj.modules.values()) for (const d of (m.parse && m.parse.data) || []) if (d.constructors && d.constructors.some((c) => c.fields && c.fields.length)) recTypes.set(d.name, d.constructors.flatMap((c) => c.fields || []));
|
|
1119
|
+
fn.hs.recordParams = [];
|
|
1120
|
+
if (exported && sig && Array.isArray(sig.argTypes) && Array.isArray(fn.params)) {
|
|
1121
|
+
sig.argTypes.forEach((t, i) => { const m = /^\(?\s*([A-Z][A-Za-z0-9_']*)\s*\)?$/.exec(t); if (m && recTypes.has(m[1]) && fn.params[i]) fn.hs.recordParams.push({ name: fn.params[i], type: m[1], fields: [...new Set(recTypes.get(m[1]))] }); });
|
|
1122
|
+
}
|
|
1123
|
+
if (ann.length) fn.paramAnnotations = ann;
|
|
1124
|
+
return fn;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function lowerPatbind(proj, mod, pb) {
|
|
1128
|
+
const names = patternVars(pb.pat);
|
|
1129
|
+
const first = names[0] || '_';
|
|
1130
|
+
const def = mod.defs.get(first);
|
|
1131
|
+
const info = { qid: def ? def.qid : `${mod.file}::${mod.name}.${first}@${pb.line}`, name: `${mod.name}.${first}`, line: pb.line, parentName: `${mod.name}.${first}` };
|
|
1132
|
+
const L = new FnLowerer(proj, mod, info, []);
|
|
1133
|
+
L.push();
|
|
1134
|
+
const v = L.lowerRhs(pb.rhs, pb.line);
|
|
1135
|
+
L.cfg.add('return', pb.line, { value: v || unknownExpr() });
|
|
1136
|
+
L.pop();
|
|
1137
|
+
return L.finishFn({ kind: 'value', arity: 0, patbind: names });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Build the Haskell IR and call graph for a set of files.
|
|
1142
|
+
* @param {Record<string,string>} fileContents path -> source (only `.hs` files are read)
|
|
1143
|
+
* @returns {{perFile:object, callGraph:object, modules:Map, diagnostics:object[]}}
|
|
1144
|
+
*/
|
|
1145
|
+
// One scan asks for the Haskell IR from several passes (rules, web analysis, supply chain, AI inventory, the deep taint engine).
|
|
1146
|
+
// Building it is the dominant allocation, so identical input returns the SAME build: the last two distinct file sets are kept,
|
|
1147
|
+
// keyed by path and content hash. A consumer must treat the result as read-only.
|
|
1148
|
+
const IR_CACHE = [];
|
|
1149
|
+
function irKey(files) {
|
|
1150
|
+
const h = createHash('sha256');
|
|
1151
|
+
for (const f of files) { h.update(f); h.update('\0'); h.update(createHash('sha256').update(String(files.__src ? files.__src[f] : '')).digest()); }
|
|
1152
|
+
return h.digest('hex');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export function buildHaskellIR(fileContents) {
|
|
1156
|
+
const names = Object.keys(fileContents || {}).filter((f) => /\.hs$/i.test(f)).sort();
|
|
1157
|
+
const keyed = Object.assign(names.slice(), { __src: fileContents });
|
|
1158
|
+
const key = irKey(keyed);
|
|
1159
|
+
const hit = IR_CACHE.find((e) => e.key === key);
|
|
1160
|
+
if (hit) return hit.ir;
|
|
1161
|
+
const ir = buildHaskellIRUncached(fileContents);
|
|
1162
|
+
IR_CACHE.unshift({ key, ir });
|
|
1163
|
+
if (IR_CACHE.length > 2) IR_CACHE.length = 2;
|
|
1164
|
+
return ir;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function buildHaskellIRUncached(fileContents) {
|
|
1168
|
+
const proj = new Project();
|
|
1169
|
+
const files = Object.keys(fileContents || {}).filter((f) => /\.hs$/i.test(f)).sort();
|
|
1170
|
+
for (const file of files) {
|
|
1171
|
+
try {
|
|
1172
|
+
const mod = buildModuleInfo(file, fileContents[file]);
|
|
1173
|
+
proj.modules.set(mod.name === 'Main' && proj.modules.has('Main') ? `Main@${file}` : mod.name, mod);
|
|
1174
|
+
for (const e of mod.parse.errors) proj.diagnostics.push({ file, line: e.line, kind: e.kind, detail: e.detail });
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
proj.diagnostics.push({ file, line: 1, kind: 'ir-parse-failure', detail: String(err && err.message) });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const instQids = [];
|
|
1180
|
+
for (const mod of proj.modules.values()) {
|
|
1181
|
+
for (const inst of mod.parse.instances) {
|
|
1182
|
+
const g = groupDecls(inst.decls);
|
|
1183
|
+
for (const f of g.funs) {
|
|
1184
|
+
const qid = `${mod.file}::${mod.name}.$inst.${inst.name}.${f.name}@${f.line}`;
|
|
1185
|
+
const key = `${inst.name}.${f.name}`;
|
|
1186
|
+
if (!proj.instanceIndex.has(key)) proj.instanceIndex.set(key, []);
|
|
1187
|
+
proj.instanceIndex.get(key).push(qid);
|
|
1188
|
+
instQids.push({ mod, inst, f, qid });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
for (const mod of proj.modules.values()) {
|
|
1193
|
+
let count = 0;
|
|
1194
|
+
for (const f of mod.groups.funs) {
|
|
1195
|
+
if (++count > HS_IR_LIMITS.maxFunctionsPerFile) { proj.diagnostics.push({ file: mod.file, kind: 'function-cap', detail: 'function cap reached; remaining functions are unknown' }); break; }
|
|
1196
|
+
try { proj.addFn(lowerTopFunction(proj, mod, f, mod.defs.get(f.name))); } catch (err) { incomplete(proj, mod, f, err); }
|
|
1197
|
+
}
|
|
1198
|
+
for (const pb of mod.groups.patbinds) {
|
|
1199
|
+
try { proj.addFn(lowerPatbind(proj, mod, pb)); } catch (err) { proj.diagnostics.push({ file: mod.file, line: pb.line, kind: 'ir-lowering-failure', detail: String(err && err.message) }); }
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
for (const { mod, inst, f, qid } of instQids) {
|
|
1203
|
+
const info = { qid, name: `${mod.name}.$inst.${inst.name}.${f.name}`, line: f.line, parentName: `${mod.name}.$inst.${inst.name}.${f.name}` };
|
|
1204
|
+
try {
|
|
1205
|
+
const L = new FnLowerer(proj, mod, info, []);
|
|
1206
|
+
L.lowerClauses(f.clauses, f.line);
|
|
1207
|
+
proj.addFn(L.finishFn({ kind: 'instance-method', cls: inst.name, arity: Math.max(0, ...f.clauses.map((c) => c.pats.length)) }));
|
|
1208
|
+
} catch (err) { proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) }); }
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const perFile = {};
|
|
1212
|
+
for (const mod of proj.modules.values()) {
|
|
1213
|
+
const functions = [...(proj.byFile.get(mod.file) || [])];
|
|
1214
|
+
const modQid = `${mod.file}::<module>@1`;
|
|
1215
|
+
const cfg = new Cfg(1);
|
|
1216
|
+
for (const fn of functions.filter((x) => ['value', 'io-action'].includes(x.hs.kind))) {
|
|
1217
|
+
cfg.add('noop', fn.line, { hs: { init: { name: fn.name, kind: fn.hs.kind, demand: 'lazy-once', executedAtLoad: false } } });
|
|
1218
|
+
}
|
|
1219
|
+
functions.push({ qid: modQid, name: `${mod.name}.<module>`, line: 1, params: [], file: mod.file, cfg: cfg.finish(1), calls: [], hs: { module: mod.name, kind: 'module-init', effect: 'pure' } });
|
|
1220
|
+
perFile[mod.file] = {
|
|
1221
|
+
file: mod.file, language: 'haskell', functions, topLevel: modQid, classes: [],
|
|
1222
|
+
imports: mod.imports.map((i) => ({ kind: 'static', module: i.module, names: i.items || [], isDefault: false, line: i.line })),
|
|
1223
|
+
hs: { module: mod.name, exports: mod.exports, errors: mod.parse.errors.length },
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
const callGraph = buildHaskellCallGraph(perFile, proj);
|
|
1227
|
+
// A function the analysed code itself calls or references takes its parameters from THOSE callers, which the taint engine
|
|
1228
|
+
// follows per call context; treating the parameter as caller-controlled as well would flag a helper that is only ever
|
|
1229
|
+
// called with constants. Only a function nothing in the project reaches is an entry point whose callers are external.
|
|
1230
|
+
const reached = new Set();
|
|
1231
|
+
for (const e of callGraph.edges || []) if (e && e.callee) reached.add(e.callee);
|
|
1232
|
+
for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) reached.add(r.target);
|
|
1233
|
+
for (const fn of callGraph.functions.values()) if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
|
|
1234
|
+
return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function incomplete(proj, mod, f, err) {
|
|
1238
|
+
const def = mod.defs.get(f.name);
|
|
1239
|
+
const cfg = new Cfg(f.line);
|
|
1240
|
+
proj.addFn({
|
|
1241
|
+
qid: def.qid, name: `${mod.name}.${f.name}`, line: f.line, params: [], file: mod.file, cfg: cfg.finish(f.line), calls: [],
|
|
1242
|
+
hs: { module: mod.name, kind: 'function', incomplete: true, reason: String(err && err.message), refs: [], deferred: [], diagnostics: [] },
|
|
1243
|
+
});
|
|
1244
|
+
proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) });
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ── call graph ───────────────────────────────────────────────────────────────
|
|
1248
|
+
function buildHaskellCallGraph(perFile, proj) {
|
|
1249
|
+
const functions = new Map();
|
|
1250
|
+
const byName = new Map();
|
|
1251
|
+
for (const ir of Object.values(perFile)) for (const fn of ir.functions) { functions.set(fn.qid, fn); byName.set(fn.name, fn.qid); }
|
|
1252
|
+
|
|
1253
|
+
const edges = [];
|
|
1254
|
+
const contexts = new Map(); // callee-with-HO-params qid -> [{caller, site, targets}]
|
|
1255
|
+
const push = (e) => edges.push(e);
|
|
1256
|
+
|
|
1257
|
+
for (const fn of functions.values()) {
|
|
1258
|
+
if (fn.hs && fn.hs.incomplete) push({ caller: fn.qid, site: null, callee: null, calleeName: null, line: fn.line, kind: 'direct', status: 'unknown', reason: 'incomplete-lowering' });
|
|
1259
|
+
for (const c of fn.calls) {
|
|
1260
|
+
const hs = c.hs || {};
|
|
1261
|
+
const base = { caller: fn.qid, site: c.site, calleeName: c.callee, line: c.line };
|
|
1262
|
+
const kind = hs.via ? 'higher-order' : 'direct';
|
|
1263
|
+
switch (hs.status) {
|
|
1264
|
+
case 'resolved':
|
|
1265
|
+
push({ ...base, callee: hs.target, kind, status: 'resolved', via: hs.via || null });
|
|
1266
|
+
if (hs.argTargets) {
|
|
1267
|
+
if (!contexts.has(hs.target)) contexts.set(hs.target, []);
|
|
1268
|
+
contexts.get(hs.target).push({ caller: fn.qid, site: c.site, argTargets: hs.argTargets });
|
|
1269
|
+
}
|
|
1270
|
+
break;
|
|
1271
|
+
case 'typeclass': {
|
|
1272
|
+
const cands = hs.candidates || [];
|
|
1273
|
+
if (!cands.length) push({ ...base, callee: null, kind, status: 'unknown', reason: 'no-visible-instance' });
|
|
1274
|
+
for (const q of cands) push({ ...base, callee: q, kind, status: 'candidate', ambiguous: true, via: hs.via || null });
|
|
1275
|
+
if (hs.argTargets) {
|
|
1276
|
+
for (const q of cands) { if (!contexts.has(q)) contexts.set(q, []); contexts.get(q).push({ caller: fn.qid, site: c.site, argTargets: hs.argTargets }); }
|
|
1277
|
+
}
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
case 'external': case 'builtin':
|
|
1281
|
+
push({ ...base, callee: null, kind, status: hs.status, certain: hs.certain });
|
|
1282
|
+
break;
|
|
1283
|
+
default:
|
|
1284
|
+
push({ ...base, callee: null, kind, status: 'unknown', reason: hs.reason || (hs.status === 'param' ? 'higher-order-parameter' : 'unresolved'), paramIndex: hs.paramIndex });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
for (const r of (fn.hs && fn.hs.refs) || []) {
|
|
1288
|
+
if (functions.has(r.target)) push({ caller: fn.qid, site: null, callee: r.target, calleeName: functions.get(r.target).name, line: r.line, kind: 'reference', status: 'potential', via: r.via, ambiguous: true });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Context-specific higher-order edges: a parameter call inside F, bound to the
|
|
1293
|
+
// function each caller actually passes.
|
|
1294
|
+
for (const e of [...edges]) {
|
|
1295
|
+
if (e.status !== 'unknown' || e.paramIndex === undefined) continue;
|
|
1296
|
+
for (const ctx of contexts.get(e.caller) || []) {
|
|
1297
|
+
for (const t of ctx.argTargets[e.paramIndex] || []) {
|
|
1298
|
+
push({ caller: e.caller, site: e.site, callee: t, calleeName: functions.get(t) ? functions.get(t).name : null, line: e.line, kind: 'higher-order', status: 'resolved-in-context', context: `${e.caller}<-${ctx.caller}@${ctx.site}`, ambiguous: true });
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const callersOf = new Map();
|
|
1304
|
+
for (const e of edges) {
|
|
1305
|
+
if (!e.callee) continue;
|
|
1306
|
+
if (!callersOf.has(e.callee)) callersOf.set(e.callee, []);
|
|
1307
|
+
callersOf.get(e.callee).push(e);
|
|
1308
|
+
}
|
|
1309
|
+
const out = new Map();
|
|
1310
|
+
for (const e of edges) { if (e.callee) { if (!out.has(e.caller)) out.set(e.caller, new Set()); out.get(e.caller).add(e.callee); } }
|
|
1311
|
+
const unknownOf = new Set(edges.filter((e) => e.status === 'unknown').map((e) => e.caller));
|
|
1312
|
+
|
|
1313
|
+
const resolve = (name) => byName.get(name) || null;
|
|
1314
|
+
|
|
1315
|
+
// Reachability that never concludes "unreachable" while an unknown call exists
|
|
1316
|
+
// on a reached path (that call could target anything).
|
|
1317
|
+
function reachability(roots) {
|
|
1318
|
+
const reached = new Set();
|
|
1319
|
+
const queue = [...roots].filter((r) => functions.has(r));
|
|
1320
|
+
let sawUnknown = false;
|
|
1321
|
+
while (queue.length) {
|
|
1322
|
+
const q = queue.pop();
|
|
1323
|
+
if (reached.has(q)) continue;
|
|
1324
|
+
reached.add(q);
|
|
1325
|
+
if (unknownOf.has(q)) sawUnknown = true;
|
|
1326
|
+
for (const t of out.get(q) || []) if (!reached.has(t)) queue.push(t);
|
|
1327
|
+
}
|
|
1328
|
+
const status = new Map();
|
|
1329
|
+
for (const q of functions.keys()) status.set(q, reached.has(q) ? 'reachable' : sawUnknown ? 'unknown' : 'unreachable');
|
|
1330
|
+
return { reached, status, sawUnknown };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Transitive-callee summaries to a fixpoint, bounded. A hit on the cap marks
|
|
1334
|
+
// every summary unknown rather than returning a partial answer as complete.
|
|
1335
|
+
function summarize(opts = {}) {
|
|
1336
|
+
const cap = opts.maxIterations || HS_IR_LIMITS.maxSummaryIterations;
|
|
1337
|
+
const sum = new Map();
|
|
1338
|
+
for (const q of functions.keys()) sum.set(q, { callees: new Set(out.get(q) || []), unknown: unknownOf.has(q) });
|
|
1339
|
+
let iterations = 0;
|
|
1340
|
+
let converged = false;
|
|
1341
|
+
while (iterations < cap) {
|
|
1342
|
+
iterations++;
|
|
1343
|
+
let changed = false;
|
|
1344
|
+
for (const [q, s] of sum) {
|
|
1345
|
+
for (const c of [...s.callees]) {
|
|
1346
|
+
const cs = sum.get(c);
|
|
1347
|
+
if (!cs) continue;
|
|
1348
|
+
for (const x of cs.callees) if (!s.callees.has(x)) { s.callees.add(x); changed = true; }
|
|
1349
|
+
if (cs.unknown && !s.unknown) { s.unknown = true; changed = true; }
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (!changed) { converged = true; break; }
|
|
1353
|
+
}
|
|
1354
|
+
if (!converged) for (const s of sum.values()) s.unknown = true;
|
|
1355
|
+
return { summaries: sum, converged, iterations, capped: !converged };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return { functions, edges, callersOf, resolve, resolveKnownCallee: resolve, contexts, reachability, summarize, unresolved: edges.filter((e) => e.status === 'unknown') };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Lower an arbitrary handler expression (a route's `do` block or lambda) as its own synthetic function, in
|
|
1363
|
+
* the context of `mod`, so it can be analysed per route. Returns the function IR (`cfg`, `calls`, ...).
|
|
1364
|
+
*/
|
|
1365
|
+
export function lowerHandlerExpr(project, mod, exprAst, label, line = 1) {
|
|
1366
|
+
const qid = `${mod.file}::${mod.name}.$handler.${label}@${line}`;
|
|
1367
|
+
const L = new FnLowerer(project, mod, { qid, name: `${mod.name}.$handler.${label}`, line, parentName: `${mod.name}.$handler.${label}` }, []);
|
|
1368
|
+
L.push();
|
|
1369
|
+
const v = L.lowerExpr(exprAst, line);
|
|
1370
|
+
L.cfg.add('return', (v && v.kind === 'call' && v.line) || line, { value: v || unknownExpr() });
|
|
1371
|
+
L.pop();
|
|
1372
|
+
return L.finishFn({ kind: 'handler', arity: 0 });
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
export { resolveGlobal as resolveName };
|
|
1376
|
+
export { buildHaskellCallGraph };
|
|
1377
|
+
export const _internals = { sigInfo, resolveGlobal, argTargets, strictNames };
|