@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,146 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Db2 for z/OS CREATE VIEW, after the Db2 13 SQL Reference: the view's name and columns, its common
3
+ // table expressions and fullselect kept as tokens, the check option, and the names its FROM clauses read.
4
+
5
+ import { Db2Syntax } from '../cursor.mjs';
6
+ import { readName } from './table.mjs';
7
+
8
+ const isName = (t) => !!t && (t.t === 'word' || t.t === 'ident');
9
+ const isWord = (t, u) => !!t && t.t === 'word' && (Array.isArray(u) ? u.includes(t.u) : t.u === u);
10
+ const isOpen = (t) => !!t && t.t === 'op' && t.v === '(';
11
+ const isClose = (t) => !!t && t.t === 'op' && t.v === ')';
12
+
13
+ // What another dialect's view looks like at the token where a Db2 for z/OS one stops.
14
+ function hint(t, after) {
15
+ if (!t) return '';
16
+ if (t.t === 'op' && t.v === '/') return " ('/' qualifies names in Db2 for i system naming, not Db2 for z/OS)";
17
+ if (t.t !== 'word') return '';
18
+ if (t.u === 'IF' && (isWord(after, 'NOT') || isWord(after, 'EXISTS'))) return ' (IF [NOT] EXISTS is not Db2 for z/OS syntax)';
19
+ if (t.u === 'MATERIALIZED') return ' (Db2 for z/OS has materialized query tables, made by CREATE TABLE, not materialized views)';
20
+ if (t.u === 'READ' && isWord(after, 'ONLY')) return ' (WITH READ ONLY is Oracle, not Db2 for z/OS)';
21
+ if (t.u === 'CONSTRAINT') return ' (a named check option is Oracle, not Db2 for z/OS)';
22
+ if (t.u === 'WITH' && isOpen(after)) return ' (view options in parentheses are not Db2 for z/OS syntax)';
23
+ return '';
24
+ }
25
+
26
+ function refuse(c, what) {
27
+ const t = c.peek();
28
+ throw new Db2Syntax(`expected ${what}${t ? ` at '${t.v ?? t.t}'` : ' at end of statement'}${hint(t, c.peek(1))}`, t || c.peek(-1));
29
+ }
30
+
31
+ function closing(toks, i) {
32
+ let depth = 0;
33
+ for (let k = i; k < toks.length; k++) {
34
+ if (isOpen(toks[k])) depth++;
35
+ else if (isClose(toks[k]) && --depth === 0) return k;
36
+ }
37
+ return toks.length;
38
+ }
39
+
40
+ const atTop = (toks, words) => {
41
+ let depth = 0;
42
+ return toks.some((t) => {
43
+ if (isOpen(t)) depth++;
44
+ else if (isClose(t)) depth--;
45
+ return depth === 0 && isWord(t, words);
46
+ });
47
+ };
48
+
49
+ // The words that end a FROM clause; FOR does too unless a period specification follows the table.
50
+ const FROM_END = ['WHERE', 'GROUP', 'HAVING', 'ORDER', 'FETCH', 'OFFSET', 'LIMIT', 'UNION', 'EXCEPT', 'INTERSECT', 'WITH', 'OPTIMIZE', 'QUERYNO', 'SKIP'];
51
+
52
+ // One FROM clause from i: a name that starts a table reference is recorded; TABLE (…), XMLTABLE (…) and
53
+ // the like are read only for the subqueries inside them. Returns the index where the clause ends.
54
+ function fromClause(toks, i, out) {
55
+ let start = true;
56
+ for (; i < toks.length; i++) {
57
+ const t = toks[i];
58
+ if (isWord(t, FROM_END) || (isWord(t, 'FOR') && !isWord(toks[i + 1], ['SYSTEM_TIME', 'BUSINESS_TIME']))) return i;
59
+ if ((t.t === 'op' && t.v === ',') || isWord(t, 'JOIN')) { start = true; continue; }
60
+ if (isOpen(t)) {
61
+ const j = closing(toks, i);
62
+ const inner = toks.slice(i + 1, j);
63
+ if (start && !atTop(inner, ['SELECT', 'UNION', 'EXCEPT', 'INTERSECT'])) fromClause(inner, 0, out);
64
+ else fromNames(inner, out);
65
+ i = j;
66
+ start = false;
67
+ continue;
68
+ }
69
+ if (!start) continue;
70
+ start = false;
71
+ if (!isName(t) || isWord(t, ['TABLE', 'FINAL', 'OLD']) || isOpen(toks[i + 1])) continue;
72
+ const parts = [t.v];
73
+ while (toks[i + 1] && toks[i + 1].t === 'op' && toks[i + 1].v === '.' && isName(toks[i + 2])) { parts.push(toks[i + 2].v); i += 2; }
74
+ out.push(parts.join('.'));
75
+ }
76
+ return i;
77
+ }
78
+
79
+ // The names in the FROM clause of every subselect in toks, subqueries included; a FROM that no SELECT
80
+ // at its own depth precedes (EXTRACT (YEAR FROM D), IS DISTINCT FROM) is not one.
81
+ function fromNames(toks, out) {
82
+ let select = false;
83
+ for (let i = 0; i < toks.length; i++) {
84
+ const t = toks[i];
85
+ if (isOpen(t)) {
86
+ const j = closing(toks, i);
87
+ fromNames(toks.slice(i + 1, j), out);
88
+ i = j;
89
+ } else if (isWord(t, 'SELECT')) select = true;
90
+ else if (isWord(t, 'FROM') && select && !isWord(toks[i - 1], 'DISTINCT')) i = fromClause(toks, i + 1, out) - 1;
91
+ }
92
+ }
93
+
94
+ function readColumnNames(c) {
95
+ return c.items().map((item) => {
96
+ if (item.length !== 1 || !isName(item[0])) throw new Db2Syntax(`expected an unqualified column name${item[0] ? ` at '${item[0].v}'` : ''}`, item[0] || c.peek(-1));
97
+ return item[0].v;
98
+ });
99
+ }
100
+
101
+ function readCommonTableExpression(c) {
102
+ const t = c.next();
103
+ if (!isName(t)) { c.pos -= t ? 1 : 0; refuse(c, 'a common table expression name'); }
104
+ const cte = { name: t.v, columns: c.isOp('(') ? readColumnNames(c) : null, fullselect: null };
105
+ c.expectWord('AS');
106
+ if (!c.isOp('(')) refuse(c, "'(' and the common table expression's fullselect");
107
+ cte.fullselect = c.group();
108
+ return cte;
109
+ }
110
+
111
+ function readView(c) {
112
+ c.expectWord('CREATE');
113
+ if (c.isWord('OR') && c.isWord('REPLACE', 1)) refuse(c, 'VIEW (Db2 for z/OS has no CREATE OR REPLACE VIEW)');
114
+ if (!c.isWord('VIEW')) refuse(c, 'VIEW');
115
+ c.next();
116
+ if (c.isWord('IF') && c.isWord(['NOT', 'EXISTS'], 1)) refuse(c, 'a view name');
117
+ const parts = readName(c);
118
+ if (parts.length > 3) refuse(c, 'a view name of at most three parts');
119
+ const node = { kind: 'CREATE VIEW', name: parts.join('.'), columns: null, with: [], fullselect: null, checkOption: null, tables: [] };
120
+ if (c.isOp('(')) node.columns = readColumnNames(c);
121
+ if (!c.word('AS')) refuse(c, node.columns ? 'AS' : "'(' or AS");
122
+ if (c.word('WITH')) {
123
+ if (c.isWord('RECURSIVE')) refuse(c, 'a common table expression (WITH RECURSIVE is not Db2 for z/OS syntax)');
124
+ do node.with.push(readCommonTableExpression(c)); while (c.op(','));
125
+ }
126
+ if (!c.isWord('SELECT') && !c.isOp('(')) refuse(c, 'a fullselect');
127
+ node.fullselect = c.until((t) => isWord(t, 'WITH'));
128
+ if (c.word('WITH')) {
129
+ const how = c.word(['CASCADED', 'LOCAL']);
130
+ if (!c.isWord('CHECK')) refuse(c, how ? 'CHECK OPTION' : 'CASCADED, LOCAL or CHECK OPTION');
131
+ c.next();
132
+ c.expectWord('OPTION');
133
+ node.checkOption = how ? how.u : 'CASCADED';
134
+ if (!c.done()) refuse(c, 'the end of the statement after CHECK OPTION');
135
+ }
136
+ const names = [];
137
+ for (const cte of node.with) fromNames(cte.fullselect, names);
138
+ fromNames(node.fullselect, names);
139
+ const local = new Set(node.with.map((cte) => cte.name.toUpperCase()));
140
+ node.tables = [...new Set(names)].filter((n) => n.includes('.') || !local.has(n.toUpperCase()));
141
+ return node;
142
+ }
143
+
144
+ export const parsers = {
145
+ 'CREATE VIEW': (c) => readView(c),
146
+ };
package/lib/diff.mjs CHANGED
@@ -9,7 +9,7 @@ import { FLOW_MODEL, SCHEMA_VERSION, TOOL_VERSION } from './version.mjs';
9
9
  // A diff finding is located by program rather than by line, so it orders on its own key. The text
