@portll/cobolwork 0.2.75 → 0.2.117

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 (52) hide show
  1. package/README.md +69 -15
  2. package/bin/cobolwork.mjs +126 -9
  3. package/lib/arith.mjs +235 -0
  4. package/lib/baseline.mjs +5 -0
  5. package/lib/build.mjs +112 -30
  6. package/lib/capabilities.mjs +12 -7
  7. package/lib/consequence.mjs +29 -0
  8. package/lib/control.mjs +1052 -91
  9. package/lib/csd.mjs +17 -2
  10. package/lib/dataflow.mjs +245 -78
  11. package/lib/enterprise-options.mjs +255 -0
  12. package/lib/equivalence.mjs +124 -0
  13. package/lib/evidence/cli.mjs +65 -0
  14. package/lib/evidence/journal.mjs +85 -0
  15. package/lib/evidence/record.mjs +110 -0
  16. package/lib/evidence/run.mjs +111 -0
  17. package/lib/evidence/seal.mjs +177 -0
  18. package/lib/evidence/slsa.mjs +64 -0
  19. package/lib/evidence/sshsig.mjs +172 -0
  20. package/lib/evidence/store.mjs +170 -0
  21. package/lib/evidence/verify.mjs +167 -0
  22. package/lib/explain.mjs +4 -1
  23. package/lib/exploitability.mjs +263 -0
  24. package/lib/ironwork.mjs +111 -0
  25. package/lib/kernel/findings.mjs +11 -0
  26. package/lib/kernel/registry.mjs +4 -0
  27. package/lib/option-diff.mjs +49 -0
  28. package/lib/options.mjs +109 -46
  29. package/lib/parser.mjs +29 -7
  30. package/lib/policy.mjs +8 -3
  31. package/lib/precompile.mjs +4 -0
  32. package/lib/reach.mjs +29 -12
  33. package/lib/revision.json +1 -1
  34. package/lib/sarif.mjs +1 -0
  35. package/lib/sbom.mjs +147 -0
  36. package/lib/scan.mjs +93 -27
  37. package/lib/sets/cics.mjs +114 -0
  38. package/lib/sets/flow.mjs +82 -2
  39. package/lib/sets/jcl.mjs +67 -0
  40. package/lib/sets/semantics.mjs +377 -0
  41. package/lib/sets/web.mjs +34 -10
  42. package/lib/sets/zowe.mjs +220 -0
  43. package/lib/sources.mjs +10 -0
  44. package/lib/tui/app.mjs +31 -13
  45. package/lib/tui/model.mjs +17 -2
  46. package/lib/utilities.mjs +85 -13
  47. package/lib/verify.mjs +128 -0
  48. package/package.json +1 -1
  49. package/rules/compliance-dora.json +507 -0
  50. package/rules/compliance-ffiec.json +507 -0
  51. package/rules/compliance-nist80053.json +507 -0
  52. package/schema/cobolwork.policy.schema.json +2 -1
package/lib/csd.mjs CHANGED
@@ -13,11 +13,13 @@ const COMMAND = /^\s*(DEFINE|ADD|ALTER|APPEND|COPY|DELETE|INITIALIZE|LIST|REMOVE
13
13
 
14
14
  // { tdqueues: Map(name -> { type, ddname, indirect, line }),
15
15
  // transactions: Map(name -> { program, cmdsec, ressec, line }),
16
- // tcpipservices: Map(name -> { port, protocol, ssl, transaction, line }) }
16
+ // tcpipservices: Map(name -> { port, protocol, ssl, authenticate, transaction, line }),
17
+ // urimaps: Map(name -> { usage, path, program, transaction, tcpipservice, line }) }
17
18
  export function parseCsd(text) {
18
19
  const tdqueues = new Map();
19
20
  const transactions = new Map();
20
21
  const tcpipservices = new Map();
22
+ const urimaps = new Map();
21
23
  const blocks = [];
22
24
  let cur = null;
23
25
  const lines = String(text).split(/\r?\n/);
@@ -48,9 +50,22 @@ export function parseCsd(text) {
48
50
  port: attrs.get('PORTNUMBER') || null,
49
51
  protocol: attrs.get('PROTOCOL') || null,
50
52
  ssl: attrs.get('SSL') || null,
53
+ // Absent is NO, as for SSL: a request arrives under the region's default user.
54
+ authenticate: attrs.get('AUTHENTICATE') || null,
51
55
  transaction: attrs.get('TRANSACTION') || null,
52
56
  line: b.line,
53
57
  });
58
+ } else if (kind === 'URIMAP') {
59
+ // PATH keeps its case: a URI path is case-sensitive, and it is printed, not compared.
60
+ const path = /\bPATH\s*\(([^)]{0,256})\)/i.exec(b.text.slice(head[0].length));
61
+ urimaps.set(name, {
62
+ usage: attrs.get('USAGE') || null,
63
+ path: path ? path[1].trim() : null,
64
+ program: attrs.get('PROGRAM') || null,
65
+ transaction: attrs.get('TRANSACTION') || null,
66
+ tcpipservice: attrs.get('TCPIPSERVICE') || null,
67
+ line: b.line,
68
+ });
54
69
  } else if (kind === 'TDQUEUE') {
55
70
  tdqueues.set(name, { type: attrs.get('TYPE') || null, ddname: attrs.get('DDNAME') || null, indirect: attrs.get('INDIRECTNAME') || null, transid: attrs.get('TRANSID') || null, line: b.line });
56
71
  } else if (kind === 'TRANSACTION') {
@@ -65,7 +80,7 @@ export function parseCsd(text) {
65
80
  });
66
81
  }
67
82
  }
68
- return { tdqueues, transactions, tcpipservices };
83
+ return { tdqueues, transactions, tcpipservices, urimaps };
69
84
  }
