@portll/cobolwork 0.5.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 (69) hide show
  1. package/README.md +43 -11
  2. package/bin/cobolwork.mjs +27 -8
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/build.mjs +33 -8
  5. package/lib/capabilities.mjs +4 -1
  6. package/lib/consequence.mjs +4 -0
  7. package/lib/control-reuse.mjs +113 -0
  8. package/lib/control.mjs +25 -1
  9. package/lib/dataflow.mjs +285 -86
  10. package/lib/equivalence.mjs +44 -5
  11. package/lib/evidence/cli.mjs +13 -5
  12. package/lib/evidence/record.mjs +16 -6
  13. package/lib/evidence/seal.mjs +17 -0
  14. package/lib/evidence/store.mjs +30 -14
  15. package/lib/evidence/timestamp.mjs +83 -0
  16. package/lib/evidence/verify.mjs +120 -42
  17. package/lib/hlasm/asm/data.mjs +116 -0
  18. package/lib/hlasm/asm/listing.mjs +43 -0
  19. package/lib/hlasm/asm/output.mjs +45 -0
  20. package/lib/hlasm/asm/sections.mjs +159 -0
  21. package/lib/hlasm/asm/symbols.mjs +78 -0
  22. package/lib/hlasm/exec.mjs +27 -0
  23. package/lib/hlasm/expr.mjs +167 -0
  24. package/lib/hlasm/instr.mjs +48 -0
  25. package/lib/hlasm/locate.mjs +333 -0
  26. package/lib/hlasm/macro/authorization.mjs +126 -0
  27. package/lib/hlasm/macro/datasets.mjs +94 -0
  28. package/lib/hlasm/macro/io.mjs +139 -0
  29. package/lib/hlasm/macro/le.mjs +53 -0
  30. package/lib/hlasm/macro/linkage.mjs +143 -0
  31. package/lib/hlasm/macro/operator.mjs +66 -0
  32. package/lib/hlasm/macro/program.mjs +142 -0
  33. package/lib/hlasm/macro/recovery.mjs +80 -0
  34. package/lib/hlasm/macro/storage.mjs +117 -0
  35. package/lib/hlasm/macro/structured.mjs +131 -0
  36. package/lib/hlasm/model.mjs +72 -0
  37. package/lib/hlasm/operands.mjs +49 -0
  38. package/lib/hlasm/read.mjs +116 -0
  39. package/lib/inventory.mjs +10 -7
  40. package/lib/ironwork-ids.mjs +24 -0
  41. package/lib/ironwork.mjs +4 -1
  42. package/lib/kernel/registry.mjs +20 -17
  43. package/lib/kernel/shared-pass.mjs +138 -0
  44. package/lib/options.mjs +26 -4
  45. package/lib/policy.mjs +6 -0
  46. package/lib/precompile-cics.mjs +1 -1
  47. package/lib/revision.json +1 -1
  48. package/lib/sarif.mjs +3 -0
  49. package/lib/scan.mjs +21 -11
  50. package/lib/sets/abend.mjs +71 -7
  51. package/lib/sets/build.mjs +43 -37
  52. package/lib/sets/cics.mjs +5 -3
  53. package/lib/sets/compile.mjs +5 -3
  54. package/lib/sets/copybook.mjs +5 -3
  55. package/lib/sets/flow.mjs +14 -7
  56. package/lib/sets/hlasm.mjs +60 -4
  57. package/lib/sets/log.mjs +5 -3
  58. package/lib/sets/priv.mjs +5 -3
  59. package/lib/sets/secrets.mjs +110 -0
  60. package/lib/sets/semantics.mjs +5 -3
  61. package/lib/sets/web.mjs +5 -3
  62. package/package.json +1 -1
  63. package/rules/compliance-cobit2019.json +88 -4
  64. package/rules/compliance-dora.json +95 -4
  65. package/rules/compliance-ffiec.json +91 -0
  66. package/rules/compliance-nist80053.json +91 -0
  67. package/rules/gitleaks-mainframe.toml +46 -15
  68. package/rules/hlasm-instructions.json +2396 -0
  69. 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,
