@portll/cobolwork 0.2.140 → 0.2.150

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.
package/README.md CHANGED
@@ -187,6 +187,11 @@ since a test that missed the route does not show it safe.
187
187
  `summary.handling`, because it is then a list of what to attack first. Upload it only where the RACF
188
188
  facts behind it may go. [docs/spec/reach.md](docs/spec/reach.md) §9 is the full specification.
189
189
 
190
+ `COBOLWORK_EXECUTION` names reports `ironwork run --coverage` wrote from runs of the estate's own
191
+ tests. A finding inside a paragraph they cover carries `executed`, the paragraph and how often the
192
+ runs entered it, and `summary.byExecution` counts the findings in paragraphs entered and never
193
+ entered ([docs/spec/evidence.md](docs/spec/evidence.md) §13.5).
194
+
190
195
  ### What each finding lets someone do, and the fix
191
196
 
192
197
  A defect finding carries two more facts, the same for every finding of its rule: what someone can do
@@ -339,13 +344,20 @@ the guard off, it just stops the host's load from deciding. A spawned scan inher
339
344
 
340
345
  ## Compliance
341
346
 
342
- Every rule is mapped to the clause of each framework that makes it an obligation, quoted verbatim:
347
+ Every rule is mapped to the clause of each framework that makes it an obligation, quoted verbatim,
348
+ and to a COBIT 2019 practice by identifier alone:
343
349
 
344
350
  | File | Instrument | Rules mapped |
345
351
  |---|---|---|
346
352
  | `rules/compliance-dora.json` | Regulation (EU) 2022/2554 (DORA) | 203 |
347
353
  | `rules/compliance-ffiec.json` | FFIEC IT Examination Handbook | 201, and 2 recorded as unmapped |
348
354
  | `rules/compliance-nist80053.json` | NIST SP 800-53 Rev. 5.2.0 | 201, and 2 recorded as unmapped |
355
+ | `rules/compliance-cobit2019.json` | COBIT 2019 (ISACA), identifiers only | 201, and 2 recorded as unmapped |
356
+
357
+ A COBIT 2019 row names the objective or practice and gives this project's own rationale; no ISACA
358
+ text is reproduced, so reading what a practice says needs a copy of the framework. The practice is
359
+ chosen at the NIST control, through a crosswalk in `diag/map-compliance.mjs`, so it cannot drift
360
+ from the rule's NIST clause.
349
361
 
350
362
  `node diag/map-compliance.mjs` refuses to write a quote the cached instrument does not contain, and
351
363
  every scan carries the mapping as `ruleCompliance`. The clause choice is a judgement that no
@@ -89,8 +89,9 @@ its source URL and retrieval date:
89
89
  - **NIST SP 800-53 Rev. 5** — a work of the US Government.
90
90
  - **FFIEC IT Examination Handbook** — a work of the US Government.
91
91
 
92
- PCI DSS and the COBIT-derived SOX material are deliberately absent: they may not be redistributed,
93
- so they belong in a licensed feed rather than in this repository. Full instrument texts are never
92
+ COBIT 2019 appears as objective and practice identifiers only, with this project's own rationale;
93
+ no text of COBIT, which is ISACA's, is reproduced. PCI DSS is deliberately absent: it may not be
94
+ redistributed. Full instrument texts are never
94
95
  committed (`feed/sources/` is ignored); the feed ships citations, not text.
95
96
 
96
97
  ## Vulnerability data
package/bin/cobolwork.mjs CHANGED
@@ -16,6 +16,8 @@ import { build, buildSarif, buildSummaryLine } from '../lib/build.mjs';
16
16
  import { capabilities } from '../lib/capabilities.mjs';
17
17
  import { commitAt, revisionOf, toolRevision } from '../lib/revision.mjs';
18
18
  import { stampFingerprints } from '../lib/kernel/identity.mjs';
19
+ import { pdsExportTree } from '../lib/kernel/source-tree.mjs';
20
+ import { SITE_FILE } from '../lib/site.mjs';
19
21
  import { tally } from '../lib/kernel/findings.mjs';
20
22
  import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
21
23
  import { readReport } from '../lib/tui/model.mjs';
@@ -86,6 +88,9 @@ Options
86
88
  baseline: the judgement each new entry records, and which rules it covers
87
89
  --advisories <file>[,<file>] a customer's own advisory extract (JSON), loaded for this scan only;
88
90
  never kept in the tree
91
+ --pds-export scan: the directory holds partitioned data sets' members as files, as
92
+ zowe zos-files download all-members writes them (hlq/llq/member.txt) or in a
93
+ directory named for each data set; findings name DATA.SET/MEMBER
89
94
  --copylib <dir>[,<dir>] copy libraries the estate keeps outside the repository, searched after the
90
95
  tree's own copybooks, as COBCPY is; a copybook found there is read, not reported missing
91
96
  --report <file> tui, explain: read a stored scan report instead of scanning