10
10
  // comparison underneath it is the shared one.
11
11
  import { byText, evidenceMap } from './kernel/findings.mjs';
12
- import { directoryTree, gitTree } from './kernel/source-tree.mjs';
12
+ import { directoryTree, gitTree, pdsExportRevision, pdsExportTree } from './kernel/source-tree.mjs';
13
13
  import { printable } from './kernel/printable.mjs';
14
14
  import { loadSite, SITE_FILE } from './site.mjs';
15
15
  import { loadBaseline, BASELINE_FILE, SUPPRESSING } from './baseline.mjs';
@@ -258,6 +258,7 @@ function siteChange(baseAt, headAt) {
258
258
  if (b.allowUnvalidatedPacks !== h.allowUnvalidatedPacks) parts.push(`allowUnvalidatedPacks ${h.allowUnvalidatedPacks}`);
259
259
  if (JSON.stringify(b.runtimeVersions) !== JSON.stringify(h.runtimeVersions)) parts.push('runtimeVersions');
260
260
  if (h.problems.length && h.problems.join() !== b.problems.join()) parts.push(`now reports: ${h.problems[0]}`);
261
+ if (h.warnings.length && h.warnings.join() !== b.warnings.join()) parts.push(`now warns: ${h.warnings[0]}`);
261
262
  return parts.length ? { file: SITE_FILE, change: printable(parts.join('; '), 400) } : null;
262
263
  }
