@portll/cobolwork 0.2.117 → 0.2.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +13 -1
  2. package/THIRD-PARTY-NOTICES.md +3 -2
  3. package/bin/cobolwork.mjs +16 -1
  4. package/lib/baseline.mjs +8 -6
  5. package/lib/bms.mjs +1 -1
  6. package/lib/capabilities.mjs +2 -1
  7. package/lib/cards.mjs +66 -0
  8. package/lib/cics-commands.mjs +136 -125
  9. package/lib/compliance.mjs +6 -5
  10. package/lib/control.mjs +295 -6
  11. package/lib/dataflow.mjs +72 -88
  12. package/lib/diff.mjs +65 -89
  13. package/lib/embedded-sql.mjs +110 -0
  14. package/lib/equivalence.mjs +30 -12
  15. package/lib/evidence/journal.mjs +11 -2
  16. package/lib/evidence/record.mjs +3 -1
  17. package/lib/evidence/slsa.mjs +17 -2
  18. package/lib/execution.mjs +77 -0
  19. package/lib/explain.mjs +5 -4
  20. package/lib/ftp.mjs +136 -0
  21. package/lib/inventory.mjs +8 -9
  22. package/lib/ironwork.mjs +4 -6
  23. package/lib/jcl.mjs +29 -54
  24. package/lib/kernel/git.mjs +60 -0
  25. package/lib/kernel/identity.mjs +7 -6
  26. package/lib/kernel/source-tree.mjs +204 -34
  27. package/lib/layout.mjs +202 -0
  28. package/lib/parser.mjs +58 -207
  29. package/lib/precompile.mjs +5 -88
  30. package/lib/revision.json +1 -1
  31. package/lib/sbom.mjs +125 -13
  32. package/lib/scan.mjs +11 -4
  33. package/lib/sets/build.mjs +1 -1
  34. package/lib/sets/cics.mjs +32 -5
  35. package/lib/sets/copybook.mjs +2 -1
  36. package/lib/sets/hidden.mjs +4 -8
  37. package/lib/sets/jcl.mjs +17 -120
  38. package/lib/sets/priv.mjs +1 -1
  39. package/lib/sets/recon.mjs +3 -2
  40. package/lib/sets/semantics.mjs +1 -1
  41. package/lib/sets/vendor.mjs +1 -1
  42. package/lib/sets/zowe.mjs +34 -8
  43. package/lib/site.mjs +17 -7
  44. package/lib/sources.mjs +82 -38
  45. package/lib/utilities.mjs +146 -4
  46. package/package.json +1 -1
  47. package/rules/compliance-cobit2019.json +2438 -0
  48. package/rules/compliance-dora.json +1 -1
  49. package/rules/compliance-ffiec.json +1 -1
  50. package/rules/compliance-nist80053.json +1 -1
  51. package/schema/cobolwork.policy.schema.json +94 -17
@@ -50,12 +50,18 @@ function closure(root, file, idx, systemDirs) {
50
50
  return out;
51
51
  }
52
52
 
53
- // Programs the change edits or adds, or whose copybooks it edits: [{ path, base, head, via }] with
54
- // each side's source digest and the copybooks that changed under an unchanged program.
53
+ // Programs the change edits, adds or deletes, or whose copybooks it edits: [{ path, base, head, via }]
54
+ // with each side's source digest (head null for a deleted program) and the copybooks that changed
55
+ // under an unchanged program.
55
56
  export function changedPrograms(baseDir, headDir, allow = null, systemDirs = []) {
56
57
  const out = [];
57
58
  const headTree = directoryTree(headDir);
58
- const baseIdx = directoryTree(baseDir).index;
59
+ const baseTree = directoryTree(baseDir);
60
+ const baseIdx = baseTree.index;
61
+ for (const base of baseTree.list().filter(isProgram).sort()) {
62
+ const path = relPath(baseDir, base);
63
+ if (!existsSync(join(headDir, path))) out.push({ path, base: sha256(readFileSync(base)), head: null, deleted: true });
64
+ }
59
65
  for (const head of headTree.list().filter((p) => isProgram(p) && (!allow || allow.has(p))).sort()) {
60
66
  const path = relPath(headDir, head);
61
67
  const h = sha256(readFileSync(head));
@@ -74,12 +80,17 @@ export function changedPrograms(baseDir, headDir, allow = null, systemDirs = [])
74
80
  // Whether a commit in base..head reads as authored by a tool, from its identities and trailers.
75
81
  const TOOL = /\[bot\]|(^|[^a-z])bot@|copilot|claude|anthropic|openai|codex|devin|cursor|gemini|aider|sweep-ai|dependabot|renovate/i;
76
82
  export function machineAuthored(repo, base, head, env = process.env) {
77
- const r = spawnSync('git', ['-C', repo, 'rev-list', '--format=%x1e%an%x00%ae%x00%cn%x00%ce%x00%B', `${base}..${head}`], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, env: cleanGitEnv(env) });
83
+ // NUL ends every field and every commit (-z), and a commit message cannot hold one.
84
+ const r = spawnSync('git', ['-C', repo, 'log', '-z', '--format=%an%x00%ae%x00%cn%x00%ce%x00%B', `${base}..${head}`], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, env: cleanGitEnv(env) });
78
85
  if (r.status !== 0) return { known: false, commits: [] };
86
+ const fields = r.stdout.split('\x00');
87
+ if (fields.at(-1) === '') fields.pop();
88
+ if (fields.length % 5 !== 0) return { known: false, commits: [] };
79
89
  const commits = [];
80
- for (const chunk of r.stdout.split('\x1e').slice(1)) {
81
- const [an, ae, cn, ce, body = ''] = chunk.split('\x00');
82
- const trailers = body.split('\n').filter((l) => /^(co-authored-by|generated-by|assisted-by):/i.test(l.trim()));
90
+ for (let i = 0; i < fields.length; i += 5) {
91
+ const [an, ae, cn, ce, body = ''] = fields.slice(i, i + 5);
92
+ // Control characters are dropped first, so one placed before a trailer does not hide it.
93
+ const trailers = body.split('\n').map((l) => l.replace(/[\x00-\x1f\x7f]/g, '').trim()).filter((l) => /^(co-authored-by|generated-by|assisted-by):/i.test(l));
83
94
  const signals = [an, ae, cn, ce, ...trailers, ...(/generated with/i.test(body) ? ['generated with'] : [])].filter((x) => x && TOOL.test(x));
84
95
  if (signals.length) commits.push({ signals: [...new Set(signals.map((s) => s.trim().slice(0, 80)))] });
85
96
  }
@@ -101,13 +112,20 @@ export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed
101
112
  problems.push(`${s.file} names head ${h ? h.slice(0, 12) : 'nothing'} and base ${b ? b.slice(0, 12) : 'nothing'}, which match no program this change edits`);
102
113
  }
103
114
  }
