@portll/cobolwork 0.5.0 → 0.7.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 (163) hide show
  1. package/README.md +89 -26
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +56 -14
  4. package/lib/baseline.mjs +9 -0
  5. package/lib/bms.mjs +21 -7
  6. package/lib/build.mjs +133 -53
  7. package/lib/capabilities.mjs +13 -11
  8. package/lib/cics-commands.mjs +501 -9
  9. package/lib/compliance.mjs +11 -0
  10. package/lib/consequence.mjs +17 -0
  11. package/lib/control-reuse.mjs +113 -0
  12. package/lib/control-workers.mjs +161 -0
  13. package/lib/control.mjs +77 -4
  14. package/lib/dataflow.mjs +402 -152
  15. package/lib/db2/cursor.mjs +15 -0
  16. package/lib/db2/read.mjs +170 -0
  17. package/lib/db2/rules.mjs +77 -0
  18. package/lib/db2/stmt/alter.mjs +473 -0
  19. package/lib/db2/stmt/grant.mjs +125 -0
  20. package/lib/db2/stmt/index.mjs +141 -0
  21. package/lib/db2/stmt/misc.mjs +325 -0
  22. package/lib/db2/stmt/routine.mjs +564 -0
  23. package/lib/db2/stmt/storage.mjs +146 -0
  24. package/lib/db2/stmt/table.mjs +540 -0
  25. package/lib/db2/stmt/view.mjs +146 -0
  26. package/lib/diff.mjs +17 -5
  27. package/lib/equivalence.mjs +44 -5
  28. package/lib/evidence/cli.mjs +26 -7
  29. package/lib/evidence/record.mjs +16 -6
  30. package/lib/evidence/seal.mjs +17 -0
  31. package/lib/evidence/store.mjs +44 -15
  32. package/lib/evidence/timestamp.mjs +83 -0
  33. package/lib/evidence/verify.mjs +120 -42
  34. package/lib/exec-reading.mjs +51 -0
  35. package/lib/execution.mjs +3 -2
  36. package/lib/explain.mjs +2 -0
  37. package/lib/exploitability.mjs +11 -2
  38. package/lib/hlasm/asm/data.mjs +128 -0
  39. package/lib/hlasm/asm/listing.mjs +43 -0
  40. package/lib/hlasm/asm/output.mjs +45 -0
  41. package/lib/hlasm/asm/sections.mjs +161 -0
  42. package/lib/hlasm/asm/symbols.mjs +78 -0
  43. package/lib/hlasm/exec.mjs +27 -0
  44. package/lib/hlasm/expr.mjs +167 -0
  45. package/lib/hlasm/instr.mjs +73 -0
  46. package/lib/hlasm/locate.mjs +333 -0
  47. package/lib/hlasm/macro/authorization.mjs +146 -0
  48. package/lib/hlasm/macro/datasets.mjs +107 -0
  49. package/lib/hlasm/macro/io.mjs +162 -0
  50. package/lib/hlasm/macro/le.mjs +53 -0
  51. package/lib/hlasm/macro/linkage.mjs +143 -0
  52. package/lib/hlasm/macro/operator.mjs +77 -0
  53. package/lib/hlasm/macro/program.mjs +184 -0
  54. package/lib/hlasm/macro/recovery.mjs +85 -0
  55. package/lib/hlasm/macro/storage.mjs +167 -0
  56. package/lib/hlasm/macro/structured.mjs +131 -0
  57. package/lib/hlasm/model.mjs +97 -0
  58. package/lib/hlasm/mvs38.mjs +47 -0
  59. package/lib/hlasm/operands.mjs +59 -0
  60. package/lib/hlasm/optable.mjs +61 -0
  61. package/lib/hlasm/read.mjs +130 -0
  62. package/lib/hlasm.mjs +44 -7
  63. package/lib/ims/dli.mjs +37 -0
  64. package/lib/ims/macro/dbd.mjs +299 -0
  65. package/lib/ims/macro/psb.mjs +286 -0
  66. package/lib/ims/model.mjs +149 -0
  67. package/lib/ims/operands.mjs +23 -0
  68. package/lib/ims/read.mjs +37 -0
  69. package/lib/ims/rules.mjs +135 -0
  70. package/lib/inventory.mjs +10 -7
  71. package/lib/ironwork-ids.mjs +24 -0
  72. package/lib/ironwork.mjs +20 -13
  73. package/lib/kernel/pds-archive.mjs +256 -0
  74. package/lib/kernel/registry.mjs +32 -23
  75. package/lib/kernel/shared-pass.mjs +163 -0
  76. package/lib/kernel/source-tree.mjs +104 -36
  77. package/lib/kernel/version-key.mjs +17 -0
  78. package/lib/layout.mjs +26 -31
  79. package/lib/options.mjs +26 -4
  80. package/lib/parser.mjs +136 -17
  81. package/lib/pli/cursor.mjs +15 -0
  82. package/lib/pli/expr.mjs +101 -0
  83. package/lib/pli/include.mjs +82 -0
  84. package/lib/pli/layout.mjs +125 -0
  85. package/lib/pli/lex.mjs +198 -0
  86. package/lib/pli/program.mjs +280 -0
  87. package/lib/pli/rules/based.mjs +95 -0
  88. package/lib/pli/rules/conditions.mjs +68 -0
  89. package/lib/pli/rules/entry.mjs +130 -0
  90. package/lib/pli/rules/index.mjs +24 -0
  91. package/lib/pli/rules/preprocessor.mjs +55 -0
  92. package/lib/pli/statements.mjs +130 -0
  93. package/lib/pli/stmt/alloc.mjs +45 -0
  94. package/lib/pli/stmt/assignment.mjs +56 -0
  95. package/lib/pli/stmt/call.mjs +104 -0
  96. package/lib/pli/stmt/conditions.mjs +94 -0
  97. package/lib/pli/stmt/control.mjs +219 -0
  98. package/lib/pli/stmt/declare.mjs +149 -0
  99. package/lib/pli/stmt/exec.mjs +55 -0
  100. package/lib/pli/stmt/io.mjs +239 -0
  101. package/lib/pli/stmt/misc.mjs +4 -0
  102. package/lib/pli/stmt/preprocessor.mjs +242 -0
  103. package/lib/pli/stmt/procedure.mjs +258 -0
  104. package/lib/pli/stmt/stream.mjs +283 -0
  105. package/lib/pli/storage.mjs +129 -0
  106. package/lib/policy.mjs +6 -0
  107. package/lib/precompile-check.mjs +124 -0
  108. package/lib/precompile-cics.mjs +8 -4
  109. package/lib/reach.mjs +11 -2
  110. package/lib/revision.json +1 -1
  111. package/lib/sarif.mjs +44 -3
  112. package/lib/scan.mjs +28 -12
  113. package/lib/sets/abend.mjs +82 -8
  114. package/lib/sets/build.mjs +43 -37
  115. package/lib/sets/cics.mjs +20 -38
  116. package/lib/sets/compile.mjs +46 -18
  117. package/lib/sets/copybook.mjs +5 -3
  118. package/lib/sets/crypto.mjs +5 -3
  119. package/lib/sets/ddl.mjs +36 -0
  120. package/lib/sets/flow.mjs +32 -8
  121. package/lib/sets/hidden.mjs +5 -3
  122. package/lib/sets/hlasm.mjs +124 -8
  123. package/lib/sets/ims.mjs +139 -0
  124. package/lib/sets/log.mjs +11 -9
  125. package/lib/sets/opaque.mjs +27 -7
  126. package/lib/sets/pli.mjs +40 -0
  127. package/lib/sets/priv.mjs +5 -3
  128. package/lib/sets/recon.mjs +5 -3
  129. package/lib/sets/secrets.mjs +112 -0
  130. package/lib/sets/semantics.mjs +8 -3
  131. package/lib/sets/web.mjs +39 -23
  132. package/lib/site.mjs +10 -0
  133. package/lib/sources.mjs +80 -20
  134. package/lib/statement-cursor.mjs +67 -0
  135. package/lib/verify.mjs +3 -2
  136. package/lib/version.mjs +6 -0
  137. package/package.json +3 -2
  138. package/rules/compliance-cobit2019.json +520 -5
  139. package/rules/compliance-dora.json +509 -5
  140. package/rules/compliance-ffiec.json +505 -1
  141. package/rules/compliance-nist80053.json +557 -1
  142. package/rules/gitleaks-mainframe.toml +46 -15
  143. package/rules/hlasm-instructions.json +2396 -0
  144. package/rules/hlasm-optables.json +8024 -0
  145. package/schema/cobolwork-baseline.schema.json +36 -0
  146. package/schema/cobolwork-build-provenance.schema.json +187 -0
  147. package/schema/cobolwork-build.schema.json +382 -0
  148. package/schema/cobolwork-capabilities.schema.json +239 -0
  149. package/schema/cobolwork-diff.schema.json +217 -0
  150. package/schema/cobolwork-evidence.schema.json +161 -0
  151. package/schema/cobolwork-execution.schema.json +53 -0
  152. package/schema/cobolwork-explain.schema.json +360 -0
  153. package/schema/cobolwork-finding.schema.json +465 -0
  154. package/schema/cobolwork-flow.schema.json +465 -0
  155. package/schema/cobolwork-gate.schema.json +211 -0
  156. package/schema/cobolwork-inventory.schema.json +206 -0
  157. package/schema/cobolwork-parse.schema.json +105 -0
  158. package/schema/cobolwork-reach.schema.json +74 -0
  159. package/schema/cobolwork-report.schema.json +559 -0
  160. package/schema/cobolwork-witness.schema.json +107 -0
  161. package/schema/cobolwork.baseline.schema.json +101 -0
  162. package/schema/cobolwork.policy.schema.json +7 -0
  163. package/schema/cobolwork.site.schema.json +116 -0