70
85
 
71
86
  // The DD a queue is written to, following one INDIRECT hop. Null when the definition does not say:
package/lib/dataflow.mjs CHANGED
@@ -12,7 +12,7 @@ import { eachWithinMemory, watchMemoryBuffer } from './kernel/memory.mjs';
12
12
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
13
13
  // own comparator. The text comparison underneath it is the shared one.
14
14
  import { byText } from './kernel/findings.mjs';
15
- import { buildControl, creditOf, hasFact } from './control.mjs';
15
+ import { buildControl, creditOf, hasFact, within } from './control.mjs';
16
16
  import { directoryTree } from './kernel/source-tree.mjs';
17
17
 
18
18
  const OS_COMMAND_ROUTINE = /^(SYSTEM|C\$SYSTEM|CBL_EXEC_RUN_UNIT|CBL_GC_HOSTED|BXPSYSTM)$/i;
@@ -51,6 +51,12 @@ export const SINK_KINDS = {
51
51
  'record-key': 'the RIDFLD of EXEC CICS READ, STARTBR or RESETBR, which decides which record is read',
52
52
  'record-update': 'the RIDFLD of a READ UPDATE whose record the program then rewrites or deletes, or of a DELETE',
53
53
  'log': 'what a program in a CICS region writes to a log: DISPLAY, EXEC CICS WRITEQ TD to a queue that neither starts a transaction nor feeds the internal reader, WRITE JOURNALNAME or WRITE OPERATOR',
54
+ 'storage-length': 'how much storage a program acquires: the FLENGTH or LENGTH of EXEC CICS GETMAIN, or the size CALL CEEGTST is given',
55
+ 'xml-document': 'the document XML PARSE reads',
56
+ 'connection-target': 'the database or queue manager a program connects to: the host variable EXEC SQL CONNECT TO or SET CONNECTION names, or the first argument of CALL MQCONN or MQCONNX',
57
+ 'screen': 'what a CICS program shows its terminal: EXEC CICS SEND TEXT FROM, SEND MAP FROM, or the map\'s output record',
58
+ 'cics-sysid': 'the SYSID of an EXEC CICS command, which decides the region the command is shipped to',
59
+ 'cics-system-resource': 'the resource an EXEC CICS SET names, which a system-programming command then changes',
54
60
  };
55
61
 
56
62
  const ARITHMETIC = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE']);
@@ -126,7 +132,7 @@ const IN_CICS = ['cics-web', 'cics-terminal', 'file-record', 'database'];
126
132
  const REFLECTED = ['cics-web', 'cics-terminal'];
127
133
  // What the system says about a failure: a command's RESP, the EIB's copy of it, the SQLCA. Sent to a
128
134
  // web client it describes the system behind the program (CWE-209); written anywhere else it is the
129
- // program's own business, so these reach only a response.
135
+ // program's own business, so these reach only a response, and Db2's message text the terminal too.
130
136
  const SYSTEM_FIELDS = new Set(['EIBRESP', 'EIBRESP2', 'SQLCODE', 'SQLSTATE', 'SQLERRMC']);
131
137
  const TO_CLIENT = ['web-response', 'http-header'];
132
138
 
@@ -219,11 +225,12 @@ export function analyze(root, opts = {}) {
219
225
  const jclPending = [];
220
226
  // CICS definitions, from CSD extracts and from DFHCSDUP job input, and the estate's own statement
221
227
  // of which DDs and queues reach the internal reader.
222
- const csd = { tdqueues: new Map(), transactions: new Map() };
228
+ const csd = { tdqueues: new Map(), transactions: new Map(), urimaps: new Map() };
223
229
  const addCsd = (text, file, lineBase = 0) => {
224
230
  const c = parseCsd(text);
225
231
  for (const [k, v] of c.tdqueues) if (!csd.tdqueues.has(k)) csd.tdqueues.set(k, v);
226
232
  for (const [k, v] of c.transactions) if (!csd.transactions.has(k)) csd.transactions.set(k, { ...v, file, line: v.line + lineBase });
233
+ for (const [k, v] of c.urimaps) if (!csd.urimaps.has(k)) csd.urimaps.set(k, { ...v, file, line: v.line + lineBase });
227
234
  };
228
235
  const jobSteps = [];
229
236
  // The maps of the repository being read, by MAPSET/MAP and by MAP alone.
@@ -286,8 +293,11 @@ export function analyze(root, opts = {}) {
286
293
  // A system field the program never declares - the EIB's, or an SQLCA the precompiler supplies -
287
294
  // still holds a value, so it needs a node of its own, as an index name does.
288
295
  const systemNodes = new Map();
296
+ // A code on a terminal's error line is ordinary; Db2's message text names tables, columns and
297
+ // constraints, so only SQLERRMC reaches the screen sink.
289
298
  const systemSource = (n, detail, at) => {
290
- if (!n.sources.some((x) => x.kind === 'system-response')) n.sources.push({ kind: 'system-response', onlyTo: TO_CLIENT, noCredit: true, ...at, detail });
299
+ const onlyTo = /^SQLERRMC\b/.test(detail) ? [...TO_CLIENT, 'screen'] : TO_CLIENT;
300
+ if (!n.sources.some((x) => x.kind === 'system-response')) n.sources.push({ kind: 'system-response', onlyTo, noCredit: true, ...at, detail });
291
301
  };
292
302
  const systemNode = (tok) => {
293
303
  let n = systemNodes.get(tok.u);
@@ -332,6 +342,13 @@ export function analyze(root, opts = {}) {
332
342
  if (n) n.sinks.push({ kind: 'log', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `DISPLAY ${n.name}${device}` });
333
343
  }
334
344
  }
345
+ // The document XML PARSE reads is the operand after PARSE; what the parser does with a DTD in
346
+ // it is the compiler's XMLPARSE option, which nothing here reads.
347
+ for (const st of prog.statements) {
348
+ if (st.verb !== 'XML' || !st.sources[0] || st.sources[0].u !== 'PARSE') continue;
349
+ const n = st.sources[1] ? nodeOfToken(st.sources[1]) : null;
350
+ if (n) n.sinks.push({ kind: 'xml-document', onlyFrom: REFLECTED, file: st.file, line: st.line, point: pointOf(st), detail: `XML PARSE ${n.name}` });
351
+ }
335
352
  for (const it of prog.items) {
336
353
  const n = nodeOf(it);
337
354
  // A tainted child makes its group partly tainted; a tainted group taints every child. The
@@ -366,7 +383,7 @@ export function analyze(root, opts = {}) {
366
383
  for (const it of prog.items) { const top = recordOf(it); if (!byRecord.has(top)) byRecord.set(top, []); byRecord.get(top).push(it); }
367
384
  const extent = (it) => (it.offset != null && it.size != null ? [it.offset, it.offset + (it.contributes || it.size)] : null);
368
385
  for (const c of ctl.checks) {
369
- 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 } : {}) };
386
+ 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 } : {}) };
370
387
  if (c.field.index) { (indexNode(c.field.index).checks ||= []).push(entry); continue; }
