@portll/cobolwork 0.2.150 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ follows. To run from a checkout instead:
34
34
 
35
35
  git clone https://github.com/Portll/cobolwork && cd cobolwork && npm link
36
36
 
37
- Node 18 or later. No dependencies, runtime or development. `npm link` puts `cobolwork` on your PATH;
37
+ Node 22 or later. No dependencies, runtime or development. `npm link` puts `cobolwork` on your PATH;
38
38
  adding `bin/` to PATH does the same thing. GnuCOBOL is needed only to regrade the parser or validate
39
39
  benchmark cases. In a GitHub workflow, `uses: Portll/cobolwork@main` runs the build gate on a pull
40
40
  request and writes SARIF for code scanning: [docs/github-action.md](docs/github-action.md).
package/lib/control.mjs CHANGED
@@ -1775,7 +1775,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1775
1775
  const needs = [...new Set([lo && lo.bit, hi && hi.bit].filter((x) => x != null))].sort((x, y) => x - y);
1776
1776
  const from = lo ? lo.v : base.lo;
1777
1777
  const to = hi ? hi.v : base.hi;
1778
- return needs.length && from <= to ? [base, { needs, lo: from, hi: to }] : [base];
1778
+ if (!needs.length || from > to) return [base];
1779
+ // Each end alone as well, so a result that needs only one of them does not wait on the other.
1780
+ const ends = lo && hi && lo.bit !== hi.bit ? [{ needs: [lo.bit], lo: lo.v, hi: base.hi }, { needs: [hi.bit], lo: base.lo, hi: hi.v }] : [];
1781
+ return [base, { needs, lo: from, hi: to }, ...ends];
1779
1782
  };
1780
1783
  // A fact "the field holds a whole number from lo to hi" (either end may be open), one per field and interval.
1781
1784
  const derivedOf = new Map();
@@ -1815,6 +1818,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
1815
1818
  // is not among them: the interval is what the data holds, not a test that ran.