package/lib/dataflow.mjs CHANGED
@@ -1,23 +1,43 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { inScope, isProgram, isBms, relPath } from './sources.mjs';
2
+ import { inScope, isProgram, isBms, isPli, relPath } from './sources.mjs';
3
3
  import { parseBms, symbolicNames } from './bms.mjs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { parseSource, buildFileIndex } from './parser.mjs';
6
6
  import { parseJcl } from './jcl.mjs';
7
7
  import { holdsDecimal } from './layout.mjs';
8
8
  import { hostVariablesIn } from './embedded-sql.mjs';
9
+ import { execReading } from './exec-reading.mjs';
10
+ import { DLI_ROUTINE, dliFunction, isGet } from './ims/dli.mjs';
11
+ import { CICS_EVERY_COMMAND } from './cics-commands.mjs';
12
+
13
+ // RESP and RESP2, which every command returns and the response-code source already reads.
14
+ const CICS_EVERY_COMMAND_OPTIONS = new Set(Object.keys(CICS_EVERY_COMMAND));
15
+ import { parsePliSource } from './pli/program.mjs';
16
+ import { membersOf, chooseMember } from './pli/include.mjs';
9
17
  import { tsoCommands } from './utilities.mjs';
10
18
  import { parseCsd, ddOfQueue } from './csd.mjs';
11
19
  import { loadSite } from './site.mjs';
12
20
  import { ssrangeAbends } from './options.mjs';
13
21
  import { isJcl } from './sources.mjs';
14
- import { eachWithinMemory, watchMemoryBuffer } from './kernel/memory.mjs';
22
+ import { watchMemoryBuffer } from './kernel/memory.mjs';
23
+ import { drive, loopOver } from './kernel/shared-pass.mjs';
24
+ import { keep, parseOrder, restore, sameAnalysis } from './control-reuse.mjs';
25
+ import { CONTROL_WORKER_MIN_BYTES, controlAhead, controlWorkerCount, programFiles } from './control-workers.mjs';
26
+ import { statSync } from 'node:fs';
27
+ import { createHash } from 'node:crypto';
28
+
15
29
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
16
30
  // own comparator. The text comparison underneath it is the shared one.
17
31
  import { byText } from './kernel/findings.mjs';
18
- import { buildControl, creditOf, hasFact, topOf, within, wholeTop } from './control.mjs';
32
+ import { buildControl, creditOf, hasFact, internFacts, namesOf, topOf, within, wholeTop } from './control.mjs';
19
33
  import { directoryTree } from './kernel/source-tree.mjs';
20
34
 
35
+ // Every reused control analysis checked against the program's own: for the equivalence runs and tests.
36
+ const verifyReuse = () => process.env.COBOLWORK_VERIFY_REUSE === '1';
37
+ // The size of program text from which a copy's control analysis is restored rather than redone. On
38
+ // one repository of 14,500 copied small programs restoring took longer than analysing.
39
+ const REUSE_MIN_BYTES = 256 * 1024;
40
+
21
41
  const OS_COMMAND_ROUTINE = /^(SYSTEM|C\$SYSTEM|CBL_EXEC_RUN_UNIT|CBL_GC_HOSTED|BXPSYSTM)$/i;
22
42
 