115
+ // Every statement that names a program is judged, and the program passes only if each does.
104
116
  const programs = changed.map((c) => {
105
- const s = statements.find((x) => x.statement && subjectDigest(x.statement, 'head') === c.head && subjectDigest(x.statement, 'base') === c.base);
117
+ if (c.deleted) return { path: c.path, deleted: true, statement: null, ok: false, because: 'the change deletes this program, and no equivalence statement can show what its callers do now' };
118
+ const matching = statements.filter((x) => x.statement && subjectDigest(x.statement, 'head') === c.head && subjectDigest(x.statement, 'base') === c.base);
119
+ const via = c.via ? { via: c.via } : {};
120
+ if (!matching.length) return { path: c.path, ...via, statement: null, ok: false, because: c.via ? `no equivalence statement names this program, whose copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')} the change edits` : 'no equivalence statement names this change' };
121
+ const judged = matching.map((s) => judge(c, s));
122
+ return judged.find((j) => !j.ok) || judged[0];
123
+ });
124
+ function judge(c, s) {
106
125
  const via = c.via ? { via: c.via } : {};
107
- if (!s) return { path: c.path, ...via, statement: null, ok: false, because: c.via ? `no equivalence statement names this program, whose copybook${c.via.length > 1 ? 's' : ''} ${c.via.join(', ')} the change edits` : 'no equivalence statement names this change' };
108
126
  const p = s.statement.predicate || {};
109
127
  const verdict = p.verdict || null;
110
- const covered = p.coverage !== null && p.coverage !== undefined;
128
+ const covered = !!p.coverage && typeof p.coverage === 'object' && Array.isArray(p.coverage.unreached) && p.coverage.unreached.every((u) => typeof u === 'string');
111
129
  const ranOn = new Set(((p.closure && p.closure.head) || []).map((x) => x.sha256));
112
130
  const missing = (c.viaDigests || []).filter((d) => !ranOn.has(d));
113
131
  const because = !PASSING.has(verdict) ? `the statement's verdict is ${verdict}${Array.isArray(p.inconclusive) && p.inconclusive.length ? ` (${p.inconclusive.join('; ')})` : ''}`
@@ -115,9 +133,9 @@ export function checkEquivalence({ baseDir, headDir, allow, files = [], allowed
115
133
  : !covered ? 'the statement measured no coverage of the changed paragraphs, so it is inconclusive'
116
134
  : allowed && !s.signed ? `the statement is ${s.reason}`
117
135
  : null;
118
- const unreached = covered && Array.isArray(p.coverage.unreached) ? p.coverage.unreached : [];
136
+ const unreached = covered ? p.coverage.unreached : [];
119
137
  return { path: c.path, ...via, statement: s.file, verdict, signed: s.signed, signer: s.signer, coverage: covered, ...(unreached.length ? { unreached } : {}), ok: because === null && !unreached.length, ...(because || unreached.length ? { because: because || `the inputs never reached ${unreached.join(', ')}` } : {}) };
120
- });
138
+ }
121
139
  const authorship = mode === 'machineAuthored' && repo && base ? machineAuthored(repo, base, head || 'HEAD', env) : null;
122
140
  const required = mode === 'always' ? true : mode === 'machineAuthored' ? (authorship && authorship.known ? authorship.commits.length > 0 : null) : false;
123
141
  return { mode, required, ...(authorship ? { machineAuthored: authorship } : {}), programs, problems };
@@ -4,7 +4,7 @@
4
4
  import { randomBytes } from 'node:crypto';