1816
1819
  const withLooser = (fact) => {
1817
1820
  const out = [fact.t];
1821
+ if (fact.pair) {
1822
+ for (const [k, other] of pairFacts.get(fact.pairKey)) if (k > fact.k && (fact.pair === 'span' || other.a === fact.a)) out.push(other.t);
1823
+ return out;
1824
+ }
1818
1825
  const own = fact.bounds;
1819
1826
  for (const c of factsByKey.get(fact.key) || []) {
1820
1827
  if (c === fact || !c.derived) continue;
@@ -2047,6 +2054,108 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2047
2054
  relations.clear();
2048
2055
  }
2049
2056
 
2057
+ // Facts over two whole-number fields, killed by a write to either: "A - B <= k" (an order) and
2058
+ // "A + B <= k" (a span, which keeps a reference modification X(A:B) inside X). They are drawn
2059
+ // only for the start and length of a reference modification and the fields a length is computed from.
2060
+ const spanPairs = new Map();
2061
+ for (const st of prog.statements) {
2062
+ for (const x of st.indexes || []) {
2063
+ if (x.kind !== 'refmod-length' || !x.from || !x.from.tok) continue;
2064
+ const len = x.tok && x.tok.t === 'word' ? resolve(x.tok) : null;
2065
+ const start = resolve(x.from.tok);
2066
+ if (!len || !start || len === start || !rangeOfField(len) || !rangeOfField(start) || inTable(len) || inTable(start)) continue;
2067
+ if (!spanPairs.has(len)) spanPairs.set(len, new Set());
2068
+ spanPairs.get(len).add(start);
2069
+ }
2070
+ }
2071
+ const starts = new Set([...spanPairs.values()].flatMap((s) => [...s]));
2072
+ const pairIds = new Map();
2073
+ const pairId = (f) => { if (!pairIds.has(f)) pairIds.set(f, pairIds.size); return pairIds.get(f); };
2074
+ const pairFacts = new Map();
2075
+ const pairKey = (kind, a, b) => (kind === 'span' && pairId(a) > pairId(b) ? `${kind}|${pairId(b)}|${pairId(a)}` : `${kind}|${pairId(a)}|${pairId(b)}`);
2076
+ const pairFact = (kind, a, b, k, at) => {
2077
+ const key = pairKey(kind, a, b);
2078
+ if (!pairFacts.has(key)) pairFacts.set(key, new Map());
2079
+ const mine = pairFacts.get(key);
2080
+ if (mine.has(k)) return mine.get(k);
2081
+ if (!arithBudget()) return null;
2082
+ const fact = { pair: kind, pairKey: key, a, b, k, t: nFacts++, file: at.file, line: at.line };
2083
+ for (const f of [a, b]) {
2084
+ const fk = fieldKey(f);
2085
+ noteField(fk, f);
2086
+ checks.push({ pseudo: true, key: fk, field: f, x: null, t: fact.t, f: null });
2087
+ }
2088
+ mine.set(k, fact);
2089
+ return fact;
2090
+ };
2091
+ // What an outcome makes true of two fields, as order and span facts: S <= E, S + L - 1 <= 80.
2092
+ const pairsOf = (rel, pol) => {
2093
+ const op = pol ? rel.op : NEGATE[rel.op];
2094
+ if (!['<', '<=', '>', '>=', '='].includes(op)) return [];
2095
+ const l = linearOf(rel.left), r = linearOf(rel.right);
2096
+ const e = l && r ? linAdd(l, r, -1) : null;
2097
+ if (!e || e.terms.size !== 2 || e.lo !== e.hi || !Number.isInteger(e.lo)) return [];
2098
+ const out = [];
2099
+ // e op 0 as one or two facts "sum <= k" over the terms, each with its signs.
2100
+ const below = (sign, strict) => {
2101
+ const terms = [...e.terms].map(([f, c]) => [f, sign * c]);
2102
+ if (terms.some(([f, c]) => Math.abs(c) !== 1 || !rangeOfField(f) || inTable(f))) return;
2103
+ const k = -sign * e.lo - (strict ? 1 : 0);
2104
+ const [[a, ca], [b, cb]] = terms;
2105
+ if (ca > 0 && cb > 0) { if ((spanPairs.get(a)?.has(b)) || (spanPairs.get(b)?.has(a))) out.push(['span', a, b, k]); }
2106
+ else if (ca > 0 && cb < 0) { if (starts.has(a)) out.push(['order', a, b, k]); }
2107
+ else if (ca < 0 && cb > 0) { if (starts.has(b)) out.push(['order', b, a, k]); }
2108
+ };
2109
+ if (op === '<' || op === '<=' || op === '=') below(1, op === '<');
2110
+ if (op === '>' || op === '>=' || op === '=') below(-1, op === '>');
2111
+ return out;
2112
+ };
2113
+ if (spanPairs.size) {
2114
+ for (const id of tests) {
2115
+ const n = nodes[id];
2116
+ if (!n.tree) continue;
2117
+ for (const o of [true, false]) {
2118
+ const at = outcomeAt.get(`${id}|${o}`);
2119
+ if (!at) continue;
2120
+ for (const { rel, pol } of relationsOf(n.tree, o)) {
2121
+ for (const [kind, a, b, k] of pairsOf(rel, pol)) {
2122
+ const fact = pairFact(kind, a, b, k, n.at || n.st);
2123
+ if (fact) derive(at, [], fact, String(o));
2124
+ }
2125
+ }
2126
+ }
2127
+ }
2128
+ }
2129
+ // The statements that only raise a field of a span, by at most `max`.
2130
+ const spanRaisers = [];
2131
+ if (spanPairs.size) {
2132
+ const spanFields = new Set([...spanPairs.keys(), ...starts]);
2133
+ nodes.forEach((n, id) => {
2134
+ if (n.kind !== 'stmt' || !n.st || n.st.at == null) return;
2135
+ const inc = increments(n.st);
2136
+ if (inc && inc.fields.some((f) => spanFields.has(f))) spanRaisers.push({ id, inc, wrote: writtenBy(n) });
2137
+ });
2138
+ }
2139
+ const spanLifted = new Set();
2140
+ // An arithmetic tree as whole-number coefficients per plain field plus a whole constant, or null.
2141
+ const linearTree = (t) => {
2142
+ switch (t.k) {
2143
+ case 'const': return t.v.d === 1n ? { terms: new Map(), c: Number(t.v.n) } : null;
2144
+ case 'field': return t.plain ? { terms: new Map([[t.f, 1]]), c: 0 } : null;
2145
+ case 'neg': { const a = linearTree(t.a); return a && { terms: new Map([...a.terms].map(([f, c]) => [f, -c])), c: -a.c }; }
2146
+ case 'bin': {
2147
+ if (t.op !== '+' && t.op !== '-') return null;
2148
+ const a = linearTree(t.a), b = linearTree(t.b);
2149
+ if (!a || !b) return null;
2150
+ const sign = t.op === '+' ? 1 : -1;
2151
+ const terms = new Map(a.terms);
2152
+ for (const [f, c] of b.terms) { const v = (terms.get(f) || 0) + sign * c; if (v) terms.set(f, v); else terms.delete(f); }
2153
+ return { terms, c: a.c + sign * b.c };
2154
+ }
2155
+ default: return null;
2156
+ }
2157
+ };
2158
+
2050
2159
  // The fields that matter: those an index reads, and those that flow into them through the
2051
2160
  // statements above and the relations between fields.
2052
2161
  const relevant = new Set();
@@ -2092,16 +2201,59 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2092
2201
  a.trees.forEach((tree, i) => {
2093
2202
  const r = a.targets[i];
2094
2203
  if (!tree || !r.f || !rangeOfField(r.f) || inTable(r.f) || !relevant.has(r.f)) return;
2095
- const fit = rangeOfField(r.f).fit;
2204
+ const { fit, hold } = rangeOfField(r.f);
2096
2205
  heldBits = prev ? prev.get(a.n.id) || null : null;
2097
2206
  for (const c of candidates(tree, leafCands, 64)) {
2098
2207
  const s = storedRange(c.iv, r.rounded);
2099
- if (s.lo < fit.lo || s.hi > fit.hi || (s.lo === fit.lo && s.hi === fit.hi)) continue;
2100
- const fact = derivedFact(r.f, s.lo, s.hi, a.n.st);
2208
+ if (s.lo === fit.lo && s.hi === fit.hi) continue;
2209
+ // A result past one end of what the field keeps loses high digits and keeps its sign, and no
2210
+ // result past what the storage holds is added, so the other end still bounds it.
2211
+ const whole = s.lo >= fit.lo && s.hi <= fit.hi;
2212
+ const top = whole || (s.lo >= hold.lo && s.hi <= fit.hi && (s.hi >= 0n || s.lo >= fit.lo));
2213
+ const bottom = whole || (s.hi <= hold.hi && s.lo >= fit.lo && (s.lo <= 0n || s.hi <= fit.hi));
2214
+ if (!top && !bottom) continue;
2215
+ const fact = derivedFact(r.f, bottom ? s.lo : null, top ? s.hi : null, a.n.st);
2101
2216
  if (fact && derive(a.n, c.needs, fact)) grew = true;
2102
2217
  }
2103
2218
  });
2104
2219
  }
