@portll/cobolwork 0.6.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 (140) hide show
  1. package/README.md +49 -18
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +30 -7
  4. package/lib/bms.mjs +21 -7
  5. package/lib/build.mjs +108 -53
  6. package/lib/capabilities.mjs +9 -10
  7. package/lib/cics-commands.mjs +501 -9
  8. package/lib/compliance.mjs +11 -0
  9. package/lib/consequence.mjs +13 -0
  10. package/lib/control-workers.mjs +161 -0
  11. package/lib/control.mjs +52 -3
  12. package/lib/dataflow.mjs +176 -125
  13. package/lib/db2/cursor.mjs +15 -0
  14. package/lib/db2/read.mjs +170 -0
  15. package/lib/db2/rules.mjs +77 -0
  16. package/lib/db2/stmt/alter.mjs +473 -0
  17. package/lib/db2/stmt/grant.mjs +125 -0
  18. package/lib/db2/stmt/index.mjs +141 -0
  19. package/lib/db2/stmt/misc.mjs +325 -0
  20. package/lib/db2/stmt/routine.mjs +564 -0
  21. package/lib/db2/stmt/storage.mjs +146 -0
  22. package/lib/db2/stmt/table.mjs +540 -0
  23. package/lib/db2/stmt/view.mjs +146 -0
  24. package/lib/diff.mjs +17 -5
  25. package/lib/evidence/cli.mjs +14 -3
  26. package/lib/evidence/record.mjs +1 -1
  27. package/lib/evidence/store.mjs +44 -31
  28. package/lib/exec-reading.mjs +51 -0
  29. package/lib/execution.mjs +3 -2
  30. package/lib/explain.mjs +2 -0
  31. package/lib/exploitability.mjs +11 -2
  32. package/lib/hlasm/asm/data.mjs +13 -1
  33. package/lib/hlasm/asm/sections.mjs +3 -1
  34. package/lib/hlasm/instr.mjs +25 -0
  35. package/lib/hlasm/macro/authorization.mjs +24 -4
  36. package/lib/hlasm/macro/datasets.mjs +30 -17
  37. package/lib/hlasm/macro/io.mjs +68 -45
  38. package/lib/hlasm/macro/le.mjs +2 -2
  39. package/lib/hlasm/macro/operator.mjs +32 -21
  40. package/lib/hlasm/macro/program.mjs +126 -84
  41. package/lib/hlasm/macro/recovery.mjs +10 -5
  42. package/lib/hlasm/macro/storage.mjs +64 -14
  43. package/lib/hlasm/macro/structured.mjs +1 -1
  44. package/lib/hlasm/model.mjs +35 -10
  45. package/lib/hlasm/mvs38.mjs +47 -0
  46. package/lib/hlasm/operands.mjs +10 -0
  47. package/lib/hlasm/optable.mjs +61 -0
  48. package/lib/hlasm/read.mjs +22 -8
  49. package/lib/hlasm.mjs +44 -7
  50. package/lib/ims/dli.mjs +37 -0
  51. package/lib/ims/macro/dbd.mjs +299 -0
  52. package/lib/ims/macro/psb.mjs +286 -0
  53. package/lib/ims/model.mjs +149 -0
  54. package/lib/ims/operands.mjs +23 -0
  55. package/lib/ims/read.mjs +37 -0
  56. package/lib/ims/rules.mjs +135 -0
  57. package/lib/ironwork.mjs +17 -13
  58. package/lib/kernel/pds-archive.mjs +256 -0
  59. package/lib/kernel/registry.mjs +14 -8
  60. package/lib/kernel/shared-pass.mjs +34 -9
  61. package/lib/kernel/source-tree.mjs +104 -36
  62. package/lib/kernel/version-key.mjs +17 -0
  63. package/lib/layout.mjs +26 -31
  64. package/lib/parser.mjs +136 -17
  65. package/lib/pli/cursor.mjs +15 -0
  66. package/lib/pli/expr.mjs +101 -0
  67. package/lib/pli/include.mjs +82 -0
  68. package/lib/pli/layout.mjs +125 -0
  69. package/lib/pli/lex.mjs +198 -0
  70. package/lib/pli/program.mjs +280 -0
  71. package/lib/pli/rules/based.mjs +95 -0
  72. package/lib/pli/rules/conditions.mjs +68 -0
  73. package/lib/pli/rules/entry.mjs +130 -0
  74. package/lib/pli/rules/index.mjs +24 -0
  75. package/lib/pli/rules/preprocessor.mjs +55 -0
  76. package/lib/pli/statements.mjs +130 -0
  77. package/lib/pli/stmt/alloc.mjs +45 -0
  78. package/lib/pli/stmt/assignment.mjs +56 -0
  79. package/lib/pli/stmt/call.mjs +104 -0
  80. package/lib/pli/stmt/conditions.mjs +94 -0
  81. package/lib/pli/stmt/control.mjs +219 -0
  82. package/lib/pli/stmt/declare.mjs +149 -0
  83. package/lib/pli/stmt/exec.mjs +55 -0
  84. package/lib/pli/stmt/io.mjs +239 -0
  85. package/lib/pli/stmt/misc.mjs +4 -0
  86. package/lib/pli/stmt/preprocessor.mjs +242 -0
  87. package/lib/pli/stmt/procedure.mjs +258 -0
  88. package/lib/pli/stmt/stream.mjs +283 -0
  89. package/lib/pli/storage.mjs +129 -0
  90. package/lib/precompile-check.mjs +124 -0
  91. package/lib/precompile-cics.mjs +7 -3
  92. package/lib/reach.mjs +11 -2
  93. package/lib/revision.json +1 -1
  94. package/lib/sarif.mjs +41 -3
  95. package/lib/scan.mjs +7 -1
  96. package/lib/sets/abend.mjs +16 -6
  97. package/lib/sets/cics.mjs +15 -35
  98. package/lib/sets/compile.mjs +41 -15
  99. package/lib/sets/crypto.mjs +5 -3
  100. package/lib/sets/ddl.mjs +36 -0
  101. package/lib/sets/flow.mjs +18 -1
  102. package/lib/sets/hidden.mjs +5 -3
  103. package/lib/sets/hlasm.mjs +72 -12
  104. package/lib/sets/ims.mjs +139 -0
  105. package/lib/sets/log.mjs +6 -6
  106. package/lib/sets/opaque.mjs +27 -7
  107. package/lib/sets/pli.mjs +40 -0
  108. package/lib/sets/recon.mjs +5 -3
  109. package/lib/sets/secrets.mjs +5 -3
  110. package/lib/sets/semantics.mjs +3 -0
  111. package/lib/sets/web.mjs +36 -22
  112. package/lib/site.mjs +10 -0
  113. package/lib/sources.mjs +80 -20
  114. package/lib/statement-cursor.mjs +67 -0
  115. package/lib/verify.mjs +3 -2
  116. package/lib/version.mjs +6 -0
  117. package/package.json +3 -2
  118. package/rules/compliance-cobit2019.json +432 -1
  119. package/rules/compliance-dora.json +414 -1
  120. package/rules/compliance-ffiec.json +414 -1
  121. package/rules/compliance-nist80053.json +466 -1
  122. package/rules/hlasm-optables.json +8024 -0
  123. package/schema/cobolwork-baseline.schema.json +36 -0
  124. package/schema/cobolwork-build-provenance.schema.json +187 -0
  125. package/schema/cobolwork-build.schema.json +382 -0
  126. package/schema/cobolwork-capabilities.schema.json +239 -0
  127. package/schema/cobolwork-diff.schema.json +217 -0
  128. package/schema/cobolwork-evidence.schema.json +161 -0
  129. package/schema/cobolwork-execution.schema.json +53 -0
  130. package/schema/cobolwork-explain.schema.json +360 -0
  131. package/schema/cobolwork-finding.schema.json +465 -0
  132. package/schema/cobolwork-flow.schema.json +465 -0
  133. package/schema/cobolwork-gate.schema.json +211 -0
  134. package/schema/cobolwork-inventory.schema.json +206 -0
  135. package/schema/cobolwork-parse.schema.json +105 -0
  136. package/schema/cobolwork-reach.schema.json +74 -0
  137. package/schema/cobolwork-report.schema.json +559 -0
  138. package/schema/cobolwork-witness.schema.json +107 -0
  139. package/schema/cobolwork.baseline.schema.json +101 -0
  140. package/schema/cobolwork.site.schema.json +116 -0
