@portll/cobolwork 0.2.117 → 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.
Files changed (51) hide show
  1. package/README.md +13 -1
  2. package/THIRD-PARTY-NOTICES.md +3 -2
  3. package/bin/cobolwork.mjs +16 -1
  4. package/lib/baseline.mjs +8 -6
  5. package/lib/bms.mjs +1 -1
  6. package/lib/capabilities.mjs +2 -1
  7. package/lib/cards.mjs +66 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/compliance.mjs +6 -5
  10. package/lib/control.mjs +295 -6
  11. package/lib/dataflow.mjs +72 -88
  12. package/lib/diff.mjs +65 -89
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/equivalence.mjs +30 -12
  15. package/lib/evidence/journal.mjs +11 -2
  16. package/lib/evidence/record.mjs +3 -1
  17. package/lib/evidence/slsa.mjs +17 -2
  18. package/lib/execution.mjs +77 -0
  19. package/lib/explain.mjs +5 -4
  20. package/lib/ftp.mjs +136 -0
  21. package/lib/inventory.mjs +8 -9
  22. package/lib/ironwork.mjs +4 -6
  23. package/lib/jcl.mjs +29 -54
  24. package/lib/kernel/git.mjs +60 -0
  25. package/lib/kernel/identity.mjs +7 -6
  26. package/lib/kernel/source-tree.mjs +204 -34
  27. package/lib/layout.mjs +202 -0
  28. package/lib/parser.mjs +58 -207
  29. package/lib/precompile.mjs +5 -88
  30. package/lib/revision.json +1 -1
  31. package/lib/sbom.mjs +125 -13
  32. package/lib/scan.mjs +11 -4
  33. package/lib/sets/build.mjs +1 -1
  34. package/lib/sets/cics.mjs +32 -5
  35. package/lib/sets/copybook.mjs +2 -1
  36. package/lib/sets/hidden.mjs +4 -8
  37. package/lib/sets/jcl.mjs +17 -120
  38. package/lib/sets/priv.mjs +1 -1
  39. package/lib/sets/recon.mjs +3 -2
  40. package/lib/sets/semantics.mjs +1 -1
  41. package/lib/sets/vendor.mjs +1 -1
  42. package/lib/sets/zowe.mjs +34 -8
  43. package/lib/site.mjs +17 -7
  44. package/lib/sources.mjs +82 -38
  45. package/lib/utilities.mjs +146 -4
  46. package/package.json +1 -1
  47. package/rules/compliance-cobit2019.json +2438 -0
  48. package/rules/compliance-dora.json +1 -1
  49. package/rules/compliance-ffiec.json +1 -1
  50. package/rules/compliance-nist80053.json +1 -1
  51. package/schema/cobolwork.policy.schema.json +94 -17
@@ -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));
@@ -574,7 +580,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
574
580
  const body = n.k === 'perform' ? add('call', { st: n.st, range: rangeOf(n.target, n.thru), succ: [step] }) : lowerList(n.body, step, loopCtx);
575
581
  nodes[t].succ = [outcome(t, true, next), outcome(t, false, body)];
576
582
  nodes[head].succ = [n.testAfter ? body : t];
577
- if (n.st.loops) loopHeads.push({ head, step, loops: n.st.loops });
583
+ if (n.st.loops) loopHeads.push({ head, step, loops: n.st.loops, test: t, testAfter: n.testAfter, body: n.k === 'inline' ? n.body : null });
578
584
  return head;
579
585
  }
