@portll/cobolwork 0.4.0 → 0.6.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.
Files changed (78) hide show
  1. package/README.md +53 -5
  2. package/bin/cobolwork.mjs +34 -9
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/bms.mjs +8 -0
  5. package/lib/build.mjs +33 -8
  6. package/lib/capabilities.mjs +6 -3
  7. package/lib/consequence.mjs +17 -0
  8. package/lib/constants.mjs +60 -0
  9. package/lib/control-reuse.mjs +113 -0
  10. package/lib/control.mjs +86 -18
  11. package/lib/csd.mjs +6 -1
  12. package/lib/dataflow.mjs +343 -95
  13. package/lib/equivalence.mjs +44 -5
  14. package/lib/evidence/cli.mjs +13 -5
  15. package/lib/evidence/record.mjs +16 -6
  16. package/lib/evidence/seal.mjs +17 -0
  17. package/lib/evidence/store.mjs +30 -14
  18. package/lib/evidence/timestamp.mjs +83 -0
  19. package/lib/evidence/verify.mjs +120 -42
  20. package/lib/hlasm/asm/data.mjs +116 -0
  21. package/lib/hlasm/asm/listing.mjs +43 -0
  22. package/lib/hlasm/asm/output.mjs +45 -0
  23. package/lib/hlasm/asm/sections.mjs +159 -0
  24. package/lib/hlasm/asm/symbols.mjs +78 -0
  25. package/lib/hlasm/exec.mjs +27 -0
  26. package/lib/hlasm/expr.mjs +167 -0
  27. package/lib/hlasm/instr.mjs +48 -0
  28. package/lib/hlasm/locate.mjs +333 -0
  29. package/lib/hlasm/macro/authorization.mjs +126 -0
  30. package/lib/hlasm/macro/datasets.mjs +94 -0
  31. package/lib/hlasm/macro/io.mjs +139 -0
  32. package/lib/hlasm/macro/le.mjs +53 -0
  33. package/lib/hlasm/macro/linkage.mjs +143 -0
  34. package/lib/hlasm/macro/operator.mjs +66 -0
  35. package/lib/hlasm/macro/program.mjs +142 -0
  36. package/lib/hlasm/macro/recovery.mjs +80 -0
  37. package/lib/hlasm/macro/storage.mjs +117 -0
  38. package/lib/hlasm/macro/structured.mjs +131 -0
  39. package/lib/hlasm/model.mjs +72 -0
  40. package/lib/hlasm/operands.mjs +49 -0
  41. package/lib/hlasm/read.mjs +116 -0
  42. package/lib/hlasm.mjs +79 -0
  43. package/lib/inventory.mjs +10 -7
  44. package/lib/ironwork-ids.mjs +24 -0
  45. package/lib/ironwork.mjs +4 -1
  46. package/lib/kernel/registry.mjs +24 -17
  47. package/lib/kernel/shared-pass.mjs +138 -0
  48. package/lib/options.mjs +26 -4
  49. package/lib/policy.mjs +6 -0
  50. package/lib/precompile-cics.mjs +1 -1
  51. package/lib/revision.json +1 -1
  52. package/lib/sarif.mjs +3 -0
  53. package/lib/scan.mjs +21 -11
  54. package/lib/sets/abend.mjs +115 -13
  55. package/lib/sets/build.mjs +43 -37
  56. package/lib/sets/cics.mjs +5 -3
  57. package/lib/sets/compile.mjs +5 -3
  58. package/lib/sets/copybook.mjs +5 -3
  59. package/lib/sets/crypto.mjs +110 -0
  60. package/lib/sets/flow.mjs +32 -7
  61. package/lib/sets/hlasm.mjs +177 -0
  62. package/lib/sets/jcl.mjs +15 -1
  63. package/lib/sets/log.mjs +5 -3
  64. package/lib/sets/priv.mjs +5 -3
  65. package/lib/sets/secrets.mjs +110 -0
  66. package/lib/sets/semantics.mjs +5 -3
  67. package/lib/sets/web.mjs +104 -7
  68. package/lib/sources.mjs +12 -2
  69. package/package.json +1 -1
  70. package/rules/compliance-cobit2019.json +216 -0
  71. package/rules/compliance-dora.json +234 -0
  72. package/rules/compliance-ffiec.json +234 -0
  73. package/rules/compliance-nist80053.json +234 -0
  74. package/rules/gitleaks-mainframe.toml +61 -16
  75. package/rules/hlasm-instructions.json +2396 -0
  76. package/rules/hlasm-operations.json +451 -0
  77. package/rules/icsf-services.json +1178 -0
  78. package/schema/cobolwork.policy.schema.json +7 -0
package/lib/dataflow.mjs CHANGED
@@ -11,13 +11,23 @@ import { parseCsd, ddOfQueue } from './csd.mjs';
11
11
  import { loadSite } from './site.mjs';
12
12
  import { ssrangeAbends } from './options.mjs';
13
13
  import { isJcl } from './sources.mjs';
14
- import { eachWithinMemory, watchMemoryBuffer } from './kernel/memory.mjs';
14
+ import { watchMemoryBuffer } from './kernel/memory.mjs';
15
+ import { drive, loopOver } from './kernel/shared-pass.mjs';
16
+ import { keep, parseOrder, restore, sameAnalysis } from './control-reuse.mjs';
17
+ import { createHash } from 'node:crypto';
18
+
15
19
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
16
20
  // own comparator. The text comparison underneath it is the shared one.
17
21
  import { byText } from './kernel/findings.mjs';
18
- import { buildControl, creditOf, hasFact, topOf, within, wholeTop } from './control.mjs';
22
+ import { buildControl, creditOf, hasFact, sparseFacts, topOf, within, wholeTop } from './control.mjs';
19
23
  import { directoryTree } from './kernel/source-tree.mjs';
20
24
 
25
+ // Every reused control analysis checked against the program's own: for the equivalence runs and tests.
26
+ const verifyReuse = () => process.env.COBOLWORK_VERIFY_REUSE === '1';
27
+ // The size of program text from which a copy's control analysis is restored rather than redone. On
28
+ // one repository of 14,500 copied small programs restoring took longer than analysing.
29
+ const REUSE_MIN_BYTES = 256 * 1024;
30
+
21
31
  const OS_COMMAND_ROUTINE = /^(SYSTEM|C\$SYSTEM|CBL_EXEC_RUN_UNIT|CBL_GC_HOSTED|BXPSYSTM)$/i;
22
32
 
23
33
  export const SOURCE_KINDS = {
@@ -73,6 +83,58 @@ const computes = (st) => ARITHMETIC.has(st.verb) || st.counts === true || (st.fn
73
83
  const computedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.why && s.why.computes) return true; return false; };
74
84
  const typedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.node.typed) return true; return false; };
75
85
 
86
+ // Most nodes are never a source or a sink. They share one empty list, frozen, so that adding to it
87
+ // fails rather than reaching every node.
88
+ const NO_ENDS = Object.freeze([]);
89
+ const addSource = (n, x) => { if (n.sources === NO_ENDS) n.sources = []; n.sources.push(x); };
90
+ const addSink = (n, x) => { if (n.sinks === NO_ENDS) n.sinks = []; n.sinks.push(x); };
91
+
92
+ // What a check's condition says, as text that differs whenever anything a reader could observe
93
+ // differs: property order, a set's order, undefined, -0 and NaN. Null for anything but plain data.
94
+ function consKey(v) {
95
+ if (v === undefined) return 'u';
96
+ if (v === null) return 'n';
97
+ if (typeof v === 'number') return Object.is(v, -0) ? '#-0' : `#${v}`;
98
+ if (typeof v === 'string') return JSON.stringify(v);
99
+ if (typeof v === 'boolean') return v ? 't' : 'f';
100
+ if (typeof v !== 'object') return null;
101
+ const parts = v instanceof Set ? [...v] : Array.isArray(v) ? v : Object.getPrototypeOf(v) === Object.prototype ? null : undefined;
102
+ if (parts === undefined) return null;
103
+ const keys = parts ? parts.map(consKey) : Object.keys(v).map((k) => { const s = consKey(v[k]); return s === null ? null : `${JSON.stringify(k)}:${s}`; });
104
+ if (keys.includes(null)) return null;
105
+ return `${v instanceof Set ? 'S' : parts ? 'A' : 'O'}[${keys.join(',')}]`;
106
+ }
107
+
108
+ // The facts a program keeps per statement: one copy of each distinct set, held as the numbers of the
109
+ // facts in it, all in one buffer per program. Neighbouring statements mostly hold the same checks, and
110
+ // few of a program's checks hold at any one statement: 26 large programs kept 1.5 GB of bitsets, 0.4%
111
+ // of their bits set.
112
+ function internFacts(facts) {
113
+ const byHash = new Map();
114
+ const kept = [];
115
+ const keptAt = new Map();
116
+ for (const [point, bits] of facts) {
117
+ let h = 2166136261;
118
+ for (let i = 0; i < bits.length; i++) h = Math.imul(h ^ bits[i], 16777619);
119
+ let same = byHash.get(h);
120
+ if (!same) byHash.set(h, same = []);
121
+ let k = same.find((x) => x.bits.length === bits.length && x.bits.every((v, i) => v === bits[i]));
122
+ if (!k) { same.push(k = { bits, held: sparseFacts(bits) }); kept.push(k); }
123
+ keptAt.set(point, k);
124
+ }
125
+ let size = 0;
126
+ for (const k of kept) if (k.held instanceof Uint16Array) size += k.held.length;
127
+ const pool = new Uint16Array(size);
128
+ let at = 0;
129
+ for (const k of kept) {
130
+ if (!(k.held instanceof Uint16Array)) continue;
131
+ pool.set(k.held, at);
132
+ k.held = pool.subarray(at, at += k.held.length);
133
+ }
134
+ for (const [point, k] of keptAt) facts.set(point, k.held);
135
+ return facts;
136
+ }
137
+
76
138
  // Every literal a program holds that could be a program name: its VALUE clauses and the literals its