2220
+ // A length computed as E - S + c from its start S, where S <= E + d held, is at least c - d, which
2221
+ // intervals alone cannot see; and S + L is then E + c, at most what E could hold plus c.
2222
+ for (const a of arith) {
2223
+ a.trees.forEach((tree, i) => {
2224
+ const r = a.targets[i];
2225
+ const mates = r.f && spanPairs.get(r.f);
2226
+ const lin = mates && tree ? linearTree(tree) : null;
2227
+ if (!lin) return;
2228
+ const fit = rangeOfField(r.f).fit;
2229
+ heldBits = prev ? prev.get(a.n.id) || null : null;
2230
+ for (const [s, cs] of lin.terms) {
2231
+ if (cs !== -1 || !mates.has(s) || s === r.f) continue;
2232
+ const rest = [...lin.terms].filter(([f]) => f !== s);
2233
+ if (rest.length > 1 || (rest.length && (rest[0][1] !== 1 || rest[0][0] === r.f))) continue;
2234
+ const e = rest.length ? rest[0][0] : null;
2235
+ const orders = e ? [...(pairFacts.get(pairKey('order', s, e))?.values() || [])] : [];
2236
+ const eCands = e ? leafCands({ f: e, tok: null, plain: true }) : [{ needs: [], lo: 0n, hi: 0n }];
2237
+ const sCands = leafCands({ f: s, tok: null, plain: true });
2238
+ // With no E, the lowest L is c less S's highest, which an interval of S already gives.
2239
+ const floors = e ? orders.map((o) => ({ needs: [o.t], lo: BigInt(lin.c - o.k) })) : sCands.map((sc) => ({ needs: sc.needs, lo: BigInt(lin.c) - sc.hi }));
2240
+ for (const fl of floors) {
2241
+ if (fl.lo < fit.lo) continue;
2242
+ for (const ec of eCands) {
2243
+ for (const sc of sCands) {
2244
+ const hi = ec.hi - sc.lo + BigInt(lin.c);
2245
+ if (hi > fit.hi || hi < fl.lo) continue;
2246
+ const needs = [...new Set([...fl.needs, ...ec.needs, ...sc.needs])].sort((x, y) => x - y);
2247
+ const fact = derivedFact(r.f, fl.lo, hi, a.n.st);
2248
+ if (fact && derive(a.n, needs, fact)) grew = true;
2249
+ const span = pairFact('span', s, r.f, Number(ec.hi) + lin.c, a.n.st);
2250
+ if (span && derive(a.n, needs, span)) grew = true;
2251
+ }
2252
+ }
2253
+ }
2254
+ }
2255
+ });
2256
+ }
2105
2257
  for (const rel of relations.values()) {
2106
2258
  if (!relevant.has(rel.f) && !relevant.has(rel.g)) continue;
2107
2259
  const at = nodes[rel.test].at || nodes[rel.test].st;
@@ -2124,6 +2276,24 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2124
2276
  }