580
586
  case 'goto': {
@@ -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 = [];
@@ -1486,6 +1532,118 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1486
1532
  // so the pair is joined at whichever of the two checks comes last.
1487
1533
  const outcomeAt = new Map();
1488
1534
  for (const node of nodes) if (node.kind === 'outcome') outcomeAt.set(`${node.test}|${node.value}`, node);
1535
+ // A backward scan for the last byte past the spaces, VARYING I FROM the field's length BY -1 UNTIL
1536
+ // I < 1 OR F(I:1) > SPACE, leaves I from 1 to that length where F > SPACES held as it began: a
1537
+ // field that compares above spaces has a byte above a space, under any collating sequence, and the
1538
+ // scan meets it before 0. The witness dies with a write to F or to anything its subscript reads.
1539
+ const keptOnStep = new Map();
1540
+ const isSpaces = (toks) => toks.length === 1 && (WORD(toks[0], 'SPACE') || WORD(toks[0], 'SPACES') || (toks[0].t === 'lit' && !toks[0].prefix && /^ +$/.test(String(toks[0].v))));
1541
+ const textOf = (toks) => toks.map((t) => (t.t === 'word' ? t.u : String(t.v))).join(' ');
1542
+ // A whole alphanumeric item or table element, with the fields its subscript reads.
1543
+ const scanned = (toks) => {
1544
+ const base = toks[0] && toks[0].t === 'word' ? resolve(toks[0]) : null;
1545
+ if (!base || base.index || base.level === 88 || base.size == null || categoryOf(base)?.k !== 'A' || variableLength(base)) return null;
1546
+ let i = 1;
1547
+ while (i + 1 < toks.length && (WORD(toks[i], 'OF') || WORD(toks[i], 'IN')) && toks[i + 1].t === 'word') i += 2;
1548
+ const reads = [];
1549
+ if (i < toks.length) {
1550
+ if (toks[i].v !== '(' || toks[toks.length - 1].v !== ')') return null;
1551
+ let depth = 0;
1552
+ for (let j = i; j < toks.length; j++) {
1553
+ const t = toks[j];
1554
+ if (t.v === ':') return null;
1555
+ if (t.v === '(' || t.v === ')') { depth += t.v === '(' ? 1 : -1; if (!depth && j < toks.length - 1) return null; continue; }
1556
+ const f = t.t === 'word' ? resolve(t) : null;
1557
+ if (f) reads.push(f.level === 88 && f.parent ? f.parent : f);
1558
+ }
1559
+ }
1560
+ return { base, reads };
1561
+ };
1562
+ // What an outcome says is above spaces (`above`) or only not all spaces.
1563
+ const notBlank = (tree, outcome, out = []) => {
1564
+ if (tree.k === 'not') return notBlank(tree.a, !outcome, out);
1565
+ if ((tree.k === 'and' && outcome) || (tree.k === 'or' && !outcome)) { notBlank(tree.a, outcome, out); notBlank(tree.b, outcome, out); return out; }
1566
+ if (tree.k !== 'rel') return out;
1567
+ const op = outcome ? tree.op : NEGATE[tree.op];
1568
+ if (isSpaces(tree.right) && (op === '>' || op === '<>')) out.push({ toks: tree.left, above: op === '>' });
1569
+ else if (isSpaces(tree.left) && (op === '<' || op === '<>')) out.push({ toks: tree.right, above: op === '<' });
1570
+ return out;
1571
+ };
1572
+ const scans = [];
1573
+ for (const { head, step, test, testAfter, body, loops } of loopHeads) {
1574
+ if (testAfter || !body || loops.length !== 1 || !body.every((el) => el.k === 'stmt' && el.st.verb === 'CONTINUE')) continue;
1575
+ const loop = loops[0];
1576
+ const counter = loop.counter ? resolve(loop.counter) : null;
1577
+ const cap = intCap(counter);
1578
+ const tree = nodes[test].tree;
1579
+ if (cap == null || !tree || tree.k !== 'or' || !loop.by || loop.by.map((t) => t.v).join('') !== '-1') continue;
1580
+ const from = loop.from || [];
1581
+ let start = numberOf(constValue(from));
1582
+ if (start == null && WORD(from[0], 'LENGTH') && WORD(from[1], 'OF')) start = scanned(from.slice(2))?.base.size ?? null;
1583
+ if (start == null || !Number.isInteger(start) || start < 1 || start > cap) continue;
1584
+ const isCounter = (toks) => toks.length === 1 && toks[0].t === 'word' && resolve(toks[0]) === counter;
1585
+ const done = (r) => r.k === 'rel' && isCounter(r.left) && (r.op === '<' ? numberOf(constValue(r.right)) === 1 : (r.op === '<=' || r.op === '=') && numberOf(constValue(r.right)) === 0);
1586
+ const probe = (r) => {
1587
+ if (r.k !== 'rel') return null;
1588
+ const [el, op] = isSpaces(r.right) ? [r.left, r.op] : isSpaces(r.left) ? [r.right, FLIP[r.op]] : [null, null];
1589
+ if (!el || (op !== '>' && op !== '<>') || el.length < 6) return null;
1590
+ const tail = el.slice(-5);
1591
+ if (tail[0].v !== '(' || !isCounter([tail[1]]) || tail[2].v !== ':' || numberOf(constValue([tail[3]])) !== 1 || tail[4].v !== ')') return null;
1592
+ const toks = el.slice(0, -5);
1593
+ const s = scanned(toks);
1594
+ return s && s.base.size === start ? { toks, above: op === '>' } : null;
1595
+ };
1596
+ const p = done(tree.a) ? probe(tree.b) : done(tree.b) ? probe(tree.a) : null;
1597
+ const exit = p && outcomeAt.get(`${test}|true`);
1598
+ if (exit) scans.push({ ...p, counter, start, exit, head, step, at: loop.counter });
1599
+ }
1600
+ // The scan's counter never passes its start: the step runs only once I < 1 has failed, so it
1601
+ // cannot wrap, and the body writes nothing.
1602
+ for (const s of scans) {
1603
+ const key = fieldKey(s.counter);
1604
+ noteField(key, s.counter);
1605
+ const h = { id: checks.length, key, field: s.counter, file: s.at.file, line: s.at.line, tCons: { hi: s.start, hiInc: true }, fCons: null, restricts: false, x: null, t: nFacts++, f: null };
1606
+ checks.push(h);
1607
+ nodes[s.head].genStmt = [...(nodes[s.head].genStmt || []), h.t];
1608
+ keptOnStep.set(s.step, [...(keptOnStep.get(s.step) || []), h.t]);
1609
+ }
1610
+ if (scans.length) {
1611
+ const witness = new Map();
1612
+ const bitFor = (toks, above) => {
1613
+ const k = `${above ? '>' : '<>'}|${textOf(toks)}`;
1614
+ if (witness.has(k)) return witness.get(k);
1615
+ const s = scanned(toks);
1616
+ const bit = nFacts++;
1617
+ for (const f of [s.base, ...s.reads]) {
1618
+ const key = fieldKey(f);
1619
+ noteField(key, f);
1620
+ checks.push({ pseudo: true, key, field: f, x: null, t: bit, f: null });
1621
+ }
1622
+ witness.set(k, bit);
1623
+ return bit;
1624
+ };
1625
+ const wanted = new Set(scans.map((s) => textOf(s.toks)));
1626
+ for (const id of tests) {
1627
+ const n = nodes[id];
1628
+ if (!n.tree) continue;
1629
+ for (const outcome of [true, false]) {
1630
+ for (const nb of notBlank(n.tree, outcome)) {
1631
+ if (!wanted.has(textOf(nb.toks)) || !scanned(nb.toks)) continue;
1632
+ n.gen[String(outcome)].push(bitFor(nb.toks, false));
1633
+ if (nb.above) n.gen[String(outcome)].push(bitFor(nb.toks, true));
1634
+ }
1635
+ }
1636
+ }
1637
+ for (const s of scans) {
1638
+ const bit = witness.get(`${s.above ? '>' : '<>'}|${textOf(s.toks)}`);
1639
+ if (bit == null) continue;
1640
+ const key = fieldKey(s.counter);
1641
+ noteField(key, s.counter);
1642
+ const y = { id: checks.length, key, field: s.counter, file: s.at.file, line: s.at.line, tCons: { numeric: true, lo: 1, loInc: true, hi: s.start, hiInc: true }, fCons: null, restricts: false, x: null, t: nFacts++, f: null, bounds: { lo: 1, hi: s.start } };
1643
+ checks.push(y);
1644
+ (s.exit.implies ||= []).push([bit, y.t]);
1645
+ }
1646
+ }
1489
1647
  const MAX_BOUNDS_PER_RELATION = 8;
1490
1648
  for (const c of [...checks]) {
1491
1649
  if (c.pseudo || c.test == null) continue;
@@ -1600,7 +1758,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1600
1758
  const r = rangeOfField(leaf.f);
1601
1759
  const base = { needs: [], lo: r.hold.lo, hi: r.hold.hi };
1602
1760
  if (!leaf.plain) return [base];
1603
- const cv = numberOf(constValue([leaf.tok]));
1761
+ const cv = leaf.tok ? numberOf(constValue([leaf.tok])) : null;
1604
1762
  if (cv != null && Number.isInteger(cv)) return [{ needs: [], lo: BigInt(cv), hi: BigInt(cv) }];
1605
1763
  if (!heldBits) return [base];
1606
1764
  let lo = null;
@@ -1669,9 +1827,19 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1669
1827
  }
1670
1828
  return out;
1671
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
+ };
1672
1840
  const expandRules = () => {
1673
1841
  indexFacts();
1674
- 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; }
1675
1843
  for (const r of rules) {
1676
1844
  const bits = withLooser(r.fact);
1677
1845
  const at = r.bucket == null ? r.n : nodes[r.n.test];
@@ -1680,6 +1848,26 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1680
1848
  else ((at.genArithOn ||= { true: [], false: [] })[r.bucket]).push(...bits);
1681
1849
  } else (r.n.derive ||= []).push({ needs: r.needs, to: bits });
