@prajwalghate/sourcetruth 0.1.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.
@@ -0,0 +1,639 @@
1
+ // Daml adapter. Reads .daml source and emits the neutral model.
2
+ //
3
+ // COMMENTS ARE STRIPPED BEFORE ANYTHING IS PARSED. Not as a nicety — as the core assumption. In the
4
+ // codebase this began life against, comments repeatedly described behaviour the code did not have:
5
+ // a helper documented as "filed as <ticket>" for a finding that was never filed, an assert described
6
+ // as guarding a boundary it did not guard, a config pin claiming a version it did not pin. Each cost
7
+ // real time. A tool that reads comments inherits every one of those lies, so this one cannot see
8
+ // them. The raw text is kept for DISPLAY and never re-parsed.
9
+ //
10
+ // Resolution rules, each validated against real choices rather than invented:
11
+ // create this with -> the entry's own unit; with `consuming` that is a STATE TRANSITION
12
+ // create Template with -> Template
13
+ // create var with -> var's type, if bound earlier in this body
14
+ // create (helper args) -> the helper's declared return type
15
+ // exercise cid Choice -> Choice looked up by name; the cid's declared type as a cross-check
16
+ // fetch cid / archive cid -> the cid's declared type among args, unit fields, or earlier binds
17
+ // fetch (fromInterfaceContractId @T cid) -> T
18
+ // Binders that feed the above: choice arguments, the unit's own fields, `x <- exercise … Choice`
19
+ // (via the choice's declared return, tuple-destructured), and `Some y ->` after `case x of`.
20
+ //
21
+ // Anything else is a HOLE. It is reported with its raw line and never guessed.
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { EDGE, EFFECT, edge, entry, unit, model } from "../model.mjs";
26
+
27
+ const SKIP_DIRS = new Set([".daml", "node_modules", ".git", "dist", "build", ".dpm"]);
28
+
29
+ /**
30
+ * Test code is excluded by default, as it is for Solidity. A Daml test package defines templates of
31
+ * its own — one real repo's had malicious factories, fake app rights and eight more doubles,
32
+ * over a third of the repo's templates — and none of them is ever deployed. Drawn on the map they
33
+ * are contracts that do not exist.
34
+ *
35
+ * A test package is a package like any other, so it is known by what it calls itself: a `daml.yaml`
36
+ * whose `name` ends in `-test` or `-tests`. Directories named that way are skipped too, for trees
37
+ * without per-package manifests. The directory you point at is never skipped — asking for a test
38
+ * package by path is asking for it. `--include-tests` brings everything back.
39
+ */
40
+ const TEST_DIR = /^tests?$|-tests?$/;
41
+ function isTestPackage(dir) {
42
+ try {
43
+ const yaml = fs.readFileSync(path.join(dir, "daml.yaml"), "utf8");
44
+ return /-tests?$/.test(/^name:\s*["']?([\w.-]+)/m.exec(yaml)?.[1] ?? "");
45
+ } catch { return false; }
46
+ }
47
+
48
+ /** Recurse for .daml files, skipping build output. `.daml/` is Daml's OWN build dir and is full of
49
+ * decompiled dependency stubs — parsing those reports a project's dependencies as its own code. */
50
+ export function findSources(root, { includeTests = false } = {}) {
51
+ const out = [];
52
+ const walk = (dir, top) => {
53
+ if (!includeTests && !top && isTestPackage(dir)) return;
54
+ let items;
55
+ try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
56
+ for (const it of items) {
57
+ const p = path.join(dir, it.name);
58
+ if (it.isDirectory()) {
59
+ if (SKIP_DIRS.has(it.name)) continue;
60
+ if (!includeTests && TEST_DIR.test(it.name)) continue;
61
+ walk(p, false);
62
+ } else if (it.isFile() && it.name.endsWith(".daml")) out.push(p);
63
+ }
64
+ };
65
+ const st = fs.statSync(root);
66
+ if (st.isFile()) return root.endsWith(".daml") ? [root] : [];
67
+ walk(root, true);
68
+ return out.sort();
69
+ }
70
+
71
+ export function stripComments(src) {
72
+ const out = src.replace(/\{-[\s\S]*?-\}/g, (m) => m.replace(/[^\n]/g, " "));
73
+ return out.split("\n").map((l) => {
74
+ let inStr = false;
75
+ for (let i = 0; i < l.length; i++) {
76
+ const ch = l[i];
77
+ if (ch === '"' && l[i - 1] !== "\\") inStr = !inStr;
78
+ if (!inStr && ch === "-" && l[i + 1] === "-") return l.slice(0, i).replace(/\s+$/, "");
79
+ }
80
+ return l;
81
+ }).join("\n");
82
+ }
83
+
84
+ const indentOf = (l) => (l.match(/^(\s*)/)?.[1].length ?? 0);
85
+ /**
86
+ * The template behind a contract-id-shaped type, or null.
87
+ *
88
+ * Unwraps the three containers Daml actually uses around a ContractId in a field or argument:
89
+ * `Optional (ContractId T)`, `[ContractId T]`, and combinations. Without the Optional case, a
90
+ * `Some cid -> do … fetch cid` arm — the standard way to act on an optional contract — reports a
91
+ * hole even though the type is right there in the declaration. That was 2 of 34 holes on one real codebase.
92
+ *
93
+ * Deliberately does NOT unwrap arbitrary nesting: if the shape is not recognised, the answer is
94
+ * null and the edge becomes a hole. A wrong target is worse than an admitted gap.
95
+ */
96
+ const cidType = (t) => {
97
+ let s = String(t).trim();
98
+ for (let i = 0; i < 3; i++) {
99
+ const before = s;
100
+ s = s.replace(/^Optional\s*\(\s*(.+?)\s*\)$/, "$1").replace(/^Optional\s+(\S+)$/, "$1");
101
+ s = s.replace(/^\[\s*(.+?)\s*\]$/, "$1");
102
+ s = s.replace(/^\(\s*(.+?)\s*\)$/, "$1");
103
+ if (s === before) break;
104
+ }
105
+ const m = /^ContractId\s+([A-Za-z_][\w.]*)$/.exec(s);
106
+ return m ? m[1].split(".").pop() : null;
107
+ };
108
+
109
+ function parseWithBlock(lines, start, minIndent) {
110
+ const fields = [];
111
+ let i = start;
112
+ for (; i < lines.length; i++) {
113
+ const l = lines[i];
114
+ if (!l.trim()) continue;
115
+ if (indentOf(l) <= minIndent) break;
116
+ const m = /^\s*([a-z_][\w']*)\s*:\s*(.+?)\s*$/.exec(l);
117
+ if (m) fields.push({ name: m[1], type: m[2] });
118
+ }
119
+ return { fields, next: i };
120
+ }
121
+
122
+ /** Split a Daml type at top-level commas: "(ContractId A, Optional (ContractId B))" -> [A, B]. */
123
+ function tupleComponents(returns) {
124
+ let s = String(returns).trim();
125
+ if (s.startsWith("(") && s.endsWith(")")) s = s.slice(1, -1);
126
+ const parts = []; let depth = 0, cur = "";
127
+ for (const ch of s) {
128
+ if (ch === "(" || ch === "[") depth++;
129
+ if (ch === ")" || ch === "]") depth--;
130
+ if (ch === "," && depth === 0) { parts.push(cur); cur = ""; } else cur += ch;
131
+ }
132
+ parts.push(cur);
133
+ return parts.map((p) => p.trim().replace(/^Optional\s*\(?\s*/, "").replace(/\)?\s*$/, ""))
134
+ .map((p) => cidType(p) ?? cidType(`ContractId ${p}`) ?? null);
135
+ }
136
+
137
+ function parseModule(file, root) {
138
+ const raw = fs.readFileSync(file, "utf8");
139
+ const src = stripComments(raw);
140
+ const lines = src.split("\n");
141
+ const rawLines = raw.split("\n");
142
+ const rel = path.relative(root, file);
143
+ const moduleName = /^module\s+([\w.]+)/m.exec(src)?.[1] ?? path.basename(file, ".daml");
144
+ const templates = [];
145
+ const functions = [];
146
+ const defs = [];
147
+
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const tm = /^template\s+([A-Z]\w*)\s*$/.exec(lines[i]);
150
+ if (tm) {
151
+ const t = { name: tm[1], module: moduleName, path: rel, line: i + 1,
152
+ fields: [], signatory: null, observer: null, ensure: null, key: null, choices: [] };
153
+ let j = i + 1;
154
+ if (/^\s+with\s*$/.test(lines[j] ?? "")) {
155
+ const r = parseWithBlock(lines, j + 1, indentOf(lines[j])); t.fields = r.fields; j = r.next;
156
+ }
157
+ let end = j;
158
+ while (end < lines.length && !(lines[end].trim() && indentOf(lines[end]) === 0)) end++;
159
+ t.endLine = end;
160
+ const body = lines.slice(j, end);
161
+ for (let k = 0; k < body.length; k++) {
162
+ const l = body[k];
163
+ let m;
164
+ if ((m = /^\s+signatory\s+(.+)$/.exec(l))) t.signatory = m[1].trim();
165
+ else if ((m = /^\s+observer\s+(.+)$/.exec(l))) t.observer = m[1].trim();
166
+ else if ((m = /^\s+ensure\s+(.+)$/.exec(l))) t.ensure = m[1].trim();
167
+ else if ((m = /^\s+key\s+(.+)$/.exec(l))) t.key = m[1].trim();
168
+ else if ((m = /^(\s+)(nonconsuming\s+)?choice\s+([A-Z]\w*)\s*:\s*(.*)$/.exec(l))) {
169
+ const ind = m[1].length;
170
+ const c = { name: m[3], consuming: !m[2], returns: m[4].trim(),
171
+ args: [], controller: null, body: "", rawBody: "",
172
+ line: j + k + 1, endLine: null, bodyLine: null, guards: [] };
173
+ let p = k + 1;
174
+ // The return type can wrap onto following lines. Stop at `with` (either form) or
175
+ // `controller`, and never consume a blank line — after comment stripping a blank is
176
+ // where a comment used to be, and treating it as the end of the header loses whatever
177
+ // follows it.
178
+ while (p < body.length && body[p].trim()
179
+ && !/^\s+with\b/.test(body[p]) && !/^\s+controller\b/.test(body[p])
180
+ && indentOf(body[p]) > ind) { c.returns += " " + body[p].trim(); p++; }
181
+
182
+ // Two `with` forms, and both appear in real code:
183
+ // with <- a block; fields follow, indented
184
+ // a : T
185
+ // with a : T <- inline, a single field on the same line
186
+ // Matching only the block form left `c.args` empty AND, worse, left `p` pointing at the
187
+ // inline line so the controller scan below missed — reporting a choice with a declared
188
+ // controller as callable by ANYONE. Found when the EVM adapter introduced "unguarded"
189
+ // and a Daml choice turned up in that list, which for Daml is impossible.
190
+ if (/^\s+with\s*$/.test(body[p] ?? "")) {
191
+ const r = parseWithBlock(body, p + 1, indentOf(body[p])); c.args = r.fields; p = r.next;
192
+ } else if ((m = /^\s+with\s+([a-z_][\w']*)\s*:\s*(.+?)\s*$/.exec(body[p] ?? ""))) {
193
+ c.args = [{ name: m[1], type: m[2] }];
194
+ p++;
195
+ }
196
+
197
+ // Skip blanks left behind by stripped comments before looking for the controller.
198
+ while (p < body.length && !body[p].trim()) p++;
199
+ if ((m = /^\s+controller\s+(.+)$/.exec(body[p] ?? ""))) { c.controller = m[1].trim(); p++; }
200
+ let q = p;
201
+ while (q < body.length && !/^\s+do\s*$/.test(body[q]) && indentOf(body[q]) > ind) q++;
202
+ if (/^\s+do\s*$/.test(body[q] ?? "")) {
203
+ c.bodyLine = j + q + 2;
204
+ let e = q + 1;
205
+ while (e < body.length && (!body[e].trim() || indentOf(body[e]) > ind)) e++;
206
+ c.body = body.slice(q + 1, e).join("\n");
207
+ c.rawBody = rawLines.slice(j + q + 1, j + e).join("\n");
208
+ c.endLine = j + e;
209
+ c.guards = c.body.split("\n")
210
+ .map((x) => /\b(assertMsg|assert|abort)\b\s*(.*)$/.exec(x.trim()))
211
+ .filter(Boolean).map((x) => x[0].slice(0, 200));
212
+ k = e - 1;
213
+ }
214
+ t.choices.push(c);
215
+ }
216
+ }
217
+ templates.push(t);
218
+ i = end - 1;
219
+ continue;
220
+ }
221
+ const fm = /^([a-z_][\w']*)\s*:(.*)$/.exec(lines[i]);
222
+ if (fm && !/^(import|module)\b/.test(lines[i])) {
223
+ let sig = fm[2].trim(), j = i + 1;
224
+ while (j < lines.length && lines[j].trim() && indentOf(lines[j]) > 0
225
+ && !/^\s*(with|where|controller|do)\b/.test(lines[j])) {
226
+ sig += " " + lines[j].trim(); j++;
227
+ if (!/->\s*$|:\s*$/.test(sig) && !/^\s*[-=]/.test(lines[j] ?? "")) break;
228
+ }
229
+ if (sig) functions.push({ name: fm[1], signature: sig.trim() });
230
+ }
231
+ // The DEFINITION, not the signature: `name a b c = …`, body running to the next column-0 line.
232
+ // Without this, every create/fetch/archive inside a top-level helper is invisible — and a choice
233
+ // that does its archiving through one looked like a choice that archives nothing.
234
+ // Split on the first `=` and validate the two halves separately. The obvious single regex —
235
+ // `^(name)((?:\s+param)*)\s*=` — has nested quantifiers, and on any column-0 line WITHOUT an
236
+ // `=` the engine explores every way to partition the line. That is not slow, it is
237
+ // non-terminating in practice: it took Splice from under a second to never finishing.
238
+ const eq = lines[i].indexOf("=");
239
+ if (eq > 0 && !/^(import|module)\b/.test(lines[i]) && lines[i][eq + 1] !== "=") {
240
+ const lhs = lines[i].slice(0, eq);
241
+ // `==`, `/=`, `<=`, `>=`, `!=` are comparisons, not definitions.
242
+ if (!/[=/<>!]$/.test(lhs)) {
243
+ const nameM = /^([a-z_][\w']*)/.exec(lhs);
244
+ const rest = nameM ? lhs.slice(nameM[1].length) : null;
245
+ if (nameM && /^[\w'\s(){}@\[\],.]*$/.test(rest)) {
246
+ let end = i + 1;
247
+ while (end < lines.length && !(lines[end].trim() && indentOf(lines[end]) === 0)) end++;
248
+ defs.push({
249
+ name: nameM[1],
250
+ params: rest.trim().split(/\s+/).filter(Boolean),
251
+ body: [lines[i].slice(eq + 1), ...lines.slice(i + 1, end)].join("\n"),
252
+ line: i + 1,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ return { module: moduleName, path: rel, templates, functions, defs };
259
+ }
260
+
261
+ /**
262
+ * Bind lambda parameters that iterate a list of contract ids.
263
+ *
264
+ * mapA (\cid -> do h <- fetch cid …) reserveHoldingCids
265
+ * forA holdingCids (\cid -> …)
266
+ *
267
+ * `cid` is an element of the list, so its type is the list's element type. This has to be a pre-pass
268
+ * over the whole body rather than part of the line scan, because in the `mapA` form the list appears
269
+ * AFTER the lambda body — a line-by-line reader meets `fetch cid` several lines before it learns
270
+ * what `cid` is.
271
+ *
272
+ * Matched by balance-counting the lambda's parentheses, not by regex: the body can contain nested
273
+ * parens and `->`, and a regex that appears to work on the samples to hand is exactly how a tool
274
+ * starts inventing targets. If the form is not matched exactly, nothing is bound and the edge stays
275
+ * a hole.
276
+ */
277
+ function lambdaBinders(body, typeOf) {
278
+ const bound = new Map();
279
+ const ITER = /\b(mapA_?|forA_?|mapM_?|forM_?)\s*/g;
280
+ let m;
281
+ while ((m = ITER.exec(body)) !== null) {
282
+ let i = ITER.lastIndex;
283
+ const isFor = m[1].startsWith("for");
284
+ let listVar = null, param = null;
285
+
286
+ const readIdent = () => {
287
+ const r = /^[a-z_][\w']*/.exec(body.slice(i));
288
+ if (!r) return null;
289
+ i += r[0].length;
290
+ return r[0];
291
+ };
292
+ const skipWs = () => { while (i < body.length && /\s/.test(body[i])) i++; };
293
+
294
+ skipWs();
295
+ if (isFor) {
296
+ listVar = readIdent();
297
+ skipWs();
298
+ }
299
+ // Expect `(\param ->`
300
+ if (body[i] !== "(") continue;
301
+ const open = i;
302
+ i++; skipWs();
303
+ if (body[i] !== "\\") continue;
304
+ i++;
305
+ param = readIdent();
306
+ if (!param) continue;
307
+ if (!isFor) {
308
+ // Balance to the lambda's closing paren, then the next identifier is the list.
309
+ let depth = 1, j = open + 1;
310
+ while (j < body.length && depth > 0) {
311
+ if (body[j] === "(") depth++;
312
+ else if (body[j] === ")") depth--;
313
+ j++;
314
+ }
315
+ if (depth !== 0) continue;
316
+ const rest = body.slice(j);
317
+ const r = /^\s*([a-z_][\w']*)/.exec(rest);
318
+ if (!r) continue;
319
+ listVar = r[1];
320
+ }
321
+ if (!listVar || !param) continue;
322
+ const t = typeOf.get(listVar);
323
+ if (t) bound.set(param, t); // cidType already strips [ ], so this is the ELEMENT type
324
+ }
325
+ return bound;
326
+ }
327
+
328
+ function resolveEdges(choice, owningTemplate, ctx) {
329
+ const { choiceIndex, templateNames, returnsOf, helperReturns } = ctx;
330
+ // Choice args shadow the unit's own fields, both are in scope in a choice body.
331
+ const typeOf = new Map([
332
+ ...(owningTemplate.fields ?? []).map((f) => [f.name, cidType(f.type)]),
333
+ ...choice.args.map((a) => [a.name, cidType(a.type)]),
334
+ ]);
335
+ // Pre-pass: lambda parameters iterating a list of cids. Must run before the line scan, because in
336
+ // `mapA (\x -> …) xs` the list is named after the body that uses x.
337
+ const bound = lambdaBinders(choice.body, typeOf);
338
+ // RULE 1: an exercise-result binding can WRAP onto the next line.
339
+ // (a, b, c, escrowRemainder) <-
340
+ // exercise cid SomeChoice with ...
341
+ // The line-by-line scan below sees `<-` and `exercise` on separate lines and binds nothing, so
342
+ // every later use of those variables cascades into a hole. Running the same match over the WHOLE
343
+ // body fixes it, because `\s*` spans newlines — that, and not any joining, is the mechanism.
344
+ // Worth 2 holes on a real protocol; proved by removing this block and watching 97% fall to 96%.
345
+ {
346
+ // The binder is matched as EITHER a parenthesised list OR a bare identifier, never as one
347
+ // optional-paren form. The earlier version was
348
+ // /\(?\s*([\w',\s]+?)\s*\)?\s*<-\s*.../
349
+ // where `\s` sits inside the character class AND in the `\s*` on both sides of it. Every
350
+ // whitespace run can therefore be divided between the class and the quantifiers in
351
+ // exponentially many ways, and on a body that is mostly blank lines with no `<-` to
352
+ // terminate on, the engine never comes back. It parsed every corpus to hand and then hung
353
+ // outright on Canton's Bong.daml — a choice whose body is `return ()` followed by 800
354
+ // characters of whitespace left behind by a stripped block comment.
355
+ //
356
+ // Both alternatives below are unambiguous: `[^()\n]*` cannot match the `)` that follows it,
357
+ // and `[\w']*` cannot match the whitespace or `<` that follows it. No overlap, no backtracking.
358
+ const RE = /(?:\(([^()\n]*)\)|([A-Za-z_][\w']*))\s*<-\s*exercise\s+[\w'.]+\s+((?:[A-Z]\w*\.)*[A-Z]\w*)\b/g;
359
+ let m;
360
+ while ((m = RE.exec(choice.body)) !== null) {
361
+ const vars = (m[1] ?? m[2] ?? "").split(",").map((v) => v.trim()).filter((v) => v && v !== "_");
362
+ const comps = returnsOf.get(m[3].split(".").pop());
363
+ if (comps) vars.forEach((v, i) => { if (comps[i]) bound.set(v, comps[i]); });
364
+ }
365
+ }
366
+ const edges = [];
367
+ let caseVar = null;
368
+ let caseTuple = null;
369
+
370
+ for (const line of choice.body.split("\n")) {
371
+ let m;
372
+ if ((m = /\bcase\s+([a-z_][\w'.]*)\s+of\b/.exec(line))) {
373
+ caseVar = m[1].split(".")[0];
374
+ caseTuple = null;
375
+ const r = /^\s*([a-z_][\w']*)\s*<-\s*case\b/.exec(line);
376
+ if (r) { const t = typeOf.get(caseVar) ?? bound.get(caseVar); if (t) bound.set(r[1], t); }
377
+ }
378
+ // RULE 2: a LITERAL TUPLE scrutinee — `case (upfrontFee > 0, feeMintDelegationCid) of`.
379
+ // The components are ordinary expressions, so the second one's type is knowable even though
380
+ // the tuple as a whole has no name. A positional pattern arm then types its binders.
381
+ if ((m = /\bcase\s*\(([^)]*)\)\s+of\b/.exec(line))) {
382
+ caseVar = null;
383
+ caseTuple = m[1].split(",").map((x) => {
384
+ const id = /^[\s(]*([a-z_][\w']*)[\s)]*$/.exec(x);
385
+ return id ? (typeOf.get(id[1]) ?? bound.get(id[1]) ?? null) : null;
386
+ });
387
+ }
388
+ if (caseTuple && (m = /^\s*\(([^)]*)\)\s*->/.exec(line))) {
389
+ m[1].split(",").map((x) => x.trim()).forEach((pat, i) => {
390
+ // `Some x` or a bare `x`; anything else (a literal, a constructor we do not model) is skipped.
391
+ const v = /^(?:Some\s+)?([a-z_][\w']*)$/.exec(pat);
392
+ if (v && caseTuple[i]) bound.set(v[1], caseTuple[i]);
393
+ });
394
+ }
395
+ // RULE 3: list and cons patterns over a list-typed scrutinee. `cidType` already strips `[ ]`,
396
+ // so the scrutinee's recorded type IS the element type.
397
+ if (caseVar && (m = /^\s*(?:\[\s*([a-z_][\w']*)\s*\]|\(\s*([a-z_][\w']*)\s*::[^)]*\))\s*->/.exec(line))) {
398
+ const t = typeOf.get(caseVar) ?? bound.get(caseVar);
399
+ const v = m[1] ?? m[2];
400
+ if (t && v) bound.set(v, t);
401
+ }
402
+ if (caseVar && (m = /\bSome\s+([a-z_][\w']*)\s*->/.exec(line))) {
403
+ const t = typeOf.get(caseVar) ?? bound.get(caseVar); if (t) bound.set(m[1], t);
404
+ }
405
+ // A choice can be MODULE-QUALIFIED: `exercise cid AR.AccrueInterest`. Capturing only the first
406
+ // capitalised token takes the alias `AR` as the choice name, the return lookup misses, the
407
+ // result variable never binds — and every later fetch or exercise on it cascades into a hole.
408
+ // That single omission accounted for most of one real codebase's remaining unresolved edges.
409
+ if ((m = /^\s*\(?\s*([\w',\s]+?)\s*\)?\s*<-\s*exercise\s+[\w'.]+\s+((?:[A-Z]\w*\.)*[A-Z]\w*)\b/.exec(line))) {
410
+ const vars = m[1].split(",").map((v) => v.trim()).filter((v) => v && v !== "_");
411
+ const comps = returnsOf.get(m[2].split(".").pop());
412
+ if (comps) vars.forEach((v, i) => { if (comps[i]) bound.set(v, comps[i]); });
413
+ }
414
+
415
+ const look = (v) => typeOf.get(v) ?? bound.get(v) ?? null;
416
+
417
+ if ((m = /\b([a-z_][\w']*)\s*<-\s*fetch\s+\(fromInterfaceContractId\s+@([\w.]+)\s+[\w']+\)/.exec(line))) {
418
+ const t = m[2].split(".").pop(); bound.set(m[1], t);
419
+ edges.push(edge({ kind: EDGE.READ, target: t, raw: line })); continue;
420
+ }
421
+ if ((m = /\b([a-z_][\w']*)\s*<-\s*fetch\s+([a-z_][\w']*)/.exec(line))) {
422
+ const t = look(m[2]); if (t) bound.set(m[1], t);
423
+ edges.push(edge({ kind: EDGE.READ, target: t, raw: line, via: m[2] })); continue;
424
+ }
425
+ if ((m = /\bfetch\s+([a-z_][\w']*)/.exec(line))) {
426
+ edges.push(edge({ kind: EDGE.READ, target: look(m[1]), raw: line, via: m[1] })); continue;
427
+ }
428
+ if ((m = /\barchive\s+\(fromInterfaceContractId\s+@([\w.]+)/.exec(line))) {
429
+ edges.push(edge({ kind: EDGE.DESTROY, target: m[1].split(".").pop(), raw: line })); continue;
430
+ }
431
+ if ((m = /\barchive\s+([a-z_][\w'.]*)/.exec(line))) {
432
+ const v = m[1].split(".")[0];
433
+ edges.push(edge({ kind: EDGE.DESTROY, target: look(v), raw: line, via: m[1] })); continue;
434
+ }
435
+ if ((m = /\bexercise\s+([a-z_][\w'.()@ ]*?)\s+((?:[A-Z]\w*\.)*[A-Z]\w*)\b/.exec(line))) {
436
+ const cidExpr = m[1].trim();
437
+ const ch = m[2].split(".").pop(); // strip the module alias; the choice is the last segment
438
+ const byName = choiceIndex.get(ch);
439
+ const viaVar = look(cidExpr.split(".")[0]);
440
+ const target = byName?.length === 1 ? byName[0] : viaVar;
441
+ edges.push(edge({ kind: EDGE.CALL, target, raw: line, via: cidExpr,
442
+ external: !target || !templateNames.has(target), meta: { choice: ch } }));
443
+ continue;
444
+ }
445
+ if (/\bcreate\s+this\b/.test(line)) {
446
+ edges.push(edge({ kind: EDGE.CREATE, target: owningTemplate.name, raw: line, self: true })); continue;
447
+ }
448
+ if ((m = /\bcreate\s+([A-Z]\w*)\b/.exec(line))) {
449
+ edges.push(edge({ kind: EDGE.CREATE, target: m[1], raw: line,
450
+ external: !templateNames.has(m[1]) })); continue;
451
+ }
452
+ if ((m = /\bcreate\s+([a-z_][\w']*)\s+with\b/.exec(line))) {
453
+ edges.push(edge({ kind: EDGE.CREATE, target: bound.get(m[1]) ?? null, raw: line, via: m[1] })); continue;
454
+ }
455
+ if ((m = /\bcreate\s+\(([a-z_][\w']*)/.exec(line))) {
456
+ const t = helperReturns.get(m[1]) ?? null;
457
+ edges.push(edge({ kind: EDGE.CREATE, target: t, raw: line,
458
+ external: !!t && !templateNames.has(t), meta: { helper: m[1] } })); continue;
459
+ }
460
+ }
461
+ return edges;
462
+ }
463
+
464
+ /**
465
+ * Split a type signature on its TOP-LEVEL arrows, so `(a -> b) -> C` is two parameters and not
466
+ * three. Done by depth-counting rather than `split("->")`, because the naive split silently
467
+ * mis-assigns every parameter after a higher-order one and the damage is invisible.
468
+ */
469
+ function arrowParts(sig) {
470
+ const out = [];
471
+ let depth = 0, buf = "";
472
+ for (let i = 0; i < sig.length; i++) {
473
+ const c = sig[i];
474
+ if (c === "(" || c === "[") depth++;
475
+ else if (c === ")" || c === "]") depth--;
476
+ if (depth === 0 && c === "-" && sig[i + 1] === ">") { out.push(buf.trim()); buf = ""; i++; continue; }
477
+ buf += c;
478
+ }
479
+ out.push(buf.trim());
480
+ return out;
481
+ }
482
+
483
+ /**
484
+ * Edges reached THROUGH a top-level helper function.
485
+ *
486
+ * Daml choices routinely delegate their ledger work to plain functions:
487
+ *
488
+ * archiveMirror mirrorCid = archive mirrorCid
489
+ * … and eight choices call it
490
+ *
491
+ * Reading only choice bodies, those eight choices archive nothing — and the report said so, in
492
+ * as many words, on a lifecycle card: "nothing ends it". That is the tool asserting a fact about
493
+ * the protocol that the protocol does not have, which is worse than any hole. (The EVM adapter has
494
+ * folded internal calls in from the start; the Daml side simply never did.)
495
+ *
496
+ * Folded edges keep `via` set to the helper that owns them, so the report can say where an edge
497
+ * really lives rather than pretending the choice wrote it inline.
498
+ */
499
+ function helperClosure(defs, ctx) {
500
+ const bySig = ctx.signatures;
501
+ const own = new Map();
502
+ for (const d of defs) {
503
+ // Zip declared parameter names against the signature's argument types, so a `ContractId T`
504
+ // parameter is typed and the helper's `fetch`/`archive` on it resolves like any other.
505
+ const sig = bySig.get(d.name);
506
+ const types = sig ? arrowParts(sig).slice(0, -1) : [];
507
+ const args = d.params.map((nm, i) => ({ name: nm, type: types[i] ?? "" }));
508
+ own.set(d.name, resolveEdges({ args, body: d.body }, { name: null, fields: [] }, ctx));
509
+ }
510
+
511
+ // Transitive: a helper may call another helper. Cycle-guarded, and a helper never absorbs itself.
512
+ //
513
+ // Identifiers are extracted from the body ONCE and intersected with the helper set, rather than
514
+ // testing one compiled RegExp per helper per body. The per-helper form is quadratic and it did
515
+ // not merely run slow — on Splice (694+ top-level bindings) it stopped returning altogether. A
516
+ // tool that hangs on a large codebase is broken whatever it finds on a small one.
517
+ const cache = new Map();
518
+ const idsCache = new Map();
519
+ const identifiers = (body) => {
520
+ let set = idsCache.get(body);
521
+ if (set) return set;
522
+ set = new Set();
523
+ // Skip qualified names: in `M.foo`, `foo` is that module's, not a local helper of the same name.
524
+ const RE = /(^|[^\w'.])([a-z_][\w']*)/g;
525
+ let m;
526
+ while ((m = RE.exec(body)) !== null) set.add(m[2]);
527
+ idsCache.set(body, set);
528
+ return set;
529
+ };
530
+ const names = new Set(own.keys());
531
+ const calls = (body, self) => {
532
+ const out = [];
533
+ for (const n of identifiers(body)) if (n !== self && names.has(n)) out.push(n);
534
+ return out;
535
+ };
536
+ // Reachability by BREADTH-FIRST WALK with one visited set, not recursion with a depth-0 cache.
537
+ // The recursive form re-derived every shared sub-helper once per path to it; on a helper graph
538
+ // with any width that is exponential. A visited set makes it linear and cycle-safe by
539
+ // construction — a mutually recursive pair is visited once, not chased.
540
+ const byName = new Map(defs.map((d) => [d.name, d]));
541
+ const closureOf = (name) => {
542
+ if (cache.has(name)) return cache.get(name);
543
+ const out = [];
544
+ const visited = new Set([name]);
545
+ const queue = [name];
546
+ while (queue.length) {
547
+ const n = queue.shift();
548
+ for (const e of own.get(n) ?? []) out.push({ ...e, through: e.through ?? n });
549
+ for (const next of calls(byName.get(n)?.body ?? "", n)) {
550
+ if (!visited.has(next)) { visited.add(next); queue.push(next); }
551
+ }
552
+ }
553
+ cache.set(name, out);
554
+ return out;
555
+ };
556
+ return { names: [...names], edgesVia: closureOf, calls };
557
+ }
558
+
559
+ const IGNORE_RETURNS = new Set(["Party","Text","Int","Decimal","Bool","Time","Date","ChoiceContext","Update","()"]);
560
+
561
+ export function parse(root, opts = {}) {
562
+ const files = findSources(root, opts);
563
+ const base = fs.statSync(root).isFile() ? path.dirname(root) : root;
564
+ const mods = files.map((f) => parseModule(f, base));
565
+ const templates = mods.flatMap((m) => m.templates);
566
+ const templateNames = new Set(templates.map((t) => t.name));
567
+
568
+ const choiceIndex = new Map();
569
+ for (const t of templates) for (const c of t.choices) {
570
+ if (!choiceIndex.has(c.name)) choiceIndex.set(c.name, []);
571
+ choiceIndex.get(c.name).push(t.name);
572
+ }
573
+ const returnsOf = new Map();
574
+ for (const t of templates) for (const c of t.choices) {
575
+ if (c.returns) returnsOf.set(c.name, tupleComponents(c.returns));
576
+ }
577
+ const helperReturns = new Map();
578
+ for (const f of mods.flatMap((m) => m.functions)) {
579
+ const last = f.signature.split("->").pop().trim().replace(/^Update\s*\(?/, "").replace(/\)?\s*$/, "");
580
+ const t = /^[A-Z]\w*(?:\.[A-Z]\w*)*$/.test(last) ? last.split(".").pop() : null;
581
+ if (t && !IGNORE_RETURNS.has(t)) helperReturns.set(f.name, t);
582
+ }
583
+ const signatures = new Map(mods.flatMap((m) => m.functions).map((f) => [f.name, f.signature]));
584
+ const ctx = { choiceIndex, templateNames, returnsOf, helperReturns, signatures };
585
+
586
+ // Edges a choice reaches through top-level helpers — see helperClosure. Built once, over every
587
+ // module, because helpers are freely imported across modules.
588
+ const closure = helperClosure(mods.flatMap((m) => m.defs), ctx);
589
+
590
+ const entries = [];
591
+ const units = templates.map((t) => {
592
+ const es = t.choices.map((c) => {
593
+ const direct = resolveEdges(c, t, ctx);
594
+ // Fold in what the helpers this choice calls do on the ledger. Deduped against the direct
595
+ // edges by kind+target+via, so a choice that both archives inline and calls an archiving
596
+ // helper is not double-counted.
597
+ const viaHelpers = closure.calls(c.body, null).flatMap((n) => closure.edgesVia(n));
598
+ const seen = new Set(direct.map((e) => `${e.kind}|${e.target}|${e.raw}`));
599
+ const edges = [...direct];
600
+ for (const e of viaHelpers) {
601
+ const k = `${e.kind}|${e.target}|${e.raw}`;
602
+ if (!seen.has(k)) { seen.add(k); edges.push(e); }
603
+ }
604
+ const createsSelf = edges.some((e) => e.kind === EDGE.CREATE && e.self);
605
+ const e = entry({
606
+ name: c.name, unit: t.name, module: t.module, path: t.path,
607
+ line: c.line, endLine: c.endLine,
608
+ // `a :: b :: optionalToList c` is Daml's cons-list controller form; `?x` marks the optional
609
+ // arm, because "this party consents only when configured" is a real distinction.
610
+ authority: String(c.controller ?? "").split(/,|::/).map((s) => s.trim()).filter(Boolean)
611
+ .map((s) => s.replace(/^optionalToList\s+/, "?")),
612
+ args: c.args, guards: c.guards, edges,
613
+ effect: !c.consuming ? EFFECT.NONE : createsSelf ? EFFECT.TRANSITION : EFFECT.TERMINAL,
614
+ returns: c.returns || null, source: c.rawBody || null, bodyLine: c.bodyLine,
615
+ });
616
+ entries.push(e);
617
+ return e;
618
+ });
619
+ return unit({
620
+ name: t.name, module: t.module, path: t.path, line: t.line, endLine: t.endLine,
621
+ signatories: t.signatory ? [t.signatory] : [],
622
+ observers: t.observer ? [t.observer] : [],
623
+ fields: t.fields,
624
+ invariants: t.ensure ? [t.ensure] : [],
625
+ keys: t.key ? [t.key] : [],
626
+ entries: es,
627
+ });
628
+ });
629
+
630
+ return model({
631
+ language: "daml", root,
632
+ modules: mods.map((m) => ({ module: m.module, path: m.path,
633
+ units: m.templates.map((t) => t.name), functions: m.functions.length })),
634
+ units, entries,
635
+ notes: files.length === 0 ? ["no .daml source found under this path"] : [],
636
+ });
637
+ }
638
+
639
+ export default { parse, findSources, stripComments, language: "daml" };