@@ -221,9 +283,33 @@ export function analyze(root, opts = {}) {
221
283
  // passes an argument, the PUT or GET of a container.
222
284
  // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }), and
223
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
+ };
224
302
  const edge = (a, b, why, dir, at, link) => {
225
303
  if (!a || !b || a === b) return;
226
- 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);
227
313
  stats.edges++;
228
314
  };
229
315
  let callSites = 0;
@@ -231,7 +317,52 @@ export function analyze(root, opts = {}) {
231
317
  // Everything the graph needs from one program is taken while its parse tree is in hand, and the
232
318
  // tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
233
319
  // 8,024-file repository; nodes, edges and a few cross-program sites are all that survive.
234
- 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 };
235
366
  const byItem = new Map();
236
367
  const byName = new Map();
237
368
  for (const it of prog.items) if (!byName.has(it.name)) byName.set(it.name, it);
@@ -245,7 +376,11 @@ export function analyze(root, opts = {}) {
245
376
  for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
246
377
  let top = item;
247
378
  while (top.parent) top = top.parent;
248
- 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;
249
384
  // Where the outermost table holding this item sits in the record: every occurrence of the
250
385
  // item is somewhere inside it. A group's size already counts its occurrences; an
251
386
  // elementary item's is one element.
@@ -267,7 +402,7 @@ export function analyze(root, opts = {}) {
267
402
  const indexNode = (name) => {
268
403
  let n = indexNodes.get(name);
269
404
  if (!n) {
270
- 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);
271
406
  nodes.push(n);
272
407
  indexNodes.set(name, n);
273
408
  stats.nodes++;
@@ -281,12 +416,12 @@ export function analyze(root, opts = {}) {
281
416
  // constraints, so only SQLERRMC reaches the screen sink.
282
417
  const systemSource = (n, detail, at) => {
283
418
  const onlyTo = /^SQLERRMC\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
284
- 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 });
285
420
  };
286
421
  const systemNode = (tok) => {
287
422
  let n = systemNodes.get(tok.u);
288
423
  if (!n) {
289
- 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);
290
425
  nodes.push(n);
291
426
  systemNodes.set(tok.u, n);
292
427
  stats.nodes++;
@@ -304,9 +439,20 @@ export function analyze(root, opts = {}) {
304
439
  // The program's statement order, where its structure could be read. Without it a check is
305
440
  // credited as it always was, wherever its field is used, and never clears anything.
306
441
  let ctl = null;
307
- try {
308
- ctl = buildControl(prog, (tok) => itemOfToken(tok) || (tok && tok.t === 'word' && indexNames.has(tok.u) ? { index: tok.u } : null));
309
- } 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
+ }
310
456
  const pointOf = (x) => (ctl ? ctl.nodeOf.get(x) : undefined);
311
457
  // Where a name inside a condition is read, which can know more than the statement does.
312
458
  const placeOf = (tok, st) => (ctl && ctl.posOf.get(tok)) ?? pointOf(st);
@@ -323,7 +469,7 @@ export function analyze(root, opts = {}) {
323
469
  const device = upon >= 0 && st.sources[upon + 1] ? ` UPON ${st.sources[upon + 1].u}` : '';
324
470
  for (const t of upon >= 0 ? st.sources.slice(0, upon) : st.sources) {
325
471
  const n = nodeOfToken(t);
326
- 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}` });
327
473
  }
328
474
  }
329
475
  // The document XML PARSE reads is the operand after PARSE; what the parser does with a DTD in
@@ -331,7 +477,7 @@ export function analyze(root, opts = {}) {
331
477
  for (const st of prog.statements) {
332
478
  if (st.verb !== 'XML' || !st.sources[0] || st.sources[0].u !== 'PARSE') continue;
333
479
  const n = st.sources[1] ? nodeOfToken(st.sources[1]) : null;
334
- 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}` });
335
481
  }
336
482
  for (const it of prog.items) {
337
483
  const n = nodeOf(it);
@@ -367,7 +513,7 @@ export function analyze(root, opts = {}) {
367
513
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
368
514
  const extent = (it) => (it.offset != null && it.size != null ? [it.offset, it.offset + (it.contributes || it.size)] : null);
369
515
  for (const c of ctl.checks) {
370
- 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 } : {}) };
371
517
  if (c.field.index) { (indexNode(c.field.index).checks ||= []).push(entry); continue; }
372
518
  const span = extent(c.field);
373
519
  for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
@@ -455,7 +601,7 @@ export function analyze(root, opts = {}) {
455
601
  if (indexed.has(use)) continue;
456
602
  indexed.add(use);
457
603
  const start = kind === 'reference-modification' && x.from && base != null ? startOf(x.from, idx, base) : null;
458
- at.sinks.push({
604
+ addSink(at, {
459
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 } : {}),
460
606
  detail: kind === 'subscript'
461
607
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
@@ -486,7 +632,7 @@ export function analyze(root, opts = {}) {
486
632
  const use = `${key}|${pointOf(st) ?? `s${st.at}`}`;
487
633
  if (indexed.has(use)) continue;
488
634
  indexed.add(use);
489
- nodeOf(it).sinks.push({
635
+ addSink(nodeOf(it), {
490
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 } : {}),
491
637
  detail: `${it.name} decides where PERFORM VARYING ${counter} stops, and ${counter} subscripts ${walks.host.name}, a table of ${walks.table.occurs}`,
492
638
  });
@@ -507,7 +653,7 @@ export function analyze(root, opts = {}) {
507
653
  const use = `arithmetic|${n.id}|${pointOf(st) ?? `s${st.at}`}`;
508
654
  if (indexed.has(use)) continue;
509
655
  indexed.add(use);
510
- n.sinks.push({
656
+ addSink(n, {
511
657
  kind: 'arithmetic', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|arithmetic|${n.id}`,
512
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' : ''}`,
513
659
  });
@@ -529,7 +675,7 @@ export function analyze(root, opts = {}) {
529
675
  const sts = touching.get(it);
530
676
  for (const st of sts.length ? sts : [null]) {
531
677
  const point = st ? pointOf(st) : undefined;
532
- nodeOf(count).sinks.push({
678
+ addSink(nodeOf(count), {
533
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 } : {}),
534
680
  detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
535
681
  });
@@ -539,18 +685,18 @@ export function analyze(root, opts = {}) {
539
685
  if (st.verb !== 'READ' && st.verb !== 'RETURN') continue;
540
686
  for (const t of st.targets) {
541
687
  const n = nodeOfToken(t);
542
- 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` });
543
689
  }
544
690
  }
545
691
  for (const fd of prog.fds || []) for (const rec of fd.records || []) {
546
692
  const readsFile = prog.statements.some(st => (st.verb === 'READ') && st.sources.concat(st.targets).some(t => t.u === fd.name));
547
- 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}` });
548
694
  }
549
695
  for (const a of prog.accepts) {
550
696
  const item = (a.targetTok ? itemOfToken(a.targetTok) : null) || byName.get(a.target);
551
697
  if (!item) continue;
552
698
  const kind = /^(COMMAND-LINE|ARGUMENT-VALUE|ENVIRONMENT|ENVIRONMENT-VALUE)$/.test(a.from || '') ? 'argv-or-env' : null;
553
- 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}` });
554
700
  }
555
701
  // A SELECT assigns either to a data item, which makes the path dynamic, or to an external
556
702
  // name. On z/OS that external name is the DD name a JCL step fills, and it is how a job's
@@ -561,7 +707,7 @@ export function analyze(root, opts = {}) {
561
707
  if (f.assign.t === 'word') {
562
708
  const item = byName.get(f.assign.v);
563
709
  if (item) {
564
- 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}` });
565
711
  continue; // a variable path is not a DD name
566
712
  }
567
713
  }
@@ -570,7 +716,9 @@ export function analyze(root, opts = {}) {
570
716
  if (!external) continue;
571
717
  const fd = (prog.fds || []).find((x) => x.name === f.name);
572
718
  const recs = (fd?.records || []).map(nodeOf);
573
- 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 });
574
722
  }
575
723
  const topOf = (it) => { let t = it; while (t && t.parent) t = t.parent; return t; };
576
724
  const ownParams = new Set((prog.paramTokens || []).map((pt) => topOf(itemOfToken(pt.tok))).filter(Boolean));
@@ -593,14 +741,14 @@ export function analyze(root, opts = {}) {
593
741
  const point = pointOf(prog.statements[c.stmtIndex]);
594
742
  if (c.kind === 'I') {
595
743
  const n = nodeOfToken(c.targetTok);
596
- 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}` });
597
745
  continue;
598
746
  }
599
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 })) });
600
748
  if (OS_COMMAND_ROUTINE.test(c.name)) {
601
749
  for (const a of c.using) {
602
750
  const n = a.tok ? nodeOfToken(a.tok) : null;
603
- 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}` });
604
752
  }
605
753
  } else if (SOCKET_ROUTINE.test(c.name)) {
606
754
  // IBM's own examples hold the function code in a field: 01 SOC-FUNCTION PIC X(16) VALUE 'SEND'.
@@ -611,23 +759,23 @@ export function analyze(root, opts = {}) {
611
759
  if (fn && SOCKET_SEND.test(String(fn).trim())) {
612
760
  for (const a of c.using.slice(1)) {
613
761
  const n = a.tok ? nodeOfToken(a.tok) : null;
614
- 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}` });
615
763
  }
616
764
  }
617
765
  } else if (Object.hasOwn(MQ_BUFFER_ARG, String(c.name).toUpperCase())) {
618
766
  const a = c.using[MQ_BUFFER_ARG[String(c.name).toUpperCase()]];
619
767
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
620
- 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}` });
621
769
  } else if (/^MQCONNX?$/i.test(String(c.name))) {
622
770
  // MQCONN(QMgrName, Hconn, CompCode, Reason); MQCONNX puts its options second.
623
771
  const a = c.using[0];
624
772
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
625
- 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` });
626
774
  } else if (String(c.name).toUpperCase() === 'CEEGTST') {
627
775
  // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
628
776
  const a = c.using[1];
629
777
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
630
- 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` });
631
779
  }