@@ -150,6 +155,7 @@ function parseArgs(argv) {
150
155
  else if (a === '--action') opts.action = value();
151
156
  else if (a === '--rule') opts.rule = list();
152
157
  else if (a === '--advisories') opts.advisoryFeeds = [...new Set(list().map(x => resolve(x)))];
158
+ else if (a === '--pds-export') opts.pdsExport = true;
153
159
  else if (a === '--copylib') opts.copylib = [...new Set(list().map(x => resolve(x)))];
154
160
  else if (a === '--report') opts.report = value();
155
161
  else if (a === '--keys') opts.keys = value();
@@ -198,6 +204,7 @@ function warnCoverage(report) {
198
204
  }
199
205
 
200
206
  const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
207
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
201
208
 
202
209
  let journal = null;
203
210
 
@@ -262,6 +269,8 @@ if (opts.copylib && opts._.length && !COPYLIB_COMMANDS.includes(opts._[0])) {
262
269
  const notDirs = (opts.copylib || []).filter((d) => { try { return !statSync(d).isDirectory(); } catch { return true; } });
263
270
  if (notDirs.length) { process.stderr.write(`cobolwork: --copylib ${notDirs.join(', ')} is not a directory\n`); process.exit(2); }
264
271
  const systemDirs = opts.copylib || [];
272
+ if (opts.pdsExport && opts._.length && opts._[0] !== 'scan') { process.stderr.write('cobolwork: --pds-export is for scan only\n'); process.exit(2); }
273
+ if (opts.pdsExport && opts.repos) { process.stderr.write('cobolwork: --pds-export reads one export; --repos does not apply\n'); process.exit(2); }
265
274
  if (opts.json && opts._.length && opts._[0] !== 'capabilities') {
266
275
  process.stderr.write(`cobolwork: --json is for capabilities; every other command writes JSON unless --format says otherwise\n`);
267
276
  process.exit(2);
@@ -332,7 +341,13 @@ try {
332
341
  process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
333
342
  } else if (command === 'scan' || command === 'flow') {
334
343
  const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
335
- const report = command === 'flow' ? scanFlow(root, flowOpts) : scanAll(root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds });
344
+ // The site file sits beside the members and is not one of them, so it is named rather than found.
345
+ const site = resolve(root, SITE_FILE);
346
+ const pds = opts.pdsExport ? pdsExportTree(root, { systemDirs }) : null;
347
+ const report = command === 'flow' ? scanFlow(root, flowOpts)
348
+ : pds ? scanAll(pds.root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds, tree: pds, feedRoot: root, site: isFile(site) && !isLink(site) ? site : null })
349
+ : scanAll(root, { ...flowOpts, only: opts.only, advisoryFeeds: opts.advisoryFeeds });
350
+ if (pds) report.summary.pdsExport = pds.export;
336
351
  if (command === 'flow') {
337
352
  report.summary.identity = stampFingerprints(report.findings, { root });
338
353
  stampFingerprints(report.checked, { root });
package/lib/baseline.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // because the finding it would hide is evidence that the tree is not to be trusted. A baseline
17
17
  // passed from outside the tree can.
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
- import { join } from 'node:path';
19
+ import { join, resolve } from 'node:path';
20
20
  import { tally } from './kernel/findings.mjs';
21
21
  import { resolvesInside } from './kernel/source-tree.mjs';
22
22
  import { printable } from './kernel/printable.mjs';
@@ -54,22 +54,24 @@ export function validateEntry(e) {
54
54
 
55
55
  // { path, source, entries, problems }. An absent file is an empty baseline, not an error; an
56
56
  // unreadable one is a problem the report carries, never a silent empty.
57
- export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent = false } = {}) {
57
+ // A source tree held in memory (a git revision) supplies its own baseline file.
58
+ export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent = false, tree = null } = {}) {
58
59
  if (!use) return null;
59
- const path = explicit || join(root, BASELINE_FILE);
60
- if (!existsSync(path)) {
60
+ const held = !explicit && tree && tree.kind !== 'directory';
61
+ const path = explicit || (held ? resolve(tree.root, BASELINE_FILE) : join(root, BASELINE_FILE));
62
+ if (held ? !tree.contains(path) : !existsSync(path)) {
61
63
  if (!explicit) return null;
62
64
  if (mayBeAbsent) return { path, source: 'explicit', entries: [], problems: [] };
63
65
  return { path, source: 'explicit', entries: [], problems: [`${path} does not exist, so nothing was suppressed`] };
64
66
  }
65
67
  const out = { path, source: explicit ? 'explicit' : 'tree', entries: [], problems: [] };
66
68
  // The tree's own baseline is the tree's, and a link out of it is not followed.
67
- if (!explicit && !resolvesInside(root, path)) {
69
+ if (!explicit && !held && !resolvesInside(root, path)) {
68
70
  out.problems.push(`${BASELINE_FILE} is a link that leads outside the tree, so nothing was suppressed`);
69
71
  return out;
70
72
  }
71
73
  let raw;
72
- try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (err) {
74
+ try { raw = JSON.parse(held ? tree.bytes(path).toString('utf8') : readFileSync(path, 'utf8')); } catch (err) {
73
75
  out.problems.push(`${path} is not readable as JSON: ${printable(err.message, 120)}`);
74
76
  return out;
75
77
  }
package/lib/bms.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // This reads text and opens nothing; the caller reads the file through the source tree, so
17
17
  // containment stays where it is enforced.
18
- import { parseOperands, splitOperands } from './jcl.mjs';
18
+ import { parseOperands, splitOperands } from './cards.mjs';
19
19
 
20
20
  // Column 72 marks a continuation and 73 to 80 are the sequence field, so a statement ends at 71.
21
21
  const END_COLUMN = 71;
@@ -17,7 +17,7 @@ export const CAPABILITIES_SCHEMA_VERSION = 1;
17
17
 
18
18
  // Each command, what it takes and what it writes.
19
19
  export const COMMANDS = {
20
- scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--evidence'], documents: ['cobolwork', 'sarif'] },
20
+ scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
21
21
  flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
22
22
  inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib', '--evidence'], documents: ['cobolwork-inventory'] },
23
23
  parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
@@ -68,6 +68,7 @@ export function capabilities() {
68
68
  jcl: JCL_EXT,
69
69
  bms: BMS_EXT,
70
70
  extensionless: 'sniffed by content: a file with no extension is classified by what it holds',
71
+ pdsExport: 'scan --pds-export: every member classified by content, whatever extension the download gave it; text, --binary and --record downloads',
71
72
  zowe: ['zowe.config.json', 'zowe.config.user.json', '.mcp.json', '.vscode/mcp.json', '.cursor/mcp.json', '.claude/settings.json', '.claude/settings.local.json', '.gemini/settings.json', 'claude_desktop_config.json', '.vscode/settings.json', 'systems.json under a mock directory'],
72
73
  },
73
74
  evidenceKinds: Object.keys(EVIDENCE),
package/lib/cards.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Card-image source: where a line's fields sit on the card, and how an operand field splits. COBOL
3
+ // in fixed or variable reference format has a sequence area (columns 1-6), an indicator (7), program
4
+ // text (8-72, or 8-250 in variable format) and what follows, which the compiler ignores. A JCL or
5
+ // utility control statement is columns 1-72, with its sequence number after. Free-format COBOL is
6
+ // text from end to end.
7
+
8
+ export const TEXT_END = { fixed: 72, variable: 250 };
9
+ export const STATEMENT_END = 72;
10
+
11
+ export function cobolCard(line, format) {
12
+ const end = TEXT_END[format];
13
+ if (!end) return { sequence: '', indicator: '', text: line, ignored: '' };
14
+ return { sequence: line.slice(0, 6), indicator: line[6] || ' ', text: line.slice(7, end), ignored: line.slice(end) };
15
+ }
16
+
17
+ export function statementCard(line) {
18
+ return { text: line.slice(0, STATEMENT_END), sequence: line.slice(STATEMENT_END) };
19
+ }
20
+
21
+ // Splits an operand field on commas that are not inside parentheses or quotes. JCL, BMS and HLASM nest
22
+ // both, and a naive split on comma turns DISP=(NEW,CATLG,DELETE) into three operands.
23
+ export function splitOperands(s) {
24
+ const out = [];
25
+ let depth = 0, quoted = false, start = 0;
26
+ for (let i = 0; i < s.length; i++) {
27
+ const c = s[i];
28
+ if (quoted) { if (c === "'") { if (s[i + 1] === "'") i++; else quoted = false; } continue; }
29
+ if (c === "'") quoted = true;
30
+ else if (c === '(') depth++;
31
+ else if (c === ')') depth = Math.max(0, depth - 1);
32
+ else if (c === ',' && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
33
+ }
34
+ out.push(s.slice(start));
35
+ return out.filter((x, i, a) => x.length || i < a.length - 1);
36
+ }
37
+
38
+ // Operands are positional until the first KEY=VALUE, and keyword after it: JCL's EXEC takes its
39
+ // procedure name positionally, and everything interesting on a DD is a keyword.
40
+ export function parseOperands(field) {
41
+ const positional = [];
42
+ const keywords = new Map();
43
+ for (const raw of splitOperands(field)) {
44
+ const part = raw.trim();
45
+ if (!part) continue;
46
+ const eq = keywordSplit(part);
47
+ if (eq < 0) positional.push(part);
48
+ else keywords.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
49
+ }
50
+ return { positional, keywords };
51
+ }
52
+
53
+ // The first '=' that is not inside parentheses or quotes. DCB=(RECFM=FB,LRECL=80) is one keyword
54
+ // whose value happens to contain more of them.
55
+ function keywordSplit(part) {
56
+ let depth = 0, quoted = false;
57
+ for (let i = 0; i < part.length; i++) {
58
+ const c = part[i];
59
+ if (quoted) { if (c === "'") { if (part[i + 1] === "'") i++; else quoted = false; } continue; }
60
+ if (c === "'") quoted = true;
61
+ else if (c === '(') depth++;
62
+ else if (c === ')') depth = Math.max(0, depth - 1);
63
+ else if (c === '=' && depth === 0) return i;
64
+ }
65
+ return -1;
66
+ }
@@ -5,10 +5,10 @@
5
5
  // against each. The difference between them is this file, and it is the difference between a
6
6
  // tooling budget and a compliance budget.
7
7
  //
8
- // DORA, NIST SP 800-53 and the FFIEC IT Examination Handbook ship here because their instruments
9
- // may be reproduced: one is EU law, the other two US Government works. PCI DSS and the COBIT-derived SOX material may not be
10
- // redistributed, so those mappings belong in the licensed feed rather than in an AGPL repository.
11
- // The split is a licensing fact before it is a product decision.
8
+ // DORA, NIST SP 800-53 and the FFIEC IT Examination Handbook ship with quotes because their
9
+ // instruments may be reproduced: one is EU law, the other two US Government works. COBIT 2019 ships
10
+ // as identifiers with this project's own rationale and no ISACA text. PCI DSS may not be
11
+ // redistributed and is not mapped.
12
12
  //
13
13
  // A framework may decline to cover a rule. Neither NIST nor FFIEC has a control that genuinely
14
14
  // covers committing an LPAR name to a repository, and that is recorded as an unmapped rule with a
@@ -19,6 +19,7 @@ const FRAMEWORKS = [
19
19
  { id: 'dora', file: '../rules/compliance-dora.json' },
20
20
  { id: 'nist-800-53r5', file: '../rules/compliance-nist80053.json' },
21
21
  { id: 'ffiec', file: '../rules/compliance-ffiec.json' },
22
+ { id: 'cobit-2019', file: '../rules/compliance-cobit2019.json' },
22
23
  ];
23
24
 
24
25
  // A framework that will not parse is a framework this scan cannot map against, which is the same
@@ -64,7 +65,7 @@ const byRule = new Map();
64
65
  for (const d of loaded) {
65
66
  for (const r of d.rows) {
66
67
  if (!byRule.has(r.ruleId)) byRule.set(r.ruleId, []);
67
- byRule.get(r.ruleId).push({ framework: r.framework, clause: r.clause, title: r.title });
68
+ byRule.get(r.ruleId).push({ framework: r.framework, clause: r.clause, ...(r.title ? { title: r.title } : {}) });
68
69
  }
69
70
  }
70
71
 
package/lib/control.mjs CHANGED
@@ -251,6 +251,12 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
251
251
  const loopBody = new Map();
252
252
  const stmtAt = new Map();
253
253
  for (const st of prog.statements) if (st.at != null && st.verb !== 'WHEN') stmtAt.set(st.at, st);
254
+ const unpassedBy = new Map();
255
+ for (const c of prog.calls || []) {
256
+ const st = prog.statements[c.stmtIndex];
257
+ if (st) unpassedBy.set(st, new Set([c.targetTok, ...c.using.filter((a) => a.mode !== 'REFERENCE' && a.tok).map((a) => a.tok)]));
258
+ }
259
+ const external = (it) => { for (let a = it; a; a = a.parent) if (a.external) return true; return false; };
254
260
  const labelAt = new Map();
255
261
  for (const l of prog.labels) if (l.at != null) labelAt.set(l.at, l);
256
262
  const labelNames = new Set(prog.labels.map((l) => l.name));
@@ -640,11 +646,14 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
640
646
  for (const t of n.st.targets || []) add(t);
641
647
  if (n.st.verb === 'CALL' || n.st.verb === 'READ' || n.st.verb === 'RETURN') {
642
648
  // A callee may write what it is passed by reference, and a READ fills the file's records.
649
+ // The field naming the program called, and what goes BY CONTENT or BY VALUE, is not passed
650
+ // for the callee to write - unless it is EXTERNAL, which any program can.
651
+ const unpassed = n.st.verb === 'CALL' ? unpassedBy.get(n.st) : null;
643
652
  for (const t of n.st.sources || []) {
644
653
  const f = resolve(t);
645
654
  if (!f) continue;
646
655
  if (f.records) for (const r of f.records) out.push(r);
647
- else if (n.st.verb === 'CALL') out.push(f);
656
+ else if (n.st.verb === 'CALL' && !(unpassed && unpassed.has(t) && !external(f))) out.push(f);
648
657
  }
649
658
  }
650
659
  }
@@ -1083,7 +1092,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1083
1092
  const bounds = { lo: 1, hi };
1084
1093
  const c = { id: checks.length, key, field, file: list[0].n.st.file, line: list[0].n.st.line, tCons: { numeric: true, lo: 1, loInc: true, hi, hiInc: true }, fCons: null, restricts: false, assigned: true, bounds };
1085
1094
  checks.push(c);
1086
- for (const a of list) if (Number(a.value) <= hi) (a.upTo ||= []).push(c);
1095
+ // "At most v" alone, which a decrement that cannot go below 0 leaves standing.
1096
+ const top = { id: checks.length, key, field, file: c.file, line: c.line, tCons: { numeric: true, hi, hiInc: true }, fCons: null, restricts: false, assigned: true, bounds: { hi } };
1097
+ checks.push(top);
1098
+ for (const a of list) if (Number(a.value) <= hi) (a.upTo ||= []).push(c, top);
1087
1099
  }
1088
1100
  }
1089
1101
  // Fact numbers: for each check, `x` that it ran (only a restricting one), `t` and `f` that it came
@@ -1419,6 +1431,40 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1419
1431
  (a.n.genStmt ||= []).push(newLo(a.field, v, a.n.st).t);
1420
1432
  }
1421
1433
  // An increment by at least m turns "at least k" into "at least k + m", where the sum fits the field.
1434
+ // The fields a statement only ever lowers, by a constant, and by how much at most.
1435
+ const decrements = (st) => {
1436
+ const seg = segOf(st);
1437
+ let fields = [];
1438
+ let max = 0;
1439
+ const amount = (t) => { const v = t && (t.t === 'word' || t.t === 'num') ? numberOf(constValue([t])) : null; return v != null && Number.isInteger(v) && v >= 0 ? v : null; };
1440
+ if (st.verb === 'SUBTRACT') {
1441
+ const from = seg.findIndex((t) => WORD(t, 'FROM'));
1442
+ if (from < 1 || seg.some((t) => WORD(t, 'GIVING') || WORD(t, 'CORRESPONDING') || WORD(t, 'CORR'))) return null;
1443
+ for (const t of seg.slice(0, from)) { const v = amount(t); if (v == null) return null; max += v; }
1444
+ const end = seg.findIndex((t, i) => i > from && PHRASE_AT(t));
1445
+ if (seg.slice(from + 1, end < 0 ? seg.length : end).some((t) => t.t === 'sep')) return null;
1446
+ fields = (st.targets || []).map((t) => resolve(t));
1447
+ } else if (st.verb === 'SET') {
1448
+ const down = seg.findIndex((t) => WORD(t, 'DOWN'));
1449
+ if (down < 1 || !WORD(seg[down + 1], 'BY') || seg.length !== down + 3 || seg.slice(0, down).some((t) => t.t !== 'word')) return null;
1450
+ max = amount(seg[down + 2]);
1451
+ if (max == null) return null;
1452
+ fields = (st.targets || []).map((t) => resolve(t));
1453
+ } else if (st.verb === 'COMPUTE') {
1454
+ const eq = seg.findIndex((t) => t.t === 'op' && t.v === '=');
1455
+ const lhs = seg.slice(0, eq).filter((t) => !WORD(t, 'ROUNDED'));
1456
+ const end = seg.findIndex((t, i) => i > eq && PHRASE_AT(t));
1457
+ const rhs = seg.slice(eq + 1, end < 0 ? seg.length : end);
1458
+ if (eq < 1 || lhs.length !== 1 || lhs[0].t !== 'word' || rhs.length !== 3 || rhs[1].t !== 'op' || rhs[1].v !== '-') return null;
1459
+ const target = resolve(lhs[0]);
1460
+ if (rhs[0].t !== 'word' || resolve(rhs[0]) !== target) return null;
1461
+ max = amount(rhs[2]);
1462
+ if (max == null) return null;
1463
+ fields = [target];
1464
+ } else return null;
1465
+ fields = [...new Set(fields.filter((f) => intCap(f) != null))];
1466
+ return fields.length ? { fields, max } : null;
1467
+ };
1422
1468
  const lifted = new Map();
1423
1469
  for (const [id, inc] of raisers) {
1424
1470
  const list = [];
@@ -1712,7 +1758,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1712
1758
  const r = rangeOfField(leaf.f);
1713
1759
  const base = { needs: [], lo: r.hold.lo, hi: r.hold.hi };
1714
1760
  if (!leaf.plain) return [base];
1715
- const cv = numberOf(constValue([leaf.tok]));
1761
+ const cv = leaf.tok ? numberOf(constValue([leaf.tok])) : null;
1716
1762
  if (cv != null && Number.isInteger(cv)) return [{ needs: [], lo: BigInt(cv), hi: BigInt(cv) }];
1717
1763
  if (!heldBits) return [base];
1718
1764
  let lo = null;
@@ -1781,9 +1827,19 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1781
1827
  }
1782
1828
  return out;
1783
1829
  };
1830
+ // The derived intervals that hold every whole number from lo to hi, an open end holding nothing.
1831
+ const containing = (key, lo, hi) => {
1832
+ const out = [];
1833
+ for (const c of factsByKey.get(key) || []) {
1834
+ if (!c.derived) continue;
1835
+ const e = wholeEnds(c.tCons);
1836
+ if (e && (e.lo == null || (lo != null && e.lo <= lo)) && (e.hi == null || (hi != null && e.hi >= hi))) out.push(c.t);
1837
+ }
1838
+ return out;
1839
+ };
1784
1840
  const expandRules = () => {
1785
1841
  indexFacts();
1786
- for (const n of nodes) { n.derive = undefined; n.genArith = undefined; }
1842
+ for (const n of nodes) { n.derive = undefined; n.genArith = undefined; n.genArithOn = undefined; }
1787
1843
  for (const r of rules) {
1788
1844
  const bits = withLooser(r.fact);
1789
1845
  const at = r.bucket == null ? r.n : nodes[r.n.test];
@@ -1792,6 +1848,26 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1792
1848
  else ((at.genArithOn ||= { true: [], false: [] })[r.bucket]).push(...bits);
1793
1849
  } else (r.n.derive ||= []).push({ needs: r.needs, to: bits });
1794
1850
  }
1851
+ // A constant moved, and a check's outcome, are values too: routes that reached an interval by
1852
+ // arithmetic and routes that reached it by a move or a test meet at it.
1853
+ for (const a of assigns) {
1854
+ const v = numberOf(a.value);
1855
+ if (v == null || !Number.isInteger(v)) continue;
1856
+ const bits = containing(fieldKey(a.field), BigInt(v), BigInt(v));
1857
+ if (bits.length) (a.n.genArith ||= []).push(...bits);
1858
+ }
1859
+ for (const id of tests) {
1860
+ const n = nodes[id];
1861
+ for (const c of n.checks || []) {
1862
+ for (const [cons, bucket] of [[c.tCons, 'true'], [c.fCons, 'false']]) {
1863
+ if (!cons || (cons.set && cons.set.size)) continue;
1864
+ const e = wholeEnds(cons);
1865
+ if (!e || (e.lo == null && e.hi == null)) continue;
1866
+ const bits = containing(c.key, e.lo, e.hi);
1867
+ if (bits.length) ((n.genArithOn ||= { true: [], false: [] })[bucket]).push(...bits);
1868
+ }
1869
+ }
1870
+ }
1795
1871
  };
1796
1872
 
1797
1873
  const ARITH_VERBS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'MOVE']);
