@portll/cobolwork 0.3.0 → 0.5.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 +35 -9
- package/bin/cobolwork.mjs +7 -1
- package/lib/bms.mjs +8 -0
- package/lib/capabilities.mjs +2 -2
- package/lib/cics-commands.mjs +308 -42
- package/lib/consequence.mjs +19 -0
- package/lib/constants.mjs +60 -0
- package/lib/control.mjs +61 -17
- package/lib/csd.mjs +6 -1
- package/lib/dataflow.mjs +107 -27
- package/lib/explain.mjs +31 -5
- package/lib/exploitability.mjs +49 -4
- package/lib/gate.mjs +31 -9
- package/lib/hlasm.mjs +79 -0
- package/lib/kernel/findings.mjs +2 -0
- package/lib/kernel/registry.mjs +6 -0
- package/lib/kernel/source-tree.mjs +1 -0
- package/lib/options.mjs +6 -2
- package/lib/packs.mjs +4 -0
- package/lib/parser.mjs +67 -12
- package/lib/precompile-cics.mjs +9 -3
- package/lib/precompile.mjs +3 -2
- package/lib/revision.json +1 -1
- package/lib/sarif.mjs +3 -2
- package/lib/scan.mjs +2 -1
- package/lib/sets/abend.mjs +186 -0
- package/lib/sets/compile.mjs +2 -1
- package/lib/sets/crypto.mjs +110 -0
- package/lib/sets/flow.mjs +18 -0
- package/lib/sets/hidden.mjs +1 -1
- package/lib/sets/hlasm.mjs +121 -0
- package/lib/sets/jcl.mjs +16 -1
- package/lib/sets/recon.mjs +1 -0
- package/lib/sets/web.mjs +99 -4
- package/lib/sources.mjs +12 -2
- package/lib/utilities.mjs +2 -0
- package/lib/verify.mjs +2 -1
- package/lib/words.mjs +377 -17
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +180 -0
- package/rules/compliance-dora.json +195 -0
- package/rules/compliance-ffiec.json +195 -0
- package/rules/compliance-nist80053.json +195 -0
- package/rules/gitleaks-mainframe.toml +24 -10
- package/rules/hlasm-operations.json +451 -0
- package/rules/icsf-services.json +1178 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The literals that can be in a data item when a statement reads it, from the program's text: its
|
|
3
|
+
// VALUE clause and its subordinates', each MOVE and STRING into it or into a group holding it, and a
|
|
4
|
+
// hop or two back through an item those name. Anything else that can write it - a computation, a
|
|
5
|
+
// READ or ACCEPT INTO, a CALL it is passed to by reference, a MOVE from a field with no constant -
|
|
6
|
+
// leaves it open, which a caller reads as "and something this cannot see".
|
|
7
|
+
const FIGURATIVE = /^(LOW-VALUES?|HIGH-VALUES?|SPACES?|ZEROS?|ZEROES|QUOTES?|NULLS?)$/;
|
|
8
|
+
const WRITERS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'READ', 'ACCEPT', 'UNSTRING', 'INITIALIZE', 'SET', 'RETURN', 'INSPECT']);
|
|
9
|
+
const NOT_OPERANDS = new Set(['TO', 'INTO', 'DELIMITED', 'BY', 'SIZE', 'OF', 'IN', 'ALL']);
|
|
10
|
+
|
|
11
|
+
// The item, its subordinates and the groups that hold it.
|
|
12
|
+
function family(p, name) {
|
|
13
|
+
const self = p.items.find((x) => x.name === name);
|
|
14
|
+
if (!self) return null;
|
|
15
|
+
const under = [];
|
|
16
|
+
const down = (it) => { for (const c of it.children || []) { if (c.level === 88) continue; under.push(c); down(c); } };
|
|
17
|
+
down(self);
|
|
18
|
+
const over = [];
|
|
19
|
+
for (let up = self.parent; up; up = up.parent) over.push(up);
|
|
20
|
+
return { self, under, over };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const nameOf = (t) => (typeof t === 'string' ? t : t && t.u);
|
|
24
|
+
|
|
25
|
+
const valuesOf = (item) => (item.values || []).map((v) => (v.t === 'lit' ? String(v.v) : FIGURATIVE.test(v.u || '') ? v.u : null)).filter((v) => v !== null);
|
|
26
|
+
|
|
27
|
+
// `ignoreCall(call)` names calls that read the item and do not write it, such as the one being judged.
|
|
28
|
+
export function constantsReaching(p, name, { depth = 2, ignoreCall = () => false } = {}, seen = new Set()) {
|
|
29
|
+
const out = { values: [], open: false };
|
|
30
|
+
if (seen.has(name)) return out;
|
|
31
|
+
seen.add(name);
|
|
32
|
+
const f = family(p, name);
|
|
33
|
+
if (!f) return { values: [], open: true };
|
|
34
|
+
const names = new Set([name, ...f.under.map((x) => x.name), ...f.over.map((x) => x.name)]);
|
|
35
|
+
out.values.push(...valuesOf(f.self), ...f.under.flatMap(valuesOf));
|
|
36
|
+
for (const st of p.statements || []) {
|
|
37
|
+
if (!(st.targets || []).some((t) => names.has(nameOf(t)))) continue;
|
|
38
|
+
if (st.verb === 'MOVE' || st.verb === 'STRING') {
|
|
39
|
+
for (const t of st.sources || []) if (t && t.t === 'lit') out.values.push(String(t.v));
|
|
40
|
+
for (const l of st.literals || []) out.values.push(String(l && l.v !== undefined ? l.v : l));
|
|
41
|
+
for (const w of (st.sources || []).filter((t) => !(t && t.t === 'lit')).map(nameOf).filter((x) => x && !NOT_OPERANDS.has(x))) {
|
|
42
|
+
if (FIGURATIVE.test(w) || /^[+-]?\d/.test(w) || names.has(w)) { if (FIGURATIVE.test(w)) out.values.push(w); continue; }
|
|
43
|
+
if (depth === 0) { out.open = true; continue; }
|
|
44
|
+
const r = constantsReaching(p, w, { depth: depth - 1, ignoreCall }, seen);
|
|
45
|
+
out.values.push(...r.values);
|
|
46
|
+
if (r.open || !r.values.length) out.open = true;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (WRITERS.has(st.verb)) out.open = true;
|
|
51
|
+
}
|
|
52
|
+
for (const c of p.calls || []) {
|
|
53
|
+
if (ignoreCall(c)) continue;
|
|
54
|
+
if ((c.using || []).some((u) => names.has(u.word) && u.mode !== 'CONTENT' && u.mode !== 'VALUE')) out.open = true;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A rule array is eight-byte keywords side by side; one held in several fields reads the same.
|
|
60
|
+
export const keywordsOf = (values) => new Set(values.flatMap((v) => (FIGURATIVE.test(v) ? [] : String(v).match(/.{1,8}/g) || []).map((k) => k.trim().toUpperCase()).filter(Boolean)));
|
package/lib/control.mjs
CHANGED
|
@@ -622,10 +622,14 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
622
622
|
|
|
623
623
|
// ---- checks ----
|
|
624
624
|
// The storage an item lies in: its record, or the record an 01 REDEFINES lays itself over.
|
|
625
|
+
const recordOf = new Map();
|
|
625
626
|
const itemsInRecord = (item) => {
|
|
626
|
-
let top = item;
|
|
627
|
+
let top = recordOf.get(item);
|
|
628
|
+
if (top) return top;
|
|
629
|
+
top = item;
|
|
627
630
|
while (top.parent) top = top.parent;
|
|
628
631
|
for (let hops = 0; top.redefinesItem && hops < 64; hops++) top = top.redefinesItem;
|
|
632
|
+
if (item.parent) recordOf.set(item, top);
|
|
629
633
|
return top;
|
|
630
634
|
};
|
|
631
635
|
const fieldKey = (f) => (f.index ? `IX:${f.index}` : f);
|
|
@@ -639,6 +643,33 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
639
643
|
const fEnd = f.offset + (f.contributes || f.size);
|
|
640
644
|
return w.offset < fEnd && f.offset < wEnd;
|
|
641
645
|
};
|
|
646
|
+
// Entries grouped by the storage their item lies in, so a lookup tries only what `overlaps` can
|
|
647
|
+
// accept: the same record, the same index, or a 66-level item, which overlaps any field.
|
|
648
|
+
const byStorage = (entries, itemOf) => {
|
|
649
|
+
const byRecord = new Map();
|
|
650
|
+
const byIndex = new Map();
|
|
651
|
+
const renames = [];
|
|
652
|
+
const plain = [];
|
|
653
|
+
const add = (map, k, e) => { const list = map.get(k); if (list) list.push(e); else map.set(k, [e]); };
|
|
654
|
+
for (const e of entries) {
|
|
655
|
+
const it = itemOf(e);
|
|
656
|
+
if (it.index) { add(byIndex, it.index, e); continue; }
|
|
657
|
+
plain.push(e);
|
|
658
|
+
if (it.level === 66) renames.push(e);
|
|
659
|
+
else add(byRecord, itemsInRecord(it), e);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
mayOverlap: (it) => {
|
|
663
|
+
if (it.index) return byIndex.get(it.index) || [];
|
|
664
|
+
if (it.level === 66) return plain;
|
|
665
|
+
const same = byRecord.get(itemsInRecord(it));
|
|
666
|
+
return same ? (renames.length ? same.concat(renames) : same) : renames;
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
};
|
|
670
|
+
// Arguments the CICS API documents as data-values, which the command reads and never fills: the
|
|
671
|
+
// program or transaction it transfers to or starts, and the system it ships the request to.
|
|
672
|
+
const CICS_READS = { LINK: ['PROGRAM', 'TRANSID', 'SYSID'], XCTL: ['PROGRAM'], START: ['TRANSID', 'SYSID'], RETURN: ['TRANSID'], LOAD: ['PROGRAM'], RELEASE: ['PROGRAM'] };
|
|
642
673
|
const writtenBy = (n) => {
|
|
643
674
|
const out = [];
|
|
644
675
|
const add = (tok) => { const f = tok ? resolve(tok) : null; if (f) out.push(f.level === 88 && f.parent ? f.parent : f); };
|
|
@@ -667,28 +698,31 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
667
698
|
}
|
|
668
699
|
}
|
|
669
700
|
} else if (n.e) {
|
|
670
|
-
// A command may fill what any option is given directly, except what it sends FROM
|
|
671
|
-
// inside that argument's own parentheses - a subscript, a
|
|
701
|
+
// A command may fill what any option is given directly, except what it sends FROM and the
|
|
702
|
+
// data-values it reads. A name inside that argument's own parentheses - a subscript, a
|
|
703
|
+
// reference modification - is read.
|
|
704
|
+
const reads = (n.e.kind === 'CICS' && CICS_READS[n.e.toks[0]?.u]) || [];
|
|
672
705
|
let depth = 0;
|
|
673
706
|
let option = null;
|
|
674
707
|
for (let i = 0; i < n.e.toks.length; i++) {
|
|
675
708
|
const t = n.e.toks[i];
|
|
676
709
|
if (t.t === 'sep') { depth += t.v === '(' ? 1 : -1; continue; }
|
|
677
710
|
if (depth === 0) { option = t.t === 'word' ? t.u : option; continue; }
|
|
678
|
-
if (depth === 1 && t.t === 'word' && option !== 'FROM') add(t);
|
|
711
|
+
if (depth === 1 && t.t === 'word' && option !== 'FROM' && !reads.includes(option)) add(t);
|
|
679
712
|
}
|
|
680
713
|
}
|
|
681
714
|
return out;
|
|
682
715
|
};
|
|
683
716
|
// A VALUE holds for the whole run only if no statement writes storage that overlaps the item.
|
|
684
|
-
let
|
|
717
|
+
let written = null;
|
|
685
718
|
const neverWritten = (it) => {
|
|
686
719
|
if (it.external) return false;
|
|
687
|
-
if (!
|
|
688
|
-
|
|
689
|
-
for (const n of nodes) if (n.kind === 'stmt' || n.kind === 'exec' || n.kind === 'step' || n.kind === 'call')
|
|
720
|
+
if (!written) {
|
|
721
|
+
const all = [];
|
|
722
|
+
for (const n of nodes) if (n.kind === 'stmt' || n.kind === 'exec' || n.kind === 'step' || n.kind === 'call') all.push(...writtenBy(n));
|
|
723
|
+
written = byStorage(all, (w) => w);
|
|
690
724
|
}
|
|
691
|
-
return !
|
|
725
|
+
return !written.mayOverlap(it).some((w) => overlaps(w, it));
|
|
692
726
|
};
|
|
693
727
|
const constValue = (toks) => {
|
|
694
728
|
if (toks.length === 3 && WORD(toks[0], 'LENGTH') && WORD(toks[1], 'OF')) {
|
|
@@ -2373,17 +2407,17 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2373
2407
|
for (const f of [c.x, c.t, c.f, c.rt, c.rf]) if (f != null) list.push(f);
|
|
2374
2408
|
factsOfKey.set(c.key, list);
|
|
2375
2409
|
}
|
|
2410
|
+
const checkedFields = byStorage([...factsOfKey.keys()].filter((key) => fields.get(key)), (key) => fields.get(key));
|
|
2376
2411
|
const killCache = new Map();
|
|
2377
2412
|
const killOf = (w) => {
|
|
2378
2413
|
if (!w) return null;
|
|
2379
2414
|
let bits = killCache.get(w);
|
|
2380
2415
|
if (bits !== undefined) return bits;
|
|
2381
2416
|
bits = null;
|
|
2382
|
-
for (const
|
|
2383
|
-
|
|
2384
|
-
if (!f || !overlaps(w, f)) continue;
|
|
2417
|
+
for (const key of checkedFields.mayOverlap(w)) {
|
|
2418
|
+
if (!overlaps(w, fields.get(key))) continue;
|
|
2385
2419
|
bits ||= new Uint32Array(words);
|
|
2386
|
-
for (const x of
|
|
2420
|
+
for (const x of factsOfKey.get(key)) bits[x >>> 5] |= 1 << (x & 31);
|
|
2387
2421
|
}
|
|
2388
2422
|
killCache.set(w, bits);
|
|
2389
2423
|
return bits;
|
|
@@ -2629,10 +2663,15 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2629
2663
|
// Every route from `entry` that reaches `stopAt` or the program's end, meeting at joins. A call
|
|
2630
2664
|
// whose range never returns ends the route, as a STOP RUN does. `calls` collects the ranges the
|
|
2631
2665
|
// routes perform, for whoever needs to know what depends on what.
|
|
2666
|
+
// A run holds a bitset per node it reaches. Those of a run nobody reads any more are handed back
|
|
2667
|
+
// and reused, since allocating them afresh for every run was most of the analysis's collection time.
|
|
2668
|
+
const spare = [];
|
|
2669
|
+
const copyOf = (bits) => { const a = spare.pop(); if (!a) return bits.slice(); a.set(bits); return a; };
|
|
2670
|
+
const release = (IN) => { for (const a of IN.values()) spare.push(a); };
|
|
2632
2671
|
function run(entry, input, stopAt, calls = null) {
|
|
2633
2672
|
const IN = new Map();
|
|
2634
2673
|
let exitOut = null;
|
|
2635
|
-
IN.set(entry, input
|
|
2674
|
+
IN.set(entry, copyOf(input));
|
|
2636
2675
|
push(entry);
|
|
2637
2676
|
while (heap.length) {
|
|
2638
2677
|
const id = pop();
|
|
@@ -2643,7 +2682,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2643
2682
|
if (id === stopAt) { if (exitOut) meetInto(exitOut, out); else exitOut = out.slice(); continue; }
|
|
2644
2683
|
for (const s of n.succ) {
|
|
2645
2684
|
const cur = IN.get(s);
|
|
2646
|
-
if (!cur) { IN.set(s, out
|
|
2685
|
+
if (!cur) { IN.set(s, copyOf(out)); push(s); } else if (meetInto(cur, out)) push(s);
|
|
2647
2686
|
}
|
|
2648
2687
|
}
|
|
2649
2688
|
return { IN, exitOut };
|
|
@@ -2675,7 +2714,10 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2675
2714
|
for (const d of n.derive || []) if (d.needs.every((x) => has(bits, x))) approved.add(d);
|
|
2676
2715
|
for (const entry of n.implies || []) if (entry[2] && has(bits, entry[0])) approved.add(entry);
|
|
2677
2716
|
}
|
|
2678
|
-
|
|
2717
|
+
release(fromNone.IN);
|
|
2718
|
+
const fromAll = run(r.entry, ONES, r.end, calls);
|
|
2719
|
+
const top = fromAll.exitOut;
|
|
2720
|
+
release(fromAll.IN);
|
|
2679
2721
|
approved = null;
|
|
2680
2722
|
for (const k of calls) { if (!callers.has(k)) callers.set(k, new Set()); callers.get(k).add(r); }
|
|
2681
2723
|
const old = summaries.get(r.key);
|
|
@@ -2704,7 +2746,9 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2704
2746
|
waiting.delete(c.key);
|
|
2705
2747
|
const input = c.stopAt == null ? ZERO : rangeIn.get(c.key);
|
|
2706
2748
|
const res = run(c.entry, input, c.stopAt);
|
|
2749
|
+
const superseded = results.get(c.key);
|
|
2707
2750
|
results.set(c.key, res.IN);
|
|
2751
|
+
if (superseded) release(superseded);
|
|
2708
2752
|
for (const [id, bits] of res.IN) {
|
|
2709
2753
|
const n = nodes[id];
|
|
2710
2754
|
if (n.kind !== 'call' || !byKey.has(n.range.key)) continue;
|
|
@@ -2723,7 +2767,7 @@ export function buildControl(prog, resolve, { extra = null } = {}) {
|
|
|
2723
2767
|
for (const IN of results.values()) {
|
|
2724
2768
|
for (const [id, bits] of IN) {
|
|
2725
2769
|
const had = facts.get(id);
|
|
2726
|
-
if (had) meetInto(had, bits); else facts.set(id, bits
|
|
2770
|
+
if (had) meetInto(had, bits); else facts.set(id, bits);
|
|
2727
2771
|
}
|
|
2728
2772
|
}
|
|
2729
2773
|
};
|
package/lib/csd.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const COMMAND = /^\s*(DEFINE|ADD|ALTER|APPEND|COPY|DELETE|INITIALIZE|LIST|REMOVE
|
|
|
14
14
|
// { tdqueues: Map(name -> { type, ddname, indirect, line }),
|
|
15
15
|
// transactions: Map(name -> { program, cmdsec, ressec, line }),
|
|
16
16
|
// tcpipservices: Map(name -> { port, protocol, ssl, authenticate, transaction, line }),
|
|
17
|
-
// urimaps: Map(name -> { usage, path, program, transaction, tcpipservice, line }) }
|
|
17
|
+
// urimaps: Map(name -> { usage, path, program, transaction, tcpipservice, scheme, host, attls, line }) }
|
|
18
18
|
export function parseCsd(text) {
|
|
19
19
|
const tdqueues = new Map();
|
|
20
20
|
const transactions = new Map();
|
|
@@ -64,6 +64,11 @@ export function parseCsd(text) {
|
|
|
64
64
|
program: attrs.get('PROGRAM') || null,
|
|
65
65
|
transaction: attrs.get('TRANSACTION') || null,
|
|
66
66
|
tcpipservice: attrs.get('TCPIPSERVICE') || null,
|
|
67
|
+
// A client URIMAP's scheme decides whether WEB OPEN URIMAP(name) asks for TLS; ATTLS(AWARE)
|
|
68
|
+
// has AT-TLS secure an HTTP scheme outside CICS.
|
|
69
|
+
scheme: attrs.get('SCHEME') || null,
|
|
70
|
+
host: attrs.get('HOST') || null,
|
|
71
|
+
attls: attrs.get('ATTLS') || null,
|
|
67
72
|
line: b.line,
|
|
68
73
|
});
|
|
69
74
|
} else if (kind === 'TDQUEUE') {
|
package/lib/dataflow.mjs
CHANGED
|
@@ -87,6 +87,7 @@ function literalNames(prog) {
|
|
|
87
87
|
// The value of the last NAME(value) option in a list: the last one wins, at either level.
|
|
88
88
|
function lastOption(name, list) {
|
|
89
89
|
for (let k = (list || []).length - 1; k >= 0; k--) {
|
|
90
|
+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- name is a literal option name
|
|
90
91
|
const m = new RegExp(`^${name}\\(([A-Z]+)\\)$`).exec(list[k]);
|
|
91
92
|
if (m) return m[1];
|
|
92
93
|
}
|
|
@@ -188,6 +189,9 @@ export function analyze(root, opts = {}) {
|
|
|
188
189
|
// Every hop of every trace, for the reader who is following one chain rather than counting
|
|
189
190
|
// findings. Off by default: a chain-shaped program turns this quadratic in the chain length.
|
|
190
191
|
const fullTrace = opts.fullTrace === true;
|
|
192
|
+
// Every statement on any route from a finding's source to its sink, not only the route the trace
|
|
193
|
+
// names: what a run must have executed before it can show a value does not arrive (reach.md §9.8).
|
|
194
|
+
const allRoutes = opts.allRoutes === true;
|
|
191
195
|
let held = 0;
|
|
192
196
|
|
|
193
197
|
// Which files, not just how many. A count names nothing to go and look at, and it cannot tell
|
|
@@ -215,11 +219,14 @@ export function analyze(root, opts = {}) {
|
|
|
215
219
|
const rel = (p) => { let r = relOf.get(p); if (r === undefined) { r = relPath(root, p); relOf.set(p, r); } return r; };
|
|
216
220
|
// `at` is the statement an edge that is not a statement's own is read at: the CALL or LINK that
|
|
217
221
|
// passes an argument, the PUT or GET of a container.
|
|
218
|
-
|
|
222
|
+
// `link` marks an edge into a callee at a call site ({ call }) or back out to it ({ ret }), and
|
|
223
|
+
// `stmt` names the statement that carries an edge whose `why` is text.
|
|
224
|
+
const edge = (a, b, why, dir, at, link) => {
|
|
219
225
|
if (!a || !b || a === b) return;
|
|
220
|
-
a.edgesOut.push(
|
|
226
|
+
a.edgesOut.push({ to: b, why, dir, ...(at != null ? { at } : {}), ...link });
|
|
221
227
|
stats.edges++;
|
|
222
228
|
};
|
|
229
|
+
let callSites = 0;
|
|
223
230
|
|
|
224
231
|
// Everything the graph needs from one program is taken while its parse tree is in hand, and the
|
|
225
232
|
// tree is then released. Holding every tree until the end is what exhausted a 4 GB heap on an
|
|
@@ -236,7 +243,9 @@ export function analyze(root, opts = {}) {
|
|
|
236
243
|
let repeats = false;
|
|
237
244
|
let table = null;
|
|
238
245
|
for (let a = item; a; a = a.parent) if ((a.occurs || 1) > 1) { repeats = true; table = a; }
|
|
239
|
-
|
|
246
|
+
let top = item;
|
|
247
|
+
while (top.parent) top = top.parent;
|
|
248
|
+
n = { id: nodes.length, pk, program: prog.id, file, name: item.name, off: item.offset ?? null, size: item.size ?? null, repeats, edgesOut: [], sources: [], sinks: [], ...(top.section === 'LINKAGE' ? { linkage: true } : {}) };
|
|
240
249
|
// Where the outermost table holding this item sits in the record: every occurrence of the
|
|
241
250
|
// item is somewhere inside it. A group's size already counts its occurrences; an
|
|
242
251
|
// elementary item's is one element.
|
|
@@ -792,7 +801,7 @@ export function analyze(root, opts = {}) {
|
|
|
792
801
|
if (verb === 'PUT' && o.has('CONTAINER')) {
|
|
793
802
|
const name = optionName(o, 'CONTAINER');
|
|
794
803
|
const from = nodeOfToken((o.get('FROM') || [])[0]);
|
|
795
|
-
if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point);
|
|
804
|
+
if (name && from) edge(from, containerNode(name), `EXEC CICS PUT CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS PUT', file: rel(at.file), line: at.line } });
|
|
796
805
|
if (name) channelOf.set(name, optionName(o, 'CHANNEL'));
|
|
797
806
|
}
|
|
798
807
|
// A service call sends every container on its channel, and its URI decides where they go.
|
|
@@ -806,7 +815,7 @@ export function analyze(root, opts = {}) {
|
|
|
806
815
|
if (verb === 'GET' && o.has('CONTAINER')) {
|
|
807
816
|
const name = optionName(o, 'CONTAINER');
|
|
808
817
|
const into = nodeOfToken((o.get('INTO') || o.get('SET') || [])[0]);
|
|
809
|
-
if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point);
|
|
818
|
+
if (name && into) edge(containerNode(name), into, `EXEC CICS GET CONTAINER(${name})`, undefined, at.point, { stmt: { verb: 'EXEC CICS GET', file: rel(at.file), line: at.line } });
|
|
810
819
|
}
|
|
811
820
|
// CONVERSE is always a client call; SEND is one only on a session this program opened. A
|
|
812
821
|
// plain WEB SEND answers the request that started the program, which is its job.
|
|
@@ -896,9 +905,9 @@ export function analyze(root, opts = {}) {
|
|
|
896
905
|
}
|
|
897
906
|
const commarea = (o.get('COMMAREA') || o.get('FROM') || [])[0];
|
|
898
907
|
const argNode = commarea ? nodeOfToken(commarea) : null;
|
|
899
|
-
if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point });
|
|
908
|
+
if (pgm && pgm.t === 'lit' && argNode) transfers.push({ verb, callee: pgm.v.toUpperCase(), argNode, point: at.point, file: at.file, line: at.line });
|
|
900
909
|
else if (pgm && pgm.t === 'word' && argNode && o.has('PROGRAM')) {
|
|
901
|
-
for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, through: pgm.u }); transfersTo.add(callee); }
|
|
910
|
+
for (const callee of programsNamed(pgm, e)) { transfers.push({ verb, callee, argNode, point: at.point, file: at.file, line: at.line, through: pgm.u }); transfersTo.add(callee); }
|
|
902
911
|
}
|
|
903
912
|
if (pgm && pgm.t === 'lit') (o.has('TRANSID') && !o.has('PROGRAM') ? startsTransactions : transfersTo).add(String(pgm.v).trim().toUpperCase());
|
|
904
913
|
}
|
|
@@ -1040,21 +1049,25 @@ export function analyze(root, opts = {}) {
|
|
|
1040
1049
|
for (const p of programs) {
|
|
1041
1050
|
for (const t of p.transfers) {
|
|
1042
1051
|
const r = calleesOf(t.callee, p.file);
|
|
1052
|
+
const site = ++callSites;
|
|
1053
|
+
const stmt = { verb: `EXEC CICS ${t.verb}`, file: rel(t.file), line: t.line };
|
|
1043
1054
|
for (const callee of r.list) {
|
|
1044
1055
|
if (!callee.commarea) continue;
|
|
1045
|
-
edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point);
|
|
1046
|
-
if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}
|
|
1056
|
+
edge(t.argNode, callee.commarea, `EXEC CICS ${t.verb} COMMAREA to ${t.callee}${t.through ? `, the program ${t.through} names` : ''}${ofN(r, t.callee)}`, undefined, t.point, { call: site, stmt });
|
|
1057
|
+
if (t.verb === 'LINK') edge(callee.commarea, t.argNode, `COMMAREA returned from ${t.callee}${ofN(r, t.callee)}`, undefined, undefined, { ret: site, stmt });
|
|
1047
1058
|
}
|
|
1048
1059
|
}
|
|
1049
1060
|
for (const c of p.callSites) {
|
|
1050
1061
|
const r = calleesOf(c.name, p.file);
|
|
1062
|
+
const site = ++callSites;
|
|
1063
|
+
const stmt = { verb: 'CALL', file: rel(c.file), line: c.line };
|
|
1051
1064
|
for (const callee of r.list) {
|
|
1052
1065
|
if (!callee.params) continue;
|
|
1053
1066
|
c.args.forEach((arg, i) => {
|
|
1054
1067
|
const param = callee.params[i];
|
|
1055
1068
|
if (!param || !arg.node || !param.node) return;
|
|
1056
|
-
edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point);
|
|
1057
|
-
if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}
|
|
1069
|
+
edge(arg.node, param.node, `CALL '${c.display}' argument ${i + 1} at ${rel(c.file)}:${c.line}${ofN(r, c.display)}`, undefined, c.point, { call: site, stmt });
|
|
1070
|
+
if (arg.mode === 'REFERENCE' && param.mode === 'REFERENCE') edge(param.node, arg.node, `CALL '${c.display}' argument ${i + 1} written back${ofN(r, c.display)}`, undefined, undefined, { ret: site, stmt });
|
|
1058
1071
|
});
|
|
1059
1072
|
}
|
|
1060
1073
|
const callee = r.list.length === 1 ? r.list[0] : null;
|
|
@@ -1347,6 +1360,7 @@ export function analyze(root, opts = {}) {
|
|
|
1347
1360
|
// last moved - at a point where the credit reaches `level`.
|
|
1348
1361
|
function refuse(start, reach, credited, level, kind, shift = 0) {
|
|
1349
1362
|
walk++;
|
|
1363
|
+
stampCtx.clear();
|
|
1350
1364
|
const want = credited.filter((c) => c.level >= level && c.sink.kind === kind && (c.sink.offset || 0) === shift);
|
|
1351
1365
|
// Past the budget no route can be ruled out, so every credit in question is lost: a finding
|
|
1352
1366
|
// may be reported unchecked, never dropped.
|
|
@@ -1380,17 +1394,15 @@ export function analyze(root, opts = {}) {
|
|
|
1380
1394
|
// stand in for it.
|
|
1381
1395
|
const guarded = since.some((n) => n.checks || n.guard);
|
|
1382
1396
|
if (t.partial) {
|
|
1383
|
-
const key = `${to.id}|${guarded}`;
|
|
1397
|
+
const key = `${to.id}|${guarded}|${t.ctx}`;
|
|
1384
1398
|
const held = parts.get(key);
|
|
1385
1399
|
if (held && held.some((r) => (r[2] ? t.vague : !t.vague && r[0] <= t.lo && t.hi <= r[1]))) continue;
|
|
1386
1400
|
if (held) held.push([t.lo, t.hi, t.vague]); else parts.set(key, [[t.lo, t.hi, t.vague]]);
|
|
1387
1401
|
} else if (guarded) {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
stampWhole[to.id] = walk;
|
|
1393
|
-
}
|
|
1402
|
+
const dk = `${to.id}|${t.ctx}`;
|
|
1403
|
+
if (stampWhole[to.id] === walk || dirty.get(dk) === walk) continue;
|
|
1404
|
+
dirty.set(dk, walk);
|
|
1405
|
+
} else if (!firstVisit(to.id, t.ctx)) continue;
|
|
1394
1406
|
open.push({ node: to, ...t, since });
|
|
1395
1407
|
}
|
|
1396
1408
|
if (edgesWalked - began > walkBudget) { stats.creditUndecided++; return new Set(want); }
|
|
@@ -1409,6 +1421,16 @@ export function analyze(root, opts = {}) {
|
|
|
1409
1421
|
// Visit stamps instead of a per-walk Set: one walk per source, and a Set of string keys per walk
|
|
1410
1422
|
// was most of the time spent on large repositories.
|
|
1411
1423
|
const stampWhole = new Int32Array(nodes.length);
|
|
1424
|
+
// A whole node reached with a call context, per walk. Reached with none, it stands in for every context.
|
|
1425
|
+
const stampCtx = new Map();
|
|
1426
|
+
const firstVisit = (id, ctx) => {
|
|
1427
|
+
if (stampWhole[id] === walk) return false;
|
|
1428
|
+
if (!ctx) { stampWhole[id] = walk; return true; }
|
|
1429
|
+
const key = `${id}|${ctx}`;
|
|
1430
|
+
if (stampCtx.get(key) === walk) return false;
|
|
1431
|
+
stampCtx.set(key, walk);
|
|
1432
|
+
return true;
|
|
1433
|
+
};
|
|
1412
1434
|
let walk = 0;
|
|
1413
1435
|
// Every walk is bounded, and so is the analysis. One source of an estate whose programs share
|
|
1414
1436
|
// their records through thousands of copybooks reached millions of nodes, and 22,905 such walks
|
|
@@ -1425,9 +1447,25 @@ export function analyze(root, opts = {}) {
|
|
|
1425
1447
|
// it does not. Both walks below cross edges through this, so neither can take a route the other
|
|
1426
1448
|
// would refuse.
|
|
1427
1449
|
const known = (n) => n.off != null && n.size != null && !n.repeats;
|
|
1450
|
+
// The call sites a value entered its callees through, innermost last, as a string; empty where any
|
|
1451
|
+
// caller may be returned to: a value born in the callee, or one that rested in storage a program
|
|
1452
|
+
// keeps between calls, which a later call from any caller can read.
|
|
1453
|
+
const CONTEXT_DEPTH = 4;
|
|
1454
|
+
function contextOver(state, e) {
|
|
1455
|
+
const ctx = state.ctx || '';
|
|
1456
|
+
if (e.ret != null) {
|
|
1457
|
+
if (!ctx) return '';
|
|
1458
|
+
const sites = ctx.split('/');
|
|
1459
|
+
return Number(sites[sites.length - 1]) === e.ret ? sites.slice(0, -1).join('/') : null;
|
|
1460
|
+
}
|
|
1461
|
+
if (e.call != null) return [...(ctx ? ctx.split('/') : []), String(e.call)].slice(-CONTEXT_DEPTH).join('/');
|
|
1462
|
+
return e.to.linkage ? ctx : '';
|
|
1463
|
+
}
|
|
1428
1464
|
function cross(state, e) {
|
|
1429
1465
|
const cur = state.node;
|
|
1430
1466
|
const to = e.to;
|
|
1467
|
+
const ctx = contextOver(state, e);
|
|
1468
|
+
if (ctx === null) return null;
|
|
1431
1469
|
let partial = false, lo = 0, hi = 0, vague = false;
|
|
1432
1470
|
if (opts.legacyGroupEdges) { /* every edge carries the whole item */ }
|
|
1433
1471
|
else if (e.dir === 'up') {
|
|
@@ -1464,7 +1502,45 @@ export function analyze(root, opts = {}) {
|
|
|
1464
1502
|
if (!vague && to.size != null) { hi = Math.min(hi, to.size); if (hi <= lo) return null; }
|
|
1465
1503
|
}
|
|
1466
1504
|
}
|
|
1467
|
-
return { partial, lo, hi, vague };
|
|
1505
|
+
return { partial, lo, hi, vague, ctx };
|
|
1506
|
+
}
|
|
1507
|
+
// The statement that carries an edge: null for storage that overlays storage, which runs nothing,
|
|
1508
|
+
// and undefined where the engine does not know which statement it is.
|
|
1509
|
+
const OVERLAYS = new Set(['group', 'redefines', 'renames']);
|
|
1510
|
+
const statementOf = (e) => {
|
|
1511
|
+
if (e.stmt) return e.stmt;
|
|
1512
|
+
if (e.why && typeof e.why === 'object') return { verb: e.why.verb, file: e.why.file, line: e.why.line };
|
|
1513
|
+
return OVERLAYS.has(e.why) ? null : undefined;
|
|
1514
|
+
};
|
|
1515
|
+
// Every route from one walk's source to each sink it reached: the edges the walk could cross that
|
|
1516
|
+
// lead on to the sink, taken or not, by node rather than by call context or bytes. That lists a
|
|
1517
|
+
// statement no route needs sooner than it leaves out one a route does.
|
|
1518
|
+
function attachRoutes(awaiting, crossed, src, cut) {
|
|
1519
|
+
const into = new Map();
|
|
1520
|
+
for (const c of crossed) {
|
|
1521
|
+
const list = into.get(c.to);
|
|
1522
|
+
if (list) list.push(c); else into.set(c.to, [c]);
|
|
1523
|
+
}
|
|
1524
|
+
for (const { finding, at } of awaiting) {
|
|
1525
|
+
const onRoute = new Set([at]);
|
|
1526
|
+
const stack = [at];
|
|
1527
|
+
const statements = new Map();
|
|
1528
|
+
let unplaced = 0;
|
|
1529
|
+
while (stack.length) {
|
|
1530
|
+
for (const c of into.get(stack.pop()) || []) {
|
|
1531
|
+
const s = statementOf(c.e);
|
|
1532
|
+
if (s) statements.set(`${s.file}:${s.line}:${s.verb}`, s);
|
|
1533
|
+
else if (s === undefined) unplaced++;
|
|
1534
|
+
if (!onRoute.has(c.from)) { onRoute.add(c.from); stack.push(c.from); }
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
finding.routes = {
|
|
1538
|
+
sources: [{ kind: src.kind, file: rel(src.file), line: src.line }],
|
|
1539
|
+
statements: [...statements.values()].sort((a, b) => byText(a.file, b.file) || a.line - b.line || byText(a.verb, b.verb)),
|
|
1540
|
+
...(unplaced ? { unplaced } : {}),
|
|
1541
|
+
complete: !cut && !unplaced,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1468
1544
|
}
|
|
1469
1545
|
for (const start of nodes) {
|
|
1470
1546
|
if (!start.sources.length || !canReach[start.id]) continue;
|
|
@@ -1473,6 +1549,7 @@ export function analyze(root, opts = {}) {
|
|
|
1473
1549
|
if (!(canReach[start.id] & reach)) continue;
|
|
1474
1550
|
if (edgesWalked >= totalBudget) { stats.sourcesNotWalked++; continue; }
|
|
1475
1551
|
walk++;
|
|
1552
|
+
stampCtx.clear();
|
|
1476
1553
|
const credited = [];
|
|
1477
1554
|
// A state is a node plus which of its bytes are tainted. Going up from a child taints only
|
|
1478
1555
|
// that child's bytes of the group; going down reaches a child only if those bytes overlap it,
|
|
@@ -1482,6 +1559,8 @@ export function analyze(root, opts = {}) {
|
|
|
1482
1559
|
// without a layout) partial taint does not descend at all.
|
|
1483
1560
|
const queue = [{ node: start, partial: false, lo: 0, hi: 0, vague: false, prev: null, why: 'source' }];
|
|
1484
1561
|
const partSeen = new Map();
|
|
1562
|
+
const crossedEdges = allRoutes ? [] : null;
|
|
1563
|
+
const awaiting = allRoutes ? [] : null;
|
|
1485
1564
|
stampWhole[start.id] = walk;
|
|
1486
1565
|
let cut = false;
|
|
1487
1566
|
const began = edgesWalked;
|
|
@@ -1530,6 +1609,7 @@ export function analyze(root, opts = {}) {
|
|
|
1530
1609
|
...(best.level ? { guard: { program: by.program, item: by.name, file: rel(best.check.file), line: best.check.line, ...(best.level === 2 ? { stops: true } : {}) } } : {}),
|
|
1531
1610
|
...(!best.level && missed ? { checkElsewhere: { program: missed.node.program, item: missed.node.name, file: rel(missed.check.file), line: missed.check.line } } : {}),
|
|
1532
1611
|
});
|
|
1612
|
+
if (awaiting) awaiting.push({ finding: findings[findings.length - 1], at: cur.id });
|
|
1533
1613
|
if (best.level && !own) credited.push({ finding: findings[findings.length - 1], node: cur, sink, level: best.level });
|
|
1534
1614
|
if (sink.group) useGroup.set(findings[findings.length - 1], `${src.kind}|${src.file}:${src.line}|${sink.kind}|${sink.group}`);
|
|
1535
1615
|
}
|
|
@@ -1539,19 +1619,19 @@ export function analyze(root, opts = {}) {
|
|
|
1539
1619
|
const to = e.to;
|
|
1540
1620
|
const crossed = cross(state, e);
|
|
1541
1621
|
if (!crossed) continue;
|
|
1542
|
-
|
|
1622
|
+
if (crossedEdges) crossedEdges.push({ from: cur.id, to: to.id, e });
|
|
1623
|
+
const { partial, lo, hi, vague, ctx } = crossed;
|
|
1543
1624
|
if (partial) {
|
|
1544
|
-
const
|
|
1625
|
+
const pk = `${to.id}|${ctx}`;
|
|
1626
|
+
const held = partSeen.get(pk);
|
|
1545
1627
|
if (held && held.some(r => (r[2] ? vague : !vague && r[0] <= lo && hi <= r[1]))) continue;
|
|
1546
|
-
if (held) held.push([lo, hi, vague]); else partSeen.set(
|
|
1547
|
-
} else
|
|
1548
|
-
|
|
1549
|
-
stampWhole[to.id] = walk;
|
|
1550
|
-
}
|
|
1551
|
-
queue.push({ node: to, partial, lo, hi, vague, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
|
|
1628
|
+
if (held) held.push([lo, hi, vague]); else partSeen.set(pk, [[lo, hi, vague]]);
|
|
1629
|
+
} else if (!firstVisit(to.id, ctx)) continue;
|
|
1630
|
+
queue.push({ node: to, partial, lo, hi, vague, ctx, prev: state, why: e.why, dir: e.dir, point: pointOfEdge(e), moves: moves(e) });
|
|
1552
1631
|
}
|
|
1553
1632
|
}
|
|
1554
1633
|
if (cut) stats.walksCut++;
|
|
1634
|
+
if (awaiting?.length) attachRoutes(awaiting, crossedEdges, src, cut);
|
|
1555
1635
|
// The walk keeps the shortest route to each sink, and credit on that route says nothing if
|
|
1556
1636
|
// another reaches the sink without it. So the walk runs again, refusing to let the value leave
|
|
1557
1637
|
// a node where the credit holds, and a sink it still reaches loses the credit: first for any
|
package/lib/explain.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { resolve, relative, sep, dirname } from 'node:path';
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { readSource, isProgram, isCopybook, isJcl } from './sources.mjs';
|
|
5
|
-
import { parseFile, buildFileIndex, detectFormat } from './parser.mjs';
|
|
5
|
+
import { parseFile, buildFileIndex, detectFormat, normalize, tokenize, segmentEnd } from './parser.mjs';
|
|
6
6
|
import { EVIDENCE } from './kernel/findings.mjs';
|
|
7
7
|
import { REGISTRY } from './kernel/registry.mjs';
|
|
8
8
|
import { WHO_ACTS } from './tui/model.mjs';
|
|
@@ -34,6 +34,21 @@ function contained(root, rel) {
|
|
|
34
34
|
return { abs };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// The last line of the COBOL statement that starts on `line`, its period or END- terminator included,
|
|
38
|
+
// so a drafter shown one line of a statement knows where the statement ends. Past MAX_EXTENT lines
|
|
39
|
+
// nothing is claimed.
|
|
40
|
+
const MAX_EXTENT = 30;
|
|
41
|
+
function statementEnd(file, line) {
|
|
42
|
+
file.tokens ??= (() => { try { return tokenize(normalize(file.text, file.format), '').tokens; } catch { return []; } })();
|
|
43
|
+
const t = file.tokens;
|
|
44
|
+
const i = t.findIndex((x) => x.line === line);
|
|
45
|
+
if (i < 0 || t[i].t !== 'word') return line;
|
|
46
|
+
const e = segmentEnd(t, i, t.length);
|
|
47
|
+
const next = t[e];
|
|
48
|
+
const end = next && next.line >= line && (next.t === 'period' || (next.t === 'word' && next.u === `END-${t[i].u}`)) ? next.line : t[e - 1].line;
|
|
49
|
+
return end > line && end - line < MAX_EXTENT ? end : line;
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
// A hop's location: from `via` ("MOVE at pos/P1.cbl:9"), else the related entry for its file.
|
|
38
53
|
function hopAt(hop, finding) {
|
|
39
54
|
const m = /\bat (.+):(\d+)$/.exec(hop.via || '');
|
|
@@ -62,7 +77,7 @@ export function explainFinding(report, fingerprint, { root }) {
|
|
|
62
77
|
else {
|
|
63
78
|
try {
|
|
64
79
|
const text = readSource(c.abs).text;
|
|
65
|
-
file = { kind: kindOf(c.abs), format: detectFormat(text), lines: text.split(/\r?\n/) };
|
|
80
|
+
file = { kind: kindOf(c.abs), format: detectFormat(text), text, lines: text.split(/\r?\n/) };
|
|
66
81
|
} catch (e) { unread.push({ path, why: `it could not be read: ${e.message}` }); }
|
|
67
82
|
}
|
|
68
83
|
files.set(path, file);
|
|
@@ -72,15 +87,26 @@ export function explainFinding(report, fingerprint, { root }) {
|
|
|
72
87
|
for (const x of [...report.findings, ...(report.suppressed || [])]) {
|
|
73
88
|
if (HIDDEN_RULES.has(x.rule) && x.path && x.line) flagged.set(`${x.path}:${x.line}`, x.rule);
|
|
74
89
|
}
|
|
75
|
-
const
|
|
90
|
+
const quoteLine = (path, file, line) => {
|
|
76
91
|
const withheld = flagged.get(`${path}:${line}`);
|
|
77
92
|
if (withheld) return { code: null, withheld };
|
|
78
|
-
const file = fileOf(path);
|
|
79
|
-
if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
|
|
80
93
|
const area = codeArea(file.lines[line - 1], file.kind, file.format);
|
|
81
94
|
const dropped = area.rest.trim() !== '' || /[^\d\s]/.test(area.seq);
|
|
82
95
|
return { code: area.text.trimEnd(), ...(dropped ? { dropped } : {}) };
|
|
83
96
|
};
|
|
97
|
+
// A statement that runs on past its first line carries endLine and the lines after the first.
|
|
98
|
+
const quote = (path, line) => {
|
|
99
|
+
const withheld = flagged.get(`${path}:${line}`);
|
|
100
|
+
if (withheld) return { code: null, withheld };
|
|
101
|
+
const file = fileOf(path);
|
|
102
|
+
if (!file || !(line > 0 && line <= file.lines.length)) return { code: null };
|
|
103
|
+
const first = quoteLine(path, file, line);
|
|
104
|
+
const endLine = file.kind === 'cobol' ? statementEnd(file, line) : line;
|
|
105
|
+
if (endLine === line) return first;
|
|
106
|
+
const rest = [];
|
|
107
|
+
for (let n = line + 1; n <= endLine; n++) rest.push({ line: n, ...quoteLine(path, file, n) });
|
|
108
|
+
return { ...first, endLine, rest };
|
|
109
|
+
};
|
|
84
110
|
|
|
85
111
|
const hops = (f.trace || []).map((h, i) => {
|
|
86
112
|
if (h.elided) return { n: i + 1, elided: h.elided, via: h.via };
|