2125
2277
  }
2126
2278
  }
2279
+ // An increment of a span's field by at most n leaves the span n looser: the sum grows by n at most,
2280
+ // and a sum that wraps or is cut short is only lower.
2281
+ for (const { id, inc, wrote } of spanRaisers) {
2282
+ const held = prev ? prev.get(id) : null;
2283
+ if (!held) continue;
2284
+ for (const mine of pairFacts.values()) {
2285
+ for (const f of [...mine.values()]) {
2286
+ if (f.pair !== 'span' || !hasFact(held, f.t)) continue;
2287
+ const count = [f.a, f.b].filter((x) => inc.fields.includes(x)).length;
2288
+ if (!count || wrote.some((w) => !inc.fields.includes(w) && (overlaps(w, f.a) || overlaps(w, f.b)))) continue;
2289
+ const to = pairFact('span', f.a, f.b, f.k + count * inc.max, nodes[id].st);
2290
+ if (!to || spanLifted.has(`${id}|${f.t}|${to.t}`)) continue;
2291
+ spanLifted.add(`${id}|${f.t}|${to.t}`);
2292
+ (nodes[id].spanLifts ||= []).push([f.t, to.t]);
2293
+ grew = true;
2294
+ }
2295
+ }
2296
+ }
2127
2297
  // A bound over an expression in one other field, X <= G - 1, takes the interval G holds at the test.
