@portll/cobolwork 0.6.0 → 0.7.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.
Files changed (140) hide show
  1. package/README.md +49 -18
  2. package/STABILITY.md +76 -0
  3. package/bin/cobolwork.mjs +30 -7
  4. package/lib/bms.mjs +21 -7
  5. package/lib/build.mjs +108 -53
  6. package/lib/capabilities.mjs +9 -10
  7. package/lib/cics-commands.mjs +501 -9
  8. package/lib/compliance.mjs +11 -0
  9. package/lib/consequence.mjs +13 -0
  10. package/lib/control-workers.mjs +161 -0
  11. package/lib/control.mjs +52 -3
  12. package/lib/dataflow.mjs +176 -125
  13. package/lib/db2/cursor.mjs +15 -0
  14. package/lib/db2/read.mjs +170 -0
  15. package/lib/db2/rules.mjs +77 -0
  16. package/lib/db2/stmt/alter.mjs +473 -0
  17. package/lib/db2/stmt/grant.mjs +125 -0
  18. package/lib/db2/stmt/index.mjs +141 -0
  19. package/lib/db2/stmt/misc.mjs +325 -0
  20. package/lib/db2/stmt/routine.mjs +564 -0
  21. package/lib/db2/stmt/storage.mjs +146 -0
  22. package/lib/db2/stmt/table.mjs +540 -0
  23. package/lib/db2/stmt/view.mjs +146 -0
  24. package/lib/diff.mjs +17 -5
  25. package/lib/evidence/cli.mjs +14 -3
  26. package/lib/evidence/record.mjs +1 -1
  27. package/lib/evidence/store.mjs +44 -31
  28. package/lib/exec-reading.mjs +51 -0
  29. package/lib/execution.mjs +3 -2
  30. package/lib/explain.mjs +2 -0
  31. package/lib/exploitability.mjs +11 -2
  32. package/lib/hlasm/asm/data.mjs +13 -1
  33. package/lib/hlasm/asm/sections.mjs +3 -1
  34. package/lib/hlasm/instr.mjs +25 -0
  35. package/lib/hlasm/macro/authorization.mjs +24 -4
  36. package/lib/hlasm/macro/datasets.mjs +30 -17
  37. package/lib/hlasm/macro/io.mjs +68 -45
  38. package/lib/hlasm/macro/le.mjs +2 -2
  39. package/lib/hlasm/macro/operator.mjs +32 -21
  40. package/lib/hlasm/macro/program.mjs +126 -84
  41. package/lib/hlasm/macro/recovery.mjs +10 -5
  42. package/lib/hlasm/macro/storage.mjs +64 -14
  43. package/lib/hlasm/macro/structured.mjs +1 -1
  44. package/lib/hlasm/model.mjs +35 -10
  45. package/lib/hlasm/mvs38.mjs +47 -0
  46. package/lib/hlasm/operands.mjs +10 -0
  47. package/lib/hlasm/optable.mjs +61 -0
  48. package/lib/hlasm/read.mjs +22 -8
  49. package/lib/hlasm.mjs +44 -7
  50. package/lib/ims/dli.mjs +37 -0
  51. package/lib/ims/macro/dbd.mjs +299 -0
  52. package/lib/ims/macro/psb.mjs +286 -0
  53. package/lib/ims/model.mjs +149 -0
  54. package/lib/ims/operands.mjs +23 -0
  55. package/lib/ims/read.mjs +37 -0
  56. package/lib/ims/rules.mjs +135 -0
  57. package/lib/ironwork.mjs +17 -13
  58. package/lib/kernel/pds-archive.mjs +256 -0
  59. package/lib/kernel/registry.mjs +14 -8
  60. package/lib/kernel/shared-pass.mjs +34 -9
  61. package/lib/kernel/source-tree.mjs +104 -36
  62. package/lib/kernel/version-key.mjs +17 -0
  63. package/lib/layout.mjs +26 -31
  64. package/lib/parser.mjs +136 -17
  65. package/lib/pli/cursor.mjs +15 -0
  66. package/lib/pli/expr.mjs +101 -0
  67. package/lib/pli/include.mjs +82 -0
  68. package/lib/pli/layout.mjs +125 -0
  69. package/lib/pli/lex.mjs +198 -0
  70. package/lib/pli/program.mjs +280 -0
  71. package/lib/pli/rules/based.mjs +95 -0
  72. package/lib/pli/rules/conditions.mjs +68 -0
  73. package/lib/pli/rules/entry.mjs +130 -0
  74. package/lib/pli/rules/index.mjs +24 -0
  75. package/lib/pli/rules/preprocessor.mjs +55 -0
  76. package/lib/pli/statements.mjs +130 -0
  77. package/lib/pli/stmt/alloc.mjs +45 -0
  78. package/lib/pli/stmt/assignment.mjs +56 -0
  79. package/lib/pli/stmt/call.mjs +104 -0
  80. package/lib/pli/stmt/conditions.mjs +94 -0
  81. package/lib/pli/stmt/control.mjs +219 -0
  82. package/lib/pli/stmt/declare.mjs +149 -0
  83. package/lib/pli/stmt/exec.mjs +55 -0
  84. package/lib/pli/stmt/io.mjs +239 -0
  85. package/lib/pli/stmt/misc.mjs +4 -0
  86. package/lib/pli/stmt/preprocessor.mjs +242 -0
  87. package/lib/pli/stmt/procedure.mjs +258 -0
  88. package/lib/pli/stmt/stream.mjs +283 -0
  89. package/lib/pli/storage.mjs +129 -0
  90. package/lib/precompile-check.mjs +124 -0
  91. package/lib/precompile-cics.mjs +7 -3
  92. package/lib/reach.mjs +11 -2
  93. package/lib/revision.json +1 -1
  94. package/lib/sarif.mjs +41 -3
  95. package/lib/scan.mjs +7 -1
  96. package/lib/sets/abend.mjs +16 -6
  97. package/lib/sets/cics.mjs +15 -35
  98. package/lib/sets/compile.mjs +41 -15
  99. package/lib/sets/crypto.mjs +5 -3
  100. package/lib/sets/ddl.mjs +36 -0
  101. package/lib/sets/flow.mjs +18 -1
  102. package/lib/sets/hidden.mjs +5 -3
  103. package/lib/sets/hlasm.mjs +72 -12
  104. package/lib/sets/ims.mjs +139 -0
  105. package/lib/sets/log.mjs +6 -6
  106. package/lib/sets/opaque.mjs +27 -7
  107. package/lib/sets/pli.mjs +40 -0
  108. package/lib/sets/recon.mjs +5 -3
  109. package/lib/sets/secrets.mjs +5 -3
  110. package/lib/sets/semantics.mjs +3 -0
  111. package/lib/sets/web.mjs +36 -22
  112. package/lib/site.mjs +10 -0
  113. package/lib/sources.mjs +80 -20
  114. package/lib/statement-cursor.mjs +67 -0
  115. package/lib/verify.mjs +3 -2
  116. package/lib/version.mjs +6 -0
  117. package/package.json +3 -2
  118. package/rules/compliance-cobit2019.json +432 -1
  119. package/rules/compliance-dora.json +414 -1
  120. package/rules/compliance-ffiec.json +414 -1
  121. package/rules/compliance-nist80053.json +466 -1
  122. package/rules/hlasm-optables.json +8024 -0
  123. package/schema/cobolwork-baseline.schema.json +36 -0
  124. package/schema/cobolwork-build-provenance.schema.json +187 -0
  125. package/schema/cobolwork-build.schema.json +382 -0
  126. package/schema/cobolwork-capabilities.schema.json +239 -0
  127. package/schema/cobolwork-diff.schema.json +217 -0
  128. package/schema/cobolwork-evidence.schema.json +161 -0
  129. package/schema/cobolwork-execution.schema.json +53 -0
  130. package/schema/cobolwork-explain.schema.json +360 -0
  131. package/schema/cobolwork-finding.schema.json +465 -0
  132. package/schema/cobolwork-flow.schema.json +465 -0
  133. package/schema/cobolwork-gate.schema.json +211 -0
  134. package/schema/cobolwork-inventory.schema.json +206 -0
  135. package/schema/cobolwork-parse.schema.json +105 -0
  136. package/schema/cobolwork-reach.schema.json +74 -0
  137. package/schema/cobolwork-report.schema.json +559 -0
  138. package/schema/cobolwork-witness.schema.json +107 -0
  139. package/schema/cobolwork.baseline.schema.json +101 -0
  140. package/schema/cobolwork.site.schema.json +116 -0
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The compile check `cobolwork build --precompile` runs before the caller's compiler: each program the
3
+ // compiler command names that holds EXEC SQL or EXEC CICS is translated by lib/precompile.mjs into a
4
+ // directory of its own, beside the stand-in copybooks and translated members it needs, and given to
5
+ // the same compiler with -fsyntax-only, so a program GnuCOBOL refuses only for its EXEC blocks is
6
+ // checked. Nothing it writes outlives the check.
7
+ import { spawnSync } from 'node:child_process';
8
+ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { basename, delimiter, dirname, extname, join, relative, resolve } from 'node:path';
11
+ import { parseBms } from './bms.mjs';
12
+ import { printable } from './kernel/printable.mjs';
13
+ import { detectFormat, parseSource } from './parser.mjs';
14
+ import { precompile } from './precompile.mjs';
15
+ import { isBms, isProgram, readSource } from './sources.mjs';
16
+
17
+ const COPY_EXTENSIONS = ['', '.cpy', '.CPY', '.cbl', '.CBL', '.cob', '.COB'];
18
+ const MAX_ERRORS = 3;
19
+
20
+ // A COPY member's text from the first directory holding it, as cobc searches them.
21
+ export const copybookIn = (dirs) => (name) => {
22
+ for (const d of dirs) {
23
+ for (const ext of COPY_EXTENSIONS) {
24
+ const p = join(d, name + ext);
25
+ try { if (statSync(p).isFile()) return readSource(p).text; } catch { /* not here */ }
26
+ }
27
+ }
28
+ return null;
29
+ };
30
+
31
+ // The BMS files under a repository, the member names its other files supply, and the mapsets it holds
32
+ // no copybook for, whose symbolic maps the translator writes.
33
+ export function repositoryBms(repoRoot) {
34
+ const files = [];
35
+ const walk = (d) => {
36
+ let es;
37
+ try { es = readdirSync(d, { withFileTypes: true }); } catch { return; }
38
+ for (const e of es) {
39
+ if (e.name === '.git' || e.name.startsWith('._')) continue;
40
+ const p = join(d, e.name);
41
+ if (e.isDirectory()) walk(p); else files.push(p);
42
+ }
43
+ };
44
+ walk(repoRoot);
45
+ const bms = files.filter(isBms);
46
+ const members = new Set(files.filter((f) => !bms.includes(f)).map((f) => basename(f, extname(f)).toUpperCase()));
47
+ const mapsets = bms.flatMap((f) => { try { return parseBms(readFileSync(f, 'latin1')).mapsets; } catch { return []; } })
48
+ .filter((ms) => ms.name && !members.has(ms.name.toUpperCase()));
49
+ return { bms, has: (n) => members.has(n.toUpperCase()), mapsets };
50
+ }
51
+
52
+ const EXEC = /EXEC\s+(?:SQL|CICS)\b/i;
53
+ const DLI = /EXEC\s+DLI\b/i;
54
+ const redact = (s) => String(s).replace(/'[^']*'|"[^"]*"/g, "'…'");
55
+
56
+ // The copy directories a cobc or gcobol argument vector names, in order: -I <dir> and -I<dir>.
57
+ function includeDirsOf(argv, cwd) {
58
+ const dirs = [];
59
+ argv.forEach((a, i) => {
60
+ if (a === '-I' && argv[i + 1]) dirs.push(resolve(cwd, argv[i + 1]));
61
+ else if (a.startsWith('-I') && a.length > 2) dirs.push(resolve(cwd, a.slice(2)));
62
+ });
63
+ return dirs;
64
+ }
65
+
66
+ // The argument vector with -o and its value taken out: a syntax check writes nothing.
67
+ const withoutOutput = (argv) => argv.filter((a, i) => a !== '-o' && argv[i - 1] !== '-o' && !(a.startsWith('-o') && a.length > 2));
68
+
69
+ // A compiler's first error lines, each named by the source line it came from and with every quoted
70
+ // literal masked, since a diagnostic may quote the program and no output of the gate carries source.
71
+ function errorsOf(stderr, translated, map, rel) {
72
+ const out = [];
73
+ for (const line of String(stderr || '').split('\n')) {
74
+ const m = /^(.*?):(\d+):\s*error:\s*(.*)$/.exec(line);
75
+ if (!m) continue;
76
+ const at = resolve(m[1]) === translated ? `${rel}:${map[Number(m[2]) - 1] ?? m[2]}` : `${basename(m[1])}:${m[2]}`;
77
+ out.push(printable(`${at}: ${redact(m[3])}`, 300));
78
+ if (out.length === MAX_ERRORS) break;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // Runs the check. `compilerPath` is the resolved compiler and `argv` the caller's arguments after it;
84
+ // `cwd` is where the caller's compiler runs, so its relative paths resolve there. Returns, for each
85
+ // program the argument vector names that holds EXEC SQL or CICS, whether the compiler accepted its
86
+ // translation and its first errors; programs holding EXEC DLI, which nothing translates, are skipped.
87
+ export function precompileCheck({ compilerPath, argv, cwd = process.cwd(), repo, copylibs = [], env = process.env }) {
88
+ const sources = argv.map((a, i) => ({ a, i, path: resolve(cwd, a) }))
89
+ .filter(({ a, i, path }) => !a.startsWith('-') && argv[i - 1] !== '-o' && argv[i - 1] !== '-I' && isProgram(path) && isFile(path));
90
+ const includeDirs = [...includeDirsOf(argv, cwd), ...String(env.COBCPY || '').split(delimiter).filter(Boolean), ...copylibs];
91
+ const others = withoutOutput(argv.filter((_, i) => !sources.some((s) => s.i === i)));
92
+ const programs = [];
93
+ const skipped = [];
94
+ let mapsets = null;
95
+ const root = mkdtempSync(join(tmpdir(), 'cw-precompile-'));
96
+ try {
97
+ for (const { path } of sources) {
98
+ const rel = printable(relative(repo, path).startsWith('..') ? relative(cwd, path) : relative(repo, path), 200);
99
+ const text = readSource(path).text;
100
+ if (DLI.test(text)) { skipped.push({ path: rel, why: 'EXEC DLI is not translated' }); continue; }
101
+ if (!EXEC.test(text)) continue;
102
+ const format = detectFormat(text);
103
+ const dirs = [dirname(path), ...includeDirs];
104
+ let items = [];
105
+ try { items = parseSource(text, path, { format, includeDirs: dirs }).programs.flatMap((p) => p.items); } catch { /* unparsed: no host-variable arrays known */ }
106
+ if (/EXEC\s+CICS\b/i.test(text) && !mapsets) mapsets = repositoryBms(repo).mapsets;
107
+ const p = precompile(text, { format, items, mapsets: /EXEC\s+CICS\b/i.test(text) ? mapsets : [], copybook: copybookIn(dirs) });
108
+ const dir = mkdtempSync(join(root, 'p-'));
109
+ const translated = join(dir, basename(path));
110
+ writeFileSync(translated, p.text);
111
+ for (const [name, body] of Object.entries(p.copybooks)) writeFileSync(join(dir, `${name}.cpy`), body);
112
+ const r = spawnSync(compilerPath, ['-fsyntax-only', '-I', dir, ...others, translated], { cwd, env, encoding: 'utf8', windowsHide: true });
113
+ const ok = !r.error && r.status === 0;
114
+ programs.push({ path: rel, ok, ...(ok ? {} : { errors: r.error ? [printable(r.error.message, 200)] : errorsOf(r.stderr, translated, p.map, rel) }) });
115
+ }
116
+ } finally {
117
+ rmSync(root, { recursive: true, force: true });
118
+ }
119
+ return { argv: ['-fsyntax-only', '-I', '<translation>', ...others, '<program>'], programs, skipped };
120
+ }
121
+
122
+ function isFile(path) {
123
+ try { return statSync(path).isFile(); } catch { return false; }
124
+ }
@@ -39,13 +39,17 @@ export function cicsOptions(text) {
39
39
  return out;
40
40
  }
41
41
 
42
- // Which table entry a command is: the one an option word identifies (SEND MAP by MAP), else the verb
43
- // alone, else the one IBM makes the default (READQ is READQ TS).
42
+ // Which table entry a command is: the longest one whose name the words after the verb spell in order
43
+ // (WEB READ HTTPHEADER; INQUIRE TRANSACTION though a PROGRAM option follows), else the one an
44
+ // option word identifies (SEND MAP by MAP), else the verb alone, else the one IBM makes the default
45
+ // (READQ is READQ TS).
44
46
  export function cicsCommand(options) {
45
47
  const verb = options[0]?.word;
46
48
  const words = new Set(options.slice(1).map((o) => o.word));
47
49
  const names = Object.keys(CICS_COMMANDS).filter((n) => n === verb || n.startsWith(`${verb} `));
48
- return names.find((n) => CICS_COMMANDS[n].identify?.some((w) => words.has(w)))
50
+ const spelled = names.map((n) => n.split(' ')).filter((w) => w.length > 1 && w.slice(1).every((x, i) => options[i + 1]?.word === x))
51
+ .sort((a, b) => b.length - a.length)[0];
52
+ return spelled?.join(' ') || names.find((n) => CICS_COMMANDS[n].identify?.some((w) => words.has(w)))
49
53
  || names.find((n) => n === verb) || names.find((n) => CICS_COMMANDS[n].default) || null;
50
54
  }
51
55
 
package/lib/reach.mjs CHANGED
@@ -14,11 +14,15 @@
14
14
  // lib/exploitability.mjs joins them with the route into a verdict.
15
15
  import { readFileSync, realpathSync } from 'node:fs';
16
16
  import { basename, sep, delimiter } from 'node:path';
17
+ import { versionProblem, unknownKeys, ignoredKey } from './kernel/version-key.mjs';
17
18
 
18
19
  const str = (v) => typeof v === 'string' && v.length > 0;
19
20
  const isoDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
20
21
  const up = (s) => String(s || '').toUpperCase();
21
22
  const ACCESS = new Set(['open', 'restricted']);
23
+ export const REACH_VERSION = 1;
24
+ const REACH_KEYS = new Set(['$schema', 'version', 'extract', 'retrieved', 'transactions', 'jobs', 'privileged']);
25
+ const PRIVILEGED_KEYS = new Set(['transactions', 'jobs']);
22
26
  const arr = (v) => (Array.isArray(v) ? v.filter(str).map(up) : []);
23
27
 
24
28
  // The feeds a scan was given: the caller's list, or COBOLWORK_REACH read when the scan runs.
@@ -34,7 +38,7 @@ export function reachFeedPaths(opts = {}) {
34
38
  // derived from a RACF unload (who may start each transaction, and which run with elevated authority)
35
39
  // and the SIT (which face a network).
36
40
  export function loadReachFeed(path, { root = null } = {}) {
37
- const feed = { file: basename(path), extract: null, retrieved: null, transactions: {}, jobs: {}, privileged: { transactions: [], jobs: [] }, refused: [], problem: null };
41
+ const feed = { file: basename(path), extract: null, retrieved: null, transactions: {}, jobs: {}, privileged: { transactions: [], jobs: [] }, refused: [], warnings: [], problem: null };
38
42
  let real;
39
43
  try { real = realpathSync(path); } catch (e) { feed.problem = `could not be read (${e.code || e.name})`; return feed; }
40
44
  if (root) {
@@ -43,6 +47,9 @@ export function loadReachFeed(path, { root = null } = {}) {
43
47
  }
44
48
  let doc;
45
49
  try { doc = JSON.parse(readFileSync(real, 'utf8')); } catch (e) { feed.problem = `is not JSON (${e.message})`; return feed; }
50
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { feed.problem = 'is not a JSON object'; return feed; }
51
+ feed.problem = versionProblem(doc, REACH_VERSION);
52
+ if (feed.problem) return feed;
46
53
  if (!str(doc.extract)) { feed.problem = 'does not say which extract it is'; return feed; }
47
54
  if (!isoDate(doc.retrieved)) { feed.problem = 'does not say when it was retrieved'; return feed; }
48
55
  feed.extract = doc.extract;
@@ -56,7 +63,9 @@ export function loadReachFeed(path, { root = null } = {}) {
56
63
  }
57
64
  }
58
65
  const pv = doc.privileged;
59
- if (pv && typeof pv === 'object' && !Array.isArray(pv)) { feed.privileged.transactions = arr(pv.transactions); feed.privileged.jobs = arr(pv.jobs); }
66
+ const held = pv && typeof pv === 'object' && !Array.isArray(pv);
67
+ if (held) { feed.privileged.transactions = arr(pv.transactions); feed.privileged.jobs = arr(pv.jobs); }
68
+ feed.warnings = [...unknownKeys(doc, REACH_KEYS).map((k) => ignoredKey(feed.file, k)), ...(held ? unknownKeys(pv, PRIVILEGED_KEYS) : []).map((k) => ignoredKey(`${feed.file} privileged`, k))];
60
69
  return feed;
61
70
  }
62
71
 
package/lib/revision.json CHANGED
@@ -1 +1 @@
1
- {"commit":"67c696c3d6f74d20137372b5f0a756e0b27753dd","tag":"v0.6.0"}
1
+ {"commit":"4d7151cd03c0ac0850eec10a2529b07f0fc04aa1","tag":"v0.7.0"}
package/lib/sarif.mjs CHANGED
@@ -45,13 +45,23 @@ const DECLARED = new Map([...Object.entries(ALL_RULES), ...Object.entries(DIFF_R
45
45
  const SET_OF = new Map();
46
46
  for (const s of REGISTRY) for (const id of Object.keys(s.rules)) SET_OF.set(id, s.name);
47
47
 
48
+ // A related entry without a path is an advisory the finding rests on, not a place in the tree.
49
+ const located = (f) => (f.related || []).filter((r) => r.path);
50
+
51
+ const cweTaxon = (cwe) => /^CWE-(\d+)$/.exec(cwe || '')?.[1] ?? null;
52
+
53
+ // The CWE taxonomy is run.taxonomies[0] whenever any rule names a CWE.
54
+ const CWE_COMPONENT = { name: 'CWE', index: 0 };
55
+
48
56
  // SARIF's level cannot say what kind of claim a result is, so evidence rides in the property bags.
49
57
  const descriptorFor = (report) => (id) => {
50
58
  const properties = {
51
59
  ...(report.ruleCwe?.[id] ? { cwe: report.ruleCwe[id] } : {}),
52
60
  ...(report.ruleEvidence?.[id] ? { evidence: report.ruleEvidence[id] } : {}),
61
+ ...(report.ruleCompliance?.[id] ? { compliance: report.ruleCompliance[id] } : {}),
53
62
  ...(SECURITY_SEVERITY[DECLARED.get(id)] ? { 'security-severity': SECURITY_SEVERITY[DECLARED.get(id)] } : {}),
54
63
  };
64
+ const taxon = cweTaxon(report.ruleCwe?.[id]);
55
65
  return {
56
66
  id,
57
67
  shortDescription: { text: report.ruleText?.[id] || id },
@@ -59,10 +69,23 @@ const descriptorFor = (report) => (id) => {
59
69
  // rule. `help` is where GitHub code scanning shows a recommendation.
60
70
  ...(report.ruleImpact?.[id] ? { fullDescription: { text: textOf(report.ruleImpact[id]) } } : {}),
61
71
  ...(report.ruleRemedy?.[id] ? { help: { text: textOf(report.ruleRemedy[id]) } } : {}),
72
+ ...(taxon ? { relationships: [{ target: { id: taxon, toolComponent: CWE_COMPONENT } }] } : {}),
62
73
  ...(Object.keys(properties).length ? { properties } : {}),
63
74
  };
64
75
  };
65
76
 
77
+ function cweTaxonomy(report, ruleIds) {
78
+ const taxa = [...new Set(ruleIds.map((id) => cweTaxon(report.ruleCwe?.[id])).filter(Boolean))].sort((a, b) => a - b);
79
+ if (!taxa.length) return [];
80
+ return [{
81
+ name: CWE_COMPONENT.name,
82
+ organization: 'MITRE',
83
+ shortDescription: { text: 'Common Weakness Enumeration' },
84
+ informationUri: 'https://cwe.mitre.org/',
85
+ taxa: taxa.map((id) => ({ id, helpUri: `https://cwe.mitre.org/data/definitions/${id}.html` })),
86
+ }];
87
+ }
88
+
66
89
  function notifications(report) {
67
90
  const bySet = report.summary?.bySet || {};
68
91
  const execution = [];
@@ -111,6 +134,9 @@ function notifications(report) {
111
134
  properties: { set },
112
135
  });
113
136
  }
137
+ for (const [id, warnings] of [['cobolwork/site-key-ignored', report.summary?.siteWarnings], ['cobolwork/feed-key-ignored', report.summary?.feedWarnings]]) {
138
+ for (const w of warnings || []) configuration.push({ descriptor: { id }, level: 'warning', message: { text: textOf(w) } });
139
+ }
114
140
  // A diff whose two sides were scanned under different configuration.
115
141
  for (const { file, change } of report.summary?.configurationChanged || []) {
116
142
  configuration.push({
@@ -144,6 +170,16 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
144
170
 
145
171
  const { execution, configuration } = notifications(report);
146
172
  const s = report.summary || {};
173
+ const taxonomies = cweTaxonomy(report, present);
174
+
175
+ // Every file a result or related location names, each listed once; locations point at it by index.
176
+ const results = [...report.findings, ...suppressed];
177
+ const uris = [...new Set(results.flatMap((f) => [f.path, ...located(f).map((r) => r.path)]).map(uriOf))].sort();
178
+ const artifactIndex = new Map(uris.map((uri, i) => [uri, i]));
179
+ const artifactLocation = (path) => {
180
+ const uri = uriOf(path);
181
+ return { uri, uriBaseId: '%SRCROOT%', index: artifactIndex.get(uri) };
182
+ };
147
183
 
148
184
  return {
149
185
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
@@ -158,6 +194,7 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
158
194
  },
159
195
  ...(extensions.length ? { extensions } : {}),
160
196
  },
197
+ ...(taxonomies.length ? { taxonomies } : {}),
161
198
  invocations: [{
162
199
  // The verdict, where a consumer that reads nothing else will still see it.
163
200
  executionSuccessful: s.nosrc !== true,
@@ -183,7 +220,8 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
183
220
  },
184
221
  },
185
222
  }],
186
- results: [...report.findings, ...suppressed].map((f) => {
223
+ ...(uris.length ? { artifacts: uris.map((uri) => ({ location: { uri, uriBaseId: '%SRCROOT%' } })) } : {}),
224
+ results: results.map((f) => {
187
225
  const set = SET_OF.get(f.rule);
188
226
  const component = set !== undefined ? indexOfSet.get(set) : undefined;
189
227
  const within = component === undefined ? null : extensions[component].rules.findIndex((r) => r.id === f.rule);
@@ -217,8 +255,8 @@ export function toSarif(report, { toolVersion = '0.1.0' } = {}) {
217
255
  // a subprogram no caller run is shown to pass.
218
256
  ...(f.abend?.inputFrom ? { inputFrom: f.abend.inputFrom } : {}),
219
257
  } } : {}),
220
- locations: [{ physicalLocation: { artifactLocation: { uri: uriOf(f.path), uriBaseId: '%SRCROOT%' }, region: { startLine: f.line || 1 } } }],
221
- ...(f.related ? { relatedLocations: f.related.map((r) => ({ physicalLocation: { artifactLocation: { uri: uriOf(r.path), uriBaseId: '%SRCROOT%' }, region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
258
+ locations: [{ physicalLocation: { artifactLocation: artifactLocation(f.path), region: { startLine: f.line || 1 } } }],
259
+ ...(located(f).length ? { relatedLocations: located(f).map((r) => ({ physicalLocation: { artifactLocation: artifactLocation(r.path), region: { startLine: r.line || 1 } }, message: { text: textOf(r.detail) } })) } : {}),
222
260
  ...(f.suppressed ? { suppressions: [{ kind: 'external', status: 'accepted', justification: `${f.suppressed.action} by ${f.suppressed.who} until ${f.suppressed.expires}: ${f.suppressed.reason}` }] } : {}),
223
261
  };
224
262
  }),
package/lib/scan.mjs CHANGED
@@ -66,6 +66,9 @@ function mergeEstate(reports) {
66
66
  effectNote: effectless.length ? `no authority facts declared in ${of(effectless)}, so no finding there was judged to run privileged: name privilegedTransactions/privilegedJobs in each repository's cobolwork.site.json, or bring them in the COBOLWORK_REACH extract` : null,
67
67
  witnessFeeds: first('witnessFeeds'), byWitness: addUp('byWitness'), witnessFeedProblems: union('witnessFeedProblems'),
68
68
  witnessUnmatched: unmatched, witnessOverruled: overruled,
69
+ executionFeeds: first('executionFeeds'), byExecution: addUp('byExecution'), executionFeedProblems: union('executionFeedProblems'),
70
+ siteWarnings: all.flatMap((x) => (x.s.siteWarnings || []).map((w) => `${x.repo}/${w}`)),
71
+ feedWarnings: union('feedWarnings'),
69
72
  };
70
73
  return Object.fromEntries(Object.entries(merged).filter(([, v]) => (Array.isArray(v) ? v.length : v)));
71
74
  }
@@ -102,7 +105,7 @@ function scanEach(root, opts) {
102
105
  filesScanned, programs: sum('programs'), programFiles: sum('programFiles'), copybookFiles: sum('copybookFiles'),
103
106
  jclFiles: sum('jclFiles'), copiesMissing: sum('copiesMissing'), filesUnreadable: sum('filesUnreadable'),
104
107
  filesEbcdic: sum('filesEbcdic'), filesOverBudget: sum('filesOverBudget'), crossProgram: sum('crossProgram'),
105
- ruleSets: opts.only || RULE_SETS, nosrc: filesScanned === 0,
108
+ ruleSets: opts.only || RULE_SETS, flowModel: FLOW_MODEL, toolVersion: TOOL_VERSION, nosrc: filesScanned === 0,
106
109
  coverageIncomplete: reports.some(({ r }) => r.summary.coverageIncomplete),
107
110
  identity: { version: FINGERPRINT_VERSION, shared },
108
111
  ...(reports[0]?.r.summary.advisoryFeeds ? { advisoryFeeds: reports[0].r.summary.advisoryFeeds } : {}),
@@ -158,7 +161,10 @@ export function applyEstateFacts(findings, checked, root, opts = {}) {
158
161
  const { byExecution } = applyExecution(findings, root, executionFeeds);
159
162
  const driven = byExploitability ? byExploitability['attacker-driven'] : 0;
160
163
  const loaded = witnessFeeds.filter((f) => !f.problem);
164
+ const feedWarnings = [...reachFeeds, ...witnessFeeds].flatMap((f) => f.warnings).sort();
161
165
  return {
166
+ ...(site.warnings.length ? { siteWarnings: site.warnings } : {}),
167
+ ...(feedWarnings.length ? { feedWarnings } : {}),
162
168
  ...(byReach ? { byReach } : {}),
163
169
  ...(byEffect ? { byEffect } : {}),
164
170
  ...(reachFeeds.some(f => !f.problem) ? { reachFeeds: reachFeeds.filter(f => !f.problem).map(f => ({ file: f.file, extract: f.extract, retrieved: f.retrieved })) } : {}),
@@ -63,6 +63,10 @@ const OPTIMIZED = {
63
63
  // reference modification's start, length, and start plus length.
64
64
  const RANGE_MESSAGES = /^IGZ00(06|07|72|73|74)S\b/;
65
65
 
66
+ // Language Environment's message for a dynamic CALL of a module no library holds, which ironwork
67
+ // ends U4038 (its assumption C450); earlier ironwork releases ended the same CALL S806.
68
+ const MODULE_NOT_FOUND = /^CEE3501S The module .* was not found\.$/;
69
+
66
70
  export function abendRunPaths(opts = {}) {
67
71
  if (opts.abendRuns) return opts.abendRuns;
68
72
  return (process.env.COBOLWORK_ABENDS || '').split(delimiter).filter(Boolean);
@@ -72,13 +76,19 @@ export function abendRunPaths(opts = {}) {
72
76
  // code CICS reports as ASRA.
73
77
  const CICS_PROGRAM_CHECK = /\((S0C7|S0C4), which CICS reports as ASRA\)$/;
74
78
 
79
+ // How ironwork ends the message of a Language Environment condition in a CICS task, U4038 in
80
+ // batch, which CICS reports as transaction abend 4038 (its assumption C454).
81
+ const CICS_LE_CONDITION = / \(U4038, which CICS reports as transaction abend 4038\)$/;
82
+
75
83
  export function abendRule(abend) {
76
- const code = (abend.code === 'ASRA' && CICS_PROGRAM_CHECK.exec(abend.message || '')?.[1]) || abend.code;
84
+ const cicsLe = abend.code === '4038' && CICS_LE_CONDITION.test(abend.message || '');
85
+ const message = cicsLe ? abend.message.replace(CICS_LE_CONDITION, '') : abend.message || '';
86
+ const code = cicsLe ? 'U4038' : (abend.code === 'ASRA' && CICS_PROGRAM_CHECK.exec(message)?.[1]) || abend.code;
77
87
  if (code === 'S0C7') return 'input-causes-abend-s0c7';
78
88
  if (code === 'S0C4') return 'input-causes-abend-s0c4';
79
89
  if (code === 'S322') return 'input-causes-hang';
80
- if (code === 'S806') return 'input-selects-program';
81
- if (RANGE_MESSAGES.test(abend.message || '')) return 'input-causes-abend-subscript-range';
90
+ if (code === 'S806' || (code === 'U4038' && MODULE_NOT_FOUND.test(message))) return 'input-selects-program';
91
+ if (RANGE_MESSAGES.test(message)) return 'input-causes-abend-subscript-range';
82
92
  return 'input-causes-abend';
83
93
  }
84
94
 
@@ -208,10 +218,10 @@ export function loadAbendRun(dir, tree) {
208
218
  const where = isString(place.file) ? posix.normalize(posix.join(rootDir ?? posix.dirname(programFile), place.file.replace(/\\/g, '/'))) : programFile;
209
219
  const line = Number.isInteger(place.line) && place.line > 0 ? place.line : 1;
210
220
  const rule = abendRule(run.abend);
211
- // An S806 is input choosing the program only where the run's own journal records the marker
212
- // fuzz put in the input reaching that CALL's program load.
221
+ // A CALL of a missing program is input choosing the program only where the run's own journal
222
+ // records the marker fuzz put in the input reaching that CALL's program load.
213
223
  if (rule === 'input-selects-program' && !recorded.sinks.some((k) => k.sink === 'dynamic-program-load' && k.reached === true && k.file === place.file && k.line === place.line)) {
214
- out.problems.push(`run ${run.journal}: its journal does not record the input reaching the CALL at ${place.file}:${place.line}, so its S806 is not reported`);
224
+ out.problems.push(`run ${run.journal}: its journal does not record the input reaching the CALL at ${place.file}:${place.line}, so its missing-program ending is not reported`);
215
225
  continue;
216
226
  }
217
227
  const used = (Array.isArray(run.input) ? run.input : []).map((id) => inputs.get(id)).filter(Boolean)
package/lib/sets/cics.mjs CHANGED
@@ -7,6 +7,7 @@ import { parseSource } from '../parser.mjs';
7
7
  import { drive, loopOver } from '../kernel/shared-pass.mjs';
8
8
  import { buildControl, hasFact } from '../control.mjs';
9
9
  import { parseCsd } from '../csd.mjs';
10
+ import { execReading } from '../exec-reading.mjs';
10
11
 
11
12
 
12
13
  // `impact` says who can do what once the construct is present, so a reader can tell a defect they
@@ -113,27 +114,6 @@ export function signOnTest(test) {
113
114
  }
114
115
 
115
116
 
116
- function execOptions(exec) {
117
- const opts = new Map();
118
- const words = [];
119
- const toks = exec.toks;
120
- for (let i = 0; i < toks.length; i++) {
121
- const t = toks[i];
122
- if (t.t !== 'word') continue;
123
- words.push(t.u);
124
- if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
125
- const inner = [];
126
- let depth = 1;
127
- for (let k = i + 2; k < toks.length && depth > 0; k++) {
128
- if (toks[k].t === 'sep') { depth += toks[k].v === '(' ? 1 : -1; if (depth === 0) break; continue; }
129
- inner.push(toks[k]);
130
- }
131
- if (!opts.has(t.u)) opts.set(t.u, inner);
132
- }
133
- }
134
- return { opts, words };
135
- }
136
-
137
117
  const subtreeRead = (x) => x.directRefs > 0 || x.children.some(subtreeRead);
138
118
 
139
119
  // Whether EIBCALEN bounds what the program reads: a condition tests it, or a field it was moved or
@@ -164,8 +144,8 @@ function testsLength(p) {
164
144
  // Commands whose failure a program ordinarily lets pass: removing a temporary-storage queue that may
165
145
  // not exist, a line written to a transient-data log, the clock, a screen sent to a terminal that may
166
146
  // have gone, an ASSIGN, and the RETURN that ends the task anyway.
167
- const QUIET_FAILURES = (words) => (words[0] === 'DELETEQ' && words[1] === 'TS') || (words[0] === 'WRITEQ' && words[1] === 'TD')
168
- || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(words[0]);
147
+ const QUIET_FAILURES = ({ verb, sub }) => (verb === 'DELETEQ' && sub === 'TS') || (verb === 'WRITEQ' && sub === 'TD')
148
+ || ['ASKTIME', 'FORMATTIME', 'SEND', 'ASSIGN', 'RETURN'].includes(verb);
169
149
  const SHOWN_IGNORED = 5;
170
150
 
171
151
  // Commands whose outcome the program cannot see: NOHANDLE with no RESP and no EIBRESP test before the
@@ -175,15 +155,16 @@ function conditionsIgnored(p, path) {
175
155
  const execs = p.execs.filter((e) => e.kind === 'CICS').sort((a, b) => a.line - b.line);
176
156
  const hits = [];
177
157
  execs.forEach((e, k) => {
178
- const { opts: o, words } = execOptions(e);
179
- if (words[0] === 'IGNORE' && words[1] === 'CONDITION') {
158
+ const reading = execReading(e);
159
+ const { opts: o, words, verb, sub } = reading;
160
+ if (verb === 'IGNORE' && sub === 'CONDITION') {
180
161
  hits.push({ e, what: `IGNORE CONDITION ${words.slice(2).join(' ')}`.trim() });
181
162
  return;
182
163
  }
183
- if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(words)) return;
164
+ if (!words.includes('NOHANDLE') || o.has('RESP') || QUIET_FAILURES(reading)) return;
184
165
  const until = execs[k + 1] ? execs[k + 1].line : Infinity;
185
166
  const tested = p.statements.some((st) => st.line > e.line && st.line < until && (st.sources || []).some((t) => t.t === 'word' && t.u === 'EIBRESP'));
186
- if (!tested) hits.push({ e, what: `${words[0]}${words[1] && words[1] !== 'NOHANDLE' && !o.has(words[1]) ? ` ${words[1]}` : ''} NOHANDLE` });
167
+ if (!tested) hits.push({ e, what: `${verb}${sub && sub !== 'NOHANDLE' && !o.has(sub) ? ` ${sub}` : ''} NOHANDLE` });
187
168
  });
188
169
  if (!hits.length) return null;
189
170
  const shown = hits.slice(0, SHOWN_IGNORED).map((h) => `${h.what} at line ${h.e.line}`).join(', ');
@@ -226,8 +207,7 @@ export function* scanCicsSteps(root, opts = {}) {
226
207
  const transfers = [];
227
208
  for (const e of p.execs) {
228
209
  if (e.kind !== 'CICS') continue;
229
- const { opts: o, words: w } = execOptions(e);
230
- const verb = w[0];
210
+ const { opts: o, verb } = execReading(e);
231
211
  if (!['LINK', 'XCTL', 'START'].includes(verb)) continue;
232
212
  const pgm = (o.get('PROGRAM') || o.get('TRANSID') || [])[0];
233
213
  const areaTok = (o.get('COMMAREA') || o.get('FROM') || [])[0];
@@ -275,7 +255,7 @@ export function* scanCicsSteps(root, opts = {}) {
275
255
  // asking the security manager, and one that folds a password's case first. Passwords are recognised
276
256
  // by field name, as the sign-on rules above recognise them, and each finding says so.
277
257
  function passwordChecks(p, path) {
278
- if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execOptions(e).words[0] || ''))) return [];
258
+ if (p.execs.some((e) => e.kind === 'CICS' && /^(VERIFY|SIGNON)$/.test(execReading(e).verb || ''))) return [];
279
259
  const byName = new Map();
280
260
  for (const it of p.items) if (!byName.has(it.name)) byName.set(it.name, it);
281
261
  const resolve = (tok) => (tok && tok.t === 'word' ? p.resolved.get(tok) || byName.get(tok.u) || null : null);
@@ -286,8 +266,8 @@ export function* scanCicsSteps(root, opts = {}) {
286
266
  // FETCH fills, and everything under any of them. A copy made by MOVE first is not followed.
287
267
  const read = new Set();
288
268
  for (const e of p.execs) {
289
- const { opts: o, words } = execOptions(e);
290
- if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(words[0] || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
269
+ const { opts: o, verb } = execReading(e);
270
+ if (e.kind === 'CICS' && /^READ(NEXT|PREV)?$/.test(verb || '')) for (const t of o.get('INTO') || []) if (t.t === 'word') read.add(t.u);
291
271
  if (e.kind === 'SQL' && (e.hostVariables || []).length) for (const h of e.hostVariables) if (h.written && h.tok) read.add(h.tok.u);
292
272
  }
293
273
  const stored = (name) => {
@@ -343,15 +323,15 @@ export function* scanCicsSteps(root, opts = {}) {
343
323
  const sends = [];
344
324
  for (const e of p.execs) {
345
325
  if (e.kind !== 'CICS') continue;
346
- const { opts: o, words: w } = execOptions(e);
347
- const opt = w[0] === 'XCTL' || w[0] === 'LINK' ? 'PROGRAM' : (w[0] === 'RETURN' || w[0] === 'START') ? 'TRANSID' : null;
326
+ const { opts: o, verb } = execReading(e);
327
+ const opt = verb === 'XCTL' || verb === 'LINK' ? 'PROGRAM' : (verb === 'RETURN' || verb === 'START') ? 'TRANSID' : null;
348
328
  const t = opt && (o.get(opt) || [])[0];
349
329
  if (!t || t.t !== 'lit') continue;
350
330
  const target = `${opt === 'PROGRAM' ? '' : 'transaction '}${String(t.v).trim().toUpperCase()}`;
351
331
  if (target === p.id) continue;
352
332
  const bits = ctl.facts.get(ctl.nodeOf.get(e));
353
333
  if (!bits) continue;
354
- sends.push({ e, verb: w[0], target, signedOn: hasFact(bits, bit) });
334
+ sends.push({ e, verb, target, signedOn: hasFact(bits, bit) });
355
335
  }
356
336
  const granted = new Set(sends.filter((x) => x.signedOn).map((x) => x.target));
357
337
  const aid = p.refs.some((r) => r.tok.u === 'EIBAID');
@@ -59,11 +59,13 @@ const REPLACE_STATEMENT = /\bREPLACE\b([\s\S]*?)(?:\.\s|\.$)/gim;
59
59
 
60
60
  // A header declares a paragraph only where the sentence before it has ended: after a line of code
61
61
  // ending in a period, or with no code before it. Without the period the name is read as part of the
62
- // statement before, and the compiler reports it undefined.
63
- function sentenceEndsBefore(text, at) {
64
- const lines = text.slice(0, at).split(/\r?\n/);
65
- lines.pop();
66
- for (let i = lines.length - 1; i >= 0; i--) {
62
+ // statement before, and the compiler reports it undefined. `lines` and `starts` are the text's lines
63
+ // and where each begins.
64
+ function sentenceEndsBefore(lines, starts, at) {
65
+ let lo = 0;
66
+ let hi = starts.length - 1;
67
+ while (lo < hi) { const mid = (lo + hi + 1) >>> 1; if (starts[mid] <= at) lo = mid; else hi = mid - 1; }
68
+ for (let i = lo - 1; i >= 0; i--) {
67
69
  const l = lines[i];
68
70
  if (/^.{6}[*/]/.test(l) || /^\s*\*>/.test(l)) continue;
69
71
  const code = l.slice(0, 72).replace(/\*>.*$/, '').replace(/^[0-9 ]{6}(?=[ \-D])/, '').trim();
@@ -72,12 +74,17 @@ function sentenceEndsBefore(text, at) {
72
74
  return true;
73
75
  }
74
76
 
75
- function declaredIn(text, names, into) {
76
- const note = (w) => { const u = w.toUpperCase(); if (names.has(u)) into.add(u); };
77
+ // Every name a text declares, upper-cased, whatever is being looked for.
78
+ function declaredWords(text) {
79
+ const words = new Set();
80
+ const note = (w) => words.add(w.toUpperCase());
81
+ const lines = text.split(/\r?\n/);
82
+ const starts = [0];
83
+ for (const m of text.matchAll(/\r?\n/g)) starts.push(m.index + m[0].length);
77
84
  for (const re of [DECLARATION, HEADER]) {
78
85
  re.lastIndex = 0;
79
86
  let m;
80
- while ((m = re.exec(text))) if (re !== HEADER || sentenceEndsBefore(text, m.index)) note(m[1]);
87
+ while ((m = re.exec(text))) if (re !== HEADER || sentenceEndsBefore(lines, starts, m.index)) note(m[1]);
81
88
  }
82
89
  for (const re of [COPY_STATEMENT, REPLACE_STATEMENT]) {
83
90
  re.lastIndex = 0;
@@ -87,9 +94,11 @@ function declaredIn(text, names, into) {
87
94
  for (const w of m[1].match(/[A-Za-z0-9][A-Za-z0-9-]*/g) || []) note(w);
88
95
  }
89
96
  }
90
- return into;
97
+ return words;
91
98
  }
92
99
 
100
+ const declaredIn = (words, names, into) => { for (const u of names) if (words.has(u)) into.add(u); return into; };
101
+
93
102
  // A reference cut at column 72 names a word the source does not hold.
94
103
  function truncated(ref, lines) {
95
104
  const line = (lines.get(ref.file) || [])[ref.line - 1] || '';
@@ -114,7 +123,23 @@ export function* scanCompileSteps(root, opts = {}) {
114
123
  because[why] = (because[why] || 0) + 1;
115
124
  undecided.push(`${tree.rel(f)} (${id}): ${why}: ${[...new Set(refs.map((x) => x.name))].slice(0, SHOWN).join(', ')}`);
116
125
  };
117
- const textOf = (p) => (tree.contains(p) ? tree.text(p) : readSource(p)).text;
126
+ // A copybook's text, lines and declared names, read once for every program that copies it.
127
+ const remembered = (read) => {
128
+ const held = new Map();
129
+ return (p) => {
130
+ let h = held.get(p);
131
+ if (!h) {
132
+ try { const text = read(p); h = { text, lines: null, words: null }; } catch (e) { h = { error: e }; }
133
+ held.set(p, h);
134
+ }
135
+ if (h.error) throw h.error;
136
+ return h;
137
+ };
138
+ };
139
+ const copyOf = remembered((p) => (tree.contains(p) ? tree.text(p) : readSource(p)).text);
140
+ const answerOf = remembered((p) => tree.text(p).text);
141
+ const wordsOf = (h) => (h.words ||= declaredWords(h.text));
142
+ const linesOf = (h) => (h.lines ||= h.text.split(/\r?\n/));
118
143
  const candidates = [];
119
144
 
120
145
  const run = yield loopOver(files, (f) => {
@@ -126,6 +151,7 @@ export function* scanCompileSteps(root, opts = {}) {
126
151
  stats.filesScanned++;
127
152
  const blocked = whyUndecided(r, src);
128
153
  const copied = [...new Set(r.copies.filter((c) => c.path).map((c) => c.path))];
154
+ const own = { text: src, lines: null, words: null };
129
155
  for (const p of r.programs) {
130
156
  // A class or a method has no PROGRAM-ID, and the data its methods use belongs to no program here.
131
157
  if (!p.id || p.diags.some((d) => d.kind === 'no-procedure-division')) continue;
@@ -133,11 +159,11 @@ export function* scanCompileSteps(root, opts = {}) {
133
159
  if (!p.unresolvedRefs.length) continue;
134
160
  if (blocked) { undecide(f, p.id, blocked, p.unresolvedRefs); continue; }
135
161
  const names = new Set(p.unresolvedRefs.map((x) => x.name));
136
- const texts = new Map([[f, src]]);
137
- for (const q of copied) { try { texts.set(q, textOf(q)); } catch { /* the parse read it once; a second failure changes nothing */ } }
162
+ const texts = new Map([[f, own]]);
163
+ for (const q of copied) { try { texts.set(q, copyOf(q)); } catch { /* the parse read it once; a second failure changes nothing */ } }
138
164
  const seen = new Set();
139
- for (const text of texts.values()) declaredIn(text, names, seen);
140
- const lines = new Map([...texts].map(([q, t]) => [q, t.split(/\r?\n/)]));
165
+ for (const h of texts.values()) declaredIn(wordsOf(h), names, seen);
166
+ const lines = new Map([...texts].map(([q, h]) => [q, linesOf(h)]));
141
167
  const left = p.unresolvedRefs.filter((x) => !seen.has(x.name) && !truncated(x, lines));
142
168
  if (!left.length) { undecide(f, p.id, 'the parse missed a declaration the text holds', p.unresolvedRefs); continue; }
143
169
  candidates.push({ file: f, id: p.id, line: p.line, refs: left, copied: new Set(r.copies.map((c) => nameOf(c.name))) });
@@ -160,7 +186,7 @@ export function* scanCompileSteps(root, opts = {}) {
160
186
  for (const c of candidates) {
161
187
  const names = new Set(c.refs.map((x) => x.name));
162
188
  const seen = new Set();
163
- for (const n of c.copied) for (const p of answering.get(n) || []) { try { declaredIn(tree.text(p).text, names, seen); } catch { /* unread declares nothing */ } }
189
+ for (const n of c.copied) for (const p of answering.get(n) || []) { try { declaredIn(wordsOf(answerOf(p)), names, seen); } catch { /* unread declares nothing */ } }
164
190
  const left = c.refs.filter((x) => !seen.has(x.name));
165
191
  if (!left.length) { undecide(c.file, c.id, 'another copybook of a name it copies declares them', c.refs); continue; }
166
192
  const firsts = new Map();
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { inScope, isProgram, relPath } from '../sources.mjs';
12
12
  import { report } from '../kernel/ruleset.mjs';
13
13
  import { treeFor, noteUnread, noteUnparsed } from '../kernel/source-tree.mjs';
14
- import { eachWithinMemory } from '../kernel/memory.mjs';
14
+ import { drive, loopOver } from '../kernel/shared-pass.mjs';
15
15
  import { constantsReaching, keywordsOf } from '../constants.mjs';
16
16
 
17
17
  const TABLE = JSON.parse(readFileSync(new URL('../../rules/icsf-services.json', import.meta.url), 'utf8'));
@@ -54,13 +54,13 @@ const argument = (call, service, parameter) => {
54
54
  };
55
55
  const shown = (values) => [...new Set(values.map((v) => (/^[A-Z-]+$/.test(v) ? v : `'${v.trim()}'`)))].join(', ');
56
56
 
57
- export function scanCrypto(root, opts = {}) {
57
+ export function* scanCryptoSteps(root, opts = {}) {
58
58
  const tree = treeFor(root, opts);
59
59
  const files = tree.list().filter(isProgram).filter(inScope(opts));
60
60
  const findings = [];
61
61
  const stats = { filesScanned: 0, filesUnreadable: 0, filesUnparsed: 0, icsfCalls: 0, argumentsUndecided: 0 };
62
62
 
63
- const run = eachWithinMemory(files, (f) => {
63
+ const run = yield loopOver(files, (f) => {
64
64
  let src;
65
65
  try { src = tree.text(f).text; } catch (e) { noteUnread(stats, tree, f, e); return 0; }
66
66
  if (!/CALL\s+['"]?CS[NF]/i.test(src)) return src.length;
@@ -108,3 +108,5 @@ export function scanCrypto(root, opts = {}) {
108
108
 
109
109
  return report('crypto', { rules: CRYPTO_RULES, findings, stats, run });
110
110
  }
111
+
112
+ export const scanCrypto = (root, opts = {}) => drive(scanCryptoSteps(root, opts));