23
43
  export const SOURCE_KINDS = {
@@ -25,6 +45,7 @@ export const SOURCE_KINDS = {
25
45
  'cics-terminal': 'EXEC CICS RECEIVE',
26
46
  'cics-web': 'EXEC CICS WEB RECEIVE or EXTRACT',
27
47
  'file-record': 'record read from a file',
48
+ 'cics-queue': 'an item EXEC CICS READQ TS or TD returns, or the data RETRIEVE returns from the START that began the task',
28
49
  'database': 'host variable filled by EXEC SQL',
29
50
  'jcl-parm': 'PARM= on a JCL EXEC statement',
30
51
  'jcl-instream': 'in-stream data on a JCL DD statement',
@@ -36,7 +57,7 @@ export const SINK_KINDS = {
36
57
  'dynamic-program-load': 'CALL whose target is a variable',
37
58
  'cics-dynamic-transfer': 'EXEC CICS LINK, XCTL or START with a variable program or transaction',
38
59
  'dynamic-sql': 'EXEC SQL EXECUTE IMMEDIATE or PREPARE from a host variable',
39
- 'dynamic-file-path': 'SELECT ... ASSIGN TO a variable',
60
+ 'dynamic-file-path': 'SELECT ... ASSIGN TO a variable, or EXEC CICS READ, WRITE, DELETE or STARTBR with FILE or DATASET from a variable',
40
61
  'outbound-http': 'EXEC CICS WEB CONVERSE, WEB SEND on a client session, or the containers INVOKE SERVICE sends',
41
62
  'socket-send': 'CALL EZASOKET or EZACICAL with SEND, SENDTO or WRITE',
42
63
  'message-queue': 'CALL MQPUT or MQPUT1',
@@ -73,6 +94,28 @@ const computes = (st) => ARITHMETIC.has(st.verb) || st.counts === true || (st.fn
73
94
  const computedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.why && s.why.computes) return true; return false; };
74
95
  const typedOnRoute = (state) => { for (let s = state; s; s = s.prev) if (s.node.typed) return true; return false; };
75
96
 
97
+ // Most nodes are never a source or a sink. They share one empty list, frozen, so that adding to it
98
+ // fails rather than reaching every node.
99
+ const NO_ENDS = Object.freeze([]);
100
+ const addSource = (n, x) => { if (n.sources === NO_ENDS) n.sources = []; n.sources.push(x); };
101
+ const addSink = (n, x) => { if (n.sinks === NO_ENDS) n.sinks = []; n.sinks.push(x); };
102
+
103
+ // What a check's condition says, as text that differs whenever anything a reader could observe
104
+ // differs: property order, a set's order, undefined, -0 and NaN. Null for anything but plain data.
105
+ function consKey(v) {
106
+ if (v === undefined) return 'u';
107
+ if (v === null) return 'n';
108
+ if (typeof v === 'number') return Object.is(v, -0) ? '#-0' : `#${v}`;
109
+ if (typeof v === 'string') return JSON.stringify(v);
110
+ if (typeof v === 'boolean') return v ? 't' : 'f';
111
+ if (typeof v !== 'object') return null;
112
+ const parts = v instanceof Set ? [...v] : Array.isArray(v) ? v : Object.getPrototypeOf(v) === Object.prototype ? null : undefined;
113
+ if (parts === undefined) return null;
114
+ const keys = parts ? parts.map(consKey) : Object.keys(v).map((k) => { const s = consKey(v[k]); return s === null ? null : `${JSON.stringify(k)}:${s}`; });
115
+ if (keys.includes(null)) return null;
116
+ return `${v instanceof Set ? 'S' : parts ? 'A' : 'O'}[${keys.join(',')}]`;
117
+ }
118
+
76
119
  // Every literal a program holds that could be a program name: its VALUE clauses and the literals its
77
120
  // statements and commands use. A menu that XCTLs through a table of names starts every one of them.
78
121
  function literalNames(prog) {
@@ -124,18 +167,21 @@ function boundsOf(counter, until) {
124
167
  // nearly every subscript descends from a file record, and reporting those would be reporting batch.
125
168
  const FROM_OUTSIDE = ['argv-or-env', 'cics-terminal', 'cics-web', 'jcl-parm', 'jcl-instream'];
126
169
  // What can reach a CICS command: a batch job never issues one.
127
- const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'database'];
170
+ const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'cics-queue', 'database'];
128
171
  const REFLECTED = ['cics-web', 'cics-terminal'];
129
172
  // What the system says about a failure: a command's RESP, the EIB's copy of it, the SQLCA. Sent to a
130
173
  // web client it describes the system behind the program (CWE-209); written anywhere else it is the
131
174
  // program's own business, so these reach only a response, and Db2's message text the terminal too.
132
175
  const SYSTEM_FIELDS = new Set(['EIBRESP', 'EIBRESP2', 'SQLCODE', 'SQLSTATE', 'SQLERRMC']);
176
+ // PL/I's SQLCA holds Db2's message text in SQLERRM, a CHAR(70) VARYING, where COBOL's has SQLERRMC.
177
+ const PLI_SYSTEM_FIELDS = new Set([...SYSTEM_FIELDS, 'SQLERRM']);
133
178
  const TO_CLIENT = ['web-response', 'http-header'];
134
179
 
135
180
  // Exfiltration is data at rest leaving the program. Terminal or web input reaching the same channel
136
181
  // is the program relaying its own caller's data, and pairing it would fire on every gateway.
137
182
 
138
183
  // A trace longer than this keeps its two ends and says how many hops it dropped.
184
+
139
185
  export const TRACE_MAX = 64;
140
186
  export const TRACE_KEEP = 24;
141
187
  // Edges one source's walk may examine, and all walks together: about fifteen times what the largest
@@ -143,7 +189,7 @@ export const TRACE_KEEP = 24;
143
189
  export const WALK_EDGES = 5_000_000;
144
190
  export const TOTAL_EDGES = 500_000_000;
145
191
 
146
- const DATA_AT_REST = ['database', 'file-record'];
192
+ const DATA_AT_REST = ['database', 'file-record', 'cics-queue'];
147
193
  // The class tests a condition can make, which restrict what a field holds rather than compare it.
148
194
  const CLASS_TEST = new Set(['NUMERIC', 'ALPHABETIC', 'ALPHABETIC-LOWER', 'ALPHABETIC-UPPER', 'POSITIVE', 'NEGATIVE']);
149
195
  const SOCKET_ROUTINE = /^(EZASOKET|EZACICAL)$/i;
@@ -153,28 +199,7 @@ const SOCKET_SEND = /^(SEND|SENDTO|WRITE|SENDMSG|WRITEV)$/i;
153
199
  // MsgDesc, PutMsgOpts, BufferLength, Buffer, …): the buffer is the sixth argument of both.
154
200
  const MQ_BUFFER_ARG = { MQPUT: 5, MQPUT1: 5 };
155
201
 
156
- function execOptions(exec) {
157
- const opts = new Map();
158
- const toks = exec.toks;
159
- const words = [];
160
- for (let i = 0; i < toks.length; i++) {
161
- const t = toks[i];
162
- if (t.t !== 'word') continue;
163
- words.push(t.u);
164
- if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
165
- const inner = [];
166
- let depth = 1;
167
- for (let k = i + 2; k < toks.length && depth > 0; k++) {
168
- if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
169
- inner.push(toks[k]);
170
- }
171
- if (!opts.has(t.u)) opts.set(t.u, inner);
172
- }
173
- }
174
- return { opts, words };
175
- }
176
-
177
- export function analyze(root, opts = {}) {
202
+ export function* analyzeSteps(root, opts = {}) {
178
203
  const repos = opts.repos || [''];
179
204
  const stats = { files: 0, programs: 0, edges: 0, nodes: 0, threw: 0, overBudget: 0, unreadable: 0, ebcdic: 0,
180
205
  jclFiles: 0, jclSteps: 0, jclStepsResolved: 0, jclCrossings: 0,
@@ -221,9 +246,33 @@ export function analyze(root, opts = {}) {
221
246
  // passes an argument, the PUT or GET of a container.
222
247
  // `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }), and
223
248
  // `stmt` names the statement that carries an edge whose `why` is text.
249
+ // Edges are kept as columns and laid out by source node before the walk. Most are a group's, a
250
+ // REDEFINES' or a RENAMES', and those share one record of why they exist.
251
+ const edgeFrom = [];
252
+ const edgeTo = [];
253
+ const edgeMeta = [];
254
+ const sharedMeta = new Map();
255
+ // Checks share a condition with the same content: 107,547 conditions of 66 Unieuro programs had
256
+ // 2,423 contents. Conditions are read and never changed once the check model has made them.
257
+ const conditions = new Map();
258
+ const sameCondition = (c) => {
259
+ const k = c ? consKey(c) : null;
260
+ if (k === null) return c;
261
+ let kept = conditions.get(k);
262
+ if (!kept) conditions.set(k, kept = c);
263
+ return kept;
264
+ };
224
265
  const edge = (a, b, why, dir, at, link) => {
225
266
  if (!a || !b || a === b) return;
226
- a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
267
+ let meta;
268
+ if (typeof why === 'string' && at == null && !link) {
269
+ const k = `${why}|${dir || ''}`;
270
+ meta = sharedMeta.get(k);
271
+ if (!meta) sharedMeta.set(k, meta = { why, dir });
272
+ } else meta = { why, dir, ...(at != null ? { at } : {}), ...link };
273
+ edgeFrom.push(a.id);
274
+ edgeTo.push(b.id);
275
+ edgeMeta.push(meta);
227
276
  stats.edges++;
228
277
  };
229
278
  let callSites = 0;
@@ -231,10 +280,58 @@ export function analyze(root, opts = {}) {
231
280
  // Everything the graph needs from one program is taken while its parse tree is in hand, and the
232
281
  // tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
233
282
  // 8,024-file repository; nodes, edges and a few cross-program sites are all that survive.
234
- function summarise(prog, file, pk, options = []) {
283
+ // A flow node holds what differs between nodes. Its program and file come from the program it
284
+ // belongs to, and every other field from the class until the node sets its own.
285
+ const programOf = [];
286
+ class FlowNode {
287
+ constructor(id, pk, name) { this.id = id; this.pk = pk; this.name = name; }
288
+ get program() { return programOf[this.pk].id; }
289
+ get file() { return programOf[this.pk].file; }
290
+ }
291
+ Object.assign(FlowNode.prototype, { off: null, size: null, repeats: false, sources: NO_ENDS, sinks: NO_ENDS });
292
+
293
+ // A program whose text another program file repeats has its control analysis kept for the copies,
294
+ // keyed by its place in its file and by what each copybook it resolved holds (lib/control-reuse.mjs).
295
+ // Only a large one: below REUSE_MIN_BYTES (opts.reuseMinBytes) keeping and restoring costs more
296
+ // than analysing again.
297
+ const reuseMin = opts.reuseMinBytes ?? REUSE_MIN_BYTES;
298
+ const textKey = (src) => createHash('sha1').update(src).digest('base64');
299
+ function repeatedTexts(tree, files) {
300
+ const count = new Map();
301
+ const later = new Set();
302
+ for (const f of files) {
303
+ let src;
304
+ try { src = tree.text(f).text; } catch { continue; }
305
+ if (src.length < reuseMin) continue;
306
+ const k = textKey(src);
307
+ if (count.has(k)) later.add(f);
308
+ count.set(k, (count.get(k) || 0) + 1);
309
+ }
310
+ const repeated = new Map();
311
+ for (const [k, n] of count) if (n > 1) repeated.set(k, { text: k, left: n, kept: new Map() });
312
+ return { repeated, later };
313
+ }
314
+ const copyText = new Map();
315
+ const copyKey = (r, tree) => r.copies.map((c) => {
316
+ if (!c.path) return `${c.status}:${c.name}`;
317
+ 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); }
318
+ return `${c.status}:${copyText.get(c.path)}`;
319
+ }).join('|');
320
+ const reuseOf = (copied, k, r, f, tree) => ({ kept: copied.kept, key: `${k}|${copyKey(r, tree)}`, files: programFiles(r, f) });
321
+ // A restored analysis checked against the program's own, where COBOLWORK_VERIFY_REUSE asks.
322
+ function sameAsOwn(ctl, prog, resolver, file, from) {
323
+ let own = null;
324
+ try { own = buildControl(prog, resolver); } catch { /* the restored one threw too, or it differs */ }
325
+ if (own) internFacts(own.facts);
326
+ if (!own !== !ctl || (own && !sameAnalysis(own, ctl, new Set(parseOrder(prog))))) throw new Error(`the control analysis ${from} for ${file} differs from its own`);
327
+ }
328
+
329
+ // `built` is the program's analysis from a worker thread (lib/control-workers.mjs), with the files
330
+ // its references name.
331
+ function summarise(prog, file, pk, options = [], reuse = null, built = null) {
332
+ programOf[pk] = { id: prog.id, file };
235
333
  const byItem = new Map();
236
- const byName = new Map();
237
- for (const it of prog.items) if (!byName.has(it.name)) byName.set(it.name, it);
334
+ const { byName, itemOfToken, indexNames, resolve: resolver } = namesOf(prog);
238
335
  const nodeOf = (item) => {
239
336
  let n = byItem.get(item);
240
337
  if (!n) {
@@ -245,7 +342,11 @@ export function analyze(root, opts = {}) {
245
342
  for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
246
343
  let top = item;
247
344
  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 } : {}) };
345
+ n = new FlowNode(nodes.length, pk, item.name);
346
+ n.off = item.offset ?? null;
347
+ n.size = item.size ?? null;
348
+ if (repeats) n.repeats = true;
349
+ if (top.section === 'LINKAGE') n.linkage = true;
249
350
  // Where the outermost table holding this item sits in the record: every occurrence of the
250
351
  // item is somewhere inside it. A group's size already counts its occurrences; an
251
352
  // elementary item's is one element.
@@ -259,15 +360,12 @@ export function analyze(root, opts = {}) {
259
360
  }
260
361
  return n;
261
362
  };
262
- // Names inside EXEC blocks never pass through the parser's reference resolution, so fall back to the name.
263
- const itemOfToken = (tok) => (tok ? prog.resolved.get(tok) || (tok.t === 'word' ? byName.get(tok.u) || null : null) : null);
264
363
  // An INDEXED BY name is not a data item, so it needs a node of its own to carry taint.
265
- const indexNames = new Set(prog.items.flatMap((it) => it.indexNames || []));
266
364
  const indexNodes = new Map();
267
365
  const indexNode = (name) => {
268
366
  let n = indexNodes.get(name);
269
367
  if (!n) {
270
- n = { id: nodes.length, pk, program: prog.id, file, name, off: null, size: null, repeats: false, edgesOut: [], sources: [], sinks: [] };
368
+ n = new FlowNode(nodes.length, pk, name);
271
369
  nodes.push(n);
272
370
  indexNodes.set(name, n);
273
371
  stats.nodes++;
@@ -277,16 +375,17 @@ export function analyze(root, opts = {}) {
277
375
  // A system field the program never declares - the EIB's, or an SQLCA the precompiler supplies -
278
376
  // still holds a value, so it needs a node of its own, as an index name does.
279
377
  const systemNodes = new Map();
378
+ const systemFields = isPli(file) ? PLI_SYSTEM_FIELDS : SYSTEM_FIELDS;
280
379
  // A code on a terminal's error line is ordinary; Db2's message text names tables, columns and
281
380
  // constraints, so only SQLERRMC reaches the screen sink.
282
381
  const systemSource = (n, detail, at) => {
283
- 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 });
382
+ const onlyTo = /^SQLERRMC?\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
383
+ if (!n.sources.some((x) => x.kind === 'system-response')) addSource(n, { kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
285
384
  };
286
385
  const systemNode = (tok) => {
287
386
  let n = systemNodes.get(tok.u);
288
387
  if (!n) {
289
- n = { id: nodes.length, pk, program: prog.id, file, name: tok.u, off: null, size: null, repeats: false, edgesOut: [], sources: [], sinks: [] };
388
+ n = new FlowNode(nodes.length, pk, tok.u);
290
389
  nodes.push(n);
291
390
  systemNodes.set(tok.u, n);
292
391
  stats.nodes++;
@@ -299,19 +398,34 @@ export function analyze(root, opts = {}) {
299
398
  if (it) return nodeOf(it);
300
399
  if (!tok || tok.t !== 'word') return null;
301
400
  if (indexNames.has(tok.u)) return indexNode(tok.u);
302
- return SYSTEM_FIELDS.has(tok.u) ? systemNode(tok) : null;
401
+ return systemFields.has(tok.u) ? systemNode(tok) : null;
303
402
  };
304
403
  // The program's statement order, where its structure could be read. Without it a check is
305
404
  // credited as it always was, wherever its field is used, and never clears anything.
306
405
  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++; }
406
+ // A kept or worker-built analysis is { value }, { threw } or { none }, the last for a program
407
+ // with no procedure division, which is not counted as unordered.
408
+ const kept = reuse && reuse.kept.get(reuse.key);
409
+ const given = kept || (built && !built.local ? built : null);
410
+ if (given) {
411
+ ctl = given.value ? restore(given.value, parseOrder(prog), kept ? reuse.files : built.files) : null;
412
+ if (given.threw) stats.programsUnordered++;
413
+ if (verifyReuse()) sameAsOwn(ctl, prog, resolver, file, kept ? 'reused' : 'built in a worker');
414
+ if (reuse && !kept) reuse.kept.set(reuse.key, given);
415
+ } else {
416
+ let threw = false;
417
+ try { ctl = buildControl(prog, resolver); } catch { threw = true; stats.programsUnordered++; }
418
+ if (ctl) internFacts(ctl.facts);
419
+ if (reuse) {
420
+ const value = ctl ? keep(ctl, parseOrder(prog), reuse.files) : null;
421
+ if (!ctl || value) reuse.kept.set(reuse.key, value ? { value } : threw ? { threw: true } : { none: true });
422
+ }
423
+ }
310
424
  const pointOf = (x) => (ctl ? ctl.nodeOf.get(x) : undefined);
311
425
  // Where a name inside a condition is read, which can know more than the statement does.
312
426
  const placeOf = (tok, st) => (ctl && ctl.posOf.get(tok)) ?? pointOf(st);
313
427
 
314
- for (const it of prog.items) if (SYSTEM_FIELDS.has(it.name)) systemSource(nodeOf(it), `${it.name}, which the system sets`, { file: it.file || file, line: it.line });
428
+ for (const it of prog.items) if (systemFields.has(it.name)) systemSource(nodeOf(it), `${it.name}, which the system sets`, { file: it.file || file, line: it.line });
315
429
  // What a program in a CICS region DISPLAYs goes to its CESE and CESO queues, a log. Terminal or web
316
430
  // input written there unchecked can carry the control characters that start a line the program
317
431
  // did not write (CWE-117). A batch program's DISPLAY is its job's own log, which no terminal
@@ -323,7 +437,7 @@ export function analyze(root, opts = {}) {
323
437
  const device = upon >= 0 && st.sources[upon + 1] ? ` UPON ${st.sources[upon + 1].u}` : '';
324
438
  for (const t of upon >= 0 ? st.sources.slice(0, upon) : st.sources) {
325
439
  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}` });
440
+ if (n) addSink(n, { kind: 'log', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `DISPLAY ${n.name}${device}` });
327
441
  }
328
442
  }
329
443
  // The document XML PARSE reads is the operand after PARSE; what the parser does with a DTD in
@@ -331,7 +445,7 @@ export function analyze(root, opts = {}) {
331
445
  for (const st of prog.statements) {
332
446
  if (st.verb !== 'XML' || !st.sources[0] || st.sources[0].u !== 'PARSE') continue;
333
447
  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}` });
448
+ if (n) addSink(n, { kind: 'xml-document', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `XML PARSE ${n.name}` });
335
449
  }
336
450
  for (const it of prog.items) {
337
451
  const n = nodeOf(it);
@@ -366,15 +480,37 @@ export function analyze(root, opts = {}) {
366
480
  const recordOf = (it) => { let top = it; while (top.parent) top = top.parent; return top; };
367
481
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
368
482
  const extent = (it) => (it.offset != null && it.size != null ? [it.offset, it.offset + (it.contributes || it.size)] : null);
483
+ // Each record's items with a known extent, by where they start, so a check finds the items
484
+ // inside its field without testing every item of the record. They are then taken in the
485
+ // record's order, which is the order their nodes are made in.
486
+ const layouts = new Map();
487
+ const layoutOf = (top, items) => {
488
+ let l = layouts.get(top);
489
+ if (!l) {
490
+ const at = [];
491
+ items.forEach((it, i) => { if (it.level !== 88 && it.offset != null && it.size != null) at.push({ i, start: it.offset, end: it.offset + (it.contributes || it.size) }); });
492
+ at.sort((a, b) => a.start - b.start || a.i - b.i);
493
+ layouts.set(top, l = { at, position: new Map(items.map((it, i) => [it, i])) });
494
+ }
495
+ return l;
496
+ };
369
497
  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 } : {}) };
498
+ 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
499
  if (c.field.index) { (indexNode(c.field.index).checks ||= []).push(entry); continue; }
500
+ const top = recordOf(c.field);
501
+ const items = byRecord.get(top);
502
+ if (!items) { if (c.field.level !== 88) (nodeOf(c.field).checks ||= []).push(entry); continue; }
503
+ const { at, position } = layoutOf(top, items);
504
+ const inside = new Set();
505
+ if (c.field.level !== 88 && position.has(c.field)) inside.add(position.get(c.field));
372
506
  const span = extent(c.field);
373
- for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
374
- if (it.level === 88) continue;
375
- const e = extent(it);
376
- if (it === c.field || (span && e && e[0] >= span[0] && e[1] <= span[1])) (nodeOf(it).checks ||= []).push(entry);
507
+ if (span) {
508
+ let lo = 0;
509
+ let hi = at.length;
510
+ while (lo < hi) { const mid = (lo + hi) >>> 1; if (at[mid].start < span[0]) lo = mid + 1; else hi = mid; }
511
+ for (let k = lo; k < at.length && at[k].start <= span[1]; k++) if (at[k].end <= span[1]) inside.add(at[k].i);
377
512
  }
513
+ for (const i of [...inside].sort((a, b) => a - b)) (nodeOf(items[i]).checks ||= []).push(entry);
378
514
  }
379
515
  }
380
516
  // Without the order, a condition that restricts what an item may hold is credited wherever the
@@ -455,7 +591,7 @@ export function analyze(root, opts = {}) {
455
591
  if (indexed.has(use)) continue;
456
592
  indexed.add(use);
457
593
  const start = kind === 'reference-modification' && x.from && base != null ? startOf(x.from, idx, base) : null;
458
- at.sinks.push({
594
+ addSink(at, {
459
595
  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
596
  detail: kind === 'subscript'
461
597
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
@@ -486,7 +622,7 @@ export function analyze(root, opts = {}) {
486
622
  const use = `${key}|${pointOf(st) ?? `s${st.at}`}`;
487
623
  if (indexed.has(use)) continue;
488
624
  indexed.add(use);
489
- nodeOf(it).sinks.push({
625
+ addSink(nodeOf(it), {
490
626
  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
627
  detail: `${it.name} decides where PERFORM VARYING ${counter} stops, and ${counter} subscripts ${walks.host.name}, a table of ${walks.table.occurs}`,
492
628
  });
@@ -507,7 +643,7 @@ export function analyze(root, opts = {}) {
507
643
  const use = `arithmetic|${n.id}|${pointOf(st) ?? `s${st.at}`}`;
508
644
  if (indexed.has(use)) continue;
509
645
  indexed.add(use);
510
- n.sinks.push({
646
+ addSink(n, {
511
647
  kind: 'arithmetic', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|arithmetic|${n.id}`,
512
648
  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
649
  });
@@ -529,7 +665,7 @@ export function analyze(root, opts = {}) {
529
665
  const sts = touching.get(it);
530
666
  for (const st of sts.length ? sts : [null]) {
531
667
  const point = st ? pointOf(st) : undefined;
532
- nodeOf(count).sinks.push({
668
+ addSink(nodeOf(count), {
533
669
  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
670
  detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
535
671
  });
@@ -539,18 +675,18 @@ export function analyze(root, opts = {}) {
539
675
  if (st.verb !== 'READ' && st.verb !== 'RETURN') continue;
540
676
  for (const t of st.targets) {
541
677
  const n = nodeOfToken(t);
542
- if (n) n.sources.push({ kind: 'file-record', file: st.file, line: st.line, detail: `${st.verb} ... INTO` });
678
+ if (n) addSource(n, { kind: 'file-record', file: st.file, line: st.line, detail: `${st.verb} ... INTO` });
543
679
  }
544
680
  }
545
681
  for (const fd of prog.fds || []) for (const rec of fd.records || []) {
546
682
  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}` });
683
+ if (readsFile) addSource(nodeOf(rec), { kind: 'file-record', file: fd.file, line: fd.line, detail: `record of file ${fd.name}` });
548
684
  }
549
685
  for (const a of prog.accepts) {
550
686
  const item = (a.targetTok ? itemOfToken(a.targetTok) : null) || byName.get(a.target);
551
687
  if (!item) continue;
552
688
  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}` });
689
+ if (kind) addSource(nodeOf(item), { kind, file: a.file, line: a.line, detail: `ACCEPT ... FROM ${a.from}` });
554
690
  }
555
691
  // A SELECT assigns either to a data item, which makes the path dynamic, or to an external
556
692
  // name. On z/OS that external name is the DD name a JCL step fills, and it is how a job's
@@ -561,7 +697,7 @@ export function analyze(root, opts = {}) {
561
697
  if (f.assign.t === 'word') {
562
698
  const item = byName.get(f.assign.v);
563
699
  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}` });
700
+ addSink(nodeOf(item), { kind: 'dynamic-file-path', file: f.file, line: f.line, detail: `SELECT ${f.name} ASSIGN TO ${f.assign.v}` });
565
701
  continue; // a variable path is not a DD name
566
702
  }
567
703
  }
@@ -570,7 +706,9 @@ export function analyze(root, opts = {}) {
570
706
  if (!external) continue;
571
707
  const fd = (prog.fds || []).find((x) => x.name === f.name);
572
708
  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 });
709
+ // READ ... INTO moves the record it read into an item, which then holds the DD's data too.
710
+ const into = prog.statements.filter((st) => st.verb === 'READ' && st.sources[0]?.u === f.name).flatMap((st) => st.targets.map(nodeOfToken).filter(Boolean));
711
+ if (recs.length) ddFiles.push({ dd: external, nodes: recs, into, file: f.file, line: f.line, select: f.name });
574
712
  }
575
713
  const topOf = (it) => { let t = it; while (t && t.parent) t = t.parent; return t; };
576
714
  const ownParams = new Set((prog.paramTokens || []).map((pt) => topOf(itemOfToken(pt.tok))).filter(Boolean));
@@ -593,14 +731,14 @@ export function analyze(root, opts = {}) {
593
731
  const point = pointOf(prog.statements[c.stmtIndex]);
594
732
  if (c.kind === 'I') {
595
733
  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}` });
734
+ if (n) addSink(n, { kind: 'dynamic-program-load', file: c.file, line: c.line, point, detail: `CALL ${c.name}` });
597
735
  continue;
598
736
  }
599
737
  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
738
  if (OS_COMMAND_ROUTINE.test(c.name)) {
601
739
  for (const a of c.using) {
602
740
  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}` });
741
+ if (n) addSink(n, { kind: 'os-command', file: c.file, line: c.line, point, detail: `CALL '${c.name}' USING ${a.word}` });
604
742
  }
605
743
  } else if (SOCKET_ROUTINE.test(c.name)) {
606
744
  // IBM's own examples hold the function code in a field: 01 SOC-FUNCTION PIC X(16) VALUE 'SEND'.
@@ -611,23 +749,30 @@ export function analyze(root, opts = {}) {
611
749
  if (fn && SOCKET_SEND.test(String(fn).trim())) {
612
750
  for (const a of c.using.slice(1)) {
613
751
  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}` });
752
+ 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
753
  }
616
754
  }
617
755
  } else if (Object.hasOwn(MQ_BUFFER_ARG, String(c.name).toUpperCase())) {
618
756
  const a = c.using[MQ_BUFFER_ARG[String(c.name).toUpperCase()]];
619
757
  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}` });
758
+ 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
759
  } else if (/^MQCONNX?$/i.test(String(c.name))) {
622
760
  // MQCONN(QMgrName, Hconn, CompCode, Reason); MQCONNX puts its options second.
623
761
  const a = c.using[0];
624
762
  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` });
763
+ 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` });
764
+ } else if (DLI_ROUTINE.test(c.name)) {
765
+ // A get call fills the I/O area, the argument after the PCB, with the segment it retrieves.
766
+ const valueOf = (a) => a.lit ?? (a.tok ? ((itemOfToken(a.tok) || {}).values || []).find(v => v.t === 'lit')?.v : null);
767
+ const call = dliFunction(c.using, valueOf);
768
+ const a = call && isGet(call.fn) ? c.using[call.index + 2] : null;
769
+ const n = a && a.tok ? nodeOfToken(a.tok) : null;
770
+ if (n) addSource(n, { kind: 'database', file: c.file, line: c.line, point, detail: `CALL '${c.name}' ${call.fn} fills ${a.word}` });
626
771
  } else if (String(c.name).toUpperCase() === 'CEEGTST') {
627
772
  // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
628
773
  const a = c.using[1];
629
774
  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` });
775
+ 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
776
  }
632
777
  }
633
778
  const transfers = [];
@@ -643,7 +788,10 @@ export function analyze(root, opts = {}) {
643
788
  const containerNode = (name) => {
644
789
  let n = containers.get(name);
645
790
  if (!n) {
646
- n = { id: nodes.length, pk, program: prog.id, file, name: `CONTAINER(${name})`, edgesOut: [], sources: [], sinks: [] };
791
+ n = new FlowNode(nodes.length, pk, `CONTAINER(${name})`);
792
+ n.off = undefined;
793
+ n.size = undefined;
794
+ n.repeats = undefined;
647
795
  nodes.push(n);
648
796
  containers.set(name, n);
649
797
  stats.nodes++;
@@ -731,7 +879,7 @@ export function analyze(root, opts = {}) {
731
879
  if (typed) continue;
732
880
  stats.protectedFields++;
733
881
  const back = field.effective.has('FSET') ? ' with FSET' : '';
734
- n.sources.push({ kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
882
+ addSource(n, { kind: 'cics-protected-field', onlyTo: ['record-key', 'record-update'], ...at,
735
883
  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
884
  }
737
885
  };
@@ -740,45 +888,44 @@ export function analyze(root, opts = {}) {
740
888
  const changes = new Map();
741
889
  for (const e of prog.execs) {
742
890
  if (e.kind !== 'CICS') continue;
743
- const { opts: o, words } = execOptions(e);
744
- if (words[0] !== 'REWRITE' && !(words[0] === 'DELETE' && !o.has('RIDFLD'))) continue;
891
+ const { opts: o, verb } = execReading(e);
892
+ if (verb !== 'REWRITE' && !(verb === 'DELETE' && !o.has('RIDFLD'))) continue;
745
893
  const file = optionName(o, 'DATASET') || optionName(o, 'FILE');
746
- if (file && !changes.has(file)) changes.set(file, words[0] === 'REWRITE' ? 'rewrites' : 'deletes');
894
+ if (file && !changes.has(file)) changes.set(file, verb === 'REWRITE' ? 'rewrites' : 'deletes');
747
895
  }
748
896
  for (const e of prog.execs) {
749
- const { opts: o, words } = execOptions(e);
897
+ const { opts: o, words, verb, sub, command, direction } = execReading(e);
750
898
  const at = { file: e.file, line: e.line, point: pointOf(e) };
751
899
  if (e.kind === 'CICS') {
752
- const verb = words[0];
753
900
  const intoTok = (o.get('INTO') || o.get('SET') || [])[0];
754
901
  for (const opt of ['RESP', 'RESP2']) {
755
902
  const r = nodeOfToken((o.get(opt) || [])[0]);
756
903
  if (r) systemSource(r, `the ${opt} of EXEC CICS ${verb}`, at);
757
904
  }
758
- if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(words[1])) {
759
- const opt = words[1] === 'OPERATOR' ? 'TEXT' : 'FROM';
905
+ if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(sub)) {
906
+ const opt = sub === 'OPERATOR' ? 'TEXT' : 'FROM';
760
907
  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'}` });
908
+ if (n) addSink(n, { kind: 'log', onlyFrom: REFLECTED, ...at, detail: `EXEC CICS WRITE ${sub} ${opt}(${n.name}), ${sub === 'OPERATOR' ? 'to the operator console' : 'to a journal'}` });
762
909
  }
763
910
  if (verb === 'RECEIVE') {
764
911
  const isWeb = words.includes('WEB');
765
912
  const n = nodeOfToken(intoTok);
766
- if (n) n.sources.push({ kind: isWeb ? 'cics-web' : 'cics-terminal', ...at, detail: `EXEC CICS ${isWeb ? 'WEB ' : ''}RECEIVE` });
913
+ if (n) addSource(n, { kind: isWeb ? 'cics-web' : 'cics-terminal', ...at, detail: `EXEC CICS ${isWeb ? 'WEB ' : ''}RECEIVE` });
767
914
  // RECEIVE MAP with no INTO fills the symbolic map, whose input record is <map>I.
768
915
  const map = optionName(o, 'MAP');
769
916
  if (!n && map) {
770
917
  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` });
918
+ if (sym) addSource(nodeOf(sym), { kind: 'cics-terminal', ...at, detail: `EXEC CICS RECEIVE MAP(${map}) into the symbolic map` });
772
919
  }
773
920
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
774
921
  }
775
922
  // What the terminal shows: SEND TEXT's FROM, or the map's output record, <map>O without FROM.
776
- if (verb === 'SEND' && (words[1] === 'TEXT' || words[1] === 'MAP')) {
923
+ if (verb === 'SEND' && (sub === 'TEXT' || sub === 'MAP')) {
777
924
  const from = nodeOfToken((o.get('FROM') || [])[0]);
778
- const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
925
+ const map = sub === 'MAP' ? optionName(o, 'MAP') : null;
779
926
  const sym = !from && map ? byName.get(`${map}O`) : null;
780
927
  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` });
928
+ if (n) addSink(n, { kind: 'screen', onlyFrom: ['system-response'], ...at, detail: `EXEC CICS SEND ${sub}${map ? `(${map})` : ''}${from ? ` FROM(${n.name})` : ', from its symbolic map'}, to the terminal` });
782
929
  }