371
388
  const span = extent(c.field);
372
389
  for (const it of byRecord.get(recordOf(c.field)) || [c.field]) {
@@ -411,9 +428,16 @@ export function analyze(root, opts = {}) {
411
428
  if (host.size == null || variable(host)) return null;
412
429
  return (host.children || []).some((c) => c.level !== 88) ? host.size / Math.max(1, host.occurs || 1) : host.size;
413
430
  };
431
+ // A whole number no sign is written on, which no value put in it can leave below 0.
432
+ const unsignedWhole = (it) => it.level !== 88 && !it.index && !(it.children || []).length
433
+ && /^9+$/.test(String(it.picture || '').toUpperCase().replace(/(\w)\((\d+)\)/g, (_, ch, n) => ch.repeat(Number(n))));
414
434
  const indexed = new Set();
415
435
  const subscripting = new Map();
436
+ // Each subscript use of a name, with the other indices of the same reference: the loop-bound
437
+ // credit needs every one of them kept in range.
438
+ const usesOf = new Map();
416
439
  for (const st of prog.statements) {
440
+ const refs = new Map();
417
441
  for (const x of st.indexes || []) {
418
442
  const host = itemOfToken(x.host);
419
443
  const idx = itemOfToken(x.tok);
@@ -426,17 +450,31 @@ export function analyze(root, opts = {}) {
426
450
  const key = `${kind}|${host.name}|${at.name}`;
427
451
  if (table && !subscripting.has(at.name)) subscripting.set(at.name, { host, table });
428
452
  const point = placeOf(x.tok, st);
429
- const use = `${key}|${point ?? `s${st.at}`}`;
453
+ if (table) {
454
+ const rec = { at: st.at, node: at, point, limit: limitOf(host, table), offset: x.offset || 0, mates: refs.get(x.host) || (refs.set(x.host, []), refs.get(x.host)) };
455
+ rec.mates.push(rec);
456
+ if (!usesOf.has(at.name)) usesOf.set(at.name, []);
457
+ usesOf.get(at.name).push(rec);
458
+ }
459
+ const offset = x.offset || 0;
460
+ const use = `${key}|${point ?? `s${st.at}`}|${offset}`;
430
461
  if (indexed.has(use)) continue;
431
462
  indexed.add(use);
432
463
  at.sinks.push({
433
- kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit: limitOf(host, table), ...(ssrange ? { ssrange } : {}),
464
+ kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit: limitOf(host, table), ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
434
465
  detail: kind === 'subscript'
435
466
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
436
467
  : `${at.name} sets the ${x.kind === 'refmod-offset' ? 'start' : 'length'} of a reference to ${host.name}, which is ${host.size} bytes`,
437
468
  });
438
469
  }
439
470
  }
471
+ // The subscript uses a loop's body makes; every use of the name when the body can run code
472
+ // elsewhere, and none for a loop whose body is a paragraph.
473
+ const bodyUses = (st, uses) => {
474
+ const body = ctl && ctl.loopBody.get(st);
475
+ if (!body || !uses) return null;
476
+ return body.leaves ? uses : uses.filter((u) => u.at >= body.from && u.at < body.to);
477
+ };
440
478
  // A counter that subscripts a table walks as far as its loop's condition lets it. PERFORM
441
479
  // VARYING I ... UNTIL I > N with N from outside puts the table's bound in the caller's hands, and
442
480
  // no subscript sink sees it: I itself is only ever a literal plus one. The subscript may be
@@ -454,7 +492,7 @@ export function analyze(root, opts = {}) {
454
492
  if (indexed.has(use)) continue;
455
493
  indexed.add(use);
456
494
  nodeOf(it).sinks.push({
457
- kind: 'loop-bound', onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point: pointOf(st), group: `${pk}|${key}`, limit: limitOf(walks.host, walks.table), ...(ssrange ? { ssrange } : {}),
495
+ 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 } : {}),
458
496
  detail: `${it.name} decides where PERFORM VARYING ${counter} stops, and ${counter} subscripts ${walks.host.name}, a table of ${walks.table.occurs}`,
459
497
  });
460
498
  }
@@ -480,14 +518,27 @@ export function analyze(root, opts = {}) {
480
518
  });
481
519
  }
482
520
  }
