@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.
- package/CHANGELOG.md +22 -0
- package/GUIDE.md +255 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/sourcetruth.mjs +188 -0
- package/examples/daml-lending/daml.yaml +7 -0
- package/examples/daml-lending/src/Lending.daml +92 -0
- package/examples/solidity-vault/foundry.toml +2 -0
- package/examples/solidity-vault/src/Strategy.sol +52 -0
- package/examples/solidity-vault/src/Vault.sol +67 -0
- package/package.json +20 -0
- package/src/adapters/daml.mjs +639 -0
- package/src/adapters/evm.mjs +1166 -0
- package/src/client/app.css +429 -0
- package/src/client/app.js +1074 -0
- package/src/layout.mjs +145 -0
- package/src/model.mjs +157 -0
- package/src/report.mjs +328 -0
- package/src/view.mjs +357 -0
|
@@ -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" };
|