783
930
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
784
931
  // user types is how a lookup works; a key the program put in a protected field and read back
@@ -790,12 +937,12 @@ export function analyze(root, opts = {}) {
790
937
  const detail = verb === 'DELETE' ? `EXEC CICS DELETE RIDFLD(${k && k.name}), which decides which record it deletes`
791
938
  : then ? `EXEC CICS READ UPDATE RIDFLD(${k && k.name}), which decides which record the program then ${then}`
792
939
  : `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 });
940
+ if (k) addSink(k, { kind: verb === 'DELETE' || then ? 'record-update' : 'record-key', onlyFrom: ['cics-protected-field'], noCredit: true, ...at, detail });
794
941
  }
795
942
  if (verb === 'GETMAIN') {
796
943
  for (const opt of ['FLENGTH', 'LENGTH']) {
797
944
  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` });
945
+ 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
946
  }
800
947
  }
801
948
  if (verb === 'PUT' && o.has('CONTAINER')) {
@@ -805,11 +952,11 @@ export function analyze(root, opts = {}) {
805
952
  if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
806
953
  }
807
954
  // A service call sends every container on its channel, and its URI decides where they go.
808
- if (verb === 'INVOKE' && (words[1] === 'SERVICE' || words[1] === 'WEBSERVICE')) {
809
- invokes.push({ at, what: words[1], channel: optionName(o, 'CHANNEL') });
955
+ if (verb === 'INVOKE' && (sub === 'SERVICE' || sub === 'WEBSERVICE')) {
956
+ invokes.push({ at, what: sub, channel: optionName(o, 'CHANNEL') });
810
957
  for (const opt of ['URI', 'URIMAP']) {
811
958
  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` });
959
+ if (n) addSink(n, { kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS INVOKE ${sub} ${opt}(...), which decides where the request goes` });
813
960
  }
814
961
  }