@@ -1985,6 +2061,8 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1985
2061
  const before = relevant.size;
1986
2062
  for (const a of arith) if (a.targets.some((r) => r.f && relevant.has(r.f))) for (const tree of a.trees) for (const l of tree ? leaves(tree) : []) relevant.add(l.f);
1987
2063
  for (const r of relations.values()) if (relevant.has(r.f) || relevant.has(r.g)) { relevant.add(r.f); relevant.add(r.g); }
2064
+ // A loop condition that bounds a relevant counter through another field makes that field relevant.
2065
+ for (const c of checks) { if (c.pseudo || !c.field || !relevant.has(c.field)) continue; for (const d of [c.tDep, c.fDep]) if (d?.g) relevant.add(d.g); }
1988
2066
  more = relevant.size > before;
1989
2067
  }
1990
2068
 
@@ -1996,6 +2074,20 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1996
2074
  let grew = false;
1997
2075
  try {
1998
2076
  indexFacts();
2077
+ // A check's outcome as an interval of its own, which arithmetic's intervals can widen into at a join.
2078
+ for (const id of tests) {
2079
+ for (const c of nodes[id].checks || []) {
2080
+ if (!relevant.has(c.field) || !rangeOfField(c.field) || inTable(c.field)) continue;
2081
+ for (const cons of [c.tCons, c.fCons]) {
2082
+ if (!cons || (cons.set && cons.set.size)) continue;
2083
+ const e = wholeEnds(cons);
2084
+ if (!e || (e.lo == null && e.hi == null)) continue;
2085
+ const had = derivedOf.get(c.field)?.size || 0;
2086
+ derivedFact(c.field, e.lo, e.hi, nodes[id].at || nodes[id].st);
2087
+ if ((derivedOf.get(c.field)?.size || 0) > had) grew = true;
2088
+ }
2089
+ }
2090
+ }
1999
2091
  for (const a of arith) {
2000
2092
  a.trees.forEach((tree, i) => {
2001
2093
  const r = a.targets[i];
@@ -2032,6 +2124,27 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2032
2124
  }
2033
2125
  }
2034
2126
  }
2127
+ // A bound over an expression in one other field, X <= G - 1, takes the interval G holds at the test.
2128
+ for (const c of checks) {
2129
+ if (c.pseudo || c.test == null || !relevant.has(c.field) || !rangeOfField(c.field) || inTable(c.field)) continue;
2130
+ for (const o of [true, false]) {
2131
+ const dep = o ? c.tDep : c.fDep;
2132
+ const at = dep && outcomeAt.get(`${c.test}|${o}`);
2133
+ if (!at || !rangeOfField(dep.g) || inTable(dep.g) || !Number.isInteger(dep.m) || !Number.isInteger(dep.b)) continue;
2134
+ const hold = rangeOfField(c.field).hold;
2135
+ heldBits = prev ? prev.get(c.test) || null : null;
2136
+ for (const cand of leafCands({ f: dep.g, tok: null, plain: true })) {
2137
+ const v = BigInt(dep.m) * (dep.needs === 'hi' ? cand.hi : cand.lo) + BigInt(dep.b);
2138
+ let lo = null, hi = null;
2139
+ if (dep.side === 'hi') hi = dep.inc ? v : v - 1n; else lo = dep.inc ? v : v + 1n;
2140
+ if (lo != null && lo <= hold.lo) lo = null;
2141
+ if (hi != null && hi >= hold.hi) hi = null;
2142
+ if ((lo == null && hi == null) || (hi != null && hi < hold.lo) || (lo != null && lo > hold.hi)) continue;
2143
+ const fact = derivedFact(c.field, lo, hi, at.st || nodes[c.test].at || nodes[c.test].st);
2144
+ if (fact && derive(at, cand.needs, fact, String(o))) grew = true;
2145
+ }
2146
+ }
2147
+ }
2035
2148
  // A relation holds after the outcome of its test that makes it true, and the fields it bounds hold
2036
2149
  // what the other side could hold.
2037
2150
  for (const id of tests) {
@@ -2161,6 +2274,31 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2161
2274
  } else if (n.kind === 'step' && stepped.has(id)) {
2162
2275
  keepAcross(n, [{ ...stepped.get(id), sizeError: false }]);
2163
2276
  }
2277
+ // An upper bound survives a decrement that cannot go below 0: one where the field was at least the
2278
+ // amount before it, so nothing is stored as an absolute value or wraps.
2279
+ if (n.kind === 'stmt' && n.st && n.st.at != null) {
2280
+ const dec = decrements(n.st);
2281
+ const wrote = dec ? writtenBy(n) : [];
2282
+ for (const field of dec ? dec.fields : []) {
2283
+ if (wrote.some((w) => w !== field && overlaps(w, field))) continue;
2284
+ const key = fieldKey(field);
2285
+ const keep = [];
2286
+ const guards = [];
2287
+ for (const c of checks) {
2288
+ if (c.key !== key || c.pseudo) continue;
2289
+ for (const [bit, cons] of [[c.t, c.tCons], [c.f, c.fCons]]) {
2290
+ if (bit == null || !cons || cons.dep || (cons.set && cons.set.size)) continue;
2291
+ const r = asRange(cons);
2292
+ if (r.hi != null && r.lo == null) keep.push(bit);
2293
+ if (r.lo != null && (r.loInc ? r.lo : Math.floor(r.lo) + 1) >= dec.max) guards.push(bit);
2294
+ }
2295
+ }
2296
+ if (!keep.length || !guards.length) continue;
2297
+ const bits = new Uint32Array(words);
2298
+ for (const b of keep) bits[b >>> 5] |= 1 << (b & 31);
2299
+ (n.keeps ||= []).push({ bits, guards });
2300
+ }
2301
+ }
2164
2302
  if (n.kind === 'step' && keptOnStep.has(id)) {
2165
2303
  const bits = new Uint32Array(words);
2166
2304
  for (const b of keptOnStep.get(id)) bits[b >>> 5] |= 1 << (b & 31);
@@ -2440,6 +2578,39 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2440
2578
  const posOf = new Map();
2441
2579
  const places = [];
2442
2580
  let placeFacts = nFacts;
2581
+ // The tightest end of a field's value the facts `bits` hold, or its picture's.
2582
+ const boundAt = (g, needs, bits) => {
2583
+ const hold = rangeOfField(g)?.hold;
2584
+ let best = hold ? Number(needs === 'hi' ? hold.hi : hold.lo) : null;
2585
+ if (best != null && !Number.isFinite(best)) best = null;
2586
+ for (const c of checks) {
2587
+ if (c.pseudo || c.key !== fieldKey(g)) continue;
2588
+ for (const [bit, cons] of [[c.t, c.tCons], [c.f, c.fCons]]) {
2589
+ if (!cons || !hasFact(bits, bit)) continue;
2590
+ const e = wholeEnds(cons);
2591
+ const v = e && (needs === 'hi' ? e.hi : e.lo);
2592
+ if (v == null) continue;
2593
+ const n = Number(v);
2594
+ if (best == null || (needs === 'hi' ? n < best : n > best)) best = n;
2595
+ }
2596
+ }
2597
+ return best;
2598
+ };
2599
+ // A relation over an expression, X <= G - 1, read with what G held at the test.
2600
+ const resolveDeps = (map, bits) => {
2601
+ const out = new Map();
2602
+ for (const [key, c] of map) {
2603
+ const dep = c && c.dep;
2604
+ const g = dep ? boundAt(dep.g, dep.needs, bits) : null;
2605
+ if (g == null || !Number.isInteger(dep.m) || !Number.isInteger(dep.b)) { out.set(key, c); continue; }
2606
+ const v = dep.m * g + dep.b;
2607
+ const plain = plainCons(c) || {};
2608
+ const had = plain[dep.side];
2609
+ const tighter = had == null || (dep.side === 'hi' ? v < had : v > had);
2610
+ out.set(key, tighter ? { ...plain, [dep.side]: v, [`${dep.side}Inc`]: dep.inc } : plain);
2611
+ }
2612
+ return out;
2613
+ };
2443
2614
  for (const id of tests) {
2444
2615
  const n = nodes[id];
2445
2616
  if (!n.st || !n.st.indexes || !n.tree || n.evaluate || !(n.st.verb === 'IF' || n.loop)) continue;
@@ -2454,7 +2625,8 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2454
2625
  for (const g of guards || []) {
2455
2626
  let cons = new Map();
2456
2627
  const r = new Map();
2457
- for (const [sub, outcome] of g) { cons = meetMaps(cons, consOf(sub, outcome)); testedWhen(sub, outcome, r); }
2628
+ const atTest = facts.get(id);
2629
+ for (const [sub, outcome] of g) { cons = meetMaps(cons, atTest ? resolveDeps(consOf(sub, outcome), atTest) : consOf(sub, outcome)); testedWhen(sub, outcome, r); }
2458
2630
  said = said ? joinMaps(said, cons) : cons;
2459
2631
  ran = ran ? new Map([...ran].filter(([k]) => r.has(k))) : r;
2460
2632
  }
package/lib/dataflow.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { inScope, isProgram, isBms, readSource, relPath } from './sources.mjs';
2
+ import { inScope, isProgram, isBms, 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';
@@ -197,6 +197,7 @@ export function analyze(root, opts = {}) {
197
197
  const nodes = [];
198
198
  const programs = [];
199
199
  const jclPending = [];
200
+ const jclTrees = new Map();
200
201
  // CICS definitions, from CSD extracts and from DFHCSDUP job input, and the estate's own statement
201
202
  // of which DDs and queues reach the internal reader.
202
203
  const csd = { tdqueues: new Map(), transactions: new Map(), urimaps: new Map() };
@@ -209,7 +210,7 @@ export function analyze(root, opts = {}) {
209
210
  const jobSteps = [];
210
211
  // The maps of the repository being read, by MAPSET/MAP and by MAP alone.
211
212
  const bmsMaps = new Map();
212
- const site = loadSite(root, opts.site || null);
213
+ const site = loadSite(root, opts.site || null, opts.tree);
213
214
  const relOf = new Map();
214
215
  const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
215
216
  // `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
@@ -959,7 +960,7 @@ export function analyze(root, opts = {}) {
959
960
  const tree = (!repo && opts.tree) ? opts.tree : directoryTree(base, opts);
960
961
  const idx = tree.index;
961
962
  const files = tree.list().filter(isProgram).filter(inScope(opts));
962
- for (const f of tree.list().filter(isJcl).filter(inScope(opts))) jclPending.push(f);
963
+ for (const f of tree.list().filter(isJcl).filter(inScope(opts))) { jclPending.push(f); jclTrees.set(f, tree); }
963
964
  for (const f of tree.list().filter((p) => /\.csd$/i.test(p)).filter(inScope(opts))) {
964
965
  try { addCsd(tree.text(f).text, f); } catch (e) { stats.unreadable++; unread.push(`${tree.rel(f)}: ${e.code || e.name}`); }
965
966
  }
@@ -1088,7 +1089,7 @@ export function analyze(root, opts = {}) {
1088
1089
  // ACCEPT FROM COMMAND-LINE is untrusted.
1089
1090
  for (const f of jclPending) {
1090
1091
  let src;
1091
- try { src = readSource(f).text; } catch (e) { stats.unreadable++; unread.push(`${rel(f)}: ${e.code || e.name}`); continue; }
1092
+ try { src = jclTrees.get(f).text(f).text; } catch (e) { stats.unreadable++; unread.push(`${rel(f)}: ${e.code || e.name}`); continue; }
1092
1093
  let job;
1093
1094
  try { job = parseJcl(src, f); } catch (e) { stats.threw++; unparsed.push(`${rel(f)}: ${e.code || e.name}`); continue; }
1094
1095
  stats.jclFiles++;