632
780
  }
633
781
  const transfers = [];
@@ -643,7 +791,10 @@ export function analyze(root, opts = {}) {
643
791
  const containerNode = (name) => {
644
792
  let n = containers.get(name);
645
793
  if (!n) {
646
- 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;
647
798
  nodes.push(n);
648
799
  containers.set(name, n);
649
800
  stats.nodes++;
@@ -731,7 +882,7 @@ export function analyze(root, opts = {}) {
731
882
  if (typed) continue;
732
883
  stats.protectedFields++;
733
884
  const back = field.effective.has('FSET') ? ' with FSET' : '';
734
- 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,
735
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}` });
736
887
  }
737
888
  };
@@ -758,17 +909,17 @@ export function analyze(root, opts = {}) {
758
909
  if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(words[1])) {
759
910
  const opt = words[1] === 'OPERATOR' ? 'TEXT' : 'FROM';
760
911
  const n = nodeOfToken((o.get(opt) || [])[0]);
761
- 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'}` });
762
913
  }
763
914
  if (verb === 'RECEIVE') {
764
915
  const isWeb = words.includes('WEB');
765
916
  const n = nodeOfToken(intoTok);
766
- 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` });
767
918
  // RECEIVE MAP with no INTO fills the symbolic map, whose input record is <map>I.
768
919
  const map = optionName(o, 'MAP');
769
920
  if (!n && map) {
770
921
  const sym = byName.get(`${map}I`);
771
- 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` });
772
923
  }