815
962
  if (verb === 'GET' && o.has('CONTAINER')) {
@@ -819,51 +966,60 @@ export function analyze(root, opts = {}) {
819
966
  }
820
967
  // CONVERSE is always a client call; SEND is one only on a session this program opened. A
821
968
  // plain WEB SEND answers the request that started the program, which is its job.
822
- if (verb === 'WEB' && (words[1] === 'CONVERSE' || (words[1] === 'SEND' && o.has('SESSTOKEN')))) {
969
+ if (verb === 'WEB' && (sub === 'CONVERSE' || (sub === 'SEND' && o.has('SESSTOKEN')))) {
823
970
  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` });
971
+ if (n) addSink(n, { kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${sub} to a remote server` });
825
972
  // The request may be a container rather than a field.
826
973
  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})` });
974
+ if (sent) addSink(containerNode(sent), { kind: 'outbound-http', onlyFrom: DATA_AT_REST, ...at, detail: `EXEC CICS WEB ${sub} sending CONTAINER(${sent})` });
828
975
  }
829
976
  // The response this program returns to its own caller, and the document built for it. CICS
830
977
  // escapes nothing on the way out - DOCUMENT SET's UNESCAPED option is about URL decoding,
831
978
  // not HTML - so whatever reaches here is what the browser is handed.
832
- if (verb === 'WEB' && words[1] === 'SEND' && !o.has('SESSTOKEN')) {
979
+ if (verb === 'WEB' && sub === 'SEND' && !o.has('SESSTOKEN')) {
833
980
  const n = nodeOfToken((o.get('FROM') || [])[0]);
834
981
  // A stored value becomes script only where the reply is markup, and a web program
835
982
  // answering with a database row is the ordinary shape of one. Input echoed back is a
836
983
  // defect whatever the reply holds.
837
984
  const media = (o.get('MEDIATYPE') || [])[0];
838
985
  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()}` : ''}` });
986
+ 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
987
  }
841
988
  if (verb === 'DOCUMENT') {
842
- const what = words[1] === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
989
+ const what = sub === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
843
990
  for (const opt of what) {
844
991
  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` });