483
- for (const it of prog.items) {
484
- if (!it.dependingOn) continue;
521
+ // The count matters wherever a statement names the table, a part of it, or a group holding it,
522
+ // so it is judged at each such statement; a table no statement names is judged where it is declared.
523
+ const odoTables = prog.items.filter((it) => it.dependingOn && byName.get(it.dependingOn));
524
+ const touching = new Map(odoTables.map((it) => [it, []]));
525
+ if (odoTables.length) {
526
+ const inside = (a, b) => { for (let x = a; x; x = x.parent) if (x === b) return true; return false; };
527
+ for (const st of prog.statements) {
528
+ const named = [...st.sources, ...st.targets, ...(st.indexes || []).map((x) => x.host)].map(itemOfToken).filter(Boolean);
529
+ for (const it of odoTables) if (named.some((n) => inside(n, it) || inside(it, n))) touching.get(it).push(st);
530
+ }
531
+ }
532
+ for (const it of odoTables) {
485
533
  const count = byName.get(it.dependingOn);
486
- if (!count) continue;
487
- nodeOf(count).sinks.push({
488
- kind: 'occurs-depending-count', onlyFrom: FROM_OUTSIDE, file: it.file, line: it.line, ...(ssrange ? { ssrange } : {}),
489
- detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
490
- });
534
+ const sts = touching.get(it);
535
+ for (const st of sts.length ? sts : [null]) {
536
+ const point = st ? pointOf(st) : undefined;
537
+ nodeOf(count).sinks.push({
538
+ 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 } : {}),
539
+ detail: `${count.name} sets how many ${it.name} entries there are, up to ${it.occurs}`,
540
+ });
541
+ }
491
542
  }
492
543
  for (const st of prog.statements) {
493
544
  if (st.verb !== 'READ' && st.verb !== 'RETURN') continue;
@@ -572,6 +623,16 @@ export function analyze(root, opts = {}) {
572
623
  const a = c.using[MQ_BUFFER_ARG[String(c.name).toUpperCase()]];
573
624
  const n = a && a.tok ? nodeOfToken(a.tok) : null;
574
625
  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}` });
626
+ } else if (/^MQCONNX?$/i.test(String(c.name))) {
627
+ // MQCONN(QMgrName, Hconn, CompCode, Reason); MQCONNX puts its options second.
628
+ const a = c.using[0];
629
+ const n = a && a.tok ? nodeOfToken(a.tok) : null;
630
+ 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` });
631
+ } else if (String(c.name).toUpperCase() === 'CEEGTST') {
632
+ // CEEGTST(heap id, size, address, feedback code): the size is the second argument.
633
+ const a = c.using[1];
634
+ const n = a && a.tok ? nodeOfToken(a.tok) : null;
635
+ 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` });
575
636
  }
576
637
  }
577
638
  const transfers = [];
@@ -714,6 +775,14 @@ export function analyze(root, opts = {}) {
714
775
  }
715
776
  if (!isWeb && o.has('MAP')) receiveMap(e, o, itemOfToken(intoTok), at);
716
777
  }
778
+ // What the terminal shows: SEND TEXT's FROM, or the map's output record, <map>O without FROM.
779
+ if (verb === 'SEND' && (words[1] === 'TEXT' || words[1] === 'MAP')) {
780
+ const from = nodeOfToken((o.get('FROM') || [])[0]);
781
+ const map = words[1] === 'MAP' ? optionName(o, 'MAP') : null;
782
+ const sym = !from && map ? byName.get(`${map}O`) : null;
783
+ const n = from || (sym ? nodeOf(sym) : null);
784
+ 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` });
785
+ }
717
786
  // RIDFLD decides which record a READ or DELETE reaches, or where a browse starts. A key the
718
787
  // user types is how a lookup works; a key the program put in a protected field and read back
719
788
  // is its own state, held where a modified client can change it.
@@ -726,6 +795,12 @@ export function analyze(root, opts = {}) {
726
795
  : `EXEC CICS ${verb} RIDFLD(${k && k.name}), which decides which record it reaches`;
727
796
  if (k) k.sinks.push({ kind: verb === 'DELETE' || then ? 'record-update' : 'record-key', onlyFrom: ['cics-protected-field'], noCredit: true, ...at, detail });
728
797
  }
798
+ if (verb === 'GETMAIN') {
799
+ for (const opt of ['FLENGTH', 'LENGTH']) {
800
+ const n = nodeOfToken((o.get(opt) || [])[0]);
801
+ 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` });
802
+ }
803
+ }
729
804
  if (verb === 'PUT' && o.has('CONTAINER')) {
730
805
  const name = optionName(o, 'CONTAINER');
731
806
  const from = nodeOfToken((o.get('FROM') || [])[0]);
@@ -794,6 +869,17 @@ export function analyze(root, opts = {}) {
794
869
  if (n) n.sinks.push({ kind: 'queue-name', onlyFrom: IN_CICS, ...at, detail: `EXEC CICS ${verb} ${ts ? 'TS' : 'TD'} ${opt}(...) names the queue` });
795
870
  }
796
871
  }
872
+ // SYSID ships a command to the region it names; SET's first option names the resource a
873
+ // system-programming command changes. A literal in either is the program's own choice. ASSIGN,
874
+ // INQUIRE and EXTRACT return SYSID into the field rather than read it.
875
+ if (o.has('SYSID') && !['ASSIGN', 'INQUIRE', 'EXTRACT'].includes(verb)) {
876
+ const n = nodeOfToken((o.get('SYSID') || [])[0]);
877
+ 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` });
878
+ }
879
+ if (verb === 'SET' && words[1] && o.has(words[1])) {
880
+ const n = nodeOfToken((o.get(words[1]) || [])[0]);
881
+ 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` });
882
+ }
797
883
  if (verb === 'READ' || verb === 'READNEXT' || verb === 'READPREV') {
798
884
  const n = nodeOfToken(intoTok);
799
885
  if (n && (o.has('FILE') || o.has('DATASET'))) n.sources.push({ kind: 'file-record', ...at, detail: `EXEC CICS ${verb} FILE ... INTO` });
@@ -845,6 +931,14 @@ export function analyze(root, opts = {}) {
845
931
  const n = nodeOfToken(h.tok);
846
932
  if (n) n.sources.push({ kind: 'database', ...at, detail: `EXEC SQL ${verb} INTO host variable` });
847
933
  }
934
+ // The location CONNECT TO or SET CONNECTION names, where a host variable holds it; what
935
+ // follows USER and USING is the credential, not the target.
936
+ if (verb === 'CONNECT' || (verb === 'SET' && words[1] === 'CONNECTION')) {
937
+ const to = e.toks.findIndex((t) => t.t === 'word' && (t.u === 'TO' || t.u === 'CONNECTION'));
938
+ const colon = to >= 0 ? e.toks[to + 1] : null;
939
+ const n = colon && colon.t === 'op' && colon.v === ':' && e.toks[to + 2] ? nodeOfToken(e.toks[to + 2]) : null;
940
+ 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` });
941
+ }
848
942
  const dynamic = words.some((w, i) => w === 'PREPARE' || (w === 'EXECUTE' && words[i + 1] === 'IMMEDIATE'));