773
924
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
774
925
  }
@@ -778,7 +929,7 @@ export function analyze(root, opts = {}) {
778
929
  const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
779
930
  const sym = !from && map ? byName.get(`${map}O`) : null;
780
931
  const n = from || (sym ? nodeOf(sym) : null);
781
- 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` });
782
933
  }
783
934
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
784
935
  // user types is how a lookup works; a key the program put in a protected field and read back
@@ -790,12 +941,12 @@ export function analyze(root, opts = {}) {
790
941
  const detail = verb === 'DELETE' ? `EXEC CICS DELETE RIDFLD(${k && k.name}), which decides which record it deletes`
791
942
  : then ? `EXEC CICS READ UPDATE RIDFLD(${k && k.name}), which decides which record the program then ${then}`
792
943
  : `EXEC CICS ${verb} RIDFLD(${k && k.name}), which decides which record it reaches`;
793
- 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 });
794
945
  }
795
946
  if (verb === 'GETMAIN') {
796
947
  for (const opt of ['FLENGTH', 'LENGTH']) {
797
948
  const n = nodeOfToken((o.get(opt) || [])[0]);
798
- 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` });
799
950
  }
800
951
  }
801
952
  if (verb === 'PUT' && o.has('CONTAINER')) {
@@ -809,7 +960,7 @@ export function analyze(root, opts = {}) {
809
960
  invokes.push({ at, what: words[1], channel: optionName(o, 'CHANNEL') });
810
961
  for (const opt of ['URI', 'URIMAP']) {
811
962
  const n = nodeOfToken((o.get(opt) || [])[0]);
812
- 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` });
813
964
  }
814
965
  }
815
966
  if (verb === 'GET' && o.has('CONTAINER')) {
@@ -821,10 +972,10 @@ export function analyze(root, opts = {}) {
821
972
  // plain WEB SEND answers the request that started the program, which is its job.
822
973
  if (verb === 'WEB' && (words[1] === 'CONVERSE' || (words[1] === 'SEND' && o.has('SESSTOKEN')))) {
823
974
  const n = nodeOfToken((o.get('FROM') || [])[0]);
824
- 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` });
825
976
  // The request may be a container rather than a field.
826
977
  const sent = o.has('CONTAINER') ? optionName(o, 'CONTAINER') : null;
827
- 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})` });
828
979
  }
829
980
  // The response this program returns to its own caller, and the document built for it. CICS
830
981
  // escapes nothing on the way out - DOCUMENT SET's UNESCAPED option is about URL decoding,
@@ -836,25 +987,25 @@ export function analyze(root, opts = {}) {
836
987
  // defect whatever the reply holds.
837
988
  const media = (o.get('MEDIATYPE') || [])[0];
838
989
  const markup = media && media.t === 'lit' && /html|xml|svg/i.test(String(media.v));
839
- 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()}` : ''}` });
840
991
  }
841
992
  if (verb === 'DOCUMENT') {
842
993
  const what = words[1] === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
843
994
  for (const opt of what) {
844
995
  const n = nodeOfToken((o.get(opt) || [])[0]);
845
- 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` });
846
997
  }