package/lib/dataflow.mjs CHANGED
@@ -1,11 +1,19 @@
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';
@@ -14,12 +22,14 @@ import { isJcl } from './sources.mjs';
14
22
  import { watchMemoryBuffer } from './kernel/memory.mjs';
15
23
  import { drive, loopOver } from './kernel/shared-pass.mjs';
16
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';
17
27
  import { createHash } from 'node:crypto';
18
28
 
19
29
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
20
30
  // own comparator. The text comparison underneath it is the shared one.
21
31
  import { byText } from './kernel/findings.mjs';
22
- import { buildControl, creditOf, hasFact, sparseFacts, topOf, within, wholeTop } from './control.mjs';
32
+ import { buildControl, creditOf, hasFact, internFacts, namesOf, topOf, within, wholeTop } from './control.mjs';
23
33
  import { directoryTree } from './kernel/source-tree.mjs';
24
34
 
25
35
  // Every reused control analysis checked against the program's own: for the equivalence runs and tests.
@@ -35,6 +45,7 @@ export const SOURCE_KINDS = {
35
45
  'cics-terminal': 'EXEC CICS RECEIVE',
36
46
  'cics-web': 'EXEC CICS WEB RECEIVE or EXTRACT',
37
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',
38
49
  'database': 'host variable filled by EXEC SQL',
39
50
  'jcl-parm': 'PARM= on a JCL EXEC statement',
40
51
  'jcl-instream': 'in-stream data on a JCL DD statement',
@@ -46,7 +57,7 @@ export const SINK_KINDS = {
46
57
  'dynamic-program-load': 'CALL whose target is a variable',
47
58
  'cics-dynamic-transfer': 'EXEC CICS LINK, XCTL or START with a variable program or transaction',
48
59
  'dynamic-sql': 'EXEC SQL EXECUTE IMMEDIATE or PREPARE from a host variable',
49
- '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',
50
61
  'outbound-http': 'EXEC CICS WEB CONVERSE, WEB SEND on a client session, or the containers INVOKE SERVICE sends',
51
62
  'socket-send': 'CALL EZASOKET or EZACICAL with SEND, SENDTO or WRITE',
52
63
  'message-queue': 'CALL MQPUT or MQPUT1',
@@ -105,36 +116,6 @@ function consKey(v) {
105
116
  return `${v instanceof Set ? 'S' : parts ? 'A' : 'O'}[${keys.join(',')}]`;
106
117
  }
107
118
 
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
-
138
119
  // Every literal a program holds that could be a program name: its VALUE clauses and the literals its
139
120
  // statements and commands use. A menu that XCTLs through a table of names starts every one of them.
140
121
  function literalNames(prog) {
@@ -186,18 +167,21 @@ function boundsOf(counter, until) {
186
167
  // nearly every subscript descends from a file record, and reporting those would be reporting batch.
187
168
  const FROM_OUTSIDE = ['argv-or-env', 'cics-terminal', 'cics-web', 'jcl-parm', 'jcl-instream'];
188
169
  // What can reach a CICS command: a batch job never issues one.
189
- const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'database'];
170
+ const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'cics-queue', 'database'];
190
171
  const REFLECTED = ['cics-web', 'cics-terminal'];
191
172
  // What the system says about a failure: a command's RESP, the EIB's copy of it, the SQLCA. Sent to a
192
173
  // web client it describes the system behind the program (CWE-209); written anywhere else it is the
193
174
  // program's own business, so these reach only a response, and Db2's message text the terminal too.
194
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']);
195
178
  const TO_CLIENT = ['web-response', 'http-header'];
196
179
 
197
180
  // Exfiltration is data at rest leaving the program. Terminal or web input reaching the same channel
198
181
  // is the program relaying its own caller's data, and pairing it would fire on every gateway.
199
182
 
200
183
  // A trace longer than this keeps its two ends and says how many hops it dropped.
184
+
201
185
  export const TRACE_MAX = 64;
202
186
  export const TRACE_KEEP = 24;
203
187
  // Edges one source's walk may examine, and all walks together: about fifteen times what the largest
@@ -205,7 +189,7 @@ export const TRACE_KEEP = 24;
205
189
  export const WALK_EDGES = 5_000_000;
206
190
  export const TOTAL_EDGES = 500_000_000;
207
191
 
208
- const DATA_AT_REST = ['database', 'file-record'];
192
+ const DATA_AT_REST = ['database', 'file-record', 'cics-queue'];
209
193
  // The class tests a condition can make, which restrict what a field holds rather than compare it.
210
194
  const CLASS_TEST = new Set(['NUMERIC', 'ALPHABETIC', 'ALPHABETIC-LOWER', 'ALPHABETIC-UPPER', 'POSITIVE', 'NEGATIVE']);
211
195
  const SOCKET_ROUTINE = /^(EZASOKET|EZACICAL)$/i;
@@ -215,27 +199,6 @@ const SOCKET_SEND = /^(SEND|SENDTO|WRITE|SENDMSG|WRITEV)$/i;
215
199
  // MsgDesc, PutMsgOpts, BufferLength, Buffer, …): the buffer is the sixth argument of both.
216
200
  const MQ_BUFFER_ARG = { MQPUT: 5, MQPUT1: 5 };
217
201
 
218
- function execOptions(exec) {
219
- const opts = new Map();
220
- const toks = exec.toks;
221
- const words = [];
222
- for (let i = 0; i < toks.length; i++) {
223
- const t = toks[i];
224
- if (t.t !== 'word') continue;
225
- words.push(t.u);
226
- if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
227
- const inner = [];
228
- let depth = 1;
229
- for (let k = i + 2; k < toks.length && depth > 0; k++) {
230
- if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
231
- inner.push(toks[k]);
232
- }
233
- if (!opts.has(t.u)) opts.set(t.u, inner);
234
- }
235
- }
236
- return { opts, words };
237
- }
238
-
239
202
  export function* analyzeSteps(root, opts = {}) {
240
203
  const repos = opts.repos || [''];
241
204
  const stats = { files: 0, programs: 0, edges: 0, nodes: 0, threw: 0, overBudget: 0, unreadable: 0, ebcdic: 0,
@@ -335,16 +298,18 @@ export function* analyzeSteps(root, opts = {}) {
335
298
  const textKey = (src) => createHash('sha1').update(src).digest('base64');
336
299
  function repeatedTexts(tree, files) {
337
300
  const count = new Map();
301
+ const later = new Set();
338
302
  for (const f of files) {
339
303
  let src;
340
304
  try { src = tree.text(f).text; } catch { continue; }
341
305
  if (src.length < reuseMin) continue;
342
306
  const k = textKey(src);
307
+ if (count.has(k)) later.add(f);
343
308
  count.set(k, (count.get(k) || 0) + 1);
344
309
  }
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;
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 };
348
313
  }
349
314
  const copyText = new Map();
350
315
  const copyKey = (r, tree) => r.copies.map((c) => {
@@ -352,20 +317,21 @@ export function* analyzeSteps(root, opts = {}) {
352
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); }
353
318
  return `${c.status}:${copyText.get(c.path)}`;
354
319
  }).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) {
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) {
358
323
  let own = null;
359
- try { own = buildControl(prog, resolver); } catch { /* the kept one threw too, or it differs */ }
324
+ try { own = buildControl(prog, resolver); } catch { /* the restored one threw too, or it differs */ }
360
325
  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`);
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`);
362
327
  }