263
264
 
@@ -307,14 +308,25 @@ export function withRefs(repo, baseRef, headRef, opts, fn) {
307
308
  }
308
309
  }
309
310
 
311
+ // The members of a PDS export's working tree whose files git tracks or would track: an archive's
312
+ // members by the archive's file.
313
+ function exportScope(repo, tree) {
314
+ const scope = gitScope(repo);
315
+ return new Set(tree.list().filter((p) => scope.has(resolve(repo, tree.origin(p).replace(/\([^)]*\)$/, '')))));
316
+ }
317
+
310
318
  // Reads each revision out of git without writing it anywhere; the head is the working tree when
311
- // headRef is null.
319
+ // headRef is null. With pdsExport each side is read as a PDS export, members named for their data
320
+ // sets on both.
312
321
  export function diffRefs(repo, baseRef, headRef = null, opts = {}) {
313
322
  const trees = { systemDirs: opts.systemDirs || [] };
314
- const base = gitTree(repo, baseRef, trees);
315
- const head = headRef ? gitTree(repo, headRef, trees) : repo;
316
- const res = diffTrees(base, head, headRef ? opts : { ...opts, allow: gitScope(repo) });
323
+ const revision = (ref) => (opts.pdsExport ? pdsExportRevision(repo, ref, trees) : gitTree(repo, ref, trees));
324
+ const base = revision(baseRef);
325
+ const head = headRef ? revision(headRef) : opts.pdsExport ? pdsExportTree(repo, trees) : repo;
326
+ const allow = headRef ? null : opts.pdsExport ? exportScope(repo, head) : gitScope(repo);
327
+ const res = diffTrees(base, head, allow ? { ...opts, allow } : opts);
317
328
  res.summary.base = baseRef;
318
329
  res.summary.head = headRef || WORKING_TREE;
330
+ if (opts.pdsExport) res.summary.pdsExport = { base: base.export, head: head.export };
319
331
  return res;
320
332
  }
@@ -1,15 +1,26 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // `cobolwork evidence verify|seal|anchor`: the command half of docs/spec/evidence.md §7-9. Returns
3
3
  // the exit status; writes one JSON document through `write`.
4
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { anchorGit, envelopeFor, seal, sealFiles, timeStampRequest } from './seal.mjs';
7
7
  import { parseAllowedSigners } from './sshsig.mjs';
8
8
  import { EvidenceRefusal, createExclusive, evidenceDirFrom, syncClose, writeAll } from './store.mjs';
9
9
  import { verifyEvidence } from './verify.mjs';
10
+ import { EVIDENCE_RESULT_SCHEMA_VERSION } from '../version.mjs';
11
+ import { evidenceClausesFor } from '../compliance.mjs';
10
12
 
11
13
  const VERBS = ['verify', 'seal', 'anchor', 'sign'];
12
14
 