992
+ if (n) addSink(n, { kind: 'web-response', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS DOCUMENT ${sub} ${opt}(...), which is sent as the response` });
846
993
  }
847
994
  }
848
- if (verb === 'WEB' && words[1] === 'WRITE' && o.has('HTTPHEADER')) {
995
+ if (verb === 'WEB' && sub === 'WRITE' && o.has('HTTPHEADER')) {
849
996
  for (const opt of ['HTTPHEADER', 'VALUE']) {
850
997
  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}(...)` });
998
+ if (n) addSink(n, { kind: 'http-header', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS WEB WRITE HTTPHEADER ${opt}(...)` });
852
999
  }
853
1000
  }
854
- if (verb === 'WEB' && (words[1] === 'OPEN' || words[1] === 'CONVERSE')) {
1001
+ if (verb === 'WEB' && (sub === 'OPEN' || sub === 'CONVERSE')) {
855
1002
  for (const opt of ['HOST', 'URIMAP', 'PATH']) {
856
1003
  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` });
1004
+ if (n) addSink(n, { kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS WEB ${sub} ${opt}(...), which decides where the request goes` });
1005
+ }
1006
+ }
1007
+ // FILE or DATASET picks the file a command reads, writes or browses from every file the region
1008
+ // defines; the commands that carry on from these act on the file already picked.
1009
+ if (['READ', 'WRITE', 'DELETE', 'STARTBR'].includes(verb)) {
1010
+ for (const opt of ['FILE', 'DATASET']) {
1011
+ const t = (o.get(opt) || [])[0];
1012
+ const n = t && t.t === 'word' ? nodeOfToken(t) : null;
1013
+ if (n) addSink(n, { kind: 'dynamic-file-path', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${opt}(${n.name}), which decides the file the command acts on` });
858
1014
  }
859
1015
  }
860
1016
  // The queue a command acts on, as distinct from what it writes there: a name from input
861
1017
  // reads, overwrites or deletes whatever queue the input asks for.
862
1018
  if (['WRITEQ', 'READQ', 'DELETEQ'].includes(verb)) {
863
- const ts = words[1] === 'TS' || !words.includes('TD');
1019
+ const ts = sub === 'TS' || !words.includes('TD');
864
1020
  for (const opt of ['QUEUE', 'QNAME']) {
865
1021
  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` });
1022
+ if (n) addSink(n, { kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
867
1023
  }
868
1024
  }
869
1025
  // SYSID ships a command to the region it names; SET's first option names the resource a
@@ -871,25 +1027,42 @@ export function analyze(root, opts = {}) {
871
1027
  // INQUIRE and EXTRACT return SYSID into the field rather than read it.
872
1028
  if (o.has('SYSID') && !['ASSIGN', 'INQUIRE', 'EXTRACT'].includes(verb)) {
873
1029
  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` });
1030
+ 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
1031
  }
876
- if (verb === 'SET' && words[1] && o.has(words[1])) {
877
- 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` });
1032
+ // The resource option is the one the table names the command by, or the synonym written (SET
1033
+ // DATASET is SET FILE).
1034
+ const resource = o.has(sub) ? sub : words[1];
1035
+ if (verb === 'SET' && resource && o.has(resource)) {
1036
+ const n = nodeOfToken((o.get(resource) || [])[0]);
1037
+ if (n) addSink(n, { kind: 'cics-system-resource', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS SET ${resource}(${n.name}), which decides the resource the command changes` });
879
1038
  }
880
1039
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
881
1040
  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` });
1041
+ if (n && (o.has('FILE') || o.has('DATASET'))) addSource(n, { kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
883
1042
  }
884
1043
  // A CONVERSE response is the remote server's data, which is untrusted input like any other.
885
- if ((verb === 'WEB' && words[1] !== 'SEND') || verb === 'EXTRACT') {
1044
+ if ((verb === 'WEB' && sub !== 'SEND') || verb === 'EXTRACT') {
886
1045
  const n = nodeOfToken(intoTok);
887
- if (n) n.sources.push({ kind: 'cics-web', ...at, detail: `EXEC CICS ${words.slice(0, 2).join(' ')}` });
1046
+ if (n) addSource(n, { kind: 'cics-web', ...at, detail: `EXEC CICS ${[verb, sub].filter(Boolean).join(' ')}` });
1047
+ }
1048
+ // The request's form fields, query parameters and headers, and what WEB EXTRACT says about the
1049
+ // request, come back in options other than INTO: each one CICS returns a value in.
1050
+ if (/^(WEB (READ|READNEXT|EXTRACT)|EXTRACT WEB)/.test(command || '')) {
1051
+ for (const [opt, toks] of o) {
1052
+ if (opt === 'INTO' || !['receives', 'both'].includes(direction(opt)) || CICS_EVERY_COMMAND_OPTIONS.has(opt)) continue;
1053
+ const n = nodeOfToken(toks[0]);
1054
+ if (n) addSource(n, { kind: 'cics-web', ...at, detail: `EXEC CICS ${command} ${opt}` });
1055
+ }
1056
+ }
1057
+ // A queue item, and the data a START passed, were stored by another task.
1058
+ if (/^READQ /.test(command || '') || command === 'RETRIEVE') {
1059
+ const n = nodeOfToken(intoTok);
1060
+ if (n) addSource(n, { kind: 'cics-queue', ...at, detail: `EXEC CICS ${command} INTO` });
888
1061
  }
889
1062
  // Where a transient-data write lands is not in the program: the CSD maps the queue to a DD
890
1063
  // and the region's JCL maps the DD to a destination. Collected here, resolved once every
891
1064
  // definition has been read.
892
- if (verb === 'WRITEQ' && words[1] === 'TD') {
1065
+ if (verb === 'WRITEQ' && sub === 'TD') {
893
1066
  const from = nodeOfToken((o.get('FROM') || [])[0]);
894
1067
  const qTok = (o.get('QUEUE') || o.get('TDQUEUE') || [])[0];
895
1068
  const qItem = qTok && qTok.t === 'word' ? itemOfToken(qTok) : null;
@@ -901,7 +1074,7 @@ export function analyze(root, opts = {}) {
901
1074
  const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
902
1075
  if (pgm && pgm.t === 'word') {
903
1076
  const n = nodeOfToken(pgm);
904
- if (n) n.sinks.push({ kind: 'cics-dynamic-transfer', ...at, detail: `EXEC CICS ${verb} with a variable program name` });
1077
+ if (n) addSink(n, { kind: 'cics-dynamic-transfer', ...at, detail: `EXEC CICS ${verb} with a variable program name` });
905
1078
  }
906
1079
  const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
907
1080
  const argNode = commarea ? nodeOfToken(commarea) : null;
@@ -917,15 +1090,20 @@ export function analyze(root, opts = {}) {
917
1090
  if (t && t.t === 'lit') startsTransactions.add(String(t.v).trim().toUpperCase());
918
1091
  }
919
1092
  }
1093
+ if (e.kind === 'DLI' && isGet(verb)) {
1094
+ for (const tok of o.get('INTO') || []) {
1095
+ const n = nodeOfToken(tok);
1096
+ if (n) addSource(n, { kind: 'database', ...at, detail: `EXEC DLI ${verb} INTO ${n.name}` });
1097
+ }
1098
+ }
920
1099
  if (e.kind === 'SQL') {
921
1100
  // A host variable names the item a qualified :G.F resolves to; the statement fills the ones
922
1101
  // Db2 writes, and those hold what the database held.
923
1102
  const hv = e.hostVariables || hostVariablesIn(e.toks);
924
- const verb = words[0];
925
1103
  for (const h of hv) {
926
1104
  if (!h.written) continue;
927
1105
  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` });