2128
2298
  for (const c of checks) {
2129
2299
  if (c.pseudo || c.test == null || !relevant.has(c.field) || !rangeOfField(c.field) || inTable(c.field)) continue;
@@ -2340,6 +2510,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2340
2510
  const killScratch = new Uint32Array(words);
2341
2511
  // A summary is joined to whatever enters the range, so it cannot assume a guard the caller lacks.
2342
2512
  let summarising = false;
2513
+ // While summarising, a fact that depends on others is taken only where the range's own statements
2514
+ // make those others on every route: then it holds whatever the caller brings.
2515
+ let approved = null;
2516
+ const has = (bits, x) => (bits[x >>> 5] & (1 << (x & 31))) !== 0;
2343
2517
  // What holds after a node, in a buffer reused by every call: the caller merges it into the next
2344
2518
  // node's facts before calling again. Null where the route ends here.
2345
2519
  const transfer = (n, input) => {
@@ -2362,7 +2536,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2362
2536
  kill = killScratch;
2363
2537
  kill.set(n.kill);
2364
2538
  for (const k of n.keeps) {
2365
- if (k.guards && (summarising || !k.guards.some((g) => input[g >>> 5] & (1 << (g & 31))))) continue;
2539
+ if (k.guards && (summarising ? !approved.has(k) : !k.guards.some((g) => has(input, g)))) continue;
2366
2540
  for (let i = 0; i < words; i++) kill[i] &= ~k.bits[i];
2367
2541
  }
2368
2542
  }
@@ -2377,26 +2551,36 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2377
2551
  // An increment that cannot wrap raises the lower bounds that held before it.
2378
2552
  if (n.lifts) {
2379
2553
  for (const { pairs, guards } of n.lifts) {
2380
- if (summarising || !guards.some((g) => input[g >>> 5] & (1 << (g & 31)))) continue;
2381
- for (const [from, to] of pairs) {
2382
- if (!(input[from >>> 5] & (1 << (from & 31)))) continue;
2554
+ if (!summarising && !guards.some((g) => has(input, g))) continue;
2555
+ for (const pair of pairs) {
2556
+ const [from, to] = pair;
2557
+ if (summarising ? !approved.has(pair) : !has(input, from)) continue;
2383
2558
  if (out === input) { scratch.set(input); out = scratch; }
2384
2559
  out[to >>> 5] |= 1 << (to & 31);
2385
2560
  }
2386
2561
  }
2387
2562
  }
2563
+ if (n.spanLifts) {
2564
+ for (const pair of n.spanLifts) {
2565
+ const [from, to] = pair;
2566
+ if (summarising ? !approved.has(pair) : !has(input, from)) continue;
2567
+ if (out === input) { scratch.set(input); out = scratch; }
2568
+ out[to >>> 5] |= 1 << (to & 31);
2569
+ }
2570
+ }
2388
2571
  // What a statement or a test leaves in a field, where the facts its operands needed held before it.
2389
- if (n.derive && !summarising) {
2572
+ if (n.derive) {
2390
2573
  for (const d of n.derive) {
2391
- if (!d.needs.every((x) => input[x >>> 5] & (1 << (x & 31)))) continue;
2574
+ if (summarising ? !approved.has(d) : !d.needs.every((x) => has(input, x))) continue;
2392
2575
  if (out === input) { scratch.set(input); out = scratch; }
2393
2576
  for (const t of d.to) out[t >>> 5] |= 1 << (t & 31);
2394
2577
  }
2395
2578
  }
2396
2579
  // A flag test's outcome turns what held before it into the bound the flag stood for.
2397
2580
  if (n.implies) {
2398
- for (const [from, to, soft] of n.implies) {
2399
- if (soft && summarising) continue;
2581
+ for (const entry of n.implies) {
2582
+ const [from, to, soft] = entry;
2583
+ if (soft && summarising && !approved.has(entry)) continue;
2400
2584
  if (!(input[from >>> 5] & (1 << (from & 31)))) continue;
2401
2585
  if (out === input) { scratch.set(input); out = scratch; }
2402
2586
  out[to >>> 5] |= 1 << (to & 31);
@@ -2477,8 +2661,22 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2477
2661
  const r = pending.shift();
2478
2662
  queued.delete(r.key);
2479
2663
  const calls = new Set();
2664
+ // From no facts, every fact the run holds was made inside the range, so what depends on it is sound as it stands.
2665
+ summarising = false;
2666
+ const fromNone = run(r.entry, ZERO, r.end);
2667
+ summarising = true;
2668
+ const bot = fromNone.exitOut;
2669
+ approved = new Set();
2670
+ for (const [id, bits] of fromNone.IN) {
2671
+ const n = nodes[id];
2672
+ for (const k of n.keeps || []) if (k.guards && k.guards.some((g) => has(bits, g))) approved.add(k);
2673
+ for (const { pairs, guards } of n.lifts || []) if (guards.some((g) => has(bits, g))) for (const pair of pairs) if (has(bits, pair[0])) approved.add(pair);
2674
+ for (const pair of n.spanLifts || []) if (has(bits, pair[0])) approved.add(pair);
2675
+ for (const d of n.derive || []) if (d.needs.every((x) => has(bits, x))) approved.add(d);
2676
+ for (const entry of n.implies || []) if (entry[2] && has(bits, entry[0])) approved.add(entry);
2677
+ }
2480
2678
  const top = run(r.entry, ONES, r.end, calls).exitOut;
2481
- const bot = run(r.entry, ZERO, r.end).exitOut;
2679
+ approved = null;
2482
2680
  for (const k of calls) { if (!callers.has(k)) callers.set(k, new Set()); callers.get(k).add(r); }
2483
2681
  const old = summaries.get(r.key);
2484
2682
  const nextS = top && bot ? { top, bot } : { top: null, bot: null };
@@ -2673,7 +2871,20 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
2673
2871
  // the caller has let the parse tree go. A statement no context reaches has no facts, and credits
2674
2872
  // nothing. One `reached` does not mark does not run, unless `partial` says the analysis stopped
2675
2873
  // short or met a paragraph the parse did not find.
2676
- return { loopBody, nodeOf, posOf, checks: checks.filter((c) => !c.pseudo), words, nodes: nodes.length, facts, reached: reachedAt, extraFacts, partial };
2874
+ const spans = [];
2875
+ for (const mine of pairFacts.values()) for (const f of mine.values()) if (f.pair === 'span') spans.push({ a: f.a, b: f.b, k: f.k, t: f.t });
2876
+ return { loopBody, nodeOf, posOf, checks: checks.filter((c) => !c.pseudo), spans, words, nodes: nodes.length, facts, reached: reachedAt, extraFacts, partial };
2877
+ }
2878
+
2879
+ // The most the facts at a point let a node's value be, as a whole number, or null for no upper end.
2880
+ export function topOf(checks, bits) {
2881
+ let cons = null;
2882
+ for (const c of checks || []) for (const [bit, said] of [[c.t, c.tCons], [c.f, c.fCons]]) if (hasFact(bits, bit)) cons = meet(cons, said);
2883
+ return cons ? wholeTop(cons) : null;
2884
+ }
2885
+ export function wholeTop(said) {
2886
+ const r = asRange(said);
2887
+ return r.hi == null ? null : r.hiInc ? Math.floor(r.hi) : Math.ceil(r.hi) - 1;
2677
2888
  }
2678
2889
 
2679
2890
  // For each token a condition reads, what each reading of it waits on: the operands to its left at
package/lib/dataflow.mjs CHANGED
@@ -15,7 +15,7 @@ import { eachWithinMemory, watchMemoryBuffer } from './kernel/memory.mjs';
15
15
  // A flow finding is located by source and sink rather than by one path and line, so it keeps its
16
16
  // own comparator. The text comparison underneath it is the shared one.
17
17
  import { byText } from './kernel/findings.mjs';
18
- import { buildControl, creditOf, hasFact, within } from './control.mjs';
18
+ import { buildControl, creditOf, hasFact, topOf, within, wholeTop } from './control.mjs';
19
19
  import { directoryTree } from './kernel/source-tree.mjs';
20
20
 
21
21
  const OS_COMMAND_ROUTINE = /^(SYSTEM|C\$SYSTEM|CBL_EXEC_RUN_UNIT|CBL_GC_HOSTED|BXPSYSTM)$/i;
@@ -406,6 +406,13 @@ export function analyze(root, opts = {}) {
406
406
  // A whole number no sign is written on, which no value put in it can leave below 0.
407
407
  const unsignedWhole = (it) => it.level !== 88 && !it.index && !(it.children || []).length
408
408
  && /^9+$/.test(String(it.picture || '').toUpperCase().replace(/(\w)\((\d+)\)/g, (_, ch, n) => ch.repeat(Number(n))));
409
+ // Where a length's reference starts: the start's node, a constant it is moved by, and the span
410
+ // facts that keep start and length together inside the item.
411
+ const startOf = (from, len, size) => {
412
+ const s = from.tok ? itemOfToken(from.tok) : null;
413
+ const spans = ctl && ctl.spans && s && len ? ctl.spans.filter((f) => ((f.a === s && f.b === len) || (f.a === len && f.b === s)) && f.k + from.offset <= size + 1).map((f) => f.t) : [];
414
+ return { node: from.tok ? nodeOfToken(from.tok) : null, offset: from.offset, size, spans };
415
+ };
409
416
  const indexed = new Set();
410
417
  const subscripting = new Map();
411
418
  // Each subscript use of a name, with the other indices of the same reference: the loop-bound
@@ -438,8 +445,9 @@ export function analyze(root, opts = {}) {
438
445
  const use = `${key}|${point ?? `s${st.at}`}|${offset}`;
439
446
  if (indexed.has(use)) continue;
440
447
  indexed.add(use);
448
+ const start = kind === 'reference-modification' && x.from && base != null ? startOf(x.from, idx, base) : null;
441
449
  at.sinks.push({
442
- kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit, ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
450
+ kind, onlyFrom: FROM_OUTSIDE, file: st.file, line: st.line, point, group: `${pk}|${key}`, limit, ...(start ? { start } : {}), ...(offset ? { offset } : {}), ...(offset > 0 && idx && unsignedWhole(idx) ? { nonNeg: true } : {}), ...(ssrange ? { ssrange } : {}),
443
451
  detail: kind === 'subscript'
444
452
  ? `${at.name} subscripts ${host.name}, a table of ${table.occurs}`
445
453
  : `${at.name} sets the ${x.kind === 'refmod-offset' ? 'start' : 'length'} of a reference to ${host.name}, which is ${host.size} bytes`,
@@ -1283,6 +1291,18 @@ export function analyze(root, opts = {}) {
1283
1291
  const bits = p.facts.get(point);
1284
1292
  return bits ? creditOf(n.checks, bits, kind, limit, offset, nonNeg) : NONE;
1285
1293
  }
1294
+ // A length is judged with its start: the last byte, S + L - 1, stays inside the item where the
1295
+ // start at its highest leaves room for the length, or where a fact bounds the two together.
1296
+ function spanned(c, sink, n) {
1297
+ if (c.level < 2 || !sink.start) return c;
1298
+ const p = programs[n.pk];
1299
+ const bits = p && p.ordered && sink.point != null ? p.facts.get(sink.point) : null;
1300
+ const s = sink.start;
1301
+ if (bits && s.spans.some((t) => hasFact(bits, t))) return c;
1302
+ const top = bits && s.node ? topOf(s.node.checks, bits) : null;
1303
+ const len = c.cons ? wholeTop(c.cons) : null;
1304
+ return top != null && len != null && top + s.offset + len - 1 <= s.size ? c : { level: 1, check: c.check };
1305
+ }
1286
1306
  // A loop bound cannot push its counter out of the table when the loop's own condition or the
1287
1307
  // body's checks keep every subscript the counter makes, and each index beside it, in range.
1288
1308
  // That holds whatever the bound is, so it does not depend on the route the input took.
@@ -1312,7 +1332,7 @@ export function analyze(root, opts = {}) {
1312
1332
  let point = sink.point;
1313
1333
  for (let h = hops.length - 1; h >= 0; h--) {
1314
1334
  const n = hops[h].node;
1315
- let c = creditAt(n, point, sink.kind, sink.limit, sink.offset, sink.nonNeg);
1335
+ let c = spanned(creditAt(n, point, sink.kind, sink.limit, sink.offset, sink.nonNeg), sink, n);
1316
1336
  // A count checked against a number above the table's maximum has been checked, not kept in range.
1317
1337
  if (c.level === 2 && sink.top != null && !within(c.cons, sink.top, false)) c = { level: 1, check: c.check };
1318
1338
  if (c.level > best.level) { best = c; by = n; }
@@ -1334,7 +1354,10 @@ export function analyze(root, opts = {}) {
1334
1354
  const at = new Map();
1335
1355
  for (const c of want) { if (!at.has(c.node.id)) at.set(c.node.id, []); at.get(c.node.id).push(c); }
1336
1356
  // Along the way no sink's limit applies, so a bound that holds only against one blocks nothing.
1337
- const holds = (since, point, k, limit = null, offset = 0, nonNeg = false) => since.some((n) => creditAt(n, point, k, limit, offset, nonNeg).level >= level);
1357
+ const holds = (since, point, k, limit = null, offset = 0, nonNeg = false, sink = null) => since.some((n) => {
1358
+ const c = creditAt(n, point, k, limit, offset, nonNeg);
1359
+ return (sink ? spanned(c, sink, n) : c).level >= level;
1360
+ });
1338
1361
  const lost = new Set();
1339
1362
  const dirty = new Map();
1340
1363
  const parts = new Map();
@@ -1343,7 +1366,7 @@ export function analyze(root, opts = {}) {
1343
1366
  const began = edgesWalked;
1344
1367
  for (let i = 0; i < open.length; i++) {
1345
1368
  const st = open[i];
1346
- 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);
1369
+ for (const c of at.get(st.node.id) || []) if (!holds(st.since, c.sink.point, c.sink.kind, c.sink.limit, c.sink.offset, c.sink.nonNeg, c.sink)) lost.add(c);
1347
1370
  for (const e of st.node.edgesOut) {
1348
1371
  edgesWalked++;
1349
1372
  const to = e.to;
package/lib/parser.mjs CHANGED
@@ -446,11 +446,18 @@ export function tokenize(norm, file) {
446
446
  return { tokens: out, diags };
447
447
  }
448
448
 
449
- // Every rule set walks the tree through here. A directory it may not read is recorded, not treated
450
- // as empty: only ENOENT means absent. Symlinks are followed while they stay inside the tree, so a
451
- // symlinked copy library is read; one pointing outside is counted and never followed, because a
452
- // scan reads the tree it was given and nothing else.
453
- export function buildFileIndex(root) {
449
+ // A drive that stalls or drops for a moment answers a listing with ENOENT or an I/O error. Such a
450
+ // listing is tried again after a pause; the pauses add up to about 1.3 seconds.
451
+ const LISTING_RETRIED = new Set(['ENOENT', 'EIO', 'ETIMEDOUT', 'ENXIO', 'EBUSY', 'EAGAIN', 'EINTR', 'ESTALE', 'ENOTCONN']);
452
+ const LISTING_PAUSES_MS = [50, 250, 1000];
453
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
454
+
455
+ // Every rule set walks the tree through here. A directory that cannot be listed is recorded, not
456
+ // treated as empty: a scan over it is incomplete, not a scan of a smaller tree. Only the root's own
457
+ // ENOENT means absent; a directory the walk found in its parent was there. Symlinks are followed
458
+ // while they stay inside the tree, so a symlinked copy library is read; one pointing outside is
459
+ // counted and never followed, because a scan reads the tree it was given and nothing else.
460
+ export function buildFileIndex(root, { readdir = readdirSync, wait = pause } = {}) {
454
461
  const index = new Map();
455
462
  const dirs = new Set();
456
463
  const unreadableDirs = [];
@@ -460,16 +467,20 @@ export function buildFileIndex(root) {
460
467
  const inside = (p) => p === top || p.startsWith(top + sep);
461
468
  const visited = new Set();
462
469
  const addFile = (p, name, d) => { index.set(p.toLowerCase(), p); if (/\.(cpy|copy|inc|cbl|cob)$/i.test(name)) dirs.add(d); };
470
+ const list = (d) => {
471
+ for (let i = 0; ; i++) {
472
+ try { return readdir(d, { withFileTypes: true }); } catch (e) {
473
+ if (i >= LISTING_PAUSES_MS.length || !LISTING_RETRIED.has(e.code)) throw e;
474
+ wait(LISTING_PAUSES_MS[i]);
475
+ }
476
+ }
477
+ };
463
478
  // Each real directory is walked once, however many links lead to it, or its programs count twice.
464
479
  const walk = (d, real) => {
465
480
  if (visited.has(real)) return;
466
481
  visited.add(real);
467
482
  let es;
468
- try { es = readdirSync(d, { withFileTypes: true }); } catch (e) {
469
- if (e.code === 'ENOENT') return;
470
- if (e.code === 'EACCES' || e.code === 'EPERM') { unreadableDirs.push(d); return; }
471
- throw e;
472
- }
483
+ try { es = list(d); } catch { unreadableDirs.push(d); return; }
473
484
  for (const e of es.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
474
485
  if (e.name === '.git' || e.name.startsWith('._')) continue;
475
486
  const p = join(d, e.name);
@@ -1513,6 +1524,9 @@ function indexTokens(seg) {
1513
1524
  const lone = (from, to) => (to - from === 1 && (seg[from].t === 'word' || seg[from].t === 'num') && /^\d+$/.test(seg[from].v) ? Number(seg[from].v) : null);
1514
1525
  const refLength = colonAt < 0 ? null : lone(colonAt + 1, close);
1515
1526
  const refStart = colonAt < 0 ? null : lone(k + 1, colonAt);
1527
+ // A length is judged against where its reference starts: a name, a name moved by a constant, or neither.
1528
+ const named = (from, to) => (seg[from].t === 'word' && !/^\d+$/.test(seg[from].v) && (to - from === 1 || (to - from === 3 && offsetOf(seg, from, k, close, colonAt))) ? { tok: seg[from], offset: offsetOf(seg, from, k, close, colonAt) } : null);
1529
+ const refFrom = colonAt < 0 || refStart != null ? null : named(k + 1, colonAt) || { tok: null, offset: 0 };
1516
1530
  for (let j = k + 1; j < close; j++) {
1517
1531
  const t = seg[j];
1518
1532
  // A number is tokenized as a word, and no name is all digits.
@@ -1521,7 +1535,7 @@ function indexTokens(seg) {
1521
1535
  if (prev && prev.t === 'word' && (prev.u === 'OF' || prev.u === 'IN' || prev.u === 'FUNCTION')) continue;
1522
1536
  const kind = colonAt < 0 ? 'subscript' : j < colonAt ? 'refmod-offset' : 'refmod-length';
1523
1537
  const other = kind === 'refmod-offset' ? refLength : kind === 'refmod-length' ? refStart : null;
1524
- out.push({ host, tok: t, kind, offset: offsetOf(seg, j, k, close, colonAt), ...(other ? { span: other } : {}) });
1538
+ out.push({ host, tok: t, kind, offset: offsetOf(seg, j, k, close, colonAt), ...(other ? { span: other } : {}), ...(kind === 'refmod-length' && refFrom ? { from: refFrom } : {}) });
1525
1539
  }
1526
1540
  }
1527
1541
  lastHost = host;
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"f5995391b095f62da69d501ea7f6bd11a75bdc6e","tag":"v0.2.150"}
1
+ {"commit":"b242a380e1b8b7d8bb8ff2d6e03e2c8a30242967","tag":"v0.3.0"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portll/cobolwork",
3
- "version": "0.2.150",
3
+ "version": "0.3.0",
4
4
  "description": "COBOL, JCL and CICS security analysis with zero runtime dependencies: reference-format parser, cross-program data flow, mainframe credential rules. Plugs into commitwork.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Portll <john@portll.net>",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "type": "module",
16
16
  "engines": {
17
- "node": ">=18"
17
+ "node": ">=22"
18
18
  },
19
19
  "bin": {
20
20
  "cobolwork": "bin/cobolwork.mjs"