@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.
- package/README.md +13 -1
- package/THIRD-PARTY-NOTICES.md +3 -2
- package/bin/cobolwork.mjs +16 -1
- package/lib/baseline.mjs +8 -6
- package/lib/bms.mjs +1 -1
- package/lib/capabilities.mjs +2 -1
- package/lib/cards.mjs +66 -0
- package/lib/cics-commands.mjs +136 -125
- package/lib/compliance.mjs +6 -5
- package/lib/control.mjs +295 -6
- package/lib/dataflow.mjs +72 -88
- package/lib/diff.mjs +65 -89
- package/lib/embedded-sql.mjs +110 -0
- package/lib/equivalence.mjs +30 -12
- package/lib/evidence/journal.mjs +11 -2
- package/lib/evidence/record.mjs +3 -1
- package/lib/evidence/slsa.mjs +17 -2
- package/lib/execution.mjs +77 -0
- package/lib/explain.mjs +5 -4
- package/lib/ftp.mjs +136 -0
- package/lib/inventory.mjs +8 -9
- package/lib/ironwork.mjs +4 -6
- package/lib/jcl.mjs +29 -54
- package/lib/kernel/git.mjs +60 -0
- package/lib/kernel/identity.mjs +7 -6
- package/lib/kernel/source-tree.mjs +204 -34
- package/lib/layout.mjs +202 -0
- package/lib/parser.mjs +58 -207
- package/lib/precompile.mjs +5 -88
- package/lib/revision.json +1 -1
- package/lib/sbom.mjs +125 -13
- package/lib/scan.mjs +11 -4
- package/lib/sets/build.mjs +1 -1
- package/lib/sets/cics.mjs +32 -5
- package/lib/sets/copybook.mjs +2 -1
- package/lib/sets/hidden.mjs +4 -8
- package/lib/sets/jcl.mjs +17 -120
- package/lib/sets/priv.mjs +1 -1
- package/lib/sets/recon.mjs +3 -2
- package/lib/sets/semantics.mjs +1 -1
- package/lib/sets/vendor.mjs +1 -1
- package/lib/sets/zowe.mjs +34 -8
- package/lib/site.mjs +17 -7
- package/lib/sources.mjs +82 -38
- package/lib/utilities.mjs +146 -4
- package/package.json +1 -1
- package/rules/compliance-cobit2019.json +2438 -0
- package/rules/compliance-dora.json +1 -1
- package/rules/compliance-ffiec.json +1 -1
- package/rules/compliance-nist80053.json +1 -1
- package/schema/cobolwork.policy.schema.json +94 -17
package/lib/equivalence.mjs
CHANGED
|
@@ -50,12 +50,18 @@ function closure(root, file, idx, systemDirs) {
|
|
|
50
50
|
return out;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// Programs the change edits or
|
|
54
|
-
// each side's source digest and the copybooks that changed
|
|
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
|
|
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
|
-
|
|
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 (
|
|
81
|
-
const [an, ae, cn, ce, body = ''] =
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 };
|
package/lib/evidence/journal.mjs
CHANGED
|
@@ -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 ?
|
|
30
|
+
let prev = line ? ledgerTip(line, path) : null;
|
|
22
31
|
const fd = openAppend(path);
|
|
23
32
|
try {
|
|
24
33
|
const out = [];
|
package/lib/evidence/record.mjs
CHANGED
|
@@ -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');
|
package/lib/evidence/slsa.mjs
CHANGED
|
@@ -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) =>
|
|
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 === '
|
|
16
|
-
if (kind
|
|
17
|
-
|
|
18
|
-
return { text:
|
|
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 {
|
|
3
|
-
import {
|
|
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 =
|
|
38
|
+
const s = tree.text(f);
|
|
40
39
|
src = s.text;
|
|
41
|
-
if (s.encoding === 'ebcdic') { out.summary.filesEbcdic++; out.ebcdic.push(
|
|
42
|
-
} catch (e) { out.summary.filesUnreadable++; out.unreadable.push(`${
|
|
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 =
|
|
47
|
+
res = tree.parse(f, src);
|
|
49
48
|
} catch (e) {
|
|
50
49
|
out.summary.filesUnreadable++;
|
|
51
|
-
out.unreadable.push(`${
|
|
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:
|
|
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
|
|
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.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
|
|
150
|
-
const
|
|
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 = [];
|