1682
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
+ }
1683
1871
  };
1684
1872
 
1685
1873
  const ARITH_VERBS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'MOVE']);
@@ -1873,6 +2061,8 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1873
2061
  const before = relevant.size;
1874
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);
1875
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); }
1876
2066
  more = relevant.size > before;
1877
2067
  }
1878
2068
 
@@ -1884,6 +2074,20 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1884
2074
  let grew = false;
1885
2075
  try {
1886
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
+ }
1887
2091
  for (const a of arith) {
1888
2092
  a.trees.forEach((tree, i) => {
1889
2093
  const r = a.targets[i];
@@ -1920,6 +2124,27 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1920
2124
  }
1921
2125
  }
1922
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
+ }
1923
2148
  // A relation holds after the outcome of its test that makes it true, and the fields it bounds hold
1924
2149
  // what the other side could hold.
1925
2150
  for (const id of tests) {
@@ -2049,6 +2274,36 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2049
2274
  } else if (n.kind === 'step' && stepped.has(id)) {
2050
2275
  keepAcross(n, [{ ...stepped.get(id), sizeError: false }]);
2051
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
+ }
2302
+ if (n.kind === 'step' && keptOnStep.has(id)) {
2303
+ const bits = new Uint32Array(words);
2304
+ for (const b of keptOnStep.get(id)) bits[b >>> 5] |= 1 << (b & 31);
2305
+ (n.keeps ||= []).push({ bits, guards: null });
2306
+ }
2052
2307
  });
2053
2308
 
2054
2309
  // ---- must-analysis ----
@@ -2323,6 +2578,39 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2323
2578
  const posOf = new Map();
2324
2579
  const places = [];
2325
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
+ };
2326
2614
  for (const id of tests) {
2327
2615
  const n = nodes[id];
2328
2616
  if (!n.st || !n.st.indexes || !n.tree || n.evaluate || !(n.st.verb === 'IF' || n.loop)) continue;
@@ -2337,7 +2625,8 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2337
2625
  for (const g of guards || []) {
2338
2626
  let cons = new Map();
2339
2627
  const r = new Map();
2340
- 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); }
2341
2630
  said = said ? joinMaps(said, cons) : cons;
2342
2631
  ran = ran ? new Map([...ran].filter(([k]) => r.has(k))) : r;
2343
2632
  }