363
328
 
364
- function summarise(prog, file, pk, options = [], reuse = null) {
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) {
365
332
  programOf[pk] = { id: prog.id, file };
366
333
  const byItem = new Map();
367
- const byName = new Map();
368
- 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);
369
335
  const nodeOf = (item) => {
370
336
  let n = byItem.get(item);
371
337
  if (!n) {
@@ -394,10 +360,7 @@ export function* analyzeSteps(root, opts = {}) {
394
360
  }
395
361
  return n;
396
362
  };
397
- // Names inside EXEC blocks never pass through the parser's reference resolution, so fall back to the name.
398
- const itemOfToken = (tok) => (tok ? prog.resolved.get(tok) || (tok.t === 'word' ? byName.get(tok.u) || null : null) : null);
399
363
  // An INDEXED BY name is not a data item, so it needs a node of its own to carry taint.
400
- const indexNames = new Set(prog.items.flatMap((it) => it.indexNames || []));
401
364
  const indexNodes = new Map();
402
365
  const indexNode = (name) => {
403
366
  let n = indexNodes.get(name);
@@ -412,10 +375,11 @@ export function* analyzeSteps(root, opts = {}) {
412
375
  // A system field the program never declares - the EIB's, or an SQLCA the precompiler supplies -
413
376
  // still holds a value, so it needs a node of its own, as an index name does.
414
377
  const systemNodes = new Map();
378
+ const systemFields = isPli(file) ? PLI_SYSTEM_FIELDS : SYSTEM_FIELDS;
415
379
  // A code on a terminal's error line is ordinary; Db2's message text names tables, columns and
416
380
  // constraints, so only SQLERRMC reaches the screen sink.
417
381
  const systemSource = (n, detail, at) => {
418
- const onlyTo = /^SQLERRMC\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
382
+ const onlyTo = /^SQLERRMC?\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
419
383
  if (!n.sources.some((x) => x.kind === 'system-response')) addSource(n, { kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
420
384
  };
421
385
  const systemNode = (tok) => {
@@ -434,30 +398,34 @@ export function* analyzeSteps(root, opts = {}) {
434
398
  if (it) return nodeOf(it);
435
399
  if (!tok || tok.t !== 'word') return null;
436
400
  if (indexNames.has(tok.u)) return indexNode(tok.u);
437
- return SYSTEM_FIELDS.has(tok.u) ? systemNode(tok) : null;
401
+ return systemFields.has(tok.u) ? systemNode(tok) : null;
438
402
  };
439
403
  // The program's statement order, where its structure could be read. Without it a check is
440
404
  // credited as it always was, wherever its field is used, and never clears anything.
441
405
  let ctl = null;
442
- const resolver = (tok) => itemOfToken(tok) || (tok && tok.t === 'word' && indexNames.has(tok.u) ? { index: tok.u } : null);
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.
443
408
  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);
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);
448
415
  } else {
449
- try { ctl = buildControl(prog, resolver); } catch { stats.programsUnordered++; }
416
+ let threw = false;
417
+ try { ctl = buildControl(prog, resolver); } catch { threw = true; stats.programsUnordered++; }
450
418
  if (ctl) internFacts(ctl.facts);
451
419
  if (reuse) {
452
420
  const value = ctl ? keep(ctl, parseOrder(prog), reuse.files) : null;
453
- if (!ctl || value) reuse.kept.set(reuse.key, ctl ? { value } : { threw: true });
421
+ if (!ctl || value) reuse.kept.set(reuse.key, value ? { value } : threw ? { threw: true } : { none: true });
454
422
  }
455
423
  }
456
424
  const pointOf = (x) => (ctl ? ctl.nodeOf.get(x) : undefined);
457
425
  // Where a name inside a condition is read, which can know more than the statement does.
458
426
  const placeOf = (tok, st) => (ctl && ctl.posOf.get(tok)) ?? pointOf(st);
459
427
 
460
- 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 });
461
429
  // What a program in a CICS region DISPLAYs goes to its CESE and CESO queues, a log. Terminal or web
462
430
  // input written there unchecked can carry the control characters that start a line the program
463
431
  // did not write (CWE-117). A batch program's DISPLAY is its job's own log, which no terminal
@@ -512,15 +480,37 @@ export function* analyzeSteps(root, opts = {}) {
512
480
  const recordOf = (it) => { let top = it; while (top.parent) top = top.parent; return top; };
513
481
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
514
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
+ };
515
497
  for (const c of ctl.checks) {
516
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 } : {}) };
517
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));
518
506
  const span = extent(c.field);