15
+ // The clauses a verified directory's evidence answers: a run journal's records, and the chain once
16
+ // it verifies.
17
+ function complianceOf(dir, verdict) {
18
+ const runs = join(dir, 'runs');
19
+ const journals = existsSync(runs) && readdirSync(runs).some((n) => n.endsWith('.jsonl'));
20
+ const present = journals ? ['journal-records', ...(verdict.verified ? ['journal-chain'] : [])] : [];
21
+ return present.flatMap((evidenceId) => evidenceClausesFor(evidenceId).map((c) => ({ evidenceId, ...c })));
22
+ }
23
+
13
24
  function anchorNewest(dir, opts) {
14
25
  const seals = sealFiles(dir);
15
26
  if (!seals.length) throw new EvidenceRefusal('there is no seal to anchor; run evidence seal first');
@@ -46,7 +57,7 @@ export function evidenceCommand(verb, opts, { toolVersion, write, env = process.
46
57
  }
47
58
  const dir = evidenceDirFrom(opts, env);
48
59
  if (!dir) throw new EvidenceRefusal('evidence needs --evidence <dir> or COBOLWORK_EVIDENCE');
49
- const emit = (doc) => write(`${JSON.stringify({ tool: 'cobolwork-evidence', verb, ...doc }, null, opts.quiet ? 0 : 1)}\n`);
60
+ const emit = (doc) => write(`${JSON.stringify({ tool: 'cobolwork-evidence', schemaVersion: EVIDENCE_RESULT_SCHEMA_VERSION, verb, ...doc }, null, opts.quiet ? 0 : 1)}\n`);
50
61
 
51
62
  if (verb === 'verify') {
52
63
  const allowed = opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null;
@@ -56,7 +67,7 @@ export function evidenceCommand(verb, opts, { toolVersion, write, env = process.
56
67
  const file = (x) => (x ? resolve(x) : null);
57
68
  const cosign = opts.cosignBundle ? { bundle: file(opts.cosignBundle), key: file(opts.cosignKey), identity: opts.certificateIdentity || null, issuer: opts.certificateOidcIssuer || null, trustedRoot: file(opts.trustedRoot), ignoreTlog: opts.insecureIgnoreTlog === true } : null;
58
69
  const verdict = verifyEvidence(dir, { allowed, anchorGit: file(opts.anchorGit), anchorRef: opts.ref || null, anchorPin: opts.anchorPin || null, tsr: file(opts.tsr), tsaCa: file(opts.tsaCa), cosign, maxUnsealed: max, env });
59
- emit(verdict);
70
+ emit({ ...verdict, compliance: complianceOf(dir, verdict) });
60
71
  return verdict.exit;
61
72
  }
62
73
  if (verb === 'seal') {
@@ -33,7 +33,7 @@ export const KINDS = {
33
33
  verdict: { fields: { verdict: str, checks: any, relaxed: strings, exit: int }, required: ['verdict'] },
34
34
  output: { fields: { name: str, sha256: hex64, bytes: int, path: str, stdout: (x) => x === true }, required: ['name', 'sha256'] },
35
35
  // ironwork's run journal closes with the program's true RETURN-CODE, which may be negative.
36
- close: { fields: { exit: (x) => x === null || Number.isSafeInteger(x), counts: any, durationMs: int, ledger: str }, required: ['exit'] },
36
+ close: { fields: { exit: (x) => x === null || Number.isSafeInteger(x), counts: any, durationMs: int, ledger: str, executor: (x) => ['vm', 'interpreter'].includes(x) }, required: ['exit'] },
37
37
  // Written by ironwork's run journal, in this format, so one verifier reads both tools.
38
38
  dd: { fields: { dd: str, event: (x) => ['open', 'close', 'end'].includes(x), mode: str, sha256: hex64, bytes: int }, required: ['dd', 'event'] },
39
39
  call: { fields: { program: str, from: str, sha256: hex64 }, required: ['program'] },
@@ -1,8 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // The evidence directory on disk: where it may be, how its files are opened (never through a
3
3
  // symbolic link, owner-only), the ledger lock, and reading a chain file back line by line.
4
- import { randomBytes } from 'node:crypto';
5
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs';
4
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs';
6
5
  import { basename, dirname, join, resolve, sep } from 'node:path';
7
6
 
8
7
  const { O_WRONLY, O_CREAT, O_EXCL, O_APPEND, O_NOFOLLOW } = constants;
@@ -139,47 +138,61 @@ export function lastLine(path) {
139
138
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
140
139
  const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
141
140
 
142
- // Moves the stale lock `seen` aside before removing it, so a lock a peer took after it was read is
143
- // put back instead of removed. Returns whether the stale lock was the one broken.
144
- export function breakStale(path, seen) {
145
- const aside = `${path}.${process.pid}.${randomBytes(4).toString('hex')}`;
146
- try { renameSync(path, aside); } catch { return false; }
147
- let same = false;
148
- try { same = statSync(aside).ino === seen.ino && readFileSync(aside, 'utf8') === seen.text; } catch { /* not the same */ }
149
- if (!same) { try { linkSync(aside, path); } catch { /* a newer lock is in place */ } }
150
- try { unlinkSync(aside); } catch { /* already gone */ }
151
- return same;
141
+ // A lock file as read: its inode and text, the pid it names and its age, or null when there is none.
142
+ // A lock whose pid and time cannot be read was left by a writer that died between creating it and
143
+ // writing them; it is aged from its modification time.
144
+ function readLock(path, now) {
145
+ let fd;
146
+ try { fd = openRead(path); } catch { return null; }
147
+ try {
148
+ const st = fstatSync(fd);
149
+ const text = readAll(fd).toString('utf8');
150
+ const [p, t] = text.trim().split(/\s+/).map(Number);
151
+ const pid = Number.isSafeInteger(p) && p > 0 ? p : null;
152
+ const since = Number.isSafeInteger(t) && t > 0 ? t : null;
153
+ return { ino: st.ino, text, pid, age: pid !== null && since !== null ? now() - since : now() - st.mtimeMs };
154
+ } finally { closeSync(fd); }
152
155
  }
153
156
 
154
- // Takes ledger.lock, breaking one that is older than staleMs and whose holder is gone. A lock whose
155
- // pid and time cannot be read was left by a writer that died between creating it and writing them;
156
- // it is aged from its modification time. Returns { release, broken } or null when the lock could
157
- // not be had within waitMs.
157
+ const isStale = (lock, staleMs) => lock !== null && lock.age > staleMs && (lock.pid === null || !pidAlive(lock.pid));
158
+
159
+ // Replaces the stale lock `seen` with this writer's own. Breakers hold ledger.lock.break while they
160
+ // break, so one breaks at a time, and rename it over the lock only while the lock is still the file
161
+ // judged stale: a lock a peer took since is never moved. Returns whether this writer holds the lock.
162
+ export function breakStale(path, seen, { staleMs = 60000, now = Date.now } = {}) {
163
+ const claim = `${path}.break`;
164
+ let fd;
165
+ try { fd = createExclusive(claim); } catch (e) {
166
+ if (e.code !== 'EEXIST') throw e;
167
+ if (isStale(readLock(claim, now), staleMs)) { try { unlinkSync(claim); } catch { /* already gone */ } }
168
+ return false;
169
+ }
170
+ writeAll(fd, `${process.pid} ${now()}\n`);
171
+ closeSync(fd);
172
+ const still = readLock(path, now);
173
+ if (still && still.ino === seen.ino && still.text === seen.text) {
174
+ try { renameSync(claim, path); return true; } catch { /* the claim is removed below */ }
175
+ }
176
+ try { unlinkSync(claim); } catch { /* already gone */ }
177
+ return false;
178
+ }
179
+
180
+ // Takes ledger.lock, breaking one that is older than staleMs and whose holder is gone. Returns
181
+ // { release, broken } or null when the lock could not be had within waitMs.
158
182
  export function takeLock(path, { waitMs = 5000, staleMs = 60000, now = Date.now } = {}) {
159
183
  const deadline = now() + waitMs;
160
- let broken = null;
184
+ const held = (broken) => ({ release: () => { try { unlinkSync(path); } catch { /* already gone */ } }, broken });
161
185
  for (;;) {
162
186
  try {
163
187
  const fd = createExclusive(path);
164
188
  writeAll(fd, `${process.pid} ${now()}\n`);
165
189
  closeSync(fd);
166
- return { release: () => { try { unlinkSync(path); } catch { /* already gone */ } }, broken };
190
+ return held(null);
167
191
  } catch (e) {
168
192
  if (e.code !== 'EEXIST') throw e;
169
193
  }
170
- let st = null;
171
- let text = null;
172
- try { st = statSync(path); text = readFileSync(path, 'utf8'); } catch { /* removed between the open and the read */ }
173
- if (text !== null) {
174
- const [p, t] = text.trim().split(/\s+/).map(Number);
175
- const pid = Number.isSafeInteger(p) && p > 0 ? p : null;
176
- const since = Number.isSafeInteger(t) && t > 0 ? t : null;
177
- const age = pid !== null && since !== null ? now() - since : now() - st.mtimeMs;
178
- if (age > staleMs && (pid === null || !pidAlive(pid)) && breakStale(path, { text, ino: st.ino })) {
179
- broken = { holderPid: pid, ageMs: Math.max(0, Math.trunc(age)) };
180
- continue;
181
- }
182
- }
194
+ const seen = readLock(path, now);
195
+ if (isStale(seen, staleMs) && breakStale(path, seen, { staleMs, now })) return held({ holderPid: seen.pid, ageMs: Math.max(0, Math.trunc(seen.age)) });
183
196
  if (now() >= deadline) return null;
184
197
  sleep(25);
185
198
  }
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // One reading of an EXEC block for every rule set and the flow engine, and the one the precompiler's
3
+ // table names: each option's argument tokens, every word in order, and for EXEC CICS the command the
4
+ // table names it and the direction each option's data moves. Cached per block, so a worker that
5
+ // rebuilds a program's parse reads each block exactly as the main thread does.
6
+ import { cicsCommand } from './precompile-cics.mjs';
7
+ import { CICS_COMMANDS, CICS_EVERY_COMMAND } from './cics-commands.mjs';
8
+
9
+ const readings = new WeakMap();
10
+
11
+ // `opts` maps each option to the tokens inside its parentheses, the first time it is written; `words`
12
+ // is every word of the block. `verb` and `sub` are the command's first two words: the table's name
13
+ // where it has one (SEND MAP however its options are ordered), else the block's first two words.
14
+ export function execReading(exec) {
15
+ let r = readings.get(exec);
16
+ if (r) return r;
17
+ const toks = exec.toks;
18
+ const opts = new Map();
19
+ const words = [];
20
+ const top = [];
21
+ let depth = 0;
22
+ for (let i = 0; i < toks.length; i++) {
23
+ const t = toks[i];
24
+ if (t.t === 'sep') { depth += t.v === '(' ? 1 : t.v === ')' ? -1 : 0; continue; }
25
+ if (t.t !== 'word') continue;
26
+ words.push(t.u);
27
+ if (depth === 0) top.push({ word: t.u });
28
+ if (toks[i + 1] && toks[i + 1].t === 'sep' && toks[i + 1].v === '(') {
29
+ const inner = [];
30
+ let d = 1;
31
+ for (let k = i + 2; k < toks.length && d > 0; k++) {
32
+ if (toks[k].t === 'sep') { d += toks[k].v === '(' ? 1 : -1; if (d === 0) break; continue; }
33
+ inner.push(toks[k]);
34
+ }
35
+ if (!opts.has(t.u)) opts.set(t.u, inner);
36
+ }
37
+ }
38
+ const command = exec.kind === 'CICS' && top.length ? cicsCommand(top) : null;
39
+ const named = command ? command.split(' ') : [];
40
+ const entry = command ? CICS_COMMANDS[command] : null;
41
+ r = {
42
+ opts, words, command,
43
+ verb: named[0] || words[0],
44
+ sub: named.length > 1 ? named[1] : words[1],
45
+ // The way an option's data moves, as the table records it: sends, receives, both, label or none;
46
+ // null for an option or a command the table does not hold.
47
+ direction: (option) => (entry ? entry.options[option] || CICS_EVERY_COMMAND[option] || entry.anyOption || null : null),
48
+ };
49
+ readings.set(exec, r);
50
+ return r;
51
+ }
package/lib/execution.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Execution: whether the estate's own runs entered the paragraph a finding is in, from the
3
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).
4
+ // exercise; one in a paragraph no run entered is code they never reach. See docs/spec/evidence.md §13.5 (execution coverage).
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { basename, delimiter, join } from 'node:path';
7
7
 
@@ -11,7 +11,8 @@ export function executionFeedPaths(opts = {}) {
11
11
  }
12
12
 
13
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 }] }] }.
14
+ // Shape (ironwork's): { programs: [{ program, detail: [{ name, line, entered }] }] }, as
15
+ // schema/cobolwork-execution.schema.json describes it.
15
16
  export function loadExecutionFeed(path) {
16
17
  const feed = { file: basename(path), programs: new Map(), problem: null };
17
18
  let doc;
package/lib/explain.mjs CHANGED
@@ -8,6 +8,7 @@ import { REGISTRY } from './kernel/registry.mjs';
8
8
  import { WHO_ACTS } from './tui/model.mjs';
9
9
  import { verificationPlan } from './verify.mjs';
10
10
  import { cobolCard, statementCard } from './cards.mjs';
11
+ import { EXPLAIN_SCHEMA_VERSION } from './version.mjs';
11
12
 
12
13
  const HIDDEN_RULES = new Set(Object.keys(REGISTRY.find((s) => s.name === 'hidden').rules));
13
14
 
@@ -151,6 +152,7 @@ export function explainFinding(report, fingerprint, { root }) {
151
152
 
152
153
  return {
153
154
  tool: 'cobolwork-explain',
155
+ schemaVersion: EXPLAIN_SCHEMA_VERSION,
154
156
  carriesSource: true,
155
157
  note: NOTE,
156
158
  fingerprint,
@@ -13,6 +13,7 @@ import { kindsOf } from './consequence.mjs';
13
13
  import { WHO } from './sets/flow.mjs';
14
14
  import { entryName, accessOfEntry, accessFromEntry, privilegedFromEntry } from './reach.mjs';
15
15
  import { verifyEvidence } from './evidence/verify.mjs';
16
+ import { versionProblem, unknownKeys, ignoredKey } from './kernel/version-key.mjs';
16
17
 
17
18
  export const HANDLING = 'This report joins routes an attacker can drive with the estate\'s own access facts. It holds no source '
18
19
  + 'text, but it is a list of what to attack first: keep it where the access records it was built from may go, and do not '
@@ -20,7 +21,7 @@ export const HANDLING = 'This report joins routes an attacker can drive with the
20
21
 
21
22
  // A system response is not input, but the caller who provokes the failure is the one who reads it back.
22
23
  const DRIVER = { ...WHO, 'system-response': 'A web caller, who can provoke the failure and reads the code sent back' };
23
- const AT_REST = { 'file-record': 'the file record', database: 'the database row' };
24
+ const AT_REST = { 'file-record': 'the file record', 'cics-queue': 'the queue item', database: 'the database row' };
24
25
 
25
26
  const at = (x) => `${x.file}:${x.line}`;
26
27
 
@@ -167,6 +168,9 @@ export function witnessFeedPaths(opts = {}) {
167
168
  }
168
169
 
169
170
  const OUTCOMES = new Set(['reproduced', 'not-reproduced']);
171
+ export const WITNESS_VERSION = 1;
172
+ const WITNESS_KEYS = new Set(['$schema', 'version', 'witness', 'recorded', 'results']);
173
+ const RESULT_KEYS = new Set(['outcome', 'by', 'on', 'system', 'reference', 'evidence']);
170
174
  const str = (v) => typeof v === 'string' && v.length > 0;
171
175
  const isoDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
172
176
 
@@ -198,7 +202,7 @@ function witnessedRun(ev, base, verdicts) {
198
202
  // { witness, recorded, results: { FINGERPRINT: { outcome: "reproduced"|"not-reproduced", by, on, system, reference? } } }
199
203
  // Only those fields are read. What was sent to the system is not one of them.
200
204
  export function loadWitnessFeed(path, { root = null } = {}) {
201
- const feed = { file: basename(path), witness: null, recorded: null, results: new Map(), refused: [], problem: null };
205
+ const feed = { file: basename(path), witness: null, recorded: null, results: new Map(), refused: [], warnings: [], problem: null };
202
206
  let real;
203
207
  try { real = realpathSync(path); } catch (e) { feed.problem = `could not be read (${e.code || e.name})`; return feed; }
204
208
  if (root) {
@@ -207,11 +211,16 @@ export function loadWitnessFeed(path, { root = null } = {}) {
207
211
  }
208
212
  let doc;
209
213
  try { doc = JSON.parse(readFileSync(real, 'utf8')); } catch (e) { feed.problem = `is not JSON (${e.message})`; return feed; }
214
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { feed.problem = 'is not a JSON object'; return feed; }
215
+ feed.problem = versionProblem(doc, WITNESS_VERSION);
216
+ if (feed.problem) return feed;
210
217
  if (!str(doc.witness)) { feed.problem = 'does not say whose tests it holds'; return feed; }
211
218
  if (!isoDate(doc.recorded)) { feed.problem = 'does not say when it was recorded'; return feed; }
212
219
  if (!doc.results || typeof doc.results !== 'object' || Array.isArray(doc.results)) { feed.problem = 'holds no results'; return feed; }
213
220
  feed.witness = doc.witness;
214
221
  feed.recorded = doc.recorded;
222
+ const extra = new Set(Object.values(doc.results).flatMap((r) => (r && typeof r === 'object' ? unknownKeys(r, RESULT_KEYS) : [])));
223
+ feed.warnings = [...unknownKeys(doc, WITNESS_KEYS).map((k) => ignoredKey(feed.file, k)), ...[...extra].sort().map((k) => ignoredKey(`${feed.file} results`, k))];
215
224
  const verdicts = new Map();
216
225
  for (const [fingerprint, r] of Object.entries(doc.results)) {
217
226
  const why = !r || typeof r !== 'object' ? 'is not an object'
@@ -3,7 +3,7 @@
3
3
  // type and type extension, program type, length, scale and exponent modifiers, and nominal value.
4
4
  // https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=statements-dc-instruction
5
5
  import { HlasmSyntax, splitOperands } from '../operands.mjs';
6
- import { readExpression } from '../expr.mjs';
6
+ import { parseExpression, readExpression } from '../expr.mjs';
7
7
 
8
8
  const EXTENSIONS = {
9
9
  C: 'AEU', G: '', X: '', B: '', F: 'D', H: '', E: 'HBD', D: 'HBD', L: 'HBDQ', P: '', Z: '',
@@ -110,7 +110,19 @@ function operands(st, nominalRequired) {
110
110
  });
111
111
  }
112
112
 
113
+ // A channel command word: command code, data address, flags and data count, each an expression.
114
+ // https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=statements-ccw-ccw0-instructions
115
+ function ccw(st) {
116
+ const list = splitOperands(st.field || '');
117
+ if (list.length !== 4) throw new HlasmSyntax(`${st.operation} takes a command code, data address, flags and data count, not ${list.length} operands`);
118
+ const [command, address, flags, count] = list.map((text) => parseExpression(text.trim()));
119
+ return { kind: st.operation, command, address, flags, count };
120
+ }
121
+
113
122
  export const parsers = {
114
123
  DC: (st) => ({ kind: 'DC', operands: operands(st, true) }),
115
124
  DS: (st) => ({ kind: 'DS', operands: operands(st, false) }),
125
+ CCW: ccw,
126
+ CCW0: ccw,
127
+ CCW1: ccw,
116
128
  };
@@ -129,7 +129,9 @@ function dxd(st) {
129
129
 
130
130
  export const parsers = {
131
131
  CSECT: (st) => noOperand('CSECT', st, false),
132
- DSECT: (st) => noOperand('DSECT', st, true),
132
+ // An unnamed dummy section, like an unnamed COM, is defined when the name entry is omitted.
133
+ // https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=sections-unnamed-section
134
+ DSECT: (st) => noOperand('DSECT', st, false),
133
135
  RSECT: (st) => noOperand('RSECT', st, false),
134
136
  COM: (st) => noOperand('COM', st, false),
135
137
  START: (st) => {
@@ -27,6 +27,26 @@ function shapeOf(pattern) {
27
27
 
28
28
  export const formatFamily = (format) => String(format || '').replace(/-[a-z]$/, '');
29
29
 
30
+ // An explicit length is assembled into a length field: 8 bits for L, 4 bits for L1 and L2, each
31
+ // holding the length less one, so 0 to 256 and 0 to 16 bytes. Checked when the length is a
32
+ // constant; a symbol's value needs the symbol table.
33
+ const LENGTH_FIELD = { L: 256, L1: 16, L2: 16 };
34
+ function constant(e) {
35
+ if (!e) return null;
36
+ if (e.t === 'num') return e.v;
37
+ if (e.t === 'sdt' && e.type === 'X') return /^[0-9A-F]+$/i.test(e.text) ? parseInt(e.text, 16) : null;
38
+ if (e.t === 'sdt' && e.type === 'B') return /^[01]+$/.test(e.text) ? parseInt(e.text, 2) : null;
39
+ if (e.t === 'paren') return constant(e.e);
40
+ if (e.t === 'unary') { const v = constant(e.e); return v === null ? null : e.op === '-' ? -v : v; }
41
+ if (e.t === 'bin') {
42
+ const l = constant(e.l);
43
+ const r = constant(e.r);
44
+ if (l === null || r === null) return null;
45
+ return { '+': l + r, '-': l - r, '*': l * r, '/': r ? Math.trunc(l / r) : 0 }[e.op] ?? null;
46
+ }
47
+ return null;
48
+ }
49
+
30
50
  export function parseInstruction(st, row) {
31
51
  const shape = shapeOf(row.operands || '');
32
52
  // An instruction with no operands takes whatever follows it as remarks: SAM24 Back to AMODE 24.
@@ -42,6 +62,11 @@ export function parseInstruction(st, row) {
42
62
  if (!s.parts) return { operand: s.name, e: parseExpression(text) };
43
63
  const a = parseAddress(text);
44
64
  if (a.parts && a.parts.length > s.parts) throw new HlasmSyntax(`operand ${i + 1} of ${st.operation} (${s.name}) has ${a.parts.length} parts in parentheses, not at most ${s.parts}`);
65
+ const field = /^D\d*\((L\d?),/.exec(s.name)?.[1];
66
+ const length = field && a.parts ? constant(a.parts[0]) : null;
67
+ if (length !== null && (length < 0 || length > LENGTH_FIELD[field])) {
68
+ throw new HlasmSyntax(`operand ${i + 1} of ${st.operation} has length ${length}, and ${field} holds 0 to ${LENGTH_FIELD[field]}`);
69
+ }
45
70
  return { operand: s.name, ...a };
46
71
  });
47
72
  return { kind: 'INSTRUCTION', mnemonic: st.operation, format: row.format, length: row.length, operands };
@@ -1,18 +1,24 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Parsers for the z/OS authorization macros MODESET, TESTAUTH, and RACROUTE.
3
- import { HlasmSyntax, NAME } from '../operands.mjs';
3
+ import { HlasmSyntax, NAME, remoteForm } from '../operands.mjs';
4
+ import { MVS38_EXTKEYS, mvs38Form } from '../mvs38.mjs';
4
5
 
5
6
  const MODESET_EXTKEYS = new Set(['ZERO', 'TCB', 'RBT1', 'RBT234', 'KEY2', 'KEY3', 'KEY4', 'KEY7']);
6
7
  const MODESET_KEYS = new Set(['ZERO', 'NZERO']);
7
8
  const MODESET_MODES = new Set(['PROB', 'SUP']);
8
- const MODESET_KNOWN = new Set(['EXTKEY', 'KEYADDR', 'KEYREG', 'SAVEKEY', 'WORKREG', 'RELATED', 'KEY', 'MODE']);
9
+ const MODESET_KNOWN = new Set(['EXTKEY', 'KEYADDR', 'KEYREG', 'SAVEKEY', 'WORKREG', 'RELATED', 'KEY', 'MODE', 'MF']);
10
+ // The list and execute forms of the MODESET that generates an SVC (z/OS 3.1 MVS Programming:
11
+ // Authorized Assembler Services Reference, Volume 3): the list form holds KEY and MODE, the execute
12
+ // form names the list.
13
+ const MODESET_LIST_KNOWN = new Set(['KEY', 'MODE', 'RELATED', 'MF']);
14
+ const MODESET_EXECUTE_KNOWN = new Set(['RELATED', 'MF']);
9
15
 
10
16
  const TESTAUTH_KNOWN = new Set(['FCTN', 'STATE', 'KEY', 'RBLEVEL', 'BRANCH']);
11
17
  const TESTAUTH_STATES = new Set(['YES', 'NO']);
12
18
 
13
19
  const RACROUTE_REQUESTS = new Set(['AUTH', 'FASTAUTH', 'DEFINE', 'DIRAUTH', 'EXTRACT', 'LIST', 'SIGNON', 'STAT', 'TOKENBLD', 'TOKENMAP', 'TOKENXTR', 'VERIFY', 'VERIFYX', 'AUDIT']);
14
20
 
15
- function parseModeset(st, ops) {
21
+ function parseModeset(st, ops, opts) {
16
22
  const { keywords } = ops;
17
23
  const node = { kind: 'MODESET', key: null, mode: null, extkey: null, keywords: {} };
18
24
 
@@ -23,9 +29,23 @@ function parseModeset(st, ops) {
23
29
  node.keywords[k] = v;
24
30
  }
25
31
 
32
+ if (keywords.has('MF')) {
33
+ const { form, list } = remoteForm(keywords.get('MF'), 'MF');
34
+ const known = form === 'L' ? MODESET_LIST_KNOWN : MODESET_EXECUTE_KNOWN;
35
+ for (const k of keywords.keys()) {
36
+ if (!known.has(k)) throw new HlasmSyntax(`MODESET: the ${form === 'L' ? 'list' : 'execute'} form does not take ${k}`);
37
+ }
38
+ if (form === 'L' && !keywords.has('KEY') && !keywords.has('MODE')) throw new HlasmSyntax('MODESET: the list form needs KEY or MODE');
39
+ node.mf = form;
40
+ if (list) node.list = list;
41
+ }
42
+
26
43
  if (keywords.has('EXTKEY')) {
27
44
  const v = keywords.get('EXTKEY').trim().toUpperCase();
28
- if (!MODESET_EXTKEYS.has(v)) {
45
+ if (MVS38_EXTKEYS.has(v)) {
46
+ mvs38Form(opts, node, 'MODESET', `EXTKEY=${v}`);
47
+ node.pswKey = MVS38_EXTKEYS.get(v);
48
+ } else if (!MODESET_EXTKEYS.has(v)) {
29
49
  throw new HlasmSyntax(`MODESET: invalid EXTKEY value ${v}`);
30
50
  }
31
51
  node.extkey = v;