849
943
  if (dynamic) for (const h of hv) {
850
944
  const n = nodeOfToken(h.tok);
@@ -860,7 +954,7 @@ export function analyze(root, opts = {}) {
860
954
  }
861
955
  const commareaItem = byName.get('DFHCOMMAREA');
862
956
  return {
863
- id: prog.id, pk, callSites, transfers, ddFiles, tdWrites,
957
+ id: prog.id, pk, file, callSites, transfers, ddFiles, tdWrites,
864
958
  params: prog.paramTokens ? prog.paramTokens.map(p => {
865
959
  const it = itemOfToken(p.tok);
866
960
  const extent = extentOf(it);
@@ -929,27 +1023,55 @@ export function analyze(root, opts = {}) {
929
1023
 
930
1024
  const byId = new Map();
931
1025
  const idCount = new Map();
1026
+ const allById = new Map();
932
1027
  for (const p of programs) {
933
1028
  if (p.id && !byId.has(p.id)) byId.set(p.id, p);
934
- if (p.id) idCount.set(p.id, (idCount.get(p.id) || 0) + 1);
1029
+ if (p.id) { idCount.set(p.id, (idCount.get(p.id) || 0) + 1); (allById.get(p.id) || allById.set(p.id, []).get(p.id)).push(p); }
935
1030
  }
1031
+ // The programs a CALL or LINK may reach. A program id the repository holds once is that program.
1032
+ // Held several times, the one in the caller's own source file is the one its executable links;
1033
+ // with none there, the holders nearest in the directory tree are, since a copy of a program tree
1034
+ // or a sibling executable's copy is farther than the caller's own build directory. Ties keep every
1035
+ // holder, and the edge says how many candidates there were.
1036
+ const sharedDirs = (a, b) => {
1037
+ const x = a.split(/[\\/]/), y = b.split(/[\\/]/);
1038
+ let n = 0;
1039
+ while (n < x.length - 1 && n < y.length - 1 && x[n] === y[n]) n++;
1040
+ return n;
1041
+ };
1042
+ const calleesOf = (name, fromFile) => {
1043
+ const all = allById.get(name) || [];
1044
+ if (all.length < 2) return { list: all, of: 0 };
1045
+ const here = all.filter((q) => q.file === fromFile);
1046
+ if (here.length) return { list: here, of: 0 };
1047
+ const depth = all.map((q) => sharedDirs(q.file, fromFile));
1048
+ const best = Math.max(...depth);
1049
+ return { list: all.filter((_, i) => depth[i] === best), of: all.length };
1050
+ };
1051
+ const ofN = (r, name) => (r.of ? `, ${r.list.length === r.of ? 'any' : 'nearest'} of ${r.of} programs named ${name}` : '');
936
1052
  const constructs = [];
937
1053
  for (const p of programs) {
938
1054
  for (const t of p.transfers) {
939
- const callee = byId.get(t.callee);
940
- if (!callee || !callee.commarea) continue;
941
- edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}`, undefined, t.point);
942
- if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}`);
1055
+ const r = calleesOf(t.callee, p.file);
1056
+ for (const callee of r.list) {
1057
+ if (!callee.commarea) continue;
1058
+ edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point);
1059
+ if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`);
1060
+ }
943
1061
  }
944
1062
  for (const c of p.callSites) {
945
- const callee = byId.get(c.name);
1063
+ const r = calleesOf(c.name, p.file);
1064
+ for (const callee of r.list) {
1065
+ if (!callee.params) continue;
1066
+ c.args.forEach((arg, i) => {
1067
+ const param = callee.params[i];
1068
+ if (!param || !arg.node || !param.node) return;
1069
+ edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point);
1070
+ if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`);
1071
+ });
1072
+ }
1073
+ const callee = r.list.length === 1 ? r.list[0] : null;
946
1074
  if (!callee || !callee.params) continue;
947
- c.args.forEach((arg, i) => {
948
- const param = callee.params[i];
949
- if (!param || !arg.node || !param.node) return;
950
- edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}`, undefined, c.point);
951
- if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back`);
952
- });
953
1075
  // A callee that declares more bytes than its caller passes reads and writes whatever follows
954
1076
  // the argument in the caller's storage. Measured over 8,640 bound pairs in 125 repositories:
955
1077
  // where the extra bytes stay inside the caller's own record they are its neighbouring fields,