519
- for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
520
- if (it.level === 88) continue;
521
- const e = extent(it);
522
- 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);
523
512
  }
513
+ for (const i of [...inside].sort((a, b) => a - b)) (nodeOf(items[i]).checks ||= []).push(entry);
524
514
  }
525
515
  }
526
516
  // Without the order, a condition that restricts what an item may hold is credited wherever the
@@ -771,6 +761,13 @@ export function* analyzeSteps(root, opts = {}) {
771
761
  const a = c.using[0];
772
762
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
773
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}` });
774
771
  } else if (String(c.name).toUpperCase() === 'CEEGTST') {
775
772
  // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
776
773
  const a = c.using[1];
@@ -891,25 +888,24 @@ export function* analyzeSteps(root, opts = {}) {
891
888
  const changes = new Map();
892
889
  for (const e of prog.execs) {
893
890
  if (e.kind !== 'CICS') continue;
894
- const { opts: o, words } = execOptions(e);
895
- 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;
896
893
  const file = optionName(o, 'DATASET') || optionName(o, 'FILE');
897
- 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');
898
895
  }
899
896
  for (const e of prog.execs) {
900
- const { opts: o, words } = execOptions(e);
897
+ const { opts: o, words, verb, sub, command, direction } = execReading(e);
901
898
  const at = { file: e.file, line: e.line, point: pointOf(e) };
902
899
  if (e.kind === 'CICS') {
903
- const verb = words[0];
904
900
  const intoTok = (o.get('INTO') || o.get('SET') || [])[0];
905
901
  for (const opt of ['RESP', 'RESP2']) {
906
902
  const r = nodeOfToken((o.get(opt) || [])[0]);
907
903
  if (r) systemSource(r, `the ${opt} of EXEC CICS ${verb}`, at);
908
904
  }
909
- if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(words[1])) {
910
- const opt = words[1] === 'OPERATOR' ? 'TEXT' : 'FROM';
905
+ if (verb === 'WRITE' && ['OPERATOR', 'JOURNALNAME', 'JOURNALNUM'].includes(sub)) {
906
+ const opt = sub === 'OPERATOR' ? 'TEXT' : 'FROM';
911
907
  const n = nodeOfToken((o.get(opt) || [])[0]);
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'}` });
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'}` });
913
909
  }