77
139
  // statements and commands use. A menu that XCTLs through a table of names starts every one of them.
78
140
  function literalNames(prog) {
@@ -174,7 +236,7 @@ function execOptions(exec) {
174
236
  return { opts, words };
175
237
  }
176
238
 
177
- export function analyze(root, opts = {}) {
239
+ export function* analyzeSteps(root, opts = {}) {
178
240
  const repos = opts.repos || [''];
179
241
  const stats = { files: 0, programs: 0, edges: 0, nodes: 0, threw: 0, overBudget: 0, unreadable: 0, ebcdic: 0,
180
242
  jclFiles: 0, jclSteps: 0, jclStepsResolved: 0, jclCrossings: 0,
@@ -189,6 +251,9 @@ export function analyze(root, opts = {}) {
189
251
  // Every hop of every trace, for the reader who is following one chain rather than counting
190
252
  // findings. Off by default: a chain-shaped program turns this quadratic in the chain length.
191
253
  const fullTrace = opts.fullTrace === true;
254
+ // Every statement on any route from a finding's source to its sink, not only the route the trace
255
+ // names: what a run must have executed before it can show a value does not arrive (reach.md §9.8).
256
+ const allRoutes = opts.allRoutes === true;
192
257
  let held = 0;
193
258
 
194
259
  // Which files, not just how many. A count names nothing to go and look at, and it cannot tell
@@ -216,10 +281,35 @@ export function analyze(root, opts = {}) {
216
281
  const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
217
282
  // `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
218
283
  // passes an argument, the PUT or GET of a container.
219
- // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }).
284
+ // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }), and
285
+ // `stmt` names the statement that carries an edge whose `why` is text.
286
+ // Edges are kept as columns and laid out by source node before the walk. Most are a group's, a
287
+ // REDEFINES' or a RENAMES', and those share one record of why they exist.
288
+ const edgeFrom = [];
289
+ const edgeTo = [];
290
+ const edgeMeta = [];
291
+ const sharedMeta = new Map();
292
+ // Checks share a condition with the same content: 107,547 conditions of 66 Unieuro programs had
293
+ // 2,423 contents. Conditions are read and never changed once the check model has made them.
294
+ const conditions = new Map();
295
+ const sameCondition = (c) => {
296
+ const k = c ? consKey(c) : null;
297
+ if (k === null) return c;
298
+ let kept = conditions.get(k);
299
+ if (!kept) conditions.set(k, kept = c);
300
+ return kept;
301
+ };
220
302
  const edge = (a, b, why, dir, at, link) => {
221
303
  if (!a || !b || a === b) return;
222
- a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
304
+ let meta;
305
+ if (typeof why === 'string' && at == null && !link) {
306
+ const k = `${why}|${dir || ''}`;
307
+ meta = sharedMeta.get(k);
308
+ if (!meta) sharedMeta.set(k, meta = { why, dir });
309
+ } else meta = { why, dir, ...(at != null ? { at } : {}), ...link };
310
+ edgeFrom.push(a.id);
311
+ edgeTo.push(b.id);
312
+ edgeMeta.push(meta);
223
313
  stats.edges++;
224
314
  };
225
315
  let callSites = 0;
@@ -227,7 +317,52 @@ export function analyze(root, opts = {}) {
227
317
  // Everything the graph needs from one program is taken while its parse tree is in hand, and the
228
318
  // tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
229
319
  // 8,024-file repository; nodes, edges and a few cross-program sites are all that survive.
230
- function summarise(prog, file, pk, options = []) {
320
+ // A flow node holds what differs between nodes. Its program and file come from the program it
321
+ // belongs to, and every other field from the class until the node sets its own.
322
+ const programOf = [];
323
+ class FlowNode {
324
+ constructor(id, pk, name) { this.id = id; this.pk = pk; this.name = name; }
325
+ get program() { return programOf[this.pk].id; }
326
+ get file() { return programOf[this.pk].file; }
327
+ }
328
+ Object.assign(FlowNode.prototype, { off: null, size: null, repeats: false, sources: NO_ENDS, sinks: NO_ENDS });
329
+
330
+ // A program whose text another program file repeats has its control analysis kept for the copies,
331
+ // keyed by its place in its file and by what each copybook it resolved holds (lib/control-reuse.mjs).
332
+ // Only a large one: below REUSE_MIN_BYTES (opts.reuseMinBytes) keeping and restoring costs more
333
+ // than analysing again.
334
+ const reuseMin = opts.reuseMinBytes ?? REUSE_MIN_BYTES;
335
+ const textKey = (src) => createHash('sha1').update(src).digest('base64');
336
+ function repeatedTexts(tree, files) {
337
+ const count = new Map();
338
+ for (const f of files) {
339
+ let src;
340
+ try { src = tree.text(f).text; } catch { continue; }
341
+ if (src.length < reuseMin) continue;
342
+ const k = textKey(src);
343
+ count.set(k, (count.get(k) || 0) + 1);
344
+ }
345
+ const out = new Map();
346
+ for (const [k, n] of count) if (n > 1) out.set(k, { text: k, left: n, kept: new Map() });
347
+ return out;
348
+ }
349
+ const copyText = new Map();
350
+ const copyKey = (r, tree) => r.copies.map((c) => {
351
+ if (!c.path) return `${c.status}:${c.name}`;
352
+ if (!copyText.has(c.path)) { let k = null; try { k = textKey(tree.text(c.path).text); } catch { /* unread: compared by status */ } copyText.set(c.path, k); }
353
+ return `${c.status}:${copyText.get(c.path)}`;
354
+ }).join('|');
355
+ const reuseOf = (copied, k, r, f, tree) => ({ kept: copied.kept, key: `${k}|${copyKey(r, tree)}`, files: [f, ...r.copies.filter((c) => c.path).map((c) => c.path)] });
356
+ // A reused analysis checked against the program's own, where COBOLWORK_VERIFY_REUSE asks.
357
+ function sameAsOwn(ctl, prog, resolver, file) {
358
+ let own = null;
359
+ try { own = buildControl(prog, resolver); } catch { /* the kept one threw too, or it differs */ }
360
+ if (own) internFacts(own.facts);
361
+ if (!own !== !ctl || (own && !sameAnalysis(own, ctl, new Set(parseOrder(prog))))) throw new Error(`the control analysis reused for ${file} differs from its own`);
362
+ }
363
+
364
+ function summarise(prog, file, pk, options = [], reuse = null) {
365
+ programOf[pk] = { id: prog.id, file };
231
366
  const byItem = new Map();
232
367
  const byName = new Map();
233
368
  for (const it of prog.items) if (!byName.has(it.name)) byName.set(it.name, it);
@@ -241,7 +376,11 @@ export function analyze(root, opts = {}) {
241
376
  for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
242
377
  let top = item;
243
378
  while (top.parent) top = top.parent;
244
- n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [], ...(top.section === 'LINKAGE' ? { linkage: true } : {}) };
379
+ n = new FlowNode(nodes.length, pk, item.name);
380
+ n.off = item.offset ?? null;
381
+ n.size = item.size ?? null;
382
+ if (repeats) n.repeats = true;
383
+ if (top.section === 'LINKAGE') n.linkage = true;
245
384
  // Where the outermost table holding this item sits in the record: every occurrence of the
246
385
  // item is somewhere inside it. A group's size already counts its occurrences; an
247
386
  // elementary item's is one element.
@@ -263,7 +402,7 @@ export function analyze(root, opts = {}) {
263
402
  const indexNode = (name) => {
264
403
  let n = indexNodes.get(name);
265
404
  if (!n) {
266
- n = { id: nodes.length, pk, program: prog.id, file, name, off: null, size: null, repeats: false, edgesOut: [], sources: [], sinks: [] };
405
+ n = new FlowNode(nodes.length, pk, name);
267
406
  nodes.push(n);
268
407
  indexNodes.set(name, n);
269
408
  stats.nodes++;
@@ -277,12 +416,12 @@ export function analyze(root, opts = {}) {
277
416
  // constraints, so only SQLERRMC reaches the screen sink.
278
417
  const systemSource = (n, detail, at) => {
279
418
  const onlyTo = /^SQLERRMC\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
280
- if (!n.sources.some((x) => x.kind === 'system-response')) n.sources.push({ kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
419
+ if (!n.sources.some((x) => x.kind === 'system-response')) addSource(n, { kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
281
420
  };
282
421
  const systemNode = (tok) => {
283
422
  let n = systemNodes.get(tok.u);
284
423
  if (!n) {
285
- n = { id: nodes.length, pk, program: prog.id, file, name: tok.u, off: null, size: null, repeats: false, edgesOut: [], sources: [], sinks: [] };
424
+ n = new FlowNode(nodes.length, pk, tok.u);
286
425
  nodes.push(n);
287
426
  systemNodes.set(tok.u, n);
288
427
  stats.nodes++;
@@ -300,9 +439,20 @@ export function analyze(root, opts = {}) {
300
439
  // The program's statement order, where its structure could be read. Without it a check is
301
440
  // credited as it always was, wherever its field is used, and never clears anything.
302
441
  let ctl = null;
303
- try {
304
- ctl = buildControl(prog, (tok) => itemOfToken(tok) || (tok && tok.t === 'word' && indexNames.has(tok.u) ? { index: tok.u } : null));
305
- } catch { stats.programsUnordered++; }
442
+ const resolver = (tok) => itemOfToken(tok) || (tok && tok.t === 'word' && indexNames.has(tok.u) ? { index: tok.u } : null);
443
+ const kept = reuse && reuse.kept.get(reuse.key);
444
+ if (kept) {
445
+ ctl = kept.threw ? null : restore(kept.value, parseOrder(prog), reuse.files);
446
+ if (!ctl) stats.programsUnordered++;
447
+ if (verifyReuse()) sameAsOwn(ctl, prog, resolver, file);
448
+ } else {
449
+ try { ctl = buildControl(prog, resolver); } catch { stats.programsUnordered++; }
450
+ if (ctl) internFacts(ctl.facts);
451
+ if (reuse) {
452
+ const value = ctl ? keep(ctl, parseOrder(prog), reuse.files) : null;
453
+ if (!ctl || value) reuse.kept.set(reuse.key, ctl ? { value } : { threw: true });
454
+ }
455
+ }
306
456
  const pointOf = (x) => (ctl ? ctl.nodeOf.get(x) : undefined);
307
457
  // Where a name inside a condition is read, which can know more than the statement does.
308
458
  const placeOf = (tok, st) => (ctl && ctl.posOf.get(tok)) ?? pointOf(st);
@@ -319,7 +469,7 @@ export function analyze(root, opts = {}) {
319
469
  const device = upon >= 0 && st.sources[upon + 1] ? ` UPON ${st.sources[upon + 1].u}` : '';
320
470
  for (const t of upon >= 0 ? st.sources.slice(0, upon) : st.sources) {
321
471
  const n = nodeOfToken(t);
322
- if (n) n.sinks.push({ kind: 'log', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `DISPLAY ${n.name}${device}` });
472
+ if (n) addSink(n, { kind: 'log', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `DISPLAY ${n.name}${device}` });
323
473
  }
324
474
  }
325
475
  // The document XML PARSE reads is the operand after PARSE; what the parser does with a DTD in
@@ -327,7 +477,7 @@ export function analyze(root, opts = {}) {
327
477
  for (const st of prog.statements) {
328
478
  if (st.verb !== 'XML' || !st.sources[0] || st.sources[0].u !== 'PARSE') continue;
329
479
  const n = st.sources[1] ? nodeOfToken(st.sources[1]) : null;
330
- if (n) n.sinks.push({ kind: 'xml-document', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `XML PARSE ${n.name}` });
480
+ if (n) addSink(n, { kind: 'xml-document', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `XML PARSE ${n.name}` });
331
481
  }
332
482
  for (const it of prog.items) {
333
483
  const n = nodeOf(it);
@@ -363,7 +513,7 @@ export function analyze(root, opts = {}) {
363
513
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
364
514
  const extent = (it) => (it.offset != null && it.size != null ? [it.offset, it.offset + (it.contributes || it.size)] : null);
365
515
  for (const c of ctl.checks) {
366
- const entry = { x: c.x, t: c.t, f: c.f, tCons: c.tCons, fCons: c.fCons, file: c.file, line: c.line, item: c.field.index || c.field.name, ...(c.bounds ? { bounds: c.bounds } : {}), ...(c.mono ? { mono: true } : {}) };
516
+ const entry = { x: c.x, t: c.t, f: c.f, tCons: sameCondition(c.tCons), fCons: sameCondition(c.fCons), file: c.file, line: c.line, item: c.field.index || c.field.name, ...(c.bounds ? { bounds: c.bounds } : {}), ...(c.mono ? { mono: true } : {}) };
367
517
  if (c.field.index) { (indexNode(c.field.index).checks ||= []).push(entry); continue; }
368
518
  const span = extent(c.field);
369
519
  for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
@@ -451,7 +601,7 @@ export function analyze(root, opts = {}) {
451
601
  if (indexed.has(use)) continue;
452
602
  indexed.add(use);
453
603
  const start = kind === 'reference-modification' && x.from && base != null ? startOf(x.from, idx, base) : null;
454
- at.sinks.push({
604
+ addSink(at, {
455
605
  kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit, ...(start ? { start } : {}), ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
456
606
  detail: kind === 'subscript'
457
607
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
@@ -482,7 +632,7 @@ export function analyze(root, opts = {}) {
482
632
  const use = `${key}|${pointOf(st) ?? `s${st.at}`}`;
483
633
  if (indexed.has(use)) continue;
484
634
  indexed.add(use);
485
- nodeOf(it).sinks.push({
635
+ addSink(nodeOf(it), {
486
636
  kind: 'loop-bound', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|${key}`, limit: limitOf(walks.host, walks.table), counterUses: bodyUses(st, usesOf.get(counter)), ...(ssrange ? { ssrange } : {}),
487
637
  detail: `${it.name} decides where PERFORM VARYING ${counter} stops, and ${counter} subscripts ${walks.host.name}, a table of ${walks.table.occurs}`,
488
638
  });
@@ -503,7 +653,7 @@ export function analyze(root, opts = {}) {
503
653
  const use = `arithmetic|${n.id}|${pointOf(st) ?? `s${st.at}`}`;
504
654
  if (indexed.has(use)) continue;
505
655
  indexed.add(use);
506
- n.sinks.push({
656
+ addSink(n, {
507
657
  kind: 'arithmetic', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|arithmetic|${n.id}`,
508
658
  detail: `${it.name} (PIC ${it.picture}${it.effectiveUsage && it.effectiveUsage !== 'DISPLAY' ? ` ${it.effectiveUsage}` : ''}) is an operand of ${st.verb}${numproc === 'PFD' ? ', compiled NUMPROC(PFD), which repairs no sign' : ''}`,
509
659
  });
@@ -525,7 +675,7 @@ export function analyze(root, opts = {}) {
525
675
  const sts = touching.get(it);
526
676
  for (const st of sts.length ? sts : [null]) {
527
677
  const point = st ? pointOf(st) : undefined;
528
- nodeOf(count).sinks.push({
678
+ addSink(nodeOf(count), {
529
679
  kind: 'occurs-depending-count', onlyFrom: FROM_OUTSIDE, file: it.file, line: it.line, ...(st ? { point, group: `${pk}|odo|${it.name}` } : {}), top: it.occurs, ...(ssrange ? { ssrange } : {}),
530
680
  detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
531
681
  });
@@ -535,18 +685,18 @@ export function analyze(root, opts = {}) {
535
685
  if (st.verb !== 'READ' && st.verb !== 'RETURN') continue;
536
686
  for (const t of st.targets) {
537
687
  const n = nodeOfToken(t);
538
- if (n) n.sources.push({ kind: 'file-record', file: st.file, line: st.line, detail: `${st.verb} ... INTO` });
688
+ if (n) addSource(n, { kind: 'file-record', file: st.file, line: st.line, detail: `${st.verb} ... INTO` });
539
689
  }
540
690
  }
541
691
  for (const fd of prog.fds || []) for (const rec of fd.records || []) {
542
692
  const readsFile = prog.statements.some(st => (st.verb === 'READ') && st.sources.concat(st.targets).some(t => t.u === fd.name));
543
- if (readsFile) nodeOf(rec).sources.push({ kind: 'file-record', file: fd.file, line: fd.line, detail: `record of file ${fd.name}` });
693
+ if (readsFile) addSource(nodeOf(rec), { kind: 'file-record', file: fd.file, line: fd.line, detail: `record of file ${fd.name}` });
544
694
  }
545
695
  for (const a of prog.accepts) {
546
696
  const item = (a.targetTok ? itemOfToken(a.targetTok) : null) || byName.get(a.target);
547
697
  if (!item) continue;
548
698
  const kind = /^(COMMAND-LINE|ARGUMENT-VALUE|ENVIRONMENT|ENVIRONMENT-VALUE)$/.test(a.from || '') ? 'argv-or-env' : null;
549
- if (kind) nodeOf(item).sources.push({ kind, file: a.file, line: a.line, detail: `ACCEPT ... FROM ${a.from}` });
699
+ if (kind) addSource(nodeOf(item), { kind, file: a.file, line: a.line, detail: `ACCEPT ... FROM ${a.from}` });
550
700
  }
551
701
  // A SELECT assigns either to a data item, which makes the path dynamic, or to an external
552
702
  // name. On z/OS that external name is the DD name a JCL step fills, and it is how a job's
@@ -557,7 +707,7 @@ export function analyze(root, opts = {}) {
557
707
  if (f.assign.t === 'word') {
558
708
  const item = byName.get(f.assign.v);
559
709
  if (item) {
560
- nodeOf(item).sinks.push({ kind: 'dynamic-file-path', file: f.file, line: f.line, detail: `SELECT ${f.name} ASSIGN TO ${f.assign.v}` });
710
+ addSink(nodeOf(item), { kind: 'dynamic-file-path', file: f.file, line: f.line, detail: `SELECT ${f.name} ASSIGN TO ${f.assign.v}` });
561
711
  continue; // a variable path is not a DD name
562
712
  }
563
713
  }
@@ -566,7 +716,9 @@ export function analyze(root, opts = {}) {
566
716
  if (!external) continue;
567
717
  const fd = (prog.fds || []).find((x) => x.name === f.name);
568
718
  const recs = (fd?.records || []).map(nodeOf);
569
- if (recs.length) ddFiles.push({ dd: external, nodes: recs, file: f.file, line: f.line, select: f.name });
719
+ // READ ... INTO moves the record it read into an item, which then holds the DD's data too.
720
+ const into = prog.statements.filter((st) => st.verb === 'READ' && st.sources[0]?.u === f.name).flatMap((st) => st.targets.map(nodeOfToken).filter(Boolean));
721
+ if (recs.length) ddFiles.push({ dd: external, nodes: recs, into, file: f.file, line: f.line, select: f.name });
570
722
  }
571
723
  const topOf = (it) => { let t = it; while (t && t.parent) t = t.parent; return t; };
572
724
  const ownParams = new Set((prog.paramTokens || []).map((pt) => topOf(itemOfToken(pt.tok))).filter(Boolean));
@@ -589,14 +741,14 @@ export function analyze(root, opts = {}) {
589
741
  const point = pointOf(prog.statements[c.stmtIndex]);
590
742
  if (c.kind === 'I') {
591
743
  const n = nodeOfToken(c.targetTok);
592
- if (n) n.sinks.push({ kind: 'dynamic-program-load', file: c.file, line: c.line, point, detail: `CALL ${c.name}` });
744
+ if (n) addSink(n, { kind: 'dynamic-program-load', file: c.file, line: c.line, point, detail: `CALL ${c.name}` });
593
745
  continue;
594
746
  }
595
747
  callSites.push({ name: String(c.name).toUpperCase(), display: c.name, file: c.file, line: c.line, point, args: c.using.map(a => ({ node: a.tok ? nodeOfToken(a.tok) : null, mode: a.mode, extent: a.tok ? argExtent(itemOfToken(a.tok)) : null })) });
596
748
  if (OS_COMMAND_ROUTINE.test(c.name)) {
597
749
  for (const a of c.using) {
598
750
  const n = a.tok ? nodeOfToken(a.tok) : null;
599
- if (n) n.sinks.push({ kind: 'os-command', file: c.file, line: c.line, point, detail: `CALL '${c.name}' USING ${a.word}` });
751
+ if (n) addSink(n, { kind: 'os-command', file: c.file, line: c.line, point, detail: `CALL '${c.name}' USING ${a.word}` });
600
752
  }
601
753
  } else if (SOCKET_ROUTINE.test(c.name)) {
602
754
  // IBM's own examples hold the function code in a field: 01 SOC-FUNCTION PIC X(16) VALUE 'SEND'.
@@ -607,23 +759,23 @@ export function analyze(root, opts = {}) {
607
759
  if (fn && SOCKET_SEND.test(String(fn).trim())) {
608
760
  for (const a of c.using.slice(1)) {
609
761
  const n = a.tok ? nodeOfToken(a.tok) : null;
610
- if (n) n.sinks.push({ kind: 'socket-send', onlyFrom: DATA_AT_REST, file: c.file, line: c.line, point, detail: `CALL '${c.name}' ${String(fn).trim()} USING ${a.word}` });
762
+ if (n) addSink(n, { kind: 'socket-send', onlyFrom: DATA_AT_REST, file: c.file, line: c.line, point, detail: `CALL '${c.name}' ${String(fn).trim()} USING ${a.word}` });
611
763
  }
612
764
  }
613
765
  } else if (Object.hasOwn(MQ_BUFFER_ARG, String(c.name).toUpperCase())) {
614
766
  const a = c.using[MQ_BUFFER_ARG[String(c.name).toUpperCase()]];
615
767
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
616
- if (n) n.sinks.push({ kind: 'message-queue', onlyFrom: DATA_AT_REST, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with buffer ${a.word}` });
768
+ if (n) addSink(n, { kind: 'message-queue', onlyFrom: DATA_AT_REST, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with buffer ${a.word}` });
617
769
  } else if (/^MQCONNX?$/i.test(String(c.name))) {
618
770
  // MQCONN(QMgrName, Hconn, CompCode, Reason); MQCONNX puts its options second.
619
771
  const a = c.using[0];
620
772
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
621
- if (n) n.sinks.push({ kind: 'connection-target', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with queue manager ${a.word}, which decides the queue manager the program connects to` });
773
+ if (n) addSink(n, { kind: 'connection-target', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL '${c.name}' with queue manager ${a.word}, which decides the queue manager the program connects to` });
622
774
  } else if (String(c.name).toUpperCase() === 'CEEGTST') {
623
775
  // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
624
776
  const a = c.using[1];
625
777
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
626
- if (n) n.sinks.push({ kind: 'storage-length', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL 'CEEGTST' with size ${a.word}, which decides how much heap storage is acquired` });
778
+ if (n) addSink(n, { kind: 'storage-length', onlyFrom: FROM_OUTSIDE, file: c.file, line: c.line, point, detail: `CALL 'CEEGTST' with size ${a.word}, which decides how much heap storage is acquired` });
627
779
  }
628
780
  }
629
781
  const transfers = [];
@@ -639,7 +791,10 @@ export function analyze(root, opts = {}) {
639
791
  const containerNode = (name) => {
640
792
  let n = containers.get(name);
641
793
  if (!n) {
642
- n = { id: nodes.length, pk, program: prog.id, file, name: `CONTAINER(${name})`, edgesOut: [], sources: [], sinks: [] };
794
+ n = new FlowNode(nodes.length, pk, `CONTAINER(${name})`);
795
+ n.off = undefined;
796
+ n.size = undefined;
797
+ n.repeats = undefined;
643
798
  nodes.push(n);
644
799
  containers.set(name, n);
645
800
  stats.nodes++;
@@ -727,7 +882,7 @@ export function analyze(root, opts = {}) {
727
882
  if (typed) continue;
728
883
  stats.protectedFields++;
729
884
  const back = field.effective.has('FSET') ? ' with FSET' : '';
730
- n.sources.push({ kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
885
+ addSource(n, { kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
731
886
  detail: `EXEC CICS RECEIVE MAP(${map.name}) returns ${item.name}, field ${field.name} of map ${map.name} in mapset ${mapset.name}, which the map marks ${marks.join(' and ')}${field.attrb ? '' : ' by default'}${back}` });
732
887
  }
733
888
  };
@@ -754,17 +909,17 @@ export function analyze(root, opts = {}) {
754
909
  if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(words[1])) {
755
910
  const opt = words[1] === 'OPERATOR' ? 'TEXT' : 'FROM';
756
911
  const n = nodeOfToken((o.get(opt) || [])[0]);
757
- if (n) n.sinks.push({ kind: 'log', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS WRITE ${words[1]} ${opt}(${n.name}), ${words[1] === 'OPERATOR' ? 'to the operator console' : 'to a journal'}` });
912
+ if (n) addSink(n, { kind: 'log', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS WRITE ${words[1]} ${opt}(${n.name}), ${words[1] === 'OPERATOR' ? 'to the operator console' : 'to a journal'}` });
758
913
  }
759
914
  if (verb === 'RECEIVE') {
760
915
  const isWeb = words.includes('WEB');
761
916
  const n = nodeOfToken(intoTok);
762
- if (n) n.sources.push({ kind: isWeb ? 'cics-web' : 'cics-terminal', ...at, detail: `EXEC CICS ${isWeb ? 'WEB ' : ''}RECEIVE` });
917
+ if (n) addSource(n, { kind: isWeb ? 'cics-web' : 'cics-terminal', ...at, detail: `EXEC CICS ${isWeb ? 'WEB ' : ''}RECEIVE` });
763
918
  // RECEIVE MAP with no INTO fills the symbolic map, whose input record is <map>I.
764
919
  const map = optionName(o, 'MAP');
765
920
  if (!n && map) {
766
921
  const sym = byName.get(`${map}I`);
767
- if (sym) nodeOf(sym).sources.push({ kind: 'cics-terminal', ...at, detail: `EXEC CICS RECEIVE MAP(${map}) into the symbolic map` });
922
+ if (sym) addSource(nodeOf(sym), { kind: 'cics-terminal', ...at, detail: `EXEC CICS RECEIVE MAP(${map}) into the symbolic map` });
768
923
  }
769
924
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
770
925
  }
@@ -774,7 +929,7 @@ export function analyze(root, opts = {}) {
774
929
  const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
775
930
  const sym = !from && map ? byName.get(`${map}O`) : null;
776
931
  const n = from || (sym ? nodeOf(sym) : null);
777
- if (n) n.sinks.push({ kind: 'screen', onlyFrom: ['system-response'], ...at, detail: `EXEC CICS SEND ${words[1]}${map ? `(${map})` : ''}${from ? ` FROM(${n.name})` : ', from its symbolic map'}, to the terminal` });
932
+ if (n) addSink(n, { kind: 'screen', onlyFrom: ['system-response'], ...at, detail: `EXEC CICS SEND ${words[1]}${map ? `(${map})` : ''}${from ? ` FROM(${n.name})` : ', from its symbolic map'}, to the terminal` });
778
933
  }
779
934
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
780
935
  // user types is how a lookup works; a key the program put in a protected field and read back
@@ -786,18 +941,18 @@ export function analyze(root, opts = {}) {
786
941
  const detail = verb === 'DELETE' ? `EXEC CICS DELETE RIDFLD(${k && k.name}), which decides which record it deletes`
787
942
  : then ? `EXEC CICS READ UPDATE RIDFLD(${k && k.name}), which decides which record the program then ${then}`
788
943
  : `EXEC CICS ${verb} RIDFLD(${k && k.name}), which decides which record it reaches`;
789
- if (k) k.sinks.push({ kind: verb === 'DELETE' || then ? 'record-update' : 'record-key', onlyFrom: ['cics-protected-field'], noCredit: true, ...at, detail });
944
+ if (k) addSink(k, { kind: verb === 'DELETE' || then ? 'record-update' : 'record-key', onlyFrom: ['cics-protected-field'], noCredit: true, ...at, detail });
790
945
  }
791
946
  if (verb === 'GETMAIN') {
792
947
  for (const opt of ['FLENGTH', 'LENGTH']) {
793
948
  const n = nodeOfToken((o.get(opt) || [])[0]);
794
- if (n) n.sinks.push({ kind: 'storage-length', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS GETMAIN ${opt}(${n.name}), which decides how much storage is acquired` });
949
+ if (n) addSink(n, { kind: 'storage-length', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS GETMAIN ${opt}(${n.name}), which decides how much storage is acquired` });
795
950
  }
796
951
  }
797
952
  if (verb === 'PUT' && o.has('CONTAINER')) {
798
953
  const name = optionName(o, 'CONTAINER');
799
954
  const from = nodeOfToken((o.get('FROM') || [])[0]);
800
- if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point);
955
+ if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS PUT', file: rel(at.file), line: at.line } });
801
956
  if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
802
957
  }
803
958
  // A service call sends every container on its channel, and its URI decides where they go.
@@ -805,22 +960,22 @@ export function analyze(root, opts = {}) {
805
960
  invokes.push({ at, what: words[1], channel: optionName(o, 'CHANNEL') });
806
961
  for (const opt of ['URI', 'URIMAP']) {
807
962
  const n = nodeOfToken((o.get(opt) || [])[0]);
808
- if (n) n.sinks.push({ kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS INVOKE ${words[1]} ${opt}(...), which decides where the request goes` });
963
+ if (n) addSink(n, { kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS INVOKE ${words[1]} ${opt}(...), which decides where the request goes` });
809
964
  }
810
965
  }
811
966
  if (verb === 'GET' && o.has('CONTAINER')) {
812
967
  const name = optionName(o, 'CONTAINER');
813
968
  const into = nodeOfToken((o.get('INTO') || o.get('SET') || [])[0]);
814
- if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point);
969
+ if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS GET', file: rel(at.file), line: at.line } });
815
970
  }
816
971
  // CONVERSE is always a client call; SEND is one only on a session this program opened. A
817
972
  // plain WEB SEND answers the request that started the program, which is its job.
818
973
  if (verb === 'WEB' && (words[1] === 'CONVERSE' || (words[1] === 'SEND' && o.has('SESSTOKEN')))) {
819
974
  const n = nodeOfToken((o.get('FROM') || [])[0]);
820
- if (n) n.sinks.push({ kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${words[1]} to a remote server` });
975
+ if (n) addSink(n, { kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${words[1]} to a remote server` });
821
976
  // The request may be a container rather than a field.
822
977
  const sent = o.has('CONTAINER') ? optionName(o, 'CONTAINER') : null;
823
- if (sent) containerNode(sent).sinks.push({ kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${words[1]} sending CONTAINER(${sent})` });
978
+ if (sent) addSink(containerNode(sent), { kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${words[1]} sending CONTAINER(${sent})` });
824
979
  }
825
980
  // The response this program returns to its own caller, and the document built for it. CICS
826
981
  // escapes nothing on the way out - DOCUMENT SET's UNESCAPED option is about URL decoding,
@@ -832,25 +987,25 @@ export function analyze(root, opts = {}) {
832
987
  // defect whatever the reply holds.
833
988
  const media = (o.get('MEDIATYPE') || [])[0];
834
989
  const markup = media && media.t === 'lit' && /html|xml|svg/i.test(String(media.v));
835
- if (n) n.sinks.push({ kind: 'web-response', onlyFrom: [...(markup ? IN_CICS : REFLECTED), 'system-response'], ...at, detail: `EXEC CICS WEB SEND answering this program's caller${markup ? ` as ${String(media.v).trim()}` : ''}` });
990
+ if (n) addSink(n, { kind: 'web-response', onlyFrom: [...(markup ? IN_CICS : REFLECTED), 'system-response'], ...at, detail: `EXEC CICS WEB SEND answering this program's caller${markup ? ` as ${String(media.v).trim()}` : ''}` });
836
991
  }
837
992
  if (verb === 'DOCUMENT') {
838
993
  const what = words[1] === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
839
994
  for (const opt of what) {
840
995
  const n = nodeOfToken((o.get(opt) || [])[0]);
841
- if (n) n.sinks.push({ kind: 'web-response', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS DOCUMENT ${words[1]} ${opt}(...), which is sent as the response` });
996
+ if (n) addSink(n, { kind: 'web-response', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS DOCUMENT ${words[1]} ${opt}(...), which is sent as the response` });
842
997
  }
843
998
  }
844
999
  if (verb === 'WEB' && words[1] === 'WRITE' && o.has('HTTPHEADER')) {
845
1000
  for (const opt of ['HTTPHEADER', 'VALUE']) {
846
1001
  const n = nodeOfToken((o.get(opt) || [])[0]);
847
- if (n) n.sinks.push({ kind: 'http-header', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS WEB WRITE HTTPHEADER ${opt}(...)` });
1002
+ if (n) addSink(n, { kind: 'http-header', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS WEB WRITE HTTPHEADER ${opt}(...)` });
848
1003
  }
849
1004
  }
850
1005
  if (verb === 'WEB' && (words[1] === 'OPEN' || words[1] === 'CONVERSE')) {
851
1006
  for (const opt of ['HOST', 'URIMAP', 'PATH']) {
852
1007
  const n = nodeOfToken((o.get(opt) || [])[0]);
853
- if (n) n.sinks.push({ kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS WEB ${words[1]} ${opt}(...), which decides where the request goes` });
1008
+ if (n) addSink(n, { kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS WEB ${words[1]} ${opt}(...), which decides where the request goes` });
854
1009
  }
855
1010
  }
856
1011
  // The queue a command acts on, as distinct from what it writes there: a name from input
@@ -859,7 +1014,7 @@ export function analyze(root, opts = {}) {
859
1014
  const ts = words[1] === 'TS' || !words.includes('TD');
860
1015
  for (const opt of ['QUEUE', 'QNAME']) {
861
1016
  const n = nodeOfToken((o.get(opt) || [])[0]);
862
- if (n) n.sinks.push({ kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
1017
+ if (n) addSink(n, { kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
863
1018
  }
864
1019
  }
865
1020
  // SYSID ships a command to the region it names; SET's first option names the resource a
@@ -867,20 +1022,20 @@ export function analyze(root, opts = {}) {
867
1022
  // INQUIRE and EXTRACT return SYSID into the field rather than read it.
868
1023
  if (o.has('SYSID') && !['ASSIGN', 'INQUIRE', 'EXTRACT'].includes(verb)) {
869
1024
  const n = nodeOfToken((o.get('SYSID') || [])[0]);
870
- if (n) n.sinks.push({ kind: 'cics-sysid', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} SYSID(${n.name}), which decides the region the command is shipped to` });
1025
+ if (n) addSink(n, { kind: 'cics-sysid', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} SYSID(${n.name}), which decides the region the command is shipped to` });
871
1026
  }
872
1027
  if (verb === 'SET' && words[1] && o.has(words[1])) {
873
1028
  const n = nodeOfToken((o.get(words[1]) || [])[0]);
874
- if (n) n.sinks.push({ kind: 'cics-system-resource', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS SET ${words[1]}(${n.name}), which decides the resource the command changes` });
1029
+ if (n) addSink(n, { kind: 'cics-system-resource', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS SET ${words[1]}(${n.name}), which decides the resource the command changes` });
875
1030
  }
876
1031
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
877
1032
  const n = nodeOfToken(intoTok);
878
- if (n && (o.has('FILE') || o.has('DATASET'))) n.sources.push({ kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
1033
+ if (n && (o.has('FILE') || o.has('DATASET'))) addSource(n, { kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
879
1034
  }
880
1035
  // A CONVERSE response is the remote server's data, which is untrusted input like any other.
881
1036
  if ((verb === 'WEB' && words[1] !== 'SEND') || verb === 'EXTRACT') {
882
1037
  const n = nodeOfToken(intoTok);
883
- if (n) n.sources.push({ kind: 'cics-web', ...at, detail: `EXEC CICS ${words.slice(0, 2).join(' ')}` });
1038
+ if (n) addSource(n, { kind: 'cics-web', ...at, detail: `EXEC CICS ${words.slice(0, 2).join(' ')}` });
884
1039
  }
885
1040
  // Where a transient-data write lands is not in the program: the CSD maps the queue to a DD
886
1041
  // and the region's JCL maps the DD to a destination. Collected here, resolved once every
@@ -897,13 +1052,13 @@ export function analyze(root, opts = {}) {
897
1052
  const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
898
1053
  if (pgm && pgm.t === 'word') {
899
1054
  const n = nodeOfToken(pgm);
900
- if (n) n.sinks.push({ kind: 'cics-dynamic-transfer', ...at, detail: `EXEC CICS ${verb} with a variable program name` });
1055
+ if (n) addSink(n, { kind: 'cics-dynamic-transfer', ...at, detail: `EXEC CICS ${verb} with a variable program name` });
901
1056
  }
902
1057
  const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
903
1058
  const argNode = commarea ? nodeOfToken(commarea) : null;
904
- if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point });
1059
+ if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point, file: at.file, line: at.line });
905
1060
  else if (pgm && pgm.t === 'word' && argNode && o.has('PROGRAM')) {
906
- for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, through: pgm.u }); transfersTo.add(callee); }
1061
+ for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, file: at.file, line: at.line, through: pgm.u }); transfersTo.add(callee); }
907
1062
  }
908
1063
  if (pgm && pgm.t === 'lit') (o.has('TRANSID') && !o.has('PROGRAM') ? startsTransactions : transfersTo).add(String(pgm.v).trim().toUpperCase());
909
1064
  }
@@ -921,7 +1076,7 @@ export function analyze(root, opts = {}) {
921
1076
  for (const h of hv) {
922
1077
  if (!h.written) continue;
923
1078
  const n = nodeOfToken(h.tok);
924
- if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} ${verb === 'SELECT' || verb === 'FETCH' ? 'INTO' : 'writes'} host variable` });
1079
+ if (n) addSource(n, { kind: 'database', ...at, detail: `EXEC SQL ${verb} ${verb === 'SELECT' || verb === 'FETCH' ? 'INTO' : 'writes'} host variable` });
925
1080
  }
926
1081
  // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
927
1082
  // follows USER and USING is the credential, not the target.
@@ -929,19 +1084,19 @@ export function analyze(root, opts = {}) {
929
1084
  const to = e.toks.findIndex((t) => t.t === 'word' && (t.u === 'TO' || t.u === 'CONNECTION'));
930
1085
  const colon = to >= 0 ? e.toks[to + 1] : null;
931
1086
  const n = colon && colon.t === 'op' && colon.v === ':' && e.toks[to + 2] ? nodeOfToken(e.toks[to + 2]) : null;
932
- if (n) n.sinks.push({ kind: 'connection-target', onlyFrom: FROM_OUTSIDE, ...at, detail: `EXEC SQL ${verb === 'SET' ? 'SET CONNECTION' : 'CONNECT TO'} :${n.name}, which decides the database the program connects to` });
1087
+ if (n) addSink(n, { kind: 'connection-target', onlyFrom: FROM_OUTSIDE, ...at, detail: `EXEC SQL ${verb === 'SET' ? 'SET CONNECTION' : 'CONNECT TO'} :${n.name}, which decides the database the program connects to` });
933
1088
  }
934
1089
  const dynamic = words.some((w, i) => w === 'PREPARE' || (w === 'EXECUTE' && words[i + 1] === 'IMMEDIATE'));
935
1090
  if (dynamic) for (const h of hv) {
936
1091
  const n = nodeOfToken(h.tok);
937
- if (n) n.sinks.push({ kind: 'dynamic-sql', ...at, detail: `EXEC SQL ${words.slice(0, 2).join(' ')} from a host variable` });
1092
+ if (n) addSink(n, { kind: 'dynamic-sql', ...at, detail: `EXEC SQL ${words.slice(0, 2).join(' ')} from a host variable` });
938
1093
  }
939
1094
  }
940
1095
  }
941
1096
  for (const inv of invokes) {
942
1097
  for (const [name, channel] of channelOf) {
943
1098
  if (inv.channel && channel && channel !== inv.channel) continue;
944
- containerNode(name).sinks.push({ kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...inv.at, detail: `EXEC CICS INVOKE ${inv.what} sending CONTAINER(${name})${inv.channel ? ` on CHANNEL(${inv.channel})` : ''}` });
1099
+ addSink(containerNode(name), { kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...inv.at, detail: `EXEC CICS INVOKE ${inv.what} sending CONTAINER(${name})${inv.channel ? ` on CHANNEL(${inv.channel})` : ''}` });
945
1100
  }
946
1101
  }
947
1102
  const commareaItem = byName.get('DFHCOMMAREA');
@@ -991,7 +1146,8 @@ export function analyze(root, opts = {}) {
991
1146
  // edges are proportional to data items and statements, not to bytes, so a repository of small
992
1147
  // programs with large record layouts can exhaust a heap the budget believes is untouched. The
993
1148
  // shared loop watches the heap itself, and the budget stays as the cheaper first line.
994
- const run = eachWithinMemory(files, (f) => {
1149
+ const repeated = repeatedTexts(tree, files);
1150
+ const run = yield loopOver(files, (f) => {
995
1151
  let src;
996
1152
  try { const s = tree.text(f); src = s.text; if (s.encoding === 'ebcdic') stats.ebcdic++; } catch (e) { stats.unreadable++; unread.push(`${tree.rel(f)}: ${e.code || e.name}`); return 0; }
997
1153
  if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) return 0;
@@ -1002,7 +1158,12 @@ export function analyze(root, opts = {}) {
1002
1158
  r = tree.parse(f, src);
1003
1159
  } catch (e) { stats.threw++; unparsed.push(`${tree.rel(f)}: ${e.code || e.name}`); return src.length; }
1004
1160
  stats.files++;
1005
- for (const p of r.programs) { programs.push(summarise(p, f, programs.length, r.options)); stats.programs++; }
1161
+ const copied = src.length >= reuseMin && repeated.size ? repeated.get(textKey(src)) : undefined;
1162
+ r.programs.forEach((p, k) => {
1163
+ programs.push(summarise(p, f, programs.length, r.options, copied ? reuseOf(copied, k, r, f, tree) : null));
1164
+ stats.programs++;
1165
+ });
1166
+ if (copied && --copied.left === 0) repeated.delete(copied.text);
1006
1167
  return src.length;
1007
1168
  }, { label: 'flow', watcher: memoryWatcher });
1008
1169
 
@@ -1046,22 +1207,24 @@ export function analyze(root, opts = {}) {
1046
1207
  for (const t of p.transfers) {
1047
1208
  const r = calleesOf(t.callee, p.file);
1048
1209
  const site = ++callSites;
1210
+ const stmt = { verb: `EXEC CICS ${t.verb}`, file: rel(t.file), line: t.line };
1049
1211
  for (const callee of r.list) {
1050
1212
  if (!callee.commarea) continue;
1051
- edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point, { call: site });
1052
- if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site });
1213
+ edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point, { call: site, stmt });
1214
+ if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site, stmt });
1053
1215
  }
1054
1216
  }
1055
1217
  for (const c of p.callSites) {
1056
1218
  const r = calleesOf(c.name, p.file);
1057
1219
  const site = ++callSites;
1220
+ const stmt = { verb: 'CALL', file: rel(c.file), line: c.line };
1058
1221
  for (const callee of r.list) {
1059
1222
  if (!callee.params) continue;
1060
1223
  c.args.forEach((arg, i) => {
1061
1224
  const param = callee.params[i];
1062
1225
  if (!param || !arg.node || !param.node) return;
1063
- edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point, { call: site });
1064
- if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`, undefined, undefined, { ret: site });
1226
+ edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point, { call: site, stmt });
1227
+ if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`, undefined, undefined, { ret: site, stmt });
1065
1228
  });
1066
1229
  }
1067
1230
  const callee = r.list.length === 1 ? r.list[0] : null;
@@ -1136,7 +1299,7 @@ export function analyze(root, opts = {}) {
1136
1299
  // followed by the text. A program with no USING cannot receive one, and saying it does
1137
1300
  // would be a path nobody could follow.
1138
1301
  if (run.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1139
- callee.params[0].node.sources.push({
1302
+ addSource(callee.params[0].node, {
1140
1303
  kind: 'jcl-parm', file: f, line: run.line,
1141
1304
  detail: `${run.how}, which runs ${run.pgm}${amb}`,
1142
1305
  });
@@ -1153,7 +1316,7 @@ export function analyze(root, opts = {}) {
1153
1316
  for (const m of callee.ddFiles || []) {
1154
1317
  if (m.dd !== dd.name.toUpperCase()) continue;
1155
1318
  for (const n of m.nodes) {
1156
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1319
+ addSink(n, { kind: 'internal-reader', file: m.file, line: m.line,
1157
1320
  detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader${amb}` });
1158
1321
  }
1159
1322
  }
@@ -1161,8 +1324,8 @@ export function analyze(root, opts = {}) {
1161
1324
  if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1162
1325
  for (const m of callee.ddFiles || []) {
1163
1326
  if (m.dd !== dd.name.toUpperCase()) continue;
1164
- for (const n of m.nodes) {
1165
- n.sources.push({
1327
+ for (const n of [...m.nodes, ...m.into]) {
1328
+ addSource(n, {
1166
1329
  kind: 'jcl-instream', file: f, line: dd.line,
1167
1330
  detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1168
1331
  });
@@ -1231,21 +1394,21 @@ export function analyze(root, opts = {}) {
1231
1394
  const def = w.queue ? csd.tdqueues.get(w.queue) : null;
1232
1395
  const dd = w.queue ? ddOfQueue(csd, w.queue) : null;
1233
1396
  if (w.queue && (readerQueues.has(w.queue) || (dd && readerDds.has(dd)))) {
1234
- w.node.sinks.push({ kind: 'internal-reader', file: w.file, line: w.line, point: w.point,
1397
+ addSink(w.node, { kind: 'internal-reader', file: w.file, line: w.line, point: w.point,
1235
1398
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}')${dd ? `, which the CSD sends to DD ${dd}` : ''}, declared as reaching the internal reader` });
1236
1399
  continue;
1237
1400
  }
1238
1401
  // Any other queue is where a region writes what it logs or prints, unless it starts a
1239
1402
  // transaction, when it is a channel to that transaction instead.
1240
1403
  if (!(def && def.transid)) {
1241
- w.node.sinks.push({ kind: 'log', onlyFrom: REFLECTED, file: w.file, line: w.line, point: w.point,
1404
+ addSink(w.node, { kind: 'log', onlyFrom: REFLECTED, file: w.file, line: w.line, point: w.point,
1242
1405
  detail: `EXEC CICS WRITEQ TD QUEUE(${w.queue ? `'${w.queue}'` : 'a name the program computes'})` });
1243
1406
  }
1244
1407
  if (!w.queue) { stats.tdWritesUndecided++; continue; }
1245
1408
  if (def && def.type === 'INTRA') continue;
1246
1409
  // A queue the CSD sends to a DD leaves the region: to a dataset, a printer or another job.
1247
1410
  if (dd) {
1248
- w.node.sinks.push({ kind: 'extrapartition-queue', onlyFrom: DATA_AT_REST, file: w.file, line: w.line, point: w.point,
1411
+ addSink(w.node, { kind: 'extrapartition-queue', onlyFrom: DATA_AT_REST, file: w.file, line: w.line, point: w.point,
1249
1412
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}'), which the CSD sends out of the region to DD ${dd}` });
1250
1413
  }
1251
1414
  if (!declared) stats.tdWritesUndecided++;
@@ -1253,6 +1416,19 @@ export function analyze(root, opts = {}) {
1253
1416
  }
1254
1417
  }
1255
1418
 
1419
+ // Each node's edges, in the order they were added: outStart[id] up to outStart[id + 1] index outTo
1420
+ // and outMeta.
1421
+ const outStart = new Int32Array(nodes.length + 1);
1422
+ for (let k = 0; k < edgeFrom.length; k++) outStart[edgeFrom[k] + 1]++;
1423
+ for (let i = 0; i < nodes.length; i++) outStart[i + 1] += outStart[i];
1424
+ const outTo = new Int32Array(edgeFrom.length);
1425
+ const outMeta = new Array(edgeFrom.length);
1426
+ {
1427
+ const fill = outStart.slice(0, nodes.length);
1428
+ for (let k = 0; k < edgeFrom.length; k++) { const at = fill[edgeFrom[k]]++; outTo[at] = edgeTo[k]; outMeta[at] = edgeMeta[k]; }
1429
+ edgeFrom.length = 0; edgeTo.length = 0; edgeMeta.length = 0;
1430
+ }
1431
+
1256
1432
  // Only nodes that can reach some sink are worth walking into. One backward pass over a transient
1257
1433
  // reverse index answers that for every source at once; it is a superset, so nothing is lost.
1258
1434
  // Two answers, one bit each: whether a node reaches a sink a source at rest could be reported at,
@@ -1263,11 +1439,11 @@ export function analyze(root, opts = {}) {
1263
1439
  const canReach = new Uint8Array(nodes.length);
1264
1440
  {
1265
1441
  const revStart = new Int32Array(nodes.length + 1);
1266
- for (const n of nodes) for (const e of n.edgesOut) revStart[e.to.id + 1]++;
1442
+ for (let k = 0; k < outTo.length; k++) revStart[outTo[k] + 1]++;
1267
1443
  for (let i = 0; i < nodes.length; i++) revStart[i + 1] += revStart[i];
1268
1444
  const fill = revStart.slice(0, nodes.length);
1269
1445
  const rev = new Int32Array(revStart[nodes.length]);
1270
- for (const n of nodes) for (const e of n.edgesOut) rev[fill[e.to.id]++] = n.id;
1446
+ for (let id = 0; id < nodes.length; id++) for (let k = outStart[id]; k < outStart[id + 1]; k++) rev[fill[outTo[k]]++] = id;
1271
1447
  const mark = (bit, seeds) => {
1272
1448
  const queue = [];
1273
1449
  for (const n of nodes) if (seeds(n)) { canReach[n.id] |= bit; queue.push(n.id); }
@@ -1375,13 +1551,14 @@ export function analyze(root, opts = {}) {
1375
1551
  for (let i = 0; i < open.length; i++) {
1376
1552
  const st = open[i];
1377
1553
  for (const c of at.get(st.node.id) || []) if (!holds(st.since, c.sink.point, c.sink.kind, c.sink.limit, c.sink.offset, c.sink.nonNeg, c.sink)) lost.add(c);
1378
- for (const e of st.node.edgesOut) {
1554
+ for (let k = outStart[st.node.id]; k < outStart[st.node.id + 1]; k++) {
1379
1555
  edgesWalked++;
1380
- const to = e.to;
1556
+ const e = outMeta[k];
1557
+ const to = nodes[outTo[k]];
1381
1558
  if (!(canReach[to.id] & reach)) continue;
1382
1559
  const mv = moves(e);
1383
1560
  if (mv && holds(st.since, pointOfEdge(e), kind, null, shift)) continue;
1384
- const t = cross(st, e);
1561
+ const t = cross(st, e, to);
1385
1562
  if (!t) continue;
1386
1563
  const since = mv ? [to] : [...st.since, to];
1387
1564
  // An arrival carrying a checked node is refused more than one that does not, so it must not
@@ -1441,24 +1618,49 @@ export function analyze(root, opts = {}) {
1441
1618
  // it does not. Both walks below cross edges through this, so neither can take a route the other
1442
1619
  // would refuse.
1443
1620
  const known = (n) => n.off != null && n.size != null && !n.repeats;
1444
- // The call sites a value entered its callees through, innermost last, as a string; empty where any
1445
- // caller may be returned to: a value born in the callee, or one that rested in storage a program
1446
- // keeps between calls, which a later call from any caller can read.
1621
+ // The call sites a value entered its callees through, innermost last, as a number standing for
1622
+ // that sequence; 0 where any caller may be returned to: a value born in the callee, or one that
1623
+ // rested in storage a program keeps between calls, which a later call from any caller can read.
1447
1624
  const CONTEXT_DEPTH = 4;
1448
- function contextOver(state, e) {
1449
- const ctx = state.ctx || '';
1625
+ const contextSites = [[]];
1626
+ const contextNumber = new Map([['', 0]]);
1627
+ const entered = new Map();
1628
+ const returned = new Map();
1629
+ const contextOf = (sites) => {
1630
+ const k = sites.join('/');
1631
+ let c = contextNumber.get(k);
1632
+ if (c === undefined) { c = contextSites.length; contextSites.push(sites); contextNumber.set(k, c); }
1633
+ return c;
1634
+ };
1635
+ function contextOver(state, e, to) {
1636
+ const ctx = state.ctx || 0;
1450
1637
  if (e.ret != null) {
1451
- if (!ctx) return '';
1452
- const sites = ctx.split('/');
1453
- return Number(sites[sites.length - 1]) === e.ret ? sites.slice(0, -1).join('/') : null;
1638
+ if (!ctx) return 0;
1639
+ let byRet = returned.get(ctx);
1640
+ if (!byRet) returned.set(ctx, byRet = new Map());
1641
+ let out = byRet.get(e.ret);
1642
+ if (out === undefined) {
1643
+ const sites = contextSites[ctx];
1644
+ out = Number(sites[sites.length - 1]) === e.ret ? contextOf(sites.slice(0, -1)) : null;
1645
+ byRet.set(e.ret, out);
1646
+ }
1647
+ return out;
1454
1648
  }
1455
- if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
1456
- return e.to.linkage ? ctx : '';
1649
+ if (e.call != null) {
1650
+ let byCall = entered.get(ctx);
1651
+ if (!byCall) entered.set(ctx, byCall = new Map());
1652
+ let out = byCall.get(e.call);
1653
+ if (out === undefined) {
1654
+ out = contextOf([...contextSites[ctx], String(e.call)].slice(-CONTEXT_DEPTH));
1655
+ byCall.set(e.call, out);
1656
+ }
1657
+ return out;
1658
+ }
1659
+ return to.linkage ? ctx : 0;
1457
1660
  }
1458
- function cross(state, e) {
1661
+ function cross(state, e, to) {
1459
1662
  const cur = state.node;
1460
- const to = e.to;
1461
- const ctx = contextOver(state, e);
1663
+ const ctx = contextOver(state, e, to);
1462
1664
  if (ctx === null) return null;
1463
1665
  let partial = false, lo = 0, hi = 0, vague = false;
1464
1666
  if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
@@ -1498,6 +1700,44 @@ export function analyze(root, opts = {}) {
1498
1700
  }
1499
1701
  return { partial, lo, hi, vague, ctx };
1500
1702
  }
1703
+ // The statement that carries an edge: null for storage that overlays storage, which runs nothing,
1704
+ // and undefined where the engine does not know which statement it is.
1705
+ const OVERLAYS = new Set(['group', 'redefines', 'renames']);
1706
+ const statementOf = (e) => {
1707
+ if (e.stmt) return e.stmt;
1708
+ if (e.why && typeof e.why === 'object') return { verb: e.why.verb, file: e.why.file, line: e.why.line };
1709
+ return OVERLAYS.has(e.why) ? null : undefined;
1710
+ };
1711
+ // Every route from one walk's source to each sink it reached: the edges the walk could cross that
1712
+ // lead on to the sink, taken or not, by node rather than by call context or bytes. That lists a
1713
+ // statement no route needs sooner than it leaves out one a route does.
1714
+ function attachRoutes(awaiting, crossed, src, cut) {
1715
+ const into = new Map();
1716
+ for (const c of crossed) {
1717
+ const list = into.get(c.to);
1718
+ if (list) list.push(c); else into.set(c.to, [c]);
1719
+ }
1720
+ for (const { finding, at } of awaiting) {
1721
+ const onRoute = new Set([at]);
1722
+ const stack = [at];
1723
+ const statements = new Map();
1724
+ let unplaced = 0;
1725
+ while (stack.length) {
1726
+ for (const c of into.get(stack.pop()) || []) {
1727
+ const s = statementOf(c.e);
1728
+ if (s) statements.set(`${s.file}:${s.line}:${s.verb}`, s);
1729
+ else if (s === undefined) unplaced++;
1730
+ if (!onRoute.has(c.from)) { onRoute.add(c.from); stack.push(c.from); }
1731
+ }
1732
+ }
1733
+ finding.routes = {
1734
+ sources: [{ kind: src.kind, file: rel(src.file), line: src.line }],
1735
+ statements: [...statements.values()].sort((a, b) => byText(a.file, b.file) || a.line - b.line || byText(a.verb, b.verb)),
1736
+ ...(unplaced ? { unplaced } : {}),
1737
+ complete: !cut && !unplaced,
1738
+ };
1739
+ }
1740
+ }
1501
1741
  for (const start of nodes) {
1502
1742
  if (!start.sources.length || !canReach[start.id]) continue;
1503
1743
  for (const src of start.sources) {
@@ -1515,6 +1755,8 @@ export function analyze(root, opts = {}) {
1515
1755
  // without a layout) partial taint does not descend at all.
1516
1756
  const queue = [{ node: start, partial: false, lo: 0, hi: 0, vague: false, prev: null, why: 'source' }];
1517
1757
  const partSeen = new Map();
1758
+ const crossedEdges = allRoutes ? [] : null;
1759
+ const awaiting = allRoutes ? [] : null;
1518
1760
  stampWhole[start.id] = walk;
1519
1761
  let cut = false;
1520
1762
  const began = edgesWalked;
@@ -1563,15 +1805,18 @@ export function analyze(root, opts = {}) {
1563
1805
  ...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
1564
1806
  ...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
1565
1807
  });
1808
+ if (awaiting) awaiting.push({ finding: findings[findings.length - 1], at: cur.id });
1566
1809
  if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1567
1810
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1568
1811
  }
1569
- for (const e of cur.edgesOut) {
1812
+ for (let k = outStart[cur.id]; k < outStart[cur.id + 1]; k++) {
1570
1813
  if (++edgesWalked - began > walkBudget) { cut = true; break; }
1571
- if (!(canReach[e.to.id] & reach)) continue;
1572
- const to = e.to;
1573
- const crossed = cross(state, e);
1814
+ const e = outMeta[k];
1815
+ const to = nodes[outTo[k]];
1816
+ if (!(canReach[to.id] & reach)) continue;
1817
+ const crossed = cross(state, e, to);
1574
1818
  if (!crossed) continue;
1819
+ if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1575
1820
  const { partial, lo, hi, vague, ctx } = crossed;
1576
1821
  if (partial) {
1577
1822
  const pk = `${to.id}|${ctx}`;
@@ -1583,6 +1828,7 @@ export function analyze(root, opts = {}) {
1583
1828
  }
1584
1829
  }
1585
1830
  if (cut) stats.walksCut++;
1831
+ if (awaiting?.length) attachRoutes(awaiting, crossedEdges, src, cut);
1586
1832
  // The walk keeps the shortest route to each sink, and credit on that route says nothing if
1587
1833
  // another reaches the sink without it. So the walk runs again, refusing to let the value leave
1588
1834
  // a node where the credit holds, and a sink it still reaches loses the credit: first for any
@@ -1666,6 +1912,8 @@ export function analyze(root, opts = {}) {
1666
1912
  return { findings, constructs, unstarted, startedBy, stats, sourceKinds: SOURCE_KINDS, sinkKinds: SINK_KINDS, ...(sinks ? { sinks } : {}), ...(sources ? { sources } : {}) };
1667
1913
  }
1668
1914
 
1915
+ export const analyze = (root, opts = {}) => drive(analyzeSteps(root, opts));
1916
+
1669
1917
  // What the size check across a CALL needs from an item: its bytes, the bytes from it to the end of
1670
1918
  // the record it sits in, whether a table inside it is variable, and where it was declared.
1671
1919
  function extentOf(item) {