1106
+ if (n) addSource(n, { kind: 'database', ...at, detail: `EXEC SQL ${verb} ${verb === 'SELECT' || verb === 'FETCH' ? 'INTO' : 'writes'} host variable` });
929
1107
  }
930
1108
  // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
931
1109
  // follows USER and USING is the credential, not the target.
@@ -933,19 +1111,19 @@ export function analyze(root, opts = {}) {
933
1111
  const to = e.toks.findIndex((t) => t.t === 'word' && (t.u === 'TO' || t.u === 'CONNECTION'));
934
1112
  const colon = to >= 0 ? e.toks[to + 1] : null;
935
1113
  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` });
1114
+ 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
1115
  }
938
1116
  const dynamic = words.some((w, i) => w === 'PREPARE' || (w === 'EXECUTE' && words[i + 1] === 'IMMEDIATE'));
939
1117
  if (dynamic) for (const h of hv) {
940
1118
  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` });
1119
+ if (n) addSink(n, { kind: 'dynamic-sql', ...at, detail: `EXEC SQL ${words.slice(0, 2).join(' ')} from a host variable` });
942
1120
  }
943
1121
  }
944
1122
  }
945
1123
  for (const inv of invokes) {
946
1124
  for (const [name, channel] of channelOf) {
947
1125
  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})` : ''}` });
1126
+ 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
1127
  }
950
1128
  }
951
1129
  const commareaItem = byName.get('DFHCOMMAREA');