914
910
  if (verb === 'RECEIVE') {
915
911
  const isWeb = words.includes('WEB');
@@ -924,12 +920,12 @@ export function* analyzeSteps(root, opts = {}) {
924
920
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
925
921
  }
926
922
  // What the terminal shows: SEND TEXT's FROM, or the map's output record, <map>O without FROM.
927
- if (verb === 'SEND' && (words[1] === 'TEXT' || words[1] === 'MAP')) {
923
+ if (verb === 'SEND' && (sub === 'TEXT' || sub === 'MAP')) {
928
924
  const from = nodeOfToken((o.get('FROM') || [])[0]);
929
- const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
925
+ const map = sub === 'MAP' ? optionName(o, 'MAP') : null;
930
926
  const sym = !from && map ? byName.get(`${map}O`) : null;
931
927
  const n = from || (sym ? nodeOf(sym) : null);
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` });
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` });
933
929
  }
934
930
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
935
931
  // user types is how a lookup works; a key the program put in a protected field and read back
@@ -956,11 +952,11 @@ export function* analyzeSteps(root, opts = {}) {
956
952
  if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
957
953
  }
958
954
  // A service call sends every container on its channel, and its URI decides where they go.
959
- if (verb === 'INVOKE' && (words[1] === 'SERVICE' || words[1] === 'WEBSERVICE')) {
960
- 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') });
961
957
  for (const opt of ['URI', 'URIMAP']) {
962
958
  const n = nodeOfToken((o.get(opt) || [])[0]);
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` });
959
+ if (n) addSink(n, { kind: 'outbound-host', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS INVOKE ${sub} ${opt}(...), which decides where the request goes` });
964
960
  }