847
998
  }
848
999
  if (verb === 'WEB' && words[1] === 'WRITE' && o.has('HTTPHEADER')) {
849
1000
  for (const opt of ['HTTPHEADER', 'VALUE']) {
850
1001
  const n = nodeOfToken((o.get(opt) || [])[0]);
851
- 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}(...)` });
852
1003
  }
853
1004
  }
854
1005
  if (verb === 'WEB' && (words[1] === 'OPEN' || words[1] === 'CONVERSE')) {
855
1006
  for (const opt of ['HOST', 'URIMAP', 'PATH']) {
856
1007
  const n = nodeOfToken((o.get(opt) || [])[0]);
857
- 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` });
858
1009
  }
859
1010
  }
860
1011
  // The queue a command acts on, as distinct from what it writes there: a name from input
@@ -863,7 +1014,7 @@ export function analyze(root, opts = {}) {
863
1014
  const ts = words[1] === 'TS' || !words.includes('TD');
864
1015
  for (const opt of ['QUEUE', 'QNAME']) {
865
1016
  const n = nodeOfToken((o.get(opt) || [])[0]);
866
- 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` });
867
1018
  }
868
1019
  }
869
1020
  // SYSID ships a command to the region it names; SET's first option names the resource a
@@ -871,20 +1022,20 @@ export function analyze(root, opts = {}) {
871
1022
  // INQUIRE and EXTRACT return SYSID into the field rather than read it.
872
1023
  if (o.has('SYSID') && !['ASSIGN', 'INQUIRE', 'EXTRACT'].includes(verb)) {
873
1024
  const n = nodeOfToken((o.get('SYSID') || [])[0]);
874
- 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` });
875
1026
  }