5
5
  import { closeSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { makeRecord, newChain } from './record.mjs';
7
+ import { makeRecord, newChain, recordHash } from './record.mjs';
8
8
  import { EvidenceRefusal, createExclusive, lastLine, openAppend, prepareDir, syncClose, takeLock, writeAll } from './store.mjs';
9
9
 
10
10
  export const LEDGER = 'ledger.jsonl';
@@ -13,12 +13,21 @@ export const LOCK = 'ledger.lock';
13
13
  const isoNow = () => new Date().toISOString();
14
14
  const runStamp = (iso) => iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
15
15
 
16
+ // The record the next ledger line extends: one whose chain, seq and hash hold.
17
+ function ledgerTip(line, path) {
18
+ let r = null;
19
+ try { r = JSON.parse(line); } catch { /* refused below */ }
20
+ const ok = r && typeof r === 'object' && /^[0-9a-f]{32}$/.test(r.chain) && Number.isInteger(r.seq) && r.seq >= 0 && typeof r.hash === 'string' && recordHash(r) === r.hash;
21
+ if (!ok) throw new EvidenceRefusal(`${path} ends in a line that is no ledger record; verify it before anything is appended`);
22
+ return r;
23
+ }
24
+
16
25
  // Appends to the ledger; the caller holds the lock. A torn ledger is refused rather than extended.
17
26
  function appendLedger(dir, entries, now) {
18
27
  const path = join(dir, LEDGER);
19
28
  const { line, torn } = lastLine(path);
20
29
  if (torn) throw new EvidenceRefusal(`${path} ends in a partial line; verify it before anything is appended`);
21
- let prev = line ? JSON.parse(line) : null;
30
+ let prev = line ? ledgerTip(line, path) : null;
22
31
  const fd = openAppend(path);
23
32
  try {
24
33
  const out = [];
@@ -36,12 +36,14 @@ export const KINDS = {
36
36
  dd: { fields: { dd: str, event: (x) => ['open', 'close', 'end'].includes(x), mode: str, sha256: hex64, bytes: int }, required: ['dd', 'event'] },
37
37
  call: { fields: { program: str, from: str, sha256: hex64 }, required: ['program'] },
38
38
  abend: { fields: { code: str, file: str, line: int }, required: ['code'] },
39
+ step: { fields: { step: str, pgm: str, outcome: str }, required: ['step', 'pgm', 'outcome'] },
40
+ sink: { fields: { sink: str, file: str, line: int, marker: str, reached: (x) => typeof x === 'boolean' }, required: ['sink', 'file', 'line', 'marker', 'reached'] },
39
41
  genesis: { fields: { createdAt: str, rotatedFrom: any }, required: ['createdAt'] },
40
42
  run: { fields: { run: str, runChain: hex32, runLength: int, runTip: hex64 }, required: ['run', 'runChain', 'runLength', 'runTip'] },
41
43
  'lock-broken': { fields: { holderPid: int, ageMs: int }, required: ['holderPid', 'ageMs'] },
42
44
  };
43
45
 
44
- export const JOURNAL_KINDS = new Set(['open', 'input', 'finding', 'suppressed', 'baseline-write', 'witness', 'verdict', 'output', 'close', 'dd', 'call', 'abend']);
46
+ export const JOURNAL_KINDS = new Set(['open', 'input', 'finding', 'suppressed', 'baseline-write', 'witness', 'verdict', 'output', 'close', 'dd', 'call', 'abend', 'step', 'sink']);
45
47
  export const LEDGER_KINDS = new Set(['genesis', 'run', 'lock-broken']);
46
48
 
47
49
  export const newChain = () => randomBytes(16).toString('hex');
@@ -20,9 +20,24 @@ function named(path, root) {
20
20
  return abs.startsWith(r + sep) ? relative(r, abs).split(sep).join('/') : basename(abs);
21
21
  }
22
22
 
23
+ // The compiler's arguments with every value replaced: an option keeps its name, a path or other value
24
+ // becomes <value>. The digest of the arguments as run lets a holder of them check the statement.
25
+ function redacted(argv) {
26
+ return argv.map((a) => {
27
+ const s = String(a);
28
+ if (!s.startsWith('-')) return '<value>';
29
+ const cut = s.search(/[=/\\]/);
30
+ return cut < 0 ? s : `${s.slice(0, cut)}${s[cut] === '=' ? '=' : ''}<value>`;
31
+ });
32
+ }
33
+
23
34
  export function slsaStatement({ provenance, docBytes, root, artifacts = [], runId = null, runTip = null, builderId = null, startedOn = null, finishedOn = null }) {
24
35
  const subject = [{ name: 'build.json', digest: { sha256: sha256(docBytes) } },
25
- ...artifacts.map((p) => ({ name: named(p, root), digest: { sha256: sha256(readFileSync(p)) } }))];
36
+ ...artifacts.map((p) => {
37
+ let bytes;
38
+ try { bytes = readFileSync(p); } catch (e) { throw new Error(`the artifact ${basename(p)} could not be read (${e.code || e.message})`); }
39
+ return { name: named(p, root), digest: { sha256: sha256(bytes) } };
40
+ })];
26
41
 
27
42
  const resolvedDependencies = (provenance.sources || []).map((s) => ({ uri: `file:${s.path}`, name: s.path, digest: { sha256: s.sha256 } }));
28
43
  if (provenance.compiler && provenance.compiler.sha256) {
@@ -36,7 +51,7 @@ export function slsaStatement({ provenance, docBytes, root, artifacts = [], runI
36
51
  revisions: provenance.revisions,
37
52
  policy: { sha256: provenance.policy.sha256, setBy: provenance.policy.setBy },
38
53
  ...(provenance.copylibs && provenance.copylibs.length ? { copylibs: provenance.copylibs.map((d) => basename(d)) } : {}),
39
- ...(provenance.compiler && provenance.compiler.argv ? { compilerArguments: provenance.compiler.argv } : {}),
54
+ ...(provenance.compiler && provenance.compiler.argv ? { compilerArguments: redacted(provenance.compiler.argv), compilerArgumentsSha256: sha256(provenance.compiler.argv.map(String).join('\0')) } : {}),
40
55
  };
41
56
  const internalParameters = {
42
57
  tool: 'cobolwork', toolVersion: provenance.toolVersion, flowModel: provenance.flowModel,
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Execution: whether the estate's own runs entered the paragraph a finding is in, from the
3
+ // reports `ironwork run --coverage` writes. A finding in a paragraph a run entered is code the tests
4
+ // exercise; one in a paragraph no run entered is code they never reach. See docs/spec/evidence.md §13.4 (execution coverage).
5
+ import { readFileSync } from 'node:fs';
6
+ import { basename, delimiter, join } from 'node:path';
7
+
8
+ export function executionFeedPaths(opts = {}) {
9
+ if (opts.executionFeeds) return opts.executionFeeds;
10
+ return (process.env.COBOLWORK_EXECUTION || '').split(delimiter).filter(Boolean);
11
+ }
12
+
13
+ // One report: per PROGRAM-ID, each paragraph's source line, name and how often a run entered it.
14
+ // Shape (ironwork's): { programs: [{ program, detail: [{ name, line, entered }] }] }.
15
+ export function loadExecutionFeed(path) {
16
+ const feed = { file: basename(path), programs: new Map(), problem: null };
17
+ let doc;
18
+ try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { feed.problem = e.code ? `could not be read (${e.code})` : `is not JSON (${e.message})`; return feed; }
19
+ if (!doc || !Array.isArray(doc.programs)) { feed.problem = 'holds no programs'; return feed; }
20
+ for (const p of doc.programs) {
21
+ const detail = Array.isArray(p?.detail) ? p.detail : null;
22
+ if (typeof p?.program !== 'string' || !detail || !detail.every((d) => typeof d?.name === 'string' && Number.isInteger(d.line) && Number.isInteger(d.entered) && d.entered >= 0)) {
23
+ feed.problem = `a program's paragraphs are not { name, line, entered }`;
24
+ return feed;
25
+ }
26
+ feed.programs.set(p.program.toUpperCase(), detail.map((d) => ({ name: d.name, line: d.line, entered: d.entered })));
27
+ }
28
+ return feed;
29
+ }
30
+
31
+ const PROGRAM_ID = /^.{0,6}[ \d]?\s*PROGRAM-ID\.?\s+['"]?([A-Za-z0-9#$@-]+)/;
32
+
33
+ // The programs a source holds, by PROGRAM-ID and the line it starts on.
34
+ function programsOf(text) {
35
+ const out = [];
36
+ text.split(/\r?\n/).forEach((line, i) => {
37
+ const m = PROGRAM_ID.exec(line);
38
+ if (m) out.push({ id: m[1].toUpperCase(), line: i + 1 });
39
+ });
40
+ return out;
41
+ }
42
+
43
+ // Each finding inside a paragraph of a covered program gets `executed: { paragraph, entered }`,
44
+ // entered summed over the feeds. A finding before the program's first paragraph, or in a program
45
+ // no feed covers, is left as it is.
46
+ export function applyExecution(findings, root, feeds) {
47
+ const good = feeds.filter((f) => !f.problem);
48
+ const byExecution = { entered: 0, 'never-entered': 0 };
49
+ if (!good.length) return { byExecution: null };
50
+ const sources = new Map();
51
+ const programs = (path) => {
52
+ if (!sources.has(path)) {
53
+ let text = null;
54
+ try { text = readFileSync(join(root, path), 'utf8'); } catch { /* not a file the scan can read again */ }
55
+ sources.set(path, text === null ? [] : programsOf(text));
56
+ }
57
+ return sources.get(path);
58
+ };
59
+ for (const f of findings) {
60
+ if (!f.path || !Number.isInteger(f.line)) continue;
61
+ const owner = programs(f.path).filter((p) => p.line <= f.line && good.some((g) => g.programs.has(p.id))).at(-1);
62
+ if (!owner) continue;
63
+ let paragraph = null;
64
+ let entered = 0;
65
+ for (const g of good) {
66
+ const detail = g.programs.get(owner.id);
67
+ const at = detail?.filter((d) => d.line >= owner.line && d.line <= f.line).at(-1);
68
+ if (!at) continue;
69
+ paragraph = at.name;
70
+ entered += at.entered;
71
+ }
72
+ if (paragraph === null) continue;
73
+ f.executed = { paragraph, entered };
74
+ byExecution[entered > 0 ? 'entered' : 'never-entered']++;
75
+ }
76
+ return { byExecution };
77
+ }
package/lib/explain.mjs CHANGED
@@ -7,15 +7,16 @@ import { EVIDENCE } from './kernel/findings.mjs';
7
7
  import { REGISTRY } from './kernel/registry.mjs';
8
8
  import { WHO_ACTS } from './tui/model.mjs';
9
9
  import { verificationPlan } from './verify.mjs';
10
+ import { cobolCard, statementCard } from './cards.mjs';
10
11
 
11
12
  const HIDDEN_RULES = new Set(Object.keys(REGISTRY.find((s) => s.name === 'hidden').rules));
12
13
 
13
14
  // The columns quoted: COBOL's indicator and program text, JCL's statement field, or the whole line.
14
15
  function codeArea(line, kind, format) {
15
- if (kind === 'cobol' && format === 'fixed') return { text: line.slice(6, 72), seq: line.slice(0, 6), rest: line.slice(72) };
16
- if (kind === 'cobol' && format === 'variable') return { text: line.slice(6, 250), seq: line.slice(0, 6), rest: line.slice(250) };
17
- if (kind === 'jcl') return { text: line.slice(0, 72), seq: '', rest: line.slice(72) };
18
- return { text: line, seq: '', rest: '' };
16
+ if (kind === 'jcl') { const c = statementCard(line); return { text: c.text, seq: '', rest: c.sequence }; }
17
+ if (kind !== 'cobol') return { text: line, seq: '', rest: '' };
18
+ const c = cobolCard(line, format);
19
+ return { text: c.indicator + c.text, seq: c.sequence, rest: c.ignored };
19
20
  }
20
21
 
21
22
  const NOTE = 'This packet contains source text from the files it names. A cobolwork report never does: '
package/lib/ftp.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { statementCard } from './cards.mjs';
3
+
4
+ // IBM's batch FTP client takes the host and its options on PARM, or the host as the first line of
5
+ // its input, reads subcommands from INPUT, and logs on from NETRC or from its input. CardDemo's job
6
+ // gives the same lines on SYSIN, which is read here when there is no INPUT, though the page below
7
+ // does not say the client falls back to it.
8
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halu001/ftpreq.htm
9
+ // A session is in cleartext unless something asks for TLS: -a TLS or -r TLS on PARM, or
10
+ // SECURE_MECHANISM TLS in an in-stream SYSFTPD. A SYSFTPD naming a data set is configuration this
11
+ // reader cannot see, so that step is not called cleartext either way.
12
+ // https://www.ibm.com/docs/en/zos/2.1.0?topic=commands-ftp-command
13
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.4.0/com.ibm.zos.v2r4.halz002/ftp_cust_client_for_tls.htm
14
+ // PUT, MPUT and APPEND send local files, and IBM's capitals - PUt, MPut, APpend, LCd - are the
15
+ // shortest spellings. A local name in quotes is a whole data set name, //DD: names a DD of the
16
+ // step, and anything else hangs off the local working directory, which only a quoted LCD fixes.
17
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/ftpsubcmd.htm
18
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/put.htm
19
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mput.htm
20
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/append.htm
21
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/lcd.htm
22
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/dd_name_support.htm
23
+ // Get and MGet copy remote files in, each to the local name given or, with none, to the foreign
24
+ // file's own name, placed as a sent name is. SIte FILETYPE=JES makes the remote host submit what
25
+ // is sent as a job and return job output to a Get; FILETYPE=SQL makes it run a query. Either holds
26
+ // for the transfers after it, until another FILETYPE.
27
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/get.htm
28
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/mget.htm
29
+ // https://www.ibm.com/docs/api/v1/content/SSLTBW_2.2.0/com.ibm.zos.v2r2.halu001/site.htm
30
+ const FTP_SENDS = [[/^PUT?$/i, 'PUT'], [/^MP(?:UT?)?$/i, 'MPUT'], [/^AP(?:P(?:E(?:ND?)?)?)?$/i, 'APPEND']];
31
+
32
+ // Every subcommand on the page above, in IBM's spelling.
33
+ const FTP_SUBCOMMANDS = ['?', '!', 'ACCount', 'APpend', 'AUth', 'AScii', 'BIG5', 'BINary', 'BLock', 'CCc', 'CD',
34
+ 'CDUp', 'CLEar', 'CLose', 'COMpress', 'CProtect', 'CWd', 'DEBug', 'DELEte', 'DELImit', 'DIr', 'DUMP', 'EBcdic',
35
+ 'EUckanji', 'FEature', 'FIle', 'Get', 'GLob', 'HAngeul', 'HElp', 'Ibmkanji', 'JIS78kj', 'JIS83kj', 'Ksc5601',
36
+ 'LANGuage', 'LCd', 'LOCSIte', 'LOCSTat', 'LMkdir', 'LPwd', 'LS', 'MDelete', 'MGet', 'MKdir', 'MKFifo', 'MOde',
37
+ 'MPut', 'MVSGet', 'MVSPut', 'NOop', 'Open', 'PAss', 'PRIvate', 'PROMpt', 'PROTect', 'PROXy', 'PUt', 'PWd', 'QUIt',
38
+ 'QUOte', 'RECord', 'REName', 'REStart', 'RMdir', 'SAfe', 'SChinese', 'SENDPort', 'SENDSite', 'SIte', 'SJiskanji',
39
+ 'SRestart', 'STAtus', 'STREam', 'STRucture', 'SUnique', 'SYstem', 'TChinese', 'TSO', 'TYpe', 'UCs2', 'User', 'Verbose']
40
+ .map((name) => ({ full: name.toUpperCase(), min: name.match(/^[^a-z]*/)[0].length }));
41
+ const subcommandOf = (word) => {
42
+ const w = word.toUpperCase();
43
+ const hit = FTP_SUBCOMMANDS.find((c) => w.length >= c.min && c.full.startsWith(w));
44
+ return hit ? hit.full : null;
45
+ };
46
+ // A value that stands for a password rather than being one: a symbolic, a mask, or the word itself.
47
+ const PLACEHOLDER = /^(&.*|\*+|X+|\?+|<.*>|\{.*\}|PASSWORD|PASSWD|PWD|PASS|SECRET)$/i;
48
+
49
+ // The session one FTP step runs, from its DDs grouped by name (ddGroups in lib/utilities.mjs).
50
+ export function ftpSession(step, dds) {
51
+ let host = null, tls = null;
52
+ const words = (step.parm || '').split('(')[0].trim().split(/\s+/).filter(Boolean);
53
+ for (let i = 0; i < words.length; i++) {
54
+ if (/^-[ar]$/i.test(words[i])) {
55
+ const mechanism = (words[++i] || '').toUpperCase();
56
+ if (mechanism === 'TLS') tls = 'asked';
57
+ else if (mechanism === 'NEVER' && !tls) tls = 'never';
58
+ } else if (!words[i].startsWith('-') && !host) host = words[i];
59
+ }
60
+ let configUnread = null;
61
+ for (const dd of dds.get('SYSFTPD') || []) {
62
+ if (dd.inStream) { if (tls !== 'never' && dd.inStream.some((l) => /^\s*SECURE_MECHANISM\s+TLS\b/i.test(l.text))) tls = 'asked'; }
63
+ else if (dd.dsn && !configUnread) configUnread = dd.dsn;
64
+ }
65
+
66
+ const input = dds.get('INPUT') || dds.get('SYSIN') || [];
67
+ const hostLine = !host && input[0]?.inStream;
68
+ let inputUnread = null, lcd = null, unixDir = false;
69
+ const sends = [], gets = [];
70
+ let filetype = null;
71
+ // With no NETRC to answer for it, the client asks for a user ID and a password and in batch reads
72
+ // them from its input after the host: "user password" on one line, or each on its own. A first
73
+ // line that is a subcommand means something else answered, a NETRC the client found for itself.
74
+ // USER and PASS give the same answers later in a session.
75
+ let expect = dds.has('NETRC') ? null : 'logon', user = null;
76
+ const passwords = [];
77
+ const password = (dd, line, value) => { if (!PLACEHOLDER.test(value)) passwords.push({ dd, line, user }); };
78
+ const local = (name) => {
79
+ const dd = /^\/\/DD:([A-Z$#@][A-Z0-9$#@]{0,7})$/i.exec(name);
80
+ if (dd) return { dd: dd[1].toUpperCase(), dsns: (dds.get(dd[1].toUpperCase()) || []).map((d) => d.dsn).filter(Boolean) };
81
+ const quoted = /^'(.+)'$/.exec(name);
82
+ if (quoted) return { dsns: [quoted[1].toUpperCase()] };
83
+ if (name.startsWith('/') || unixDir) return { dsns: [] };
84
+ return lcd ? { dsns: [`${lcd}.${name.toUpperCase()}`] } : { dsns: [], relative: name };
85
+ };
86
+ for (const dd of input) {
87
+ if (!dd.inStream) { if (dd.dsn && !inputUnread) inputUnread = dd.dsn; continue; }
88
+ for (const { line, text } of dd.inStream) {
89
+ const [verb, ...args] = statementCard(text).text.trim().split(/\s+/);
90
+ if (!verb) continue;
91
+ if (hostLine && !host) { host = verb; continue; }
92
+ const sub = subcommandOf(verb);
93
+ if (expect && !sub) {
94
+ if (expect === 'logon') user = verb;
95
+ if (expect === 'password') password(dd, line, verb);
96
+ else if (args[0]) password(dd, line, args[0]);
97
+ expect = expect === 'logon' && !args[0] ? 'password' : null;
98
+ continue;
99
+ }
100
+ expect = null;
101
+ if (sub === 'USER' && args[0]) {
102
+ user = args[0];
103
+ if (args[1]) password(dd, line, args[1]); else expect = 'password';
104
+ continue;
105
+ }
106
+ if (sub === 'PASS' && args[0]) { password(dd, line, args[0]); continue; }
107
+ if (/^LCD?$/i.test(verb) && args[0]) {
108
+ const quoted = /^'(.+)'$/.exec(args[0]);
109
+ unixDir = args[0].startsWith('/');
110
+ lcd = quoted ? quoted[1].toUpperCase() : lcd && !unixDir ? `${lcd}.${args[0].toUpperCase()}` : null;
111
+ continue;
112
+ }
113
+ const site = sub === 'SITE' ? args : sub === 'QUOTE' && subcommandOf(args[0] || '') === 'SITE' ? args.slice(1) : null;
114
+ if (site) {
115
+ for (const a of site) { const t = /^FILE(?:T(?:Y(?:P(?:E)?)?)?)?=(\w+)$/i.exec(a); if (t) filetype = t[1].toUpperCase() === 'SEQ' ? null : t[1].toUpperCase(); }
116
+ continue;
117
+ }
118
+ const typed = filetype ? { filetype } : {};
119
+ if (sub === 'GET' && args[0]) {
120
+ const to = args[1] && !args[1].startsWith('(') ? args[1] : args[0];
121
+ gets.push({ line, verb: 'GET', name: args[0], ...typed, ...local(to) });
122
+ continue;
123
+ }
124
+ if (sub === 'MGET') {
125
+ for (const name of args.filter((a) => !a.startsWith('(') && !/[*?]/.test(a))) gets.push({ line, verb: 'MGET', name, ...typed, ...local(name) });
126
+ continue;
127
+ }
128
+ const sent = FTP_SENDS.find(([re]) => re.test(verb));
129
+ if (!sent) continue;
130
+ const foreign = sent[1] !== 'MPUT' && args[1] && !args[1].startsWith('(') ? args[1] : null;
131
+ for (const name of sent[1] === 'MPUT' ? args : args.slice(0, 1)) sends.push({ line, verb: sent[1], name, foreign: foreign || name, ...typed, ...local(name) });
132
+ }
133
+ }
134
+ const cleartext = tls === 'never' || (!tls && !configUnread);
135
+ return { host, cleartext, configUnread, inputUnread, logon: dds.has('NETRC') ? 'NETRC' : null, sends, gets, passwords, input };
136
+ }
package/lib/inventory.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { dirname } from 'node:path';
3
- import { parseSource, buildFileIndex, detectFormat } from './parser.mjs';
4
- import { inScope, isCopybook, isJcl, isProgram, readSource, relPath } from './sources.mjs';
2
+ import { detectFormat } from './parser.mjs';
3
+ import { inScope, isCopybook, isJcl, isProgram, relPath } from './sources.mjs';
5
4
  import { SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
6
5
  import { treeFor } from './kernel/source-tree.mjs';
7
6
 
@@ -36,19 +35,19 @@ export function inventory(root, opts = {}) {
36
35
  out.summary.programFiles++;
37
36
  let src;
38
37
  try {
39
- const s = readSource(f);
38
+ const s = tree.text(f);
40
39
  src = s.text;
41
- if (s.encoding === 'ebcdic') { out.summary.filesEbcdic++; out.ebcdic.push(relPath(root, f)); }
42
- } catch (e) { out.summary.filesUnreadable++; out.unreadable.push(`${relPath(root, f)}: ${e.code || e.name}`); continue; }
40
+ if (s.encoding === 'ebcdic') { out.summary.filesEbcdic++; out.ebcdic.push(tree.rel(f)); }
41
+ } catch (e) { out.summary.filesUnreadable++; out.unreadable.push(`${tree.rel(f)}: ${e.code || e.name}`); continue; }
43
42
  // A file with a program extension and no program in it is usually a copybook named .cbl; it is
44
43
  // counted so the difference between program files and programs read is accounted for.
45
44
  if (!/PROCEDURE\s+DIVISION|PROGRAM-ID/i.test(src)) { out.summary.notPrograms++; continue; }
46
45
  let res;
47
46
  try {
48
- res = parseSource(src, f, { format: 'auto', includeDirs: idx.copyDirs, fileIndex: idx.index, mainDir: dirname(f), copyFormat: 'auto', systemDirs: opts.systemDirs || [] });
47
+ res = tree.parse(f, src);
49
48
  } catch (e) {
50
49
  out.summary.filesUnreadable++;
51
- out.unreadable.push(`${relPath(root, f)}: ${e.code || e.name}`);
50
+ out.unreadable.push(`${tree.rel(f)}: ${e.code || e.name}`);
52
51
  continue;
53
52
  }
54
53
  out.summary.filesScanned++;
@@ -60,7 +59,7 @@ export function inventory(root, opts = {}) {
60
59
  if (/EXEC\s+DLI/i.test(src)) out.summary.withDli++;
61
60
  for (const c of res.copies) {
62
61
  if (c.status === 'resolved') out.summary.copiesResolved++;
63
- else if (String(c.status).startsWith('refused')) { out.summary.copiesRefused++; out.refusedCopies.push({ name: c.name, from: relPath(root, c.file), line: c.line, why: c.status }); }
62
+ else if (String(c.status).startsWith('refused')) { out.summary.copiesRefused++; out.refusedCopies.push({ name: c.name, from: tree.rel(c.file), line: c.line, why: c.status }); }
64
63
  // Before the system names, which a copybook in the tree is free to take.
65
64
  else if (c.status === 'expansion-limit') out.summary.copiesOverLimit = (out.summary.copiesOverLimit || 0) + 1;
66
65
  else if (c.status === 'system' || SYSTEM_COPY.test(c.name)) out.summary.copiesSystem++;
package/lib/ironwork.mjs CHANGED
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
7
7
  import { resolve } from 'node:path';
8
8
  import { directoryTree } from './kernel/source-tree.mjs';
9
9
  import { printable } from './kernel/printable.mjs';
10
- import { buildFileIndex, VERBS } from './parser.mjs';
10
+ import { buildFileIndex } from './parser.mjs';
11
11
  import { isProgram, relPath } from './sources.mjs';
12
12
  import { EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS } from './words.mjs';
13
13
 
@@ -20,15 +20,13 @@ const NOT_AN_ERROR = /^(?:informational:|.*?: warning: )/;
20
20
  const PER_PROGRAM_MS = 60000;
21
21
  const MAX_LISTED = 10;
22
22
 
23
- // ironwork refuses by name what it does not model yet; that says nothing about the program. Where its
24
- // parser stops at a statement it does not read (ENTRY, ALTER), or at a name the CICS, DL/I or SQL
25
- // translator declares, it says so without the name, so both are read as its gap, not the program's.
23
+ // ironwork refuses by name what it does not model yet; that says nothing about the program. A name
24
+ // the CICS, DL/I or SQL translator declares it reports as undefined, without saying so, and that is
25
+ // read as its gap, not the program's.
26
26
  const NOT_SUPPORTED = /\bnot supported\b/i;
27
27
  const TRANSLATOR_NAMES = [EIB_FIELDS, DIB_FIELDS, SQLCA_FIELDS];
28
28
  function notModelled(message) {
29
29
  if (NOT_SUPPORTED.test(message)) return true;
30
- const verb = /^a statement, found ([A-Z][A-Z-]*)$/.exec(message);
31
- if (verb) return VERBS.has(verb[1]);
32
30
  const name = /^([A-Z][A-Z0-9-]*) is not defined$/.exec(message);
33
31
  return !!name && TRANSLATOR_NAMES.some((s) => s.has(name[1]));
34
32
  }
package/lib/jcl.mjs CHANGED
@@ -12,17 +12,16 @@
12
12
  // output as `unreadable`, never dropped, because a step nobody parsed is not a step that is not
13
13
  // there.
14
14
  import { readSource } from './sources.mjs';
15
- import { copiesOf } from './utilities.mjs';
15
+ import { copiesOf, tsoCommands } from './utilities.mjs';
16
+ import { statementCard, parseOperands } from './cards.mjs';
17
+
18
+ export { splitOperands, parseOperands } from './cards.mjs';
16
19
 
17
20
  // The operations a statement may carry. Anything else in the operation field is a statement this
18
21
  // reader does not know, which is a diagnostic rather than a silent skip.
19
22
  export const OPERATIONS = new Set(['JOB', 'EXEC', 'DD', 'PROC', 'PEND', 'SET', 'IF', 'THEN',
20
23
  'ELSE', 'ENDIF', 'INCLUDE', 'JCLLIB', 'OUTPUT', 'CNTL', 'ENDCNTL', 'XMIT', 'COMMAND', 'NOTIFY', 'EXPORT']);
21
24
 
22
- // Columns 73 to 80 are the sequence area. They are not the statement, and a payload hidden there
23
- // is the hidden-content rules' business, not this reader's.
24
- const STATEMENT_COLUMNS = 72;
25
-
26
25
  // A continued operand resumes in these columns. Outside them it is not a continuation, whatever
27
26
  // the previous line ended with.
28
27
  const CONTINUE_FROM = 4;
@@ -41,53 +40,6 @@ const SYSTEM_SYMBOLS = new Set(`SYSUID SYSALVL SYSCLONE SYSNAME SYSOSLVL SYSPLEX
41
40
  YYMMDD LYYMMDD HHMMSS LHHMMSS DAY HR MIN SEC JDAY MON YR2 YR4 WDAY
42
41
  LDATE LDAY LHR LMIN LSEC LJDAY LMON LYR2 LYR4 LWDAY LTIME JOBNAME DS SEQ DATE TIME`.split(/\s+/));
43
42
 
44
- // Splits an operand field on commas that are not inside parentheses or quotes. JCL nests both, and
45
- // a naive split on comma turns DISP=(NEW,CATLG,DELETE) into three operands.
46
- export function splitOperands(s) {
47
- const out = [];
48
- let depth = 0, quoted = false, start = 0;
49
- for (let i = 0; i < s.length; i++) {
50
- const c = s[i];
51
- if (quoted) { if (c === "'") { if (s[i + 1] === "'") i++; else quoted = false; } continue; }
52
- if (c === "'") quoted = true;
53
- else if (c === '(') depth++;
54
- else if (c === ')') depth = Math.max(0, depth - 1);
55
- else if (c === ',' && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
56
- }
57
- out.push(s.slice(start));
58
- return out.filter((x, i, a) => x.length || i < a.length - 1);
59
- }
60
-
61
- // Operands are positional until the first KEY=VALUE, and keyword after it. Both forms matter:
62
- // EXEC takes its procedure name positionally, and everything interesting on DD is a keyword.
63
- export function parseOperands(field) {
64
- const positional = [];
65
- const keywords = new Map();
66
- for (const raw of splitOperands(field)) {
67
- const part = raw.trim();
68
- if (!part) continue;
69
- const eq = keywordSplit(part);
70
- if (eq < 0) positional.push(part);
71
- else keywords.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
72
- }
73
- return { positional, keywords };
74
- }
75
-
76
- // The first '=' that is not inside parentheses or quotes. DCB=(RECFM=FB,LRECL=80) is one keyword
77
- // whose value happens to contain more of them.
78
- function keywordSplit(part) {
79
- let depth = 0, quoted = false;
80
- for (let i = 0; i < part.length; i++) {
81
- const c = part[i];
82
- if (quoted) { if (c === "'") { if (part[i + 1] === "'") i++; else quoted = false; } continue; }
83
- if (c === "'") quoted = true;
84
- else if (c === '(') depth++;
85
- else if (c === ')') depth = Math.max(0, depth - 1);
86
- else if (c === '=' && depth === 0) return i;
87
- }
88
- return -1;
89
- }
90
-
91
43
  // Symbolic substitution. &NAME ends at a non-name character, and a trailing dot is a separator
92
44
  // that is consumed rather than kept: &PREFIX..DATA resolves to <prefix>.DATA.
93
45
  export function substitute(text, symbols) {
@@ -146,8 +98,10 @@ export function foldStatements(src) {
146
98
  const line = i + 1;
147
99
  const raw = phys[i];
148
100
  if (raw.length > 80) diags.push({ sev: 'warn', line, text: 'line is longer than 80 columns' });
149
- const text = raw.slice(0, STATEMENT_COLUMNS).replace(/\s+$/, '');
150
- const sequence = raw.slice(STATEMENT_COLUMNS);
101
+ // The sequence area is not the statement; a payload hidden there is the hidden-content rules' business.
102
+ const card = statementCard(raw);
103
+ const text = card.text.replace(/\s+$/, '');
104
+ const sequence = card.sequence;
151
105
 
152
106
  if (stream) {
153
107
  // A custom DLM ends the stream and nothing else does. The default ends at /* or at the next
@@ -402,6 +356,27 @@ export function parseJcl(src, file, opts = {}) {
402
356
  else diags.push({ sev: 'warn', line: 0, text: `symbolic parameter &${s} has no value, so the operands using it were read unsubstituted` });
403
357
  }
404
358
 
359
+ // A TSO batch step's ALLOCATE commands define DDs its JCL does not show. Each is kept as a DD of the
360
+ // step, marked `allocated`, with the datasets of a list concatenated under it as JCL would.
361
+ for (const s of steps) {
362
+ const tso = tsoCommands(s);
363
+ for (const n of tso.notes) diags.push({ sev: 'info', ...n });
364
+ for (const a of tso.allocations) {
365
+ const ends = a.datasets.length ? a.datasets : [{ written: null, dsn: null }];
366
+ ends.forEach((d, i) => {
367
+ const dd = {
368
+ name: i === 0 ? a.dd : null, procStep: null, dsn: d.dsn, rawDsn: d.written, referback: null,
369
+ disp: a.status, access: accessOf(a.status), temporary: false, sysout: a.sysout,
370
+ inStream: null, dlm: null, concatenated: i > 0, keywords: new Map(), line: a.line, endLine: a.line,
371
+ step: s.name, allocated: true,
372
+ };
373
+ dds.push(dd);
374
+ s.dds.push(dd);
375
+ stepOf.set(dd, s);
376
+ });
377
+ }
378
+ }
379
+
405
380
  // What each step's program copies, from lib/utilities.mjs. A program that table does not know
406
381
  // copies nothing here, which is not the same as copying nothing.
407
382
  const copies = [];