965
961
  }
966
962
  if (verb === 'GET' && o.has('CONTAINER')) {
@@ -970,17 +966,17 @@ export function* analyzeSteps(root, opts = {}) {
970
966
  }
971
967
  // CONVERSE is always a client call; SEND is one only on a session this program opened. A
972
968
  // plain WEB SEND answers the request that started the program, which is its job.
973
- if (verb === 'WEB' && (words[1] === 'CONVERSE' || (words[1] === 'SEND' && o.has('SESSTOKEN')))) {
969
+ if (verb === 'WEB' && (sub === 'CONVERSE' || (sub === 'SEND' && o.has('SESSTOKEN')))) {
974
970
  const n = nodeOfToken((o.get('FROM') || [])[0]);
975
- if (n) addSink(n, { 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` });
976
972
  // The request may be a container rather than a field.
977
973
  const sent = o.has('CONTAINER') ? optionName(o, 'CONTAINER') : null;
978
- if (sent) addSink(containerNode(sent), { 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})` });
979
975
  }
980
976
  // The response this program returns to its own caller, and the document built for it. CICS
981
977
  // escapes nothing on the way out - DOCUMENT SET's UNESCAPED option is about URL decoding,
982
978
  // not HTML - so whatever reaches here is what the browser is handed.
983
- if (verb === 'WEB' && words[1] === 'SEND' && !o.has('SESSTOKEN')) {
979
+ if (verb === 'WEB' && sub === 'SEND' && !o.has('SESSTOKEN')) {
984
980
  const n = nodeOfToken((o.get('FROM') || [])[0]);
985
981
  // A stored value becomes script only where the reply is markup, and a web program
986
982
  // answering with a database row is the ordinary shape of one. Input echoed back is a
@@ -990,28 +986,37 @@ export function* analyzeSteps(root, opts = {}) {
990
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()}` : ''}` });
991
987
  }
992
988
  if (verb === 'DOCUMENT') {
993
- const what = words[1] === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
989
+ const what = sub === 'SET' ? ['VALUE', 'SYMBOLLIST'] : ['TEXT', 'FROM', 'BINARY'];
994
990
  for (const opt of what) {
995
991
  const n = nodeOfToken((o.get(opt) || [])[0]);
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` });
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` });
997
993
  }
998
994
  }
999
- if (verb === 'WEB' && words[1] === 'WRITE' && o.has('HTTPHEADER')) {
995
+ if (verb === 'WEB' && sub === 'WRITE' && o.has('HTTPHEADER')) {
1000
996
  for (const opt of ['HTTPHEADER', 'VALUE']) {
1001
997
  const n = nodeOfToken((o.get(opt) || [])[0]);
1002
998
  if (n) addSink(n, { kind: 'http-header', onlyFrom: [...IN_CICS, 'system-response'], ...at, detail: `EXEC CICS WEB WRITE HTTPHEADER ${opt}(...)` });
1003
999
  }
1004
1000
  }
1005
- if (verb === 'WEB' && (words[1] === 'OPEN' || words[1] === 'CONVERSE')) {
1001
+ if (verb === 'WEB' && (sub === 'OPEN' || sub === 'CONVERSE')) {
1006
1002
  for (const opt of ['HOST', 'URIMAP', 'PATH']) {
1007
1003
  const n = nodeOfToken((o.get(opt) || [])[0]);
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` });
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` });
1009
1014
  }
1010
1015
  }