876
1027
  if (verb === 'SET' && words[1] && o.has(words[1])) {
877
1028
  const n = nodeOfToken((o.get(words[1]) || [])[0]);
878
- 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` });
879
1030
  }
880
1031
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
881
1032
  const n = nodeOfToken(intoTok);
882
- 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` });
883
1034
  }
884
1035
  // A CONVERSE response is the remote server's data, which is untrusted input like any other.
885
1036
  if ((verb === 'WEB' && words[1] !== 'SEND') || verb === 'EXTRACT') {
886
1037
  const n = nodeOfToken(intoTok);
887
- 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(' ')}` });
888
1039
  }
889
1040
  // Where a transient-data write lands is not in the program: the CSD maps the queue to a DD
890
1041
  // and the region's JCL maps the DD to a destination. Collected here, resolved once every
@@ -901,7 +1052,7 @@ export function analyze(root, opts = {}) {
901
1052
  const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
902
1053
  if (pgm && pgm.t === 'word') {
903
1054
  const n = nodeOfToken(pgm);
904
- 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` });
905
1056
  }
906
1057
  const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
907
1058
  const argNode = commarea ? nodeOfToken(commarea) : null;
@@ -925,7 +1076,7 @@ export function analyze(root, opts = {}) {
925
1076
  for (const h of hv) {
926
1077
  if (!h.written) continue;
927
1078
  const n = nodeOfToken(h.tok);
928
- 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` });
929
1080
  }
930
1081
  // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
931
1082
  // follows USER and USING is the credential, not the target.
@@ -933,19 +1084,19 @@ export function analyze(root, opts = {}) {
933
1084
  const to = e.toks.findIndex((t) => t.t === 'word' && (t.u === 'TO' || t.u === 'CONNECTION'));
934
1085
  const colon = to >= 0 ? e.toks[to + 1] : null;
935
1086
  const n = colon && colon.t === 'op' && colon.v === ':' && e.toks[to + 2] ? nodeOfToken(e.toks[to + 2]) : null;
936
- 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` });
937
1088
  }
938
1089
  const dynamic = words.some((w, i) => w === 'PREPARE' || (w === 'EXECUTE' && words[i + 1] === 'IMMEDIATE'));
939
1090
  if (dynamic) for (const h of hv) {
940
1091
  const n = nodeOfToken(h.tok);
941
- 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` });
942
1093
  }
943
1094
  }
944
1095
  }
945
1096
  for (const inv of invokes) {
946
1097
  for (const [name, channel] of channelOf) {
947
1098
  if (inv.channel && channel && channel !== inv.channel) continue;
948
- 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})` : ''}` });
949
1100
  }
950
1101
  }
951
1102
  const commareaItem = byName.get('DFHCOMMAREA');
@@ -995,7 +1146,8 @@ export function analyze(root, opts = {}) {
995
1146
  // edges are proportional to data items and statements, not to bytes, so a repository of small
996
1147
  // programs with large record layouts can exhaust a heap the budget believes is untouched. The
997
1148
  // shared loop watches the heap itself, and the budget stays as the cheaper first line.
998
- const run = eachWithinMemory(files, (f) => {
1149
+ const repeated = repeatedTexts(tree, files);
1150
+ const run = yield loopOver(files, (f) => {
999
1151
  let src;
1000
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; }
1001
1153
  if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) return 0;
@@ -1006,7 +1158,12 @@ export function analyze(root, opts = {}) {
1006
1158
  r = tree.parse(f, src);
1007
1159
  } catch (e) { stats.threw++; unparsed.push(`${tree.rel(f)}: ${e.code || e.name}`); return src.length; }
1008
1160
  stats.files++;
1009
- 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);
1010
1167
  return src.length;
1011
1168
  }, { label: 'flow', watcher: memoryWatcher });
1012
1169
 
@@ -1142,7 +1299,7 @@ export function analyze(root, opts = {}) {
1142
1299
  // followed by the text. A program with no USING cannot receive one, and saying it does
1143
1300
  // would be a path nobody could follow.
1144
1301
  if (run.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1145
- callee.params[0].node.sources.push({
1302
+ addSource(callee.params[0].node, {
1146
1303
  kind: 'jcl-parm', file: f, line: run.line,
1147
1304
  detail: `${run.how}, which runs ${run.pgm}${amb}`,
1148
1305
  });
@@ -1159,7 +1316,7 @@ export function analyze(root, opts = {}) {
1159
1316
  for (const m of callee.ddFiles || []) {
1160
1317
  if (m.dd !== dd.name.toUpperCase()) continue;
1161
1318
  for (const n of m.nodes) {
1162
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1319
+ addSink(n, { kind: 'internal-reader', file: m.file, line: m.line,
1163
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}` });
1164
1321
  }