@@ -977,6 +1155,16 @@ export function analyze(root, opts = {}) {
977
1155
  const tree = (!repo && opts.tree) ? opts.tree : directoryTree(base, opts);
978
1156
  const idx = tree.index;
979
1157
  const files = tree.list().filter(isProgram).filter(inScope(opts));
1158
+ // PL/I programs join the same graph unless the scan is told not to read them.
1159
+ const readsPli = opts.pli ?? process.env.COBOLWORK_PLI !== '0';
1160
+ if (readsPli) files.push(...tree.list().filter(isPli).filter(inScope(opts)));
1161
+ // A PL/I %INCLUDE names a member by base name; any file of the tree may hold it.
1162
+ const pliMembers = readsPli ? membersOf(tree.list()) : null;
1163
+ const readMember = (name, from) => {
1164
+ const path = chooseMember(pliMembers.get(name), from);
1165
+ if (!path) return null;
1166
+ try { return { path, text: tree.text(path).text }; } catch { return null; }
1167
+ };
980
1168
  for (const f of tree.list().filter(isJcl).filter(inScope(opts))) { jclPending.push(f); jclTrees.set(f, tree); }
981
1169
  for (const f of tree.list().filter((p) => /\.csd$/i.test(p)).filter(inScope(opts))) {
982
1170
  try { addCsd(tree.text(f).text, f); } catch (e) { stats.unreadable++; unread.push(`${tree.rel(f)}: ${e.code || e.name}`); }
@@ -995,20 +1183,38 @@ export function analyze(root, opts = {}) {
995
1183
  // edges are proportional to data items and statements, not to bytes, so a repository of small
996
1184
  // programs with large record layouts can exhaust a heap the budget believes is untouched. The
997
1185
  // shared loop watches the heap itself, and the budget stays as the cheaper first line.
998
- const run = eachWithinMemory(files, (f) => {
1186
+ const { repeated, later } = repeatedTexts(tree, files);
1187
+ // The larger COBOL programs' control analyses are built in worker threads ahead of the loop, where
1188
+ // the tree can say how it parses as data. A copy whose first is analysed already is restored instead.
1189
+ const workers = tree.parseSpec ? (opts.controlWorkers ?? controlWorkerCount()) : 0;
1190
+ const minWorkerBytes = opts.controlWorkerMinBytes ?? CONTROL_WORKER_MIN_BYTES;
1191
+ const sizeOf = (f) => { try { return statSync(f).size; } catch { return 0; } };
1192
+ const toWorkers = new Set(workers > 0 ? files.filter((f) => !isPli(f) && !later.has(f) && sizeOf(f) >= minWorkerBytes) : []);
1193
+ const ahead = toWorkers.size ? controlAhead(tree.parseSpec, files, (f) => toWorkers.has(f), { workers }) : null;
1194
+ const readProgram = (f, i) => {
999
1195
  let src;
1000
1196
  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
- if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) return 0;
1197
+ const pli = isPli(f);
1198
+ if (!pli && !/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) return 0;
1002
1199
  if (held + src.length > budget) { stats.overBudget++; return 0; }
1003
1200
  held += src.length;
1004
1201
  let r;
1005
1202
  try {
1006
- r = tree.parse(f, src);
1203
+ r = pli ? parsePliSource(src, f, { readMember }) : tree.parse(f, src);
1007
1204
  } catch (e) { stats.threw++; unparsed.push(`${tree.rel(f)}: ${e.code || e.name}`); return src.length; }
1008
1205
  stats.files++;
1009
- for (const p of r.programs) { programs.push(summarise(p, f, programs.length, r.options)); stats.programs++; }
1206
+ const copied = src.length >= reuseMin && repeated.size ? repeated.get(textKey(src)) : undefined;
1207
+ const answer = ahead ? ahead.take(f, i) : null;
1208
+ const builtFiles = answer && answer.length === r.programs.length ? programFiles(r, f) : null;
1209
+ r.programs.forEach((p, k) => {
1210
+ programs.push(summarise(p, f, programs.length, r.options, copied ? reuseOf(copied, k, r, f, tree) : null, builtFiles && { ...answer[k], files: builtFiles }));
1211
+ stats.programs++;
1212
+ });
1213
+ if (copied && --copied.left === 0) repeated.delete(copied.text);
1010
1214
  return src.length;
1011
- }, { label: 'flow', watcher: memoryWatcher });
1215
+ };
1216
+ let run;
1217
+ try { run = yield loopOver(files, readProgram, { label: 'flow', watcher: memoryWatcher }); } finally { ahead?.close(); }
1012
1218
 
1013
1219
  // A file the loop never reached is not the same as one past the byte budget, and they are
1014
1220
  // counted separately so a report can say which limit it hit.
@@ -1142,7 +1348,7 @@ export function analyze(root, opts = {}) {
1142
1348
  // followed by the text. A program with no USING cannot receive one, and saying it does
1143
1349
  // would be a path nobody could follow.
1144
1350
  if (run.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1145
- callee.params[0].node.sources.push({
1351
+ addSource(callee.params[0].node, {
1146
1352
  kind: 'jcl-parm', file: f, line: run.line,
1147
1353
  detail: `${run.how}, which runs ${run.pgm}${amb}`,
1148
1354
  });
@@ -1159,7 +1365,7 @@ export function analyze(root, opts = {}) {
1159
1365
  for (const m of callee.ddFiles || []) {
1160
1366
  if (m.dd !== dd.name.toUpperCase()) continue;
1161
1367
  for (const n of m.nodes) {
1162
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1368
+ addSink(n, { kind: 'internal-reader', file: m.file, line: m.line,
1163
1369
  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
1370
  }
1165
1371
  }
@@ -1167,8 +1373,8 @@ export function analyze(root, opts = {}) {
1167
1373
  if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1168
1374
  for (const m of callee.ddFiles || []) {
1169
1375
  if (m.dd !== dd.name.toUpperCase()) continue;
1170
- for (const n of m.nodes) {
1171
- n.sources.push({
1376
+ for (const n of [...m.nodes, ...m.into]) {
1377
+ addSource(n, {
1172
1378
  kind: 'jcl-instream', file: f, line: dd.line,
1173
1379
  detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1174
1380
  });
@@ -1237,21 +1443,21 @@ export function analyze(root, opts = {}) {
1237
1443
  const def = w.queue ? csd.tdqueues.get(w.queue) : null;
1238
1444
  const dd = w.queue ? ddOfQueue(csd, w.queue) : null;
1239
1445
  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,
1446
+ addSink(w.node, { kind: 'internal-reader', file: w.file, line: w.line, point: w.point,
1241
1447
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}')${dd ? `, which the CSD sends to DD ${dd}` : ''}, declared as reaching the internal reader` });
1242
1448
  continue;
1243
1449
  }
1244
1450
  // Any other queue is where a region writes what it logs or prints, unless it starts a
1245
1451
  // transaction, when it is a channel to that transaction instead.
1246
1452
  if (!(def && def.transid)) {
1247
- w.node.sinks.push({ kind: 'log', onlyFrom: REFLECTED, file: w.file, line: w.line, point: w.point,
1453
+ addSink(w.node, { kind: 'log', onlyFrom: REFLECTED, file: w.file, line: w.line, point: w.point,
1248
1454
  detail: `EXEC CICS WRITEQ TD QUEUE(${w.queue ? `'${w.queue}'` : 'a name the program computes'})` });
1249
1455
  }
1250
1456
  if (!w.queue) { stats.tdWritesUndecided++; continue; }
1251
1457
  if (def && def.type === 'INTRA') continue;
1252
1458
  // A queue the CSD sends to a DD leaves the region: to a dataset, a printer or another job.
1253
1459
  if (dd) {
1254
- w.node.sinks.push({ kind: 'extrapartition-queue', onlyFrom: DATA_AT_REST, file: w.file, line: w.line, point: w.point,
1460
+ addSink(w.node, { kind: 'extrapartition-queue', onlyFrom: DATA_AT_REST, file: w.file, line: w.line, point: w.point,
1255
1461
  detail: `EXEC CICS WRITEQ TD QUEUE('${w.queue}'), which the CSD sends out of the region to DD ${dd}` });
1256
1462
  }
1257
1463
  if (!declared) stats.tdWritesUndecided++;
@@ -1259,6 +1465,19 @@ export function analyze(root, opts = {}) {
1259
1465
  }
1260
1466
  }
1261
1467
 
1468
+ // Each node's edges, in the order they were added: outStart[id] up to outStart[id + 1] index outTo
1469
+ // and outMeta.
1470
+ const outStart = new Int32Array(nodes.length + 1);
1471
+ for (let k = 0; k < edgeFrom.length; k++) outStart[edgeFrom[k] + 1]++;
1472
+ for (let i = 0; i < nodes.length; i++) outStart[i + 1] += outStart[i];
1473
+ const outTo = new Int32Array(edgeFrom.length);
1474
+ const outMeta = new Array(edgeFrom.length);
1475
+ {
1476
+ const fill = outStart.slice(0, nodes.length);
1477
+ for (let k = 0; k < edgeFrom.length; k++) { const at = fill[edgeFrom[k]]++; outTo[at] = edgeTo[k]; outMeta[at] = edgeMeta[k]; }
1478
+ edgeFrom.length = 0; edgeTo.length = 0; edgeMeta.length = 0;
1479
+ }
1480
+
1262
1481
  // Only nodes that can reach some sink are worth walking into. One backward pass over a transient
1263
1482
  // reverse index answers that for every source at once; it is a superset, so nothing is lost.
1264
1483
  // Two answers, one bit each: whether a node reaches a sink a source at rest could be reported at,
@@ -1269,11 +1488,11 @@ export function analyze(root, opts = {}) {
1269
1488
  const canReach = new Uint8Array(nodes.length);
1270
1489
  {
1271
1490
  const revStart = new Int32Array(nodes.length + 1);
1272
- for (const n of nodes) for (const e of n.edgesOut) revStart[e.to.id + 1]++;
1491
+ for (let k = 0; k < outTo.length; k++) revStart[outTo[k] + 1]++;
1273
1492
  for (let i = 0; i < nodes.length; i++) revStart[i + 1] += revStart[i];
1274
1493
  const fill = revStart.slice(0, nodes.length);
1275
1494
  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;
1495
+ for (let id = 0; id < nodes.length; id++) for (let k = outStart[id]; k < outStart[id + 1]; k++) rev[fill[outTo[k]]++] = id;
1277
1496
  const mark = (bit, seeds) => {
1278
1497
  const queue = [];
1279
1498
  for (const n of nodes) if (seeds(n)) { canReach[n.id] |= bit; queue.push(n.id); }
@@ -1381,25 +1600,26 @@ export function analyze(root, opts = {}) {
1381
1600
  for (let i = 0; i < open.length; i++) {
1382
1601
  const st = open[i];
1383
1602
  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) {
1603
+ for (let k = outStart[st.node.id]; k < outStart[st.node.id + 1]; k++) {
1385
1604
  edgesWalked++;
1386
- const to = e.to;
1605
+ const e = outMeta[k];
1606
+ const to = nodes[outTo[k]];
1387
1607
  if (!(canReach[to.id] & reach)) continue;
1388
1608
  const mv = moves(e);
1389
1609
  if (mv && holds(st.since, pointOfEdge(e), kind, null, shift)) continue;
1390
- const t = cross(st, e);
1610
+ const t = cross(st, e, to);
1391
1611
  if (!t) continue;
1392
1612
  const since = mv ? [to] : [...st.since, to];
1393
1613
  // An arrival carrying a checked node is refused more than one that does not, so it must not
1394
1614
  // stand in for it.
1395
1615
  const guarded = since.some((n) => n.checks || n.guard);
1396
1616
  if (t.partial) {
1397
- const key = `${to.id}|${guarded}|${t.ctx}`;
1617
+ const key = atContext(to.id, t.ctx, guarded ? 1 : 0);
1398
1618
  const held = parts.get(key);
1399
1619
  if (held && held.some((r) => (r[2] ? t.vague : !t.vague && r[0] <= t.lo && t.hi <= r[1]))) continue;
1400
1620
  if (held) held.push([t.lo, t.hi, t.vague]); else parts.set(key, [[t.lo, t.hi, t.vague]]);
1401
1621
  } else if (guarded) {
1402
- const dk = `${to.id}|${t.ctx}`;
1622
+ const dk = atContext(to.id, t.ctx);
1403
1623
  if (stampWhole[to.id] === walk || dirty.get(dk) === walk) continue;
1404
1624
  dirty.set(dk, walk);
1405
1625
  } else if (!firstVisit(to.id, t.ctx)) continue;
@@ -1423,10 +1643,12 @@ export function analyze(root, opts = {}) {
1423
1643
  const stampWhole = new Int32Array(nodes.length);
1424
1644
  // A whole node reached with a call context, per walk. Reached with none, it stands in for every context.
1425
1645
  const stampCtx = new Map();
1646
+ // A node and a call context as one number, with room for a second bit of state.
1647
+ const atContext = (id, ctx, bit = 0) => (ctx * 2 + bit) * nodes.length + id;
1426
1648
  const firstVisit = (id, ctx) => {
1427
1649
  if (stampWhole[id] === walk) return false;
1428
1650
  if (!ctx) { stampWhole[id] = walk; return true; }
1429
- const key = `${id}|${ctx}`;
1651
+ const key = atContext(id, ctx);
1430
1652
  if (stampCtx.get(key) === walk) return false;
1431
1653
  stampCtx.set(key, walk);
1432
1654
  return true;
@@ -1447,24 +1669,49 @@ export function analyze(root, opts = {}) {
1447
1669
  // it does not. Both walks below cross edges through this, so neither can take a route the other
1448
1670
  // would refuse.
1449
1671
  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.
1672
+ // The call sites a value entered its callees through, innermost last, as a number standing for
1673
+ // that sequence; 0 where any caller may be returned to: a value born in the callee, or one that
1674
+ // rested in storage a program keeps between calls, which a later call from any caller can read.
1453
1675
  const CONTEXT_DEPTH = 4;
1454
- function contextOver(state, e) {
1455
- const ctx = state.ctx || '';
1676
+ const contextSites = [[]];
1677
+ const contextNumber = new Map([['', 0]]);
1678
+ const entered = new Map();
1679
+ const returned = new Map();
1680
+ const contextOf = (sites) => {
1681
+ const k = sites.join('/');
1682
+ let c = contextNumber.get(k);
1683
+ if (c === undefined) { c = contextSites.length; contextSites.push(sites); contextNumber.set(k, c); }
1684
+ return c;
1685
+ };
1686
+ function contextOver(state, e, to) {
1687
+ const ctx = state.ctx || 0;
1456
1688
  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;
1689
+ if (!ctx) return 0;
1690
+ let byRet = returned.get(ctx);
1691
+ if (!byRet) returned.set(ctx, byRet = new Map());
1692
+ let out = byRet.get(e.ret);
1693
+ if (out === undefined) {
1694
+ const sites = contextSites[ctx];
1695
+ out = Number(sites[sites.length - 1]) === e.ret ? contextOf(sites.slice(0, -1)) : null;
1696
+ byRet.set(e.ret, out);
1697
+ }
1698
+ return out;
1460
1699
  }
1461
- if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
1462
- return e.to.linkage ? ctx : '';
1700
+ if (e.call != null) {
1701
+ let byCall = entered.get(ctx);
1702
+ if (!byCall) entered.set(ctx, byCall = new Map());
1703
+ let out = byCall.get(e.call);
1704
+ if (out === undefined) {
1705
+ out = contextOf([...contextSites[ctx], String(e.call)].slice(-CONTEXT_DEPTH));
1706
+ byCall.set(e.call, out);
1707
+ }
1708
+ return out;
1709
+ }
1710
+ return to.linkage ? ctx : 0;
1463
1711
  }
1464
- function cross(state, e) {
1712
+ function cross(state, e, to) {
1465
1713
  const cur = state.node;
1466
- const to = e.to;
1467
- const ctx = contextOver(state, e);
1714
+ const ctx = contextOver(state, e, to);
1468
1715
  if (ctx === null) return null;
1469
1716
  let partial = false, lo = 0, hi = 0, vague = false;
1470
1717
  if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
@@ -1613,16 +1860,17 @@ export function analyze(root, opts = {}) {
1613
1860
  if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1614
1861
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1615
1862
  }
1616
- for (const e of cur.edgesOut) {
1863
+ for (let k = outStart[cur.id]; k < outStart[cur.id + 1]; k++) {
1617
1864
  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);
1865
+ const e = outMeta[k];
1866
+ const to = nodes[outTo[k]];
1867
+ if (!(canReach[to.id] & reach)) continue;
1868
+ const crossed = cross(state, e, to);
1621
1869
  if (!crossed) continue;
1622
1870
  if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1623
1871
  const { partial, lo, hi, vague, ctx } = crossed;
1624
1872
  if (partial) {
1625
- const pk = `${to.id}|${ctx}`;
1873
+ const pk = atContext(to.id, ctx);
1626
1874
  const held = partSeen.get(pk);
1627
1875
  if (held && held.some(r => (r[2] ? vague : !vague && r[0] <= lo && hi <= r[1]))) continue;
1628
1876
  if (held) held.push([lo, hi, vague]); else partSeen.set(pk, [[lo, hi, vague]]);
@@ -1715,6 +1963,8 @@ export function analyze(root, opts = {}) {
1715
1963
  return { findings, constructs, unstarted, startedBy, stats, sourceKinds: SOURCE_KINDS, sinkKinds: SINK_KINDS, ...(sinks ? { sinks } : {}), ...(sources ? { sources } : {}) };
1716
1964
  }
1717
1965
 
1966
+ export const analyze = (root, opts = {}) => drive(analyzeSteps(root, opts));
1967
+
1718
1968
  // What the size check across a CALL needs from an item: its bytes, the bytes from it to the end of
1719
1969
  // the record it sits in, whether a table inside it is variable, and where it was declared.
1720
1970
  function extentOf(item) {