@@ -1008,47 +1130,52 @@ export function analyze(root, opts = {}) {
1008
1130
  if (run) jobSteps.push({ file: f, job: job.jobs[0]?.name || null, step: step.name, line: l.line, pgm: run[1].toUpperCase() });
1009
1131
  }
1010
1132
  }
1011
- const callee = byId.get(step.pgm.toUpperCase());
1012
- if (!callee) continue; // a system utility, or a program not in this tree
1133
+ const holders = allById.get(step.pgm.toUpperCase()) || [];
1134
+ if (!holders.length) continue; // a system utility, or a program not in this tree
1013
1135
  stats.jclStepsResolved++;
1136
+ // A program id held several times has no caller file to choose by, so each holder is a candidate.
1137
+ const amb = holders.length > 1 ? `, any of ${holders.length} programs named ${step.pgm}` : '';
1138
+ for (const callee of holders) {
1014
1139
 
1015
- // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1016
- // followed by the text. A program with no USING cannot receive one, and saying it does
1017
- // would be a path nobody could follow.
1018
- if (step.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1019
- callee.params[0].node.sources.push({
1020
- kind: 'jcl-parm', file: f, line: step.line,
1021
- detail: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}, which runs ${step.pgm}`,
1022
- });
1023
- stats.jclCrossings++;
1024
- }
1140
+ // PARM arrives in the first PROCEDURE DIVISION USING item: on z/OS a halfword length
1141
+ // followed by the text. A program with no USING cannot receive one, and saying it does
1142
+ // would be a path nobody could follow.
1143
+ if (step.parm !== null && callee.params && callee.params[0] && callee.params[0].node) {
1144
+ callee.params[0].node.sources.push({
1145
+ kind: 'jcl-parm', file: f, line: step.line,
1146
+ detail: `PARM= on step ${step.name || '(unnamed)'} of ${rel(f)}, which runs ${step.pgm}${amb}`,
1147
+ });
1148
+ stats.jclCrossings++;
1149
+ }
1025
1150
 
1026
- // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1027
- // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1028
- // join is the whole point of reading both.
1029
- for (const dd of step.dds) {
1030
- // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1031
- // TO a DD the job sends to the internal reader is submitted as a job.
1032
- if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1151
+ // In-stream data reaches whatever record the program reads from that DD. The COBOL says
1152
+ // ASSIGN TO SYSIN and the job says //SYSIN DD *; neither half names the other, and the
1153
+ // join is the whole point of reading both.
1154
+ for (const dd of step.dds) {
1155
+ // The same join in the other direction: what the program writes through SELECT ... ASSIGN
1156
+ // TO a DD the job sends to the internal reader is submitted as a job.
1157
+ if (dd.name && dd.sysout && /\bINTRDR\b/i.test(dd.sysout)) {
1158
+ for (const m of callee.ddFiles || []) {
1159
+ if (m.dd !== dd.name.toUpperCase()) continue;
1160
+ for (const n of m.nodes) {
1161
+ n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1162
+ detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader${amb}` });
1163
+ }
1164
+ }
1165
+ }
1166
+ if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1033
1167
  for (const m of callee.ddFiles || []) {
1034
1168
  if (m.dd !== dd.name.toUpperCase()) continue;
1035
1169
  for (const n of m.nodes) {
1036
- n.sinks.push({ kind: 'internal-reader', file: m.file, line: m.line,
1037
- detail: `records written through SELECT ${m.select} to //${dd.name}, which step ${step.name || '(unnamed)'} of ${rel(f)} sends to the internal reader` });
1170
+ n.sources.push({
1171
+ kind: 'jcl-instream', file: f, line: dd.line,
1172
+ detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}${amb}`,
1173
+ });
1174
+ stats.jclCrossings++;
1038
1175
  }
1039
1176
  }
1040
- }
1041
- if (!dd.inStream || !dd.inStream.length || !dd.name) continue;
1042
- for (const m of callee.ddFiles || []) {
1043
- if (m.dd !== dd.name.toUpperCase()) continue;
1044
- for (const n of m.nodes) {
1045
- n.sources.push({
1046
- kind: 'jcl-instream', file: f, line: dd.line,
1047
- detail: `${dd.inStream.length} lines of in-stream data on //${dd.name} in step ${step.name || '(unnamed)'}, read through SELECT ${m.select}`,
1048
- });
1049
- stats.jclCrossings++;
1050
- }
1051
- }
1177
+
1178
+ }
1052
1179
  }
1053
1180
  }
1054
1181
  }