1165
1322
  }
@@ -1167,8 +1324,8 @@ export function analyze(root, opts = {}) {
1167
1324
  if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1168
1325
  for (const m of callee.ddFiles || []) {
1169
1326
  if (m.dd !== dd.name.toUpperCase()) continue;
1170
- for (const n of m.nodes) {
1171
- n.sources.push({
1327
+ for (const n of [...m.nodes, ...m.into]) {
1328
+ addSource(n, {
1172
1329
  kind: 'jcl-instream', file: f, line: dd.line,
1173
1330
  detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1174
1331
  });
@@ -1237,21 +1394,21 @@ export function analyze(root, opts = {}) {
1237
1394
  const def = w.queue ? csd.tdqueues.get(w.queue) : null;
1238
1395
  const dd = w.queue ? ddOfQueue(csd, w.queue) : null;
1239
1396
  if (w.queue && (readerQueues.has(w.queue) || (dd && readerDds.has(dd)))) {
1240
- 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,
1241
1398
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}')${dd ? `, which the CSD sends to DD ${dd}` : ''}, declared as reaching the internal reader` });
1242
1399
  continue;
1243
1400
  }
1244
1401
  // Any other queue is where a region writes what it logs or prints, unless it starts a
1245
1402
  // transaction, when it is a channel to that transaction instead.
1246
1403
  if (!(def && def.transid)) {
1247
- 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,
1248
1405
  detail: `EXEC CICS WRITEQ TD QUEUE(${w.queue ? `'${w.queue}'` : 'a name the program computes'})` });
1249
1406
  }
1250
1407
  if (!w.queue) { stats.tdWritesUndecided++; continue; }
1251
1408
  if (def && def.type === 'INTRA') continue;
1252
1409
  // A queue the CSD sends to a DD leaves the region: to a dataset, a printer or another job.
1253
1410
  if (dd) {
1254
- 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,
1255
1412
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}'), which the CSD sends out of the region to DD ${dd}` });
1256
1413
  }
1257
1414
  if (!declared) stats.tdWritesUndecided++;
@@ -1259,6 +1416,19 @@ export function analyze(root, opts = {}) {
1259
1416
  }
1260
1417
  }
1261
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
+
1262
1432
  // Only nodes that can reach some sink are worth walking into. One backward pass over a transient
1263
1433
  // reverse index answers that for every source at once; it is a superset, so nothing is lost.
1264
1434
  // Two answers, one bit each: whether a node reaches a sink a source at rest could be reported at,
@@ -1269,11 +1439,11 @@ export function analyze(root, opts = {}) {
1269
1439
  const canReach = new Uint8Array(nodes.length);
1270
1440
  {
1271
1441
  const revStart = new Int32Array(nodes.length + 1);
1272
- 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]++;
1273
1443
  for (let i = 0; i < nodes.length; i++) revStart[i + 1] += revStart[i];
1274
1444
  const fill = revStart.slice(0, nodes.length);
1275
1445
  const rev = new Int32Array(revStart[nodes.length]);
1276
- 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;
1277
1447
  const mark = (bit, seeds) => {
1278
1448
  const queue = [];
1279
1449
  for (const n of nodes) if (seeds(n)) { canReach[n.id] |= bit; queue.push(n.id); }
@@ -1381,13 +1551,14 @@ export function analyze(root, opts = {}) {
1381
1551
  for (let i = 0; i < open.length; i++) {
1382
1552
  const st = open[i];
1383
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);
1384
- for (const e of st.node.edgesOut) {
1554
+ for (let k = outStart[st.node.id]; k < outStart[st.node.id + 1]; k++) {
1385
1555
  edgesWalked++;
1386
- const to = e.to;
1556
+ const e = outMeta[k];
1557
+ const to = nodes[outTo[k]];
1387
1558
  if (!(canReach[to.id] & reach)) continue;
1388
1559
  const mv = moves(e);
1389
1560
  if (mv && holds(st.since, pointOfEdge(e), kind, null, shift)) continue;
1390
- const t = cross(st, e);
1561
+ const t = cross(st, e, to);
1391
1562
  if (!t) continue;
1392
1563
  const since = mv ? [to] : [...st.since, to];
1393
1564
  // An arrival carrying a checked node is refused more than one that does not, so it must not
@@ -1447,24 +1618,49 @@ export function analyze(root, opts = {}) {
1447
1618
  // it does not. Both walks below cross edges through this, so neither can take a route the other
1448
1619
  // would refuse.
1449
1620
  const known = (n) => n.off != null && n.size != null && !n.repeats;
1450
- // The call sites a value entered its callees through, innermost last, as a string; empty where any
1451
- // caller may be returned to: a value born in the callee, or one that rested in storage a program
1452
- // 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.
1453
1624
  const CONTEXT_DEPTH = 4;
1454
- function contextOver(state, e) {
1455
- 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;
1456
1637
  if (e.ret != null) {
1457
- if (!ctx) return '';
1458
- const sites = ctx.split('/');
1459
- 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;
1460
1648
  }
1461
- if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
1462
- 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;
1463
1660
  }
1464
- function cross(state, e) {
1661
+ function cross(state, e, to) {
1465
1662
  const cur = state.node;
1466
- const to = e.to;
1467
- const ctx = contextOver(state, e);
1663
+ const ctx = contextOver(state, e, to);
1468
1664
  if (ctx === null) return null;
1469
1665
  let partial = false, lo = 0, hi = 0, vague = false;
1470
1666
  if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
@@ -1613,11 +1809,12 @@ export function analyze(root, opts = {}) {
1613
1809
  if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1614
1810
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1615
1811
  }
1616
- for (const e of cur.edgesOut) {
1812
+ for (let k = outStart[cur.id]; k < outStart[cur.id + 1]; k++) {
1617
1813
  if (++edgesWalked - began > walkBudget) { cut = true; break; }
1618
- if (!(canReach[e.to.id] & reach)) continue;
1619
- const to = e.to;
1620
- 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);
1621
1818
  if (!crossed) continue;
1622
1819
  if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1623
1820
  const { partial, lo, hi, vague, ctx } = crossed;
@@ -1715,6 +1912,8 @@ export function analyze(root, opts = {}) {
1715
1912
  return { findings, constructs, unstarted, startedBy, stats, sourceKinds: SOURCE_KINDS, sinkKinds: SINK_KINDS, ...(sinks ? { sinks } : {}), ...(sources ? { sources } : {}) };
1716
1913
  }
1717
1914
 
1915
+ export const analyze = (root, opts = {}) => drive(analyzeSteps(root, opts));
1916
+
1718
1917
  // What the size check across a CALL needs from an item: its bytes, the bytes from it to the end of
1719
1918
  // the record it sits in, whether a table inside it is variable, and where it was declared.
1720
1919
  function extentOf(item) {