1011
1016
  // The queue a command acts on, as distinct from what it writes there: a name from input
1012
1017
  // reads, overwrites or deletes whatever queue the input asks for.
1013
1018
  if (['WRITEQ', 'READQ', 'DELETEQ'].includes(verb)) {
1014
- const ts = words[1] === 'TS' || !words.includes('TD');
1019
+ const ts = sub === 'TS' || !words.includes('TD');
1015
1020
  for (const opt of ['QUEUE', 'QNAME']) {
1016
1021
  const n = nodeOfToken((o.get(opt) || [])[0]);
1017
1022
  if (n) addSink(n, { kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
@@ -1024,23 +1029,40 @@ export function* analyzeSteps(root, opts = {}) {
1024
1029
  const n = nodeOfToken((o.get('SYSID') || [])[0]);
1025
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` });
1026
1031
  }
1027
- if (verb === 'SET' && words[1] && o.has(words[1])) {
1028
- const n = nodeOfToken((o.get(words[1]) || [])[0]);
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` });
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` });
1030
1038
  }
1031
1039
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
1032
1040
  const n = nodeOfToken(intoTok);
1033
1041
  if (n && (o.has('FILE') || o.has('DATASET'))) addSource(n, { kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
1034
1042
  }
1035
1043
  // A CONVERSE response is the remote server's data, which is untrusted input like any other.
1036
- if ((verb === 'WEB' && words[1] !== 'SEND') || verb === 'EXTRACT') {
1044
+ if ((verb === 'WEB' && sub !== 'SEND') || verb === 'EXTRACT') {
1045
+ const n = nodeOfToken(intoTok);
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') {
1037
1059
  const n = nodeOfToken(intoTok);
1038
- if (n) addSource(n, { kind: 'cics-web', ...at, detail: `EXEC CICS ${words.slice(0, 2).join(' ')}` });
1060
+ if (n) addSource(n, { kind: 'cics-queue', ...at, detail: `EXEC CICS ${command} INTO` });
1039
1061
  }
1040
1062
  // Where a transient-data write lands is not in the program: the CSD maps the queue to a DD
1041
1063
  // and the region's JCL maps the DD to a destination. Collected here, resolved once every
1042
1064
  // definition has been read.
1043
- if (verb === 'WRITEQ' && words[1] === 'TD') {
1065
+ if (verb === 'WRITEQ' && sub === 'TD') {
1044
1066
  const from = nodeOfToken((o.get('FROM') || [])[0]);
1045
1067
  const qTok = (o.get('QUEUE') || o.get('TDQUEUE') || [])[0];
1046
1068
  const qItem = qTok && qTok.t === 'word' ? itemOfToken(qTok) : null;
@@ -1068,11 +1090,16 @@ export function* analyzeSteps(root, opts = {}) {
1068
1090
  if (t && t.t === 'lit') startsTransactions.add(String(t.v).trim().toUpperCase());
1069
1091
  }
1070
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
+ }
1071
1099
  if (e.kind === 'SQL') {
1072
1100
  // A host variable names the item a qualified :G.F resolves to; the statement fills the ones
1073
1101
  // Db2 writes, and those hold what the database held.
1074
1102
  const hv = e.hostVariables || hostVariablesIn(e.toks);
1075
- const verb = words[0];
1076
1103
  for (const h of hv) {
1077
1104
  if (!h.written) continue;
1078
1105
  const n = nodeOfToken(h.tok);
@@ -1128,6 +1155,16 @@ export function* analyzeSteps(root, opts = {}) {
1128
1155
  const tree = (!repo && opts.tree) ? opts.tree : directoryTree(base, opts);
1129
1156
  const idx = tree.index;
1130
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
+ };
1131
1168
  for (const f of tree.list().filter(isJcl).filter(inScope(opts))) { jclPending.push(f); jclTrees.set(f, tree); }
1132
1169
  for (const f of tree.list().filter((p) => /\.csd$/i.test(p)).filter(inScope(opts))) {
1133
1170
  try { addCsd(tree.text(f).text, f); } catch (e) { stats.unreadable++; unread.push(`${tree.rel(f)}: ${e.code || e.name}`); }
@@ -1146,26 +1183,38 @@ export function* analyzeSteps(root, opts = {}) {
1146
1183
  // edges are proportional to data items and statements, not to bytes, so a repository of small
1147
1184
  // programs with large record layouts can exhaust a heap the budget believes is untouched. The
1148
1185
  // shared loop watches the heap itself, and the budget stays as the cheaper first line.
1149
- const repeated = repeatedTexts(tree, files);
1150
- const run = yield loopOver(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) => {
1151
1195
  let src;
1152
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; }
1153
- 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;
1154
1199
  if (held + src.length > budget) { stats.overBudget++; return 0; }
1155
1200
  held += src.length;
1156
1201
  let r;
1157
1202
  try {
1158
- r = tree.parse(f, src);
1203
+ r = pli ? parsePliSource(src, f, { readMember }) : tree.parse(f, src);
1159
1204
  } catch (e) { stats.threw++; unparsed.push(`${tree.rel(f)}: ${e.code || e.name}`); return src.length; }
1160
1205
  stats.files++;
1161
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;
1162
1209
  r.programs.forEach((p, k) => {
1163
- programs.push(summarise(p, f, programs.length, r.options, copied ? reuseOf(copied, k, r, f, tree) : null));
1210
+ programs.push(summarise(p, f, programs.length, r.options, copied ? reuseOf(copied, k, r, f, tree) : null, builtFiles && { ...answer[k], files: builtFiles }));
1164
1211
  stats.programs++;
1165
1212
  });
1166
1213
  if (copied && --copied.left === 0) repeated.delete(copied.text);
1167
1214
  return src.length;
1168
- }, { label: 'flow', watcher: memoryWatcher });
1215
+ };
1216
+ let run;
1217
+ try { run = yield loopOver(files, readProgram, { label: 'flow', watcher: memoryWatcher }); } finally { ahead?.close(); }
1169
1218
 
1170
1219
  // A file the loop never reached is not the same as one past the byte budget, and they are
1171
1220
  // counted separately so a report can say which limit it hit.
@@ -1565,12 +1614,12 @@ export function* analyzeSteps(root, opts = {}) {
1565
1614
  // stand in for it.
1566
1615
  const guarded = since.some((n) => n.checks || n.guard);
1567
1616
  if (t.partial) {
1568
- const key = `${to.id}|${guarded}|${t.ctx}`;
1617
+ const key = atContext(to.id, t.ctx, guarded ? 1 : 0);
1569
1618
  const held = parts.get(key);
1570
1619
  if (held && held.some((r) => (r[2] ? t.vague : !t.vague && r[0] <= t.lo && t.hi <= r[1]))) continue;
1571
1620
  if (held) held.push([t.lo, t.hi, t.vague]); else parts.set(key, [[t.lo, t.hi, t.vague]]);
1572
1621
  } else if (guarded) {
1573
- const dk = `${to.id}|${t.ctx}`;
1622
+ const dk = atContext(to.id, t.ctx);
1574
1623
  if (stampWhole[to.id] === walk || dirty.get(dk) === walk) continue;
1575
1624
  dirty.set(dk, walk);
1576
1625
  } else if (!firstVisit(to.id, t.ctx)) continue;
@@ -1594,10 +1643,12 @@ export function* analyzeSteps(root, opts = {}) {
1594
1643
  const stampWhole = new Int32Array(nodes.length);
1595
1644
  // A whole node reached with a call context, per walk. Reached with none, it stands in for every context.
1596
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;
1597
1648
  const firstVisit = (id, ctx) => {
1598
1649
  if (stampWhole[id] === walk) return false;
1599
1650
  if (!ctx) { stampWhole[id] = walk; return true; }
1600
- const key = `${id}|${ctx}`;
1651
+ const key = atContext(id, ctx);
1601
1652
  if (stampCtx.get(key) === walk) return false;
1602
1653
  stampCtx.set(key, walk);
1603
1654
  return true;
@@ -1819,7 +1870,7 @@ export function* analyzeSteps(root, opts = {}) {
1819
1870
  if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
1820
1871
  const { partial, lo, hi, vague, ctx } = crossed;
1821
1872
  if (partial) {
1822
- const pk = `${to.id}|${ctx}`;
1873
+ const pk = atContext(to.id, ctx);
1823
1874
  const held = partSeen.get(pk);
1824
1875
  if (held && held.some(r => (r[2] ? vague : !vague && r[0] <= lo && hi <= r[1]))) continue;
1825
1876
  if (held) held.push([lo, hi, vague]); else partSeen.set(pk, [[lo, hi, vague]]);
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The Db2 statement cursor: the shared token cursor, failing with Db2Syntax.
3
+ import { tokenCursor, split } from '../statement-cursor.mjs';
4
+
5
+ export class Db2Syntax extends Error {
6
+ constructor(message, tok) {
7
+ super(message);
8
+ this.name = 'Db2Syntax';
9
+ this.line = tok ? tok.line : null;
10
+ this.col = tok ? tok.col : null;
11
+ }
12
+ }
13
+
14
+ export const cursor = (toks) => tokenCursor(toks, Db2Syntax);
15
+ export { split };