@@ -1059,6 +1186,12 @@ export function analyze(root, opts = {}) {
1059
1186
  // in this tree reaches.
1060
1187
  const roots = [];
1061
1188
  for (const [name, t] of csd.transactions) if (t.program && byId.has(t.program)) roots.push({ transaction: name, file: rel(t.file), line: t.line, program: t.program });
1189
+ // A URI map serves its program to an HTTP request under an alias transaction, CWBA unless it names
1190
+ // one, and that transaction is what the region checks the caller may start.
1191
+ for (const [name, u] of csd.urimaps) {
1192
+ if ((u.usage && u.usage !== 'SERVER') || !u.program || !byId.has(u.program)) continue;
1193
+ roots.push({ transaction: u.transaction || 'CWBA', urimap: name, ...(u.path ? { uri: u.path } : {}), file: rel(u.file), line: u.line, program: u.program });
1194
+ }
1062
1195
  for (const j of jobSteps) if (byId.has(j.pgm)) roots.push({ job: j.job, step: j.step, file: rel(j.file), line: j.line, program: j.pgm });
1063
1196
  const STARTED_BY_LISTED = 8;
1064
1197
  for (const r of roots) {
@@ -1162,25 +1295,45 @@ export function analyze(root, opts = {}) {
1162
1295
  const moves = (e) => !(typeof e.why === 'string' && STRUCTURAL.has(e.why));
1163
1296
  const NONE = { level: 0, check: null };
1164
1297
  // `limit` is how many entries or bytes the sink's index may name, where one number says it.
1165
- function creditAt(n, point, kind, limit = null) {
1298
+ function creditAt(n, point, kind, limit = null, offset = 0, nonNeg = false) {
1166
1299
  const p = programs[n.pk];
1167
1300
  if (!p || !p.ordered) return n.guard ? { level: 1, check: n.guard } : NONE;
1168
1301
  if (!n.checks || point == null) return NONE;
1169
1302
  const bits = p.facts.get(point);
1170
- return bits ? creditOf(n.checks, bits, kind, limit) : NONE;
1303
+ return bits ? creditOf(n.checks, bits, kind, limit, offset, nonNeg) : NONE;
1304
+ }
1305
+ // A loop bound cannot push its counter out of the table when the loop's own condition or the
1306
+ // body's checks keep every subscript the counter makes, and each index beside it, in range.
1307
+ // That holds whatever the bound is, so it does not depend on the route the input took.
1308
+ function counterCredit(sink) {
1309
+ const uses = sink.kind === 'loop-bound' ? sink.counterUses : null;
1310
+ if (!uses || !uses.length) return null;
1311
+ let first = null;
1312
+ for (const u of uses) {
1313
+ for (const m of u.mates) {
1314
+ const c = creditAt(m.node, m.point, 'subscript', m.limit, m.offset || 0);
1315
+ if (c.level < 2 || m.limit == null || !within(c.cons, m.limit)) return null;
1316
+ if (m === u && !first) first = { credit: c, node: m.node };
1317
+ }
1318
+ }
1319
+ return first;
1171
1320
  }
1172
1321
  // The best credit on a route. Each node is judged at the statement its value leaves by, which for
1173
1322
  // the last is the sink's own. A structural hop - into a group, across a REDEFINES - moves no value
1174
1323
  // in time, so the node before it is judged where the value next moves.
1175
1324
  function routeCredit(hops, sink, src) {
1176
1325
  if (sink.noCredit || (src && src.noCredit)) return { best: NONE, by: null, missed: null };
1326
+ const own = counterCredit(sink);
1327
+ if (own) return { best: own.credit, by: own.node, missed: null, own: true };
1177
1328
  let best = NONE;
1178
1329
  let by = null;
1179
1330
  let missed = null;
1180
1331
  let point = sink.point;
1181
1332
  for (let h = hops.length - 1; h >= 0; h--) {
1182
1333
  const n = hops[h].node;
1183
- const c = creditAt(n, point, sink.kind, sink.limit);
1334
+ let c = creditAt(n, point, sink.kind, sink.limit, sink.offset, sink.nonNeg);
1335
+ // A count checked against a number above the table's maximum has been checked, not kept in range.
1336
+ if (c.level === 2 && sink.top != null && !within(c.cons, sink.top, false)) c = { level: 1, check: c.check };
1184
1337
  if (c.level > best.level) { best = c; by = n; }
1185
1338
  const any = (n.checks && n.checks.find((c) => c.x != null)) || n.guard;
1186
1339
  if (!c.level && any && !missed) missed = { node: n, check: any };
@@ -1191,16 +1344,16 @@ export function analyze(root, opts = {}) {
1191
1344
  // The credited findings of one source whose sink can be reached without the credit: walking from
1192
1345
  // the source, a value may not leave a node - or any node it reached by a structural hop since it
1193
1346
  // last moved - at a point where the credit reaches `level`.
1194
- function refuse(start, reach, credited, level, kind) {
1347
+ function refuse(start, reach, credited, level, kind, shift = 0) {
1195
1348
  walk++;
1196
- const want = credited.filter((c) => c.level >= level && c.sink.kind === kind);
1349
+ const want = credited.filter((c) => c.level >= level && c.sink.kind === kind && (c.sink.offset || 0) === shift);
1197
1350
  // Past the budget no route can be ruled out, so every credit in question is lost: a finding
1198
1351
  // may be reported unchecked, never dropped.
1199
1352
  if (edgesWalked >= totalBudget) { stats.creditUndecided++; return new Set(want); }
1200
1353
  const at = new Map();
1201
1354
  for (const c of want) { if (!at.has(c.node.id)) at.set(c.node.id, []); at.get(c.node.id).push(c); }
1202
1355
  // Along the way no sink's limit applies, so a bound that holds only against one blocks nothing.
1203
- const holds = (since, point, k, limit = null) => since.some((n) => creditAt(n, point, k, limit).level >= level);
1356
+ const holds = (since, point, k, limit = null, offset = 0, nonNeg = false) => since.some((n) => creditAt(n, point, k, limit, offset, nonNeg).level >= level);
1204
1357
  const lost = new Set();
1205
1358
  const dirty = new Map();
1206
1359
  const parts = new Map();
@@ -1209,13 +1362,13 @@ export function analyze(root, opts = {}) {
1209
1362
  const began = edgesWalked;
1210
1363
  for (let i = 0; i < open.length; i++) {
1211
1364
  const st = open[i];
1212
- for (const c of at.get(st.node.id) || []) if (!holds(st.since, c.sink.point, c.sink.kind, c.sink.limit)) lost.add(c);
1365
+ 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)) lost.add(c);
1213
1366
  for (const e of st.node.edgesOut) {
1214
1367
  edgesWalked++;
1215
1368
  const to = e.to;
1216
1369
  if (!(canReach[to.id] & reach)) continue;
1217
1370
  const mv = moves(e);
1218
- if (mv && holds(st.since, pointOfEdge(e), kind)) continue;
1371
+ if (mv && holds(st.since, pointOfEdge(e), kind, null, shift)) continue;
1219
1372
  const t = cross(st, e);
1220
1373
  if (!t) continue;
1221
1374
  const since = mv ? [to] : [...st.since, to];
@@ -1354,7 +1507,7 @@ export function analyze(root, opts = {}) {
1354
1507
  : [...hops.slice(0, TRACE_KEEP).map(hop),
1355
1508
  { program: null, item: null, file: null, via: `… ${hops.length - TRACE_KEEP * 2} hops not listed`, elided: hops.length - TRACE_KEEP * 2 },
1356
1509
  ...hops.slice(-TRACE_KEEP).map(hop)];
1357
- const { best, by, missed } = routeCredit(hops, sink, src);
1510
+ const { best, by, missed, own } = routeCredit(hops, sink, src);
1358
1511
  const screen = hops.find((h) => h.node.screen)?.node.screen;
1359
1512
  findings.push({
1360
1513
  rule: `${src.kind}-to-${sink.kind}`,
@@ -1370,7 +1523,7 @@ export function analyze(root, opts = {}) {
1370
1523
  ...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
1371
1524
  ...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
1372
1525
  });
1373
- if (best.level) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1526
+ if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
1374
1527
  if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
1375
1528
  }
1376
1529
  for (const e of cur.edgesOut) {
@@ -1399,16 +1552,23 @@ export function analyze(root, opts = {}) {
1399
1552
  // cross() like the first walk, so it cannot take a route the first would refuse.
1400
1553
  if (credited.length) {
1401
1554
  // Per kind of sink, because what a check leaves may stop one kind and say nothing to another.
1555
+ // And per shift, because a value that stops a bare index does not stop one moved down by a constant.
1556
+ const shiftsOf = (list, kind) => new Set(list.filter((c) => c.sink.kind === kind).map((c) => c.sink.offset || 0));
1402
1557
  for (const kind of new Set(credited.map((c) => c.sink.kind))) {
1403
- for (const c of refuse(start, reach, credited, 1, kind)) {
1404
- const g = c.finding.guard;
1405
- delete c.finding.guard;
1406
- c.finding.checkElsewhere = { program: g.program, item: g.item, file: g.file, line: g.line };
1407
- c.level = 0;
1558
+ for (const shift of shiftsOf(credited, kind)) {
1559
+ for (const c of refuse(start, reach, credited, 1, kind, shift)) {
1560
+ const g = c.finding.guard;
1561
+ delete c.finding.guard;
1562
+ c.finding.checkElsewhere = { program: g.program, item: g.item, file: g.file, line: g.line };
1563
+ c.level = 0;
1564
+ }
1408
1565
  }
1409
1566
  }
1410
1567
  for (const kind of new Set(credited.filter((c) => c.level === 2).map((c) => c.sink.kind))) {
1411
- for (const c of refuse(start, reach, credited, 2, kind)) if (c.level === 2) { delete c.finding.guard.stops; c.level = 1; }
1568
+ const two = credited.filter((c) => c.level === 2);
1569
+ for (const shift of shiftsOf(two, kind)) {
1570
+ for (const c of refuse(start, reach, credited, 2, kind, shift)) if (c.level === 2) { delete c.finding.guard.stops; c.level = 1; }
1571
+ }
1412
1572
  }
1413
1573
  }
1414
1574
  }
@@ -1423,6 +1583,13 @@ export function analyze(root, opts = {}) {
1423
1583
  if (!k || rank(f) < rank(k) || (rank(f) === rank(k) && (byText(f.sink.file, k.sink.file) || f.sink.line - k.sink.line) < 0)) kept.set(key, f);
1424
1584
  }
1425
1585
  const keep = new Set(kept.values());
1586
+ // The uses left out are named on the one kept, with how far each is checked, so a reader can see them.
1587
+ const level = (f) => (f.guard ? (f.guard.stops ? 2 : 1) : 0);
1588
+ for (const [f, key] of useGroup) {
1589
+ if (keep.has(f)) continue;
1590
+ const k = kept.get(key);
1591
+ (k.also ||= []).push({ file: f.sink.file, line: f.sink.line, program: f.sink.program, detail: f.sink.detail, level: level(f), unreached: !!f.unreached, ...(f.guard ? { guardLine: f.guard.line } : {}) });
1592
+ }
1426
1593
  let w = 0;
1427
1594
  for (const f of findings) if (!useGroup.has(f) || keep.has(f)) findings[w++] = f;
1428
1595
  findings.length = w;
@@ -1455,7 +1622,7 @@ export function analyze(root, opts = {}) {
1455
1622
  }
1456
1623
  }
1457
1624
  for (const c of constructs) { Object.assign(c, startedByOf(c.pk)); delete c.pk; }
1458
- stats.entryPoints = { transactions: csd.transactions.size, jobSteps: jobSteps.length, roots: roots.length, programsStarted: programs.filter((p) => p.startedBy).length, programsWithoutEntry: unstarted.length };
1625
+ stats.entryPoints = { transactions: csd.transactions.size, uriMaps: csd.urimaps.size, jobSteps: jobSteps.length, roots: roots.length, programsStarted: programs.filter((p) => p.startedBy).length, programsWithoutEntry: unstarted.length };
1459
1626
  const startedBy = {};
1460
1627
  for (const p of programs) if (p.id && p.startedBy && !startedBy[p.id]) startedBy[p.id] = p.startedBy;
1461
1628
  return { findings, constructs, unstarted, startedBy, stats, sourceKinds: SOURCE_KINDS, sinkKinds: SINK_KINDS, ...(sinks ? { sinks } : {}), ...(sources ? { sources } : {}) };