@portll/cobolwork 0.4.0 → 0.6.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 (78) hide show
  1. package/README.md +53 -5
  2. package/bin/cobolwork.mjs +34 -9
  3. package/lib/baseline.mjs +9 -0
  4. package/lib/bms.mjs +8 -0
  5. package/lib/build.mjs +33 -8
  6. package/lib/capabilities.mjs +6 -3
  7. package/lib/consequence.mjs +17 -0
  8. package/lib/constants.mjs +60 -0
  9. package/lib/control-reuse.mjs +113 -0
  10. package/lib/control.mjs +86 -18
  11. package/lib/csd.mjs +6 -1
  12. package/lib/dataflow.mjs +343 -95
  13. package/lib/equivalence.mjs +44 -5
  14. package/lib/evidence/cli.mjs +13 -5
  15. package/lib/evidence/record.mjs +16 -6
  16. package/lib/evidence/seal.mjs +17 -0
  17. package/lib/evidence/store.mjs +30 -14
  18. package/lib/evidence/timestamp.mjs +83 -0
  19. package/lib/evidence/verify.mjs +120 -42
  20. package/lib/hlasm/asm/data.mjs +116 -0
  21. package/lib/hlasm/asm/listing.mjs +43 -0
  22. package/lib/hlasm/asm/output.mjs +45 -0
  23. package/lib/hlasm/asm/sections.mjs +159 -0
  24. package/lib/hlasm/asm/symbols.mjs +78 -0
  25. package/lib/hlasm/exec.mjs +27 -0
  26. package/lib/hlasm/expr.mjs +167 -0
  27. package/lib/hlasm/instr.mjs +48 -0
  28. package/lib/hlasm/locate.mjs +333 -0
  29. package/lib/hlasm/macro/authorization.mjs +126 -0
  30. package/lib/hlasm/macro/datasets.mjs +94 -0
  31. package/lib/hlasm/macro/io.mjs +139 -0
  32. package/lib/hlasm/macro/le.mjs +53 -0
  33. package/lib/hlasm/macro/linkage.mjs +143 -0
  34. package/lib/hlasm/macro/operator.mjs +66 -0
  35. package/lib/hlasm/macro/program.mjs +142 -0
  36. package/lib/hlasm/macro/recovery.mjs +80 -0
  37. package/lib/hlasm/macro/storage.mjs +117 -0
  38. package/lib/hlasm/macro/structured.mjs +131 -0
  39. package/lib/hlasm/model.mjs +72 -0
  40. package/lib/hlasm/operands.mjs +49 -0
  41. package/lib/hlasm/read.mjs +116 -0
  42. package/lib/hlasm.mjs +79 -0
  43. package/lib/inventory.mjs +10 -7
  44. package/lib/ironwork-ids.mjs +24 -0
  45. package/lib/ironwork.mjs +4 -1
  46. package/lib/kernel/registry.mjs +24 -17
  47. package/lib/kernel/shared-pass.mjs +138 -0
  48. package/lib/options.mjs +26 -4
  49. package/lib/policy.mjs +6 -0
  50. package/lib/precompile-cics.mjs +1 -1
  51. package/lib/revision.json +1 -1
  52. package/lib/sarif.mjs +3 -0
  53. package/lib/scan.mjs +21 -11
  54. package/lib/sets/abend.mjs +115 -13
  55. package/lib/sets/build.mjs +43 -37
  56. package/lib/sets/cics.mjs +5 -3
  57. package/lib/sets/compile.mjs +5 -3
  58. package/lib/sets/copybook.mjs +5 -3
  59. package/lib/sets/crypto.mjs +110 -0
  60. package/lib/sets/flow.mjs +32 -7
  61. package/lib/sets/hlasm.mjs +177 -0
  62. package/lib/sets/jcl.mjs +15 -1
  63. package/lib/sets/log.mjs +5 -3
  64. package/lib/sets/priv.mjs +5 -3
  65. package/lib/sets/secrets.mjs +110 -0
  66. package/lib/sets/semantics.mjs +5 -3
  67. package/lib/sets/web.mjs +104 -7
  68. package/lib/sources.mjs +12 -2
  69. package/package.json +1 -1
  70. package/rules/compliance-cobit2019.json +216 -0
  71. package/rules/compliance-dora.json +234 -0
  72. package/rules/compliance-ffiec.json +234 -0
  73. package/rules/compliance-nist80053.json +234 -0
  74. package/rules/gitleaks-mainframe.toml +61 -16
  75. package/rules/hlasm-instructions.json +2396 -0
  76. package/rules/hlasm-operations.json +451 -0
  77. package/rules/icsf-services.json +1178 -0
  78. package/schema/cobolwork.policy.schema.json +7 -0
package/README.md CHANGED
@@ -63,6 +63,19 @@ request and writes SARIF for code scanning: [docs/github-action.md](docs/github-
63
63
  | JCL | credentials and security commands in in-stream data, destructive statements, `DLM=` tricks, FTP in cleartext or sending production data, production data touched by a test job |
64
64
  | The source | names nothing declares (code that cannot compile), shadowed copybooks, payloads hidden in columns 73-80 or aimed at AI readers |
65
65
  | The estate | production names outside production jobs, routable addresses, compiler and runtime versions with published advisories |
66
+ | Assembler | a switch to key zero or supervisor state, an instruction run through `EX`, cross-memory calls, a module named at run time, the security product called directly, and the CSECT or ENTRY a COBOL `CALL` or a job step reaches |
67
+ | Cryptography | a single-length DES key, an MD5 or SHA-1 hash, or a fixed initialization vector asked of ICSF, read from IBM's parameter lists; an outbound CICS connection asking for HTTP |
68
+ | Secrets | a credential written into a program or copybook: a literal `VALUE` on an item named for one, or a literal password in `EXEC SQL CONNECT` or `EXEC CICS SIGNON` |
69
+
70
+ HLASM is read, not assembled. Each statement is parsed by what its operation is: a machine
71
+ instruction against IBM's operand syntax for its mnemonic (`rules/hlasm-instructions.json`, every
72
+ z/Architecture mnemonic with its page in the Principles of Operation), an assembler instruction,
73
+ or a z/OS, Language Environment or HLASM Toolkit macro against the keywords IBM lists. The location
74
+ and length of each symbol is worked out as the assembler would, and nothing after a statement of
75
+ unknown length is placed. Macros are not expanded and conditional assembly is not evaluated, so an
76
+ operation a site macro issues is seen where the macro is defined, and not where it is used. A scan
77
+ names each macro it did not expand, each COPY member the tree does not hold, and each statement it
78
+ could not read.
66
79
 
67
80
  [docs/rule-sets.md](docs/rule-sets.md) describes each in full, with what it deliberately leaves out.
68
81
  Vendor packs for CA ACF2 and Top Secret, Control-M and Connect:Direct load only for estates that
@@ -107,6 +120,15 @@ that caller's storage. A value the subprogram keeps in its own storage can reach
107
120
  Findings carry no source text, so a report can be stored and passed on without carrying the code
108
121
  with it.
109
122
 
123
+ `trace` is one route, the shortest, and sources that reach one sink are merged into one finding,
124
+ counted in `sources`. With `--all-routes`, `scan` and `flow` give each path finding `routes`: every
125
+ source, and every statement on any route from one of them to the sink, by verb, file and line.
126
+ `complete` is false where the walk stopped at its budget or a step has no statement the engine can
127
+ place. It leaves out no statement on a route the engine follows, and may name one no route takes:
128
+ what a reader needs before saying a run covered every route (`docs/spec/reach.md` §9.8). Routes
129
+ through what the engine does not read, such as an unparsed program or a caller written in another
130
+ language, are not in it.
131
+
110
132
  `fingerprint` is what the finding is, rather than where it is printed today: the rule, the program
111
133
  and the paragraph or section it sits in (the job, step and DD for JCL), and the flagged statement's
112
134
  own text. No line number goes into it, so code added above a finding does not change it. `diff`
@@ -199,8 +221,13 @@ entered ([docs/spec/evidence.md](docs/spec/evidence.md) §13.5).
199
221
 
200
222
  `COBOLWORK_ABENDS` names fuzz runs ironwork's harness wrote. Each abend an input caused becomes a
201
223
  finding at the line it happened, with the input and the run's journal, once that journal verifies:
202
- `input-causes-abend-s0c7`, `-s0c4`, `-subscript-range`, or `input-causes-abend` for any other code
203
- ([docs/spec/evidence.md](docs/spec/evidence.md) §13.6).
224
+ `input-causes-abend-s0c7`, `-s0c4` (in a CICS task, the ASRA whose message names that check),
225
+ `-subscript-range`, `input-causes-hang` for an input that keeps a loop running past the statement limit (S322), `input-selects-program`
226
+ for an S806 whose journal shows the input reaching the CALL, or `input-causes-abend` for any other
227
+ code ([docs/spec/evidence.md](docs/spec/evidence.md) §13.6). It reads manifests in format
228
+ `ironwork-fuzz/v1`, and `ironwork-fuzz-interface/v1` from a subprogram fuzzed with generated
229
+ arguments, whose findings say no caller run shows a caller passes them and warn by default; it
230
+ reports one in any other format as a problem instead of guessing at it.
204
231
 
205
232
  ### What each finding lets someone do, and the fix
206
233
 
@@ -328,11 +355,32 @@ stand-in in `diag/precompiler.mjs`, which rewrites what the parser would otherwi
328
355
  tests compare the parser with the compiler's answers kept in `test/fixtures/parser/*.golden.json`,
329
356
  so they run without GnuCOBOL.
330
357
 
331
- `bench/cases/` holds 102 CWE-labelled cases, each paired with a near-miss negative: the same shape
358
+ HLASM is graded against z390, an HLASM-compatible assembler, by `diag/hlasm-oracle.mjs` and
359
+ `diag/hlasm-grade-z390.mjs`. Each file is assembled twice, the second time with sixteen bytes after
360
+ every macro call, and only values the two runs agree on are graded. None of them depends on how
361
+ z390's macros, or IBM's, expand. Measured 2026-10-03 over the files z390 assembles:
362
+
363
+ | Corpus | Files | Symbol locations | Statement locations | Instruction lengths | External names |
364
+ |---|---|---|---|---|---|
365
+ | 12 repositories, dev | 48 | 935 agree of 936, 0 differ | 2,511 of 2,546, 0 differ | 2,273 of 2,273 | 55 of 55 |
366
+ | 17 repositories, held out | 76 | 546 agree of 557, 0 differ | 1,631 of 1,675, 0 differ | 1,710 of 1,710 | 90 of 92 |
367
+
368
+ A value not agreeing was not placed, never placed differently: it follows a statement of unknown
369
+ length. Two of z390's values are not graded, because it differs from the Language Reference, which
370
+ the reader follows: an EQU's length, where z390 gives 1 and the reference the length of the
371
+ leftmost term; and anything after a literal pool where z390 pads a literal that the reference packs.
372
+ A file with an error that can move a location is not graded either, such as an undefined symbol in
373
+ a statement holding a literal, which z390 then leaves out of the pool. Of the statements outside conditional
374
+ assembly, 99.5% parse on the dev corpus and 99.7% on the held-out one; the rest are counted by
375
+ kind. `bench/hlasm-locate/` holds small programs, one assembler feature each, with z390's answers
376
+ recorded beside them, so the tests check the locator without z390.
377
+
378
+ `bench/cases/` holds 137 CWE-labelled cases, each paired with a near-miss negative: the same shape
332
379
  with the flaw removed. `node bench/run.mjs` scores any scanner's findings against them, by rule and
333
380
  file, never by line, and `npm test` fails if any case scores differently from its declaration.
334
- `--validate` compiles every COBOL case with GnuCOBOL and checks every JCL case against the
335
- statement grammar, a weaker witness, and says so.
381
+ `--validate` compiles every COBOL case with GnuCOBOL, assembles every HLASM case with z390 when
382
+ `Z390` names a release (stubbing the macros z390 does not ship), and checks every JCL case against
383
+ the statement grammar, a weaker witness, and says so.
336
384
 
337
385
  ## Coverage on a busy machine
338
386
 
package/bin/cobolwork.mjs CHANGED
@@ -19,7 +19,7 @@ import { stampFingerprints } from '../lib/kernel/identity.mjs';
19
19
  import { pdsExportTree } from '../lib/kernel/source-tree.mjs';
20
20
  import { SITE_FILE } from '../lib/site.mjs';
21
21
  import { tally } from '../lib/kernel/findings.mjs';
22
- import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, SUPPRESSING } from '../lib/baseline.mjs';
22
+ import { loadBaseline, applyBaseline, baselineEntries, BASELINE_FILE, BASELINE_VERSION, SUPPRESSING } from '../lib/baseline.mjs';
23
23
  import { readReport } from '../lib/tui/model.mjs';
24
24
  import { nodeTerminal } from '../lib/tui/terminal.mjs';
25
25
  import { runTui } from '../lib/tui/run.mjs';
@@ -28,7 +28,6 @@ import { printable } from '../lib/kernel/printable.mjs';
28
28
  import { startEvidence, recordInputs, recordHashed, recordFindings, recordOutput, recordVerdict, recordBaselineWrite, finishEvidence } from '../lib/evidence/run.mjs';
29
29
  import { evidenceCommand } from '../lib/evidence/cli.mjs';
30
30
  import { slsaStatement } from '../lib/evidence/slsa.mjs';
31
- import { parseAllowedSigners } from '../lib/evidence/sshsig.mjs';
32
31
 
33
32
  const STARTED = new Date().toISOString();
34
33
 
@@ -80,6 +79,7 @@ Options
80
79
  --rules-path gitleaks print the path of the mainframe credential rules and exit
81
80
  --quiet summary only
82
81
  --full-trace list every hop of a data flow path, not its two ends
82
+ --all-routes give each data flow finding every statement on any route from its sources (scan, flow)
83
83
  --base <ref> diff: the git revision to compare against
84
84
  --head <ref> diff: the revision under review (default: the working tree)
85
85
  --baseline <file> judgements to apply; without it, ${BASELINE_FILE} in the scanned tree
@@ -107,18 +107,29 @@ Options
107
107
  the SLSA Provenance v1 predicate (unsigned; the pipeline signs it)
108
108
  --artifact <path>[,<path>] build: what the compiler produced, named as subjects of the SLSA statement
109
109
  --equivalence <file>[,<file>] build --base: ironwork equivalence statements for the programs the
110
- change edits; with --allowed-signers each must be signed by one. The policy's
111
- requireEquivalence (never, machineAuthored, always) says when one is required
110
+ change edits, each signed by one of --allowed-signers. The policy's
111
+ requireEquivalence (never, machineAuthored, always) says when one is required;
112
+ where one may be, a build with no --allowed-signers fails the check
112
113
  --ironwork <path> build: after a pass, run ironwork check on every program, for an estate that
113
114
  compiles with IBM Enterprise COBOL; a program ironwork rejects exits 4, one it
114
115
  does not model yet leaves the build undecided
115
116
  --evidence <dir> scan, flow, diff, gate, build, baseline, inventory: record this run in a
116
117
  hash-chained journal and ledger there (or COBOLWORK_EVIDENCE); never inside
117
118
  the tree being read
118
- --allowed-signers <file> evidence verify: OpenSSH allowed_signers for namespace cobolwork-evidence
119
+ --allowed-signers <file> evidence verify and build: OpenSSH allowed_signers for namespace
120
+ cobolwork-evidence; build refuses one inside the repository
119
121
  --anchor-git <repo>, --ref <ref>, --push, --max-unsealed <n>
120
122
  evidence: the git witness, the ref that counts (default @{upstream}), whether
121
123
  anchor pushes, and how many ledger records may follow the newest seal
124
+ --anchor-pin <commit> evidence verify: the witness commit an earlier verify reported as
125
+ witnessCommit; the witness must still hold every seal it held
126
+ --tsq <file>, --tsr <file>, --tsa-ca <file>
127
+ evidence anchor writes an RFC 3161 request (and keeps it beside the seal);
128
+ verify reads the response, checking imprint and nonce, and has OpenSSL check
129
+ the authority's signature against the CA certificate
130
+ --cosign-bundle <file> with --cosign-key <file> or --certificate-identity <id> and
131
+ --certificate-oidc-issuer <url>, [--trusted-root <file>] [--insecure-ignore-tlog]
132
+ evidence verify: a transparency-log bundle over a seal, checked by cosign
122
133
 
123
134
  Exit codes: 0 the command ran, 2 it could not run. A run that examined nothing says so in
124
135
  summary.filesScanned and summary.nosrc rather than reporting a clean zero.
@@ -145,6 +156,7 @@ function parseArgs(argv) {
145
156
  else if (a === '--rules-path') opts.rulesPath = argv[++i] || 'gitleaks';
146
157
  else if (a === '--quiet') opts.quiet = true;
147
158
  else if (a === '--full-trace') opts.fullTrace = true;
159
+ else if (a === '--all-routes') opts.allRoutes = true;
148
160
  else if (a === '--base') opts.base = value();
149
161
  else if (a === '--head') opts.head = value();
150
162
  else if (a === '--baseline') opts.baseline = value();
@@ -180,6 +192,15 @@ function parseArgs(argv) {
180
192
  else if (a === '--push') opts.push = true;
181
193
  else if (a === '--max-unsealed') opts.maxUnsealed = value();
182
194
  else if (a === '--tsq') opts.tsq = value();
195
+ else if (a === '--anchor-pin') opts.anchorPin = value();
196
+ else if (a === '--tsr') opts.tsr = value();
197
+ else if (a === '--tsa-ca') opts.tsaCa = value();
198
+ else if (a === '--cosign-bundle') opts.cosignBundle = value();
199
+ else if (a === '--certificate-identity') opts.certificateIdentity = value();
200
+ else if (a === '--certificate-oidc-issuer') opts.certificateOidcIssuer = value();
201
+ else if (a === '--cosign-key') opts.cosignKey = value();
202
+ else if (a === '--trusted-root') opts.trustedRoot = value();
203
+ else if (a === '--insecure-ignore-tlog') opts.insecureIgnoreTlog = true;
183
204
  else if (a === '--expect-key') opts.expectKey = value();
184
205
  else if (a === '--help' || a === '-h') opts.help = true;
185
206
  else if (a === '--version' || a === '-v') opts.version = true;
@@ -250,6 +271,10 @@ if (opts.fullTrace && opts._.length && !TRACE_COMMANDS.includes(opts._[0])) {
250
271
  `);
251
272
  process.exit(2);
252
273
  }
274
+ if (opts.allRoutes && opts._.length && !['scan', 'flow'].includes(opts._[0])) {
275
+ process.stderr.write(`cobolwork: --all-routes is for scan and flow\n`);
276
+ process.exit(2);
277
+ }
253
278
  const ADVISORY_COMMANDS = ['scan', 'baseline', 'tui', 'explain', 'build'];
254
279
  if (opts.advisoryFeeds && opts._.length && !ADVISORY_COMMANDS.includes(opts._[0])) {
255
280
  process.stderr.write(`cobolwork: ${opts._[0]} reads no advisories; ${ADVISORY_COMMANDS.join(', ')} do\n`);
@@ -291,7 +316,7 @@ if (opts.evidence !== undefined && opts._.length && ![...JOURNALED, 'evidence'].
291
316
  process.stderr.write(`cobolwork: ${opts._[0]} records no evidence; ${JOURNALED.join(', ')} and evidence do\n`);
292
317
  process.exit(2);
293
318
  }
294
- const evidenceFlag = ['sshKey', 'signer', 'allowedSigners', 'anchorGit', 'ref', 'push', 'maxUnsealed', 'tsq', 'expectKey'].find((k) => opts[k] !== undefined);
319
+ const evidenceFlag = ['sshKey', 'signer', 'allowedSigners', 'anchorGit', 'ref', 'push', 'maxUnsealed', 'tsq', 'expectKey', 'anchorPin', 'tsr', 'tsaCa', 'cosignBundle', 'certificateIdentity', 'certificateOidcIssuer', 'cosignKey', 'trustedRoot', 'insecureIgnoreTlog'].find((k) => opts[k] !== undefined);
295
320
  if (opts.equivalence && !opts.base) { process.stderr.write('cobolwork: --equivalence judges a change; it needs --base\n'); process.exit(2); }
296
321
  if (evidenceFlag && opts._.length && opts._[0] !== 'evidence' && !(evidenceFlag === 'allowedSigners' && opts._[0] === 'build')) {
297
322
  process.stderr.write(`cobolwork: --${evidenceFlag.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} is for evidence only\n`);
@@ -340,7 +365,7 @@ try {
340
365
  } else if (command === 'evidence') {
341
366
  process.exitCode = evidenceCommand(target, opts, { toolVersion: VERSION, write: (s) => process.stdout.write(s) });
342
367
  } else if (command === 'scan' || command === 'flow') {
343
- const flowOpts = { repos, fullTrace: opts.fullTrace === true, systemDirs };
368
+ const flowOpts = { repos, fullTrace: opts.fullTrace === true, allRoutes: opts.allRoutes === true, systemDirs };
344
369
  // The site file sits beside the members and is not one of them, so it is named rather than found.
345
370
  const site = resolve(root, SITE_FILE);
346
371
  const pds = opts.pdsExport ? pdsExportTree(root, { systemDirs }) : null;
@@ -387,7 +412,7 @@ try {
387
412
  if (opts.only || opts.repos) { process.stderr.write('cobolwork: build judges one repository with every rule set; --only and --repos do not apply\n'); process.exit(2); }
388
413
  if (opts.baseline) { process.stderr.write('cobolwork: build reads the baseline the change was written against; --baseline does not apply, --no-baseline does\n'); process.exit(2); }
389
414
  if (opts.head && !opts.base) { process.stderr.write('cobolwork: build --head needs --base\n'); process.exit(2); }
390
- const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, ironwork: opts.ironwork || null, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs, equivalence: opts.equivalence || [], allowed: opts.allowedSigners ? parseAllowedSigners(readFileSync(resolve(opts.allowedSigners), 'utf8')) : null });
415
+ const result = build(root, { base: opts.base || null, head: opts.head || null, policy: opts.policy || null, noBaseline: opts.noBaseline === true, compiler: compilerArgv, ironwork: opts.ironwork || null, advisoryFeeds: opts.advisoryFeeds || null, copylibs: systemDirs, equivalence: opts.equivalence || [], allowedSigners: opts.allowedSigners || null });
391
416
  stampRevisions(result.doc.summary, opts.head || null);
392
417
  Object.assign(result.report.summary, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
393
418
  Object.assign(result.provenance, { toolRevision: result.doc.summary.toolRevision, revision: result.doc.summary.revision });
@@ -430,7 +455,7 @@ try {
430
455
  if (held.problems.length) { process.stderr.write(`cobolwork: ${path} holds entries that do not validate, so it is left as it is:\n ${held.problems.join('\n ')}\n`); process.exit(2); }
431
456
  const report = scanAll(root, { repos, only: opts.only, advisoryFeeds: opts.advisoryFeeds, systemDirs });
432
457
  const { entries, added } = baselineEntries(report.findings, held.entries, { action, reason: opts.reason, who: opts.who, expires: expires.toISOString(), at, rules: opts.rule || null });
433
- const written = JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', entries }, null, 1) + '\n';
458
+ const written = JSON.stringify({ _comment: 'Judgements over cobolwork findings, matched by fingerprint. A suppression lapses at its expires date and the finding comes back.', version: BASELINE_VERSION, entries }, null, 1) + '\n';
434
459
  writeFileSync(path, written);
435
460
  recordInputs(journal, 0, root);
436
461
  recordFindings(journal, report);
package/lib/baseline.mjs CHANGED
@@ -22,6 +22,7 @@ import { resolvesInside } from './kernel/source-tree.mjs';
22
22
  import { printable } from './kernel/printable.mjs';
23
23
 
24
24
  export const BASELINE_FILE = 'cobolwork.baseline.json';
25
+ export const BASELINE_VERSION = 1;
25
26
  export const SUPPRESSING = ['accept', 'false-positive', 'wont-fix', 'incorrect-scan-result'];
26
27
  export const ACTIONS = [...SUPPRESSING, 'note', 'resolved'];
27
28
 
@@ -75,6 +76,14 @@ export function loadBaseline(root, { explicit = null, use = true, mayBeAbsent =
75
76
  out.problems.push(`${path} is not readable as JSON: ${printable(err.message, 120)}`);
76
77
  return out;
77
78
  }
79
+ // A file with no version predates versioning and is read as version 0, which holds the same entries.
80
+ const version = raw && typeof raw === 'object' && raw.version !== undefined ? raw.version : 0;
81
+ if (!Number.isInteger(version) || version < 0) { out.problems.push(`${path} has version ${printable(JSON.stringify(version), 40)}, which is not a whole number from 0`); return out; }
82
+ if (version > BASELINE_VERSION) {
83
+ out.problems.push(`${path} is baseline version ${version}, and this cobolwork reads up to version ${BASELINE_VERSION}; upgrade cobolwork, so nothing was suppressed`);
84
+ return out;
85
+ }
86
+ out.version = BASELINE_VERSION;
78
87
  const entries = Array.isArray(raw?.entries) ? raw.entries : null;
79
88
  if (!entries) { out.problems.push(`${path} has no entries array`); return out; }
80
89
  entries.forEach((e, i) => {
package/lib/bms.mjs CHANGED
@@ -55,6 +55,13 @@ const sublist = (v) => {
55
55
  return (inner ? splitOperands(inner[1]) : [v]).map((x) => x.trim()).filter(Boolean);
56
56
  };
57
57
 
58
+ // L'NAME is NAME's length, and T', S', I', K', N', D' and O' its other attributes: the quote after the
59
+ // letter opens no string when the letter starts a term and a symbol follows. CL8'TEXT' and D'1.5'
60
+ // are constants, and their quotes do open one.
61
+ // https://www.ibm.com/docs/en/hla-and-tf/1.6.0?topic=instructions-data-attributes
62
+ const attributeReference = (text, i) => /[LTSIKNDO]/i.test(text[i - 1] || '')
63
+ && !/[A-Z0-9$#@_]/i.test(text[i - 2] || '') && /[A-Z$#@_&]/i.test(text[i + 1] || '');
64
+
58
65
  // Folds physical lines into statements. A map continues a statement in two ways, often both in one
59
66
  // file. An operand that fills the line to column 71 carries on in column 16 of the next line, which
60
67
  // is how a long INITIAL literal is split. A comma followed by a blank ends the line's operands and
@@ -78,6 +85,7 @@ export function foldStatements(src) {
78
85
  for (let i = from; i < text.length; i++) {
79
86
  const c = text[i];
80
87
  if (o.quoted) { o.st.field += c; if (c === "'") o.quoted = false; continue; }
88
+ if (c === "'" && attributeReference(text, i)) { o.st.field += c; continue; }
81
89
  if (c === "'") { o.quoted = true; o.st.field += c; continue; }
82
90
  if (c === ' ' || c === '\t') {
83
91
  o.reading = o.st.field.endsWith(',');
package/lib/build.mjs CHANGED
@@ -7,12 +7,13 @@ import { optionChanges } from './option-diff.mjs';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
- import { basename, extname, join } from 'node:path';
10
+ import { basename, extname, join, resolve } from 'node:path';
11
+ import { parseAllowedSigners } from './evidence/sshsig.mjs';
11
12
  import { scanAll } from './scan.mjs';
12
13
  import { withRefs, configurationChanges, WORKING_TREE } from './diff.mjs';
13
14
  import { pairLists, findExecutable } from './gate.mjs';
14
15
  import { pairingKeys } from './kernel/identity.mjs';
15
- import { directoryTree } from './kernel/source-tree.mjs';
16
+ import { directoryTree, resolvesInside } from './kernel/source-tree.mjs';
16
17
  import { printable } from './kernel/printable.mjs';
17
18
  import { ALL_RULES } from './kernel/registry.mjs';
18
19
  import { loadBaseline, applyBaseline, SUPPRESSING } from './baseline.mjs';
@@ -21,7 +22,8 @@ import { isProgram, isJcl, isSource, readSource, relPath } from './sources.mjs';
21
22
  import { parseJclFile } from './jcl.mjs';
22
23
  import { POLICY_FILE, treePolicy, floorPolicy, combinePolicies, canonical, policyHash } from './policy.mjs';
23
24
  import { classesOf, tierOf, TIERS, TIER_RANK } from './consequence.mjs';
24
- import { optionCards, enterpriseChecks, compilerChecks, scriptedChecks, compilerInvocations, compilerTasks, compileStepOptions, compilerOf } from './options.mjs';
25
+ import { optionCards, enterpriseChecks, compilerChecks, scriptedChecks, compilerInvocations, compilerTasks, compileStepOptions, compilerOf, olderThan } from './options.mjs';
26
+ import { isBuildFile, pinsIn } from './sets/build.mjs';
25
27
  import { FLOW_MODEL, TOOL_VERSION } from './version.mjs';
26
28
  import { checkWithIronwork, ironworkVerdict, ironworkReasons, ironworkVersion, listed } from './ironwork.mjs';
27
29
  import { buildFileIndex } from './parser.mjs';
@@ -62,8 +64,11 @@ function introducedIn(before, after, baseRoot, headRoot) {
62
64
  // Why a finding blocks, or null. `renewed` is a finding whose waiver expired or ran past maxDays: in
63
65
  // ratchet mode it is judged as new, because the promise that carried it has lapsed.
64
66
  export function blockingReason(f, { policy, ratchet, introduced, renewed }) {
67
+ if (f.abend?.inputFrom === 'interface' && policy.interface !== 'tier') return null;
65
68
  const setting = policy.rules[f.rule];
66
69
  if (setting === 'warn') return null;
70
+ // A rule whose firing rate the corpus has not recorded blocks only where the policy names it.
71
+ if (ALL_RULES[f.rule]?.measured === false) return setting === 'block' ? 'rule' : null;
67
72
  const tier = tierOf(f);
68
73
  if (ratchet && TIER_RANK[tier] >= TIER_RANK[policy.always]) return 'tier';
69
74
  if (ratchet && !introduced.has(f) && !renewed.has(f.fingerprint)) return null;
@@ -115,6 +120,19 @@ function declaredCompiles(root, files) {
115
120
 
116
121
  const memberName = (file) => basename(file).replace(/\.[^.]*$/, '').toUpperCase();
117
122
 
123
+ // The GnuCOBOL version the repository's build files pin, the oldest where they pin several.
124
+ function pinnedGnucobol(root, files) {
125
+ let oldest = null;
126
+ for (const file of files.filter(isBuildFile)) {
127
+ let text;
128
+ try { text = readSource(file).text; } catch { continue; }
129
+ for (const p of pinsIn(text)) {
130
+ if (p.product === 'gnucobol' && (!oldest || olderThan(p.version, oldest.version))) oldest = { version: p.version, where: relPath(root, file) };
131
+ }
132
+ }
133
+ return oldest;
134
+ }
135
+
118
136
  // The options every program in the tree is compiled with, held to the policy. A program's options
119
137
  // come from the estate's declared defaults, the JCL step that compiles it and its own option cards;
120
138
  // where it has none of those, from the cobc and gcobol commands the repository's build scripts run.
@@ -127,13 +145,14 @@ function optionsCheck({ root, allow, site, policy, compiler }) {
127
145
  if (compiler && !kind) {
128
146
  return { ok: null, forbidden: 0, reasons: [`the gate reads the options of cobc, of gcobol and of Enterprise COBOL option cards; for ${printable(compiler[0], 60)} it cannot tell what reaches the compiler`], add: [], generated: new Map() };
129
147
  }
148
+ const tree = directoryTree(root);
149
+ const files = [...new Set(tree.list())].filter((p) => !allow || allow.has(p)).sort();
150
+ const pinned = pinnedGnucobol(root, files);
130
151
  if (compiler) {
131
- const r = compilerChecks(kind, compiler.slice(1), { required, forbid: policy.forbid[kind] });
152
+ const r = compilerChecks(kind, compiler.slice(1), { required, forbid: policy.forbid[kind], pinned });
132
153
  const missing = r.checks.filter((c) => c.ok === false).map((c) => c.why);
133
154
  return { ok: missing.length || r.forbidden.length ? false : true, forbidden: r.forbidden.length, reasons: [...r.forbidden, ...missing], add: r.forbidden.length ? [] : r.add, compiler: kind, generated: new Map() };
134
155
  }
135
- const tree = directoryTree(root);
136
- const files = [...new Set(tree.list())].filter((p) => !allow || allow.has(p)).sort();
137
156
  const programs = files.filter(isProgram);
138
157
  const declared = declaredCompiles(root, files);
139
158
  let ok = true;
@@ -150,7 +169,7 @@ function optionsCheck({ root, allow, site, policy, compiler }) {
150
169
  };
151
170
 
152
171
  for (const inv of declared.invocations) {
153
- const r = scriptedChecks(inv.compiler, inv.args, { required, forbid: policy.forbid[inv.compiler] });
172
+ const r = scriptedChecks(inv.compiler, inv.args, { required, forbid: policy.forbid[inv.compiler], pinned });
154
173
  record(inv.file, r.checks);
155
174
  forbidden += r.forbidden.length;
156
175
  for (const why of [...r.checks.filter((c) => c.ok === false).map((c) => c.why), ...r.forbidden]) fail(`${inv.file}:${inv.line}: ${why}`);
@@ -364,9 +383,15 @@ function policyChanged(baseDir, headDir) {
364
383
 
365
384
  // Runs the gate over `repo`. `compiler` is the argument vector after `--`, or null. Returns
366
385
  // { doc, report, provenance, exit }; throws with code EBUILD when it could not run.
367
- export function build(repo, { base = null, head = null, policy = null, noBaseline = false, compiler = null, ironwork = null, advisoryFeeds = null, copylibs = [], equivalence = [], allowed = null, now = new Date().toISOString(), env = process.env } = {}) {
386
+ export function build(repo, { base = null, head = null, policy = null, noBaseline = false, compiler = null, ironwork = null, advisoryFeeds = null, copylibs = [], equivalence = [], allowed: given = null, allowedSigners = null, now = new Date().toISOString(), env = process.env } = {}) {
368
387
  const floor = policy ? floorPolicy(policy, repo) : null;
369
388
  if (floor && floor.problems.length) throw refusal(floor.problems.join('; '));
389
+ let allowed = given;
390
+ if (allowedSigners) {
391
+ const path = resolve(allowedSigners);
392
+ if (resolvesInside(repo, path)) throw refusal(`--allowed-signers ${path} is inside the repository; signers the change can edit vouch for nothing`);
393
+ allowed = parseAllowedSigners(readFileSync(path, 'utf8'));
394
+ }
370
395
  if (compiler && ironwork) throw refusal('one compiler per build: -- names one and --ironwork another');
371
396
  let compilerPath = null;
372
397
  if (compiler) {
@@ -12,13 +12,14 @@ import { VERDICT_EXIT } from './gate.mjs';
12
12
  import { CHECKS, COMPILERS, DEFAULT_POLICY } from './policy.mjs';
13
13
  import { CLASSES, TIERS } from './consequence.mjs';
14
14
  import { toolRevision } from './revision.mjs';
15
+ import { IRONWORK_FORMATS, IRONWORK_MINIMUM, NOT_RUN_ABENDS, RUN_ENDINGS } from './ironwork-ids.mjs';
15
16
 
16
17
  export const CAPABILITIES_SCHEMA_VERSION = 1;
17
18
 
18
19
  // Each command, what it takes and what it writes.
19
20
  export const COMMANDS = {
20
- scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
21
- flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
21
+ scan: { args: ['<path>'], options: ['--format', '--out', '--repos', '--only', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--advisories', '--copylib', '--pds-export', '--evidence'], documents: ['cobolwork', 'sarif'] },
22
+ flow: { args: ['<path>'], options: ['--out', '--repos', '--quiet', '--full-trace', '--all-routes', '--baseline', '--no-baseline', '--copylib', '--evidence'], documents: ['cobolwork-flow'] },
22
23
  inventory: { args: ['<path>'], options: ['--out', '--quiet', '--copylib', '--evidence'], documents: ['cobolwork-inventory'] },
23
24
  parse: { args: ['<file>'], options: ['--out'], documents: ['cobolwork-parse'] },
24
25
  sbom: { args: ['<path>'], options: ['--name', '--out', '--quiet', '--copylib', '--evidence'], documents: ['cyclonedx'] },
@@ -28,7 +29,7 @@ export const COMMANDS = {
28
29
  explain: { args: ['<path>', '<fingerprint>'], options: ['--report', '--out', '--baseline', '--no-baseline', '--advisories', '--copylib'], documents: ['cobolwork-explain'] },
29
30
  gate: { args: ['<repo>'], options: ['--base', '--head', '--target', '--cobc', '--exit-code', '--target-only', '--out', '--evidence'], documents: ['cobolwork-gate'] },
30
31
  build: { args: ['<repo>', '[-- <compiler> <arg>...]'], options: ['--base', '--head', '--policy', '--provenance', '--provenance-format', '--artifact', '--equivalence', '--allowed-signers', '--ironwork', '--advisories', '--copylib', '--no-baseline', '--format', '--out', '--evidence'], documents: ['cobolwork-build', 'cobolwork-build-provenance', 'sarif'] },
31
- evidence: { args: ['verify|seal|anchor|sign', '[statement]'], options: ['--evidence', '--ssh-key', '--signer', '--expect-key', '--allowed-signers', '--anchor-git', '--ref', '--push', '--max-unsealed', '--tsq', '--out', '--quiet'], documents: ['cobolwork-evidence'] },
32
+ evidence: { args: ['verify|seal|anchor|sign', '[statement]'], options: ['--evidence', '--ssh-key', '--signer', '--expect-key', '--allowed-signers', '--anchor-git', '--ref', '--anchor-pin', '--push', '--max-unsealed', '--tsq', '--tsr', '--tsa-ca', '--cosign-bundle', '--cosign-key', '--certificate-identity', '--certificate-oidc-issuer', '--trusted-root', '--insecure-ignore-tlog', '--out', '--quiet'], documents: ['cobolwork-evidence'] },
32
33
  capabilities: { args: [], options: ['--json'], documents: ['cobolwork-capabilities'] },
33
34
  };
34
35
 
@@ -81,5 +82,7 @@ export function capabilities() {
81
82
  exit: { ...BUILD_EXIT, couldNotRun: 2 },
82
83
  },
83
84
  gate: { exit: { ...VERDICT_EXIT, couldNotRun: 2 } },
85
+ // The ironwork releases cobolwork reads: from `minimum` on, each format from the release it names.
86
+ ironwork: { minimum: IRONWORK_MINIMUM, formats: IRONWORK_FORMATS, runEndings: RUN_ENDINGS, notRunAbends: NOT_RUN_ABENDS },
84
87
  };
85
88
  }
@@ -69,6 +69,7 @@ const RULE_CLASSES = {
69
69
  'vendor-privileged-command': [ESCALATES],
70
70
  'jcl-instream-security-command': [ESCALATES],
71
71
  'jcl-instream-credential': [ESCALATES],
72
+ 'credential-in-source': [ESCALATES],
72
73
  'web-uri-carries-a-credential': [ESCALATES],
73
74
  'log-writes-a-credential': [ESCALATES],
74
75
  'zowe-config-secret-in-clear': [ESCALATES],
@@ -122,8 +123,24 @@ const RULE_CLASSES = {
122
123
  // have landed, where the input chose.
123
124
  'input-causes-abend-s0c7': [],
124
125
  'input-causes-abend': [],
126
+ 'input-causes-hang': [],
127
+ 'input-selects-program': [ESCALATES],
125
128
  'input-causes-abend-s0c4': [MUTATES],
126
129
  'input-causes-abend-subscript-range': [MUTATES],
130
+ // Key zero or supervisor state is the operating system's authority, taken by the code itself.
131
+ 'hlasm-supervisor-state-change': [ESCALATES],
132
+ // Nothing says input reaches EX's register, or which address space a cross-memory call reaches:
133
+ // the linkage tables grant that, and the source does not show them. Severity decides.
134
+ 'hlasm-executes-built-instruction': [],
135
+ 'hlasm-cross-memory-service': [],
136
+ 'hlasm-runtime-module-name': [ESCALATES],
137
+ // Disclosure in transit; severity decides.
138
+ 'web-client-opens-cleartext': [],
139
+ 'web-receive-length-exceeds-area': [MUTATES],
140
+ // Weak cryptography discloses or lets data be changed undetected; severity decides.
141
+ 'icsf-single-length-des-key': [],
142
+ 'icsf-weak-hash': [],
143
+ 'icsf-fixed-initialization-vector': [],
127
144
  };
128
145
 
129
146
  // Coverage and context assert no defect, so they carry no consequence.
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // The literals that can be in a data item when a statement reads it, from the program's text: its
3
+ // VALUE clause and its subordinates', each MOVE and STRING into it or into a group holding it, and a
4
+ // hop or two back through an item those name. Anything else that can write it - a computation, a
5
+ // READ or ACCEPT INTO, a CALL it is passed to by reference, a MOVE from a field with no constant -
6
+ // leaves it open, which a caller reads as "and something this cannot see".
7
+ const FIGURATIVE = /^(LOW-VALUES?|HIGH-VALUES?|SPACES?|ZEROS?|ZEROES|QUOTES?|NULLS?)$/;
8
+ const WRITERS = new Set(['COMPUTE', 'ADD', 'SUBTRACT', 'MULTIPLY', 'DIVIDE', 'READ', 'ACCEPT', 'UNSTRING', 'INITIALIZE', 'SET', 'RETURN', 'INSPECT']);
9
+ const NOT_OPERANDS = new Set(['TO', 'INTO', 'DELIMITED', 'BY', 'SIZE', 'OF', 'IN', 'ALL']);
10
+
11
+ // The item, its subordinates and the groups that hold it.
12
+ function family(p, name) {
13
+ const self = p.items.find((x) => x.name === name);
14
+ if (!self) return null;
15
+ const under = [];
16
+ const down = (it) => { for (const c of it.children || []) { if (c.level === 88) continue; under.push(c); down(c); } };
17
+ down(self);
18
+ const over = [];
19
+ for (let up = self.parent; up; up = up.parent) over.push(up);
20
+ return { self, under, over };
21
+ }
22
+
23
+ const nameOf = (t) => (typeof t === 'string' ? t : t && t.u);
24
+
25
+ const valuesOf = (item) => (item.values || []).map((v) => (v.t === 'lit' ? String(v.v) : FIGURATIVE.test(v.u || '') ? v.u : null)).filter((v) => v !== null);
26
+
27
+ // `ignoreCall(call)` names calls that read the item and do not write it, such as the one being judged.
28
+ export function constantsReaching(p, name, { depth = 2, ignoreCall = () => false } = {}, seen = new Set()) {
29
+ const out = { values: [], open: false };
30
+ if (seen.has(name)) return out;
31
+ seen.add(name);
32
+ const f = family(p, name);
33
+ if (!f) return { values: [], open: true };
34
+ const names = new Set([name, ...f.under.map((x) => x.name), ...f.over.map((x) => x.name)]);
35
+ out.values.push(...valuesOf(f.self), ...f.under.flatMap(valuesOf));
36
+ for (const st of p.statements || []) {
37
+ if (!(st.targets || []).some((t) => names.has(nameOf(t)))) continue;
38
+ if (st.verb === 'MOVE' || st.verb === 'STRING') {
39
+ for (const t of st.sources || []) if (t && t.t === 'lit') out.values.push(String(t.v));
40
+ for (const l of st.literals || []) out.values.push(String(l && l.v !== undefined ? l.v : l));
41
+ for (const w of (st.sources || []).filter((t) => !(t && t.t === 'lit')).map(nameOf).filter((x) => x && !NOT_OPERANDS.has(x))) {
42
+ if (FIGURATIVE.test(w) || /^[+-]?\d/.test(w) || names.has(w)) { if (FIGURATIVE.test(w)) out.values.push(w); continue; }
43
+ if (depth === 0) { out.open = true; continue; }
44
+ const r = constantsReaching(p, w, { depth: depth - 1, ignoreCall }, seen);
45
+ out.values.push(...r.values);
46
+ if (r.open || !r.values.length) out.open = true;
47
+ }
48
+ continue;
49
+ }
50
+ if (WRITERS.has(st.verb)) out.open = true;
51
+ }
52
+ for (const c of p.calls || []) {
53
+ if (ignoreCall(c)) continue;
54
+ if ((c.using || []).some((u) => names.has(u.word) && u.mode !== 'CONTENT' && u.mode !== 'VALUE')) out.open = true;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // A rule array is eight-byte keywords side by side; one held in several fields reads the same.
60
+ export const keywordsOf = (values) => new Set(values.flatMap((v) => (FIGURATIVE.test(v) ? [] : String(v).match(/.{1,8}/g) || []).map((k) => k.trim().toUpperCase()).filter(Boolean)));
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // A program's control analysis, kept so that a byte-identical copy of it elsewhere in the repository
3
+ // is not analysed again.
4
+ //
5
+ // The analysis refers to objects of the program's parse: statements, items, tokens. Kept, each such
6
+ // reference is the object's position in a walk of the parse that visits it in one fixed order, so the
7
+ // copy's parse, walked the same way, supplies the copy's own objects. A string equal to one of the
8
+ // program's file paths is kept as that file's place in the program's list of files, and becomes the
9
+ // copy's path. Anything else the analysis holds is copied as it is. A path inside a longer string, or
10
+ // a value that is not plain data, makes the analysis not kept at all: the copy is analysed afresh.
11
+
12
+ const FAIL = Symbol('not kept');
13
+
14
+ // The objects reachable from root, breadth first: array elements, Map keys and values, Set values and
15
+ // own enumerable properties, in their order. Typed arrays are not entered.
16
+ export function parseOrder(root) {
17
+ const order = [root];
18
+ const seen = new Set(order);
19
+ for (let i = 0; i < order.length; i++) {
20
+ const o = order[i];
21
+ const visit = (v) => { if (v !== null && typeof v === 'object' && !seen.has(v)) { seen.add(v); order.push(v); } };
22
+ if (ArrayBuffer.isView(o)) continue;
23
+ if (Array.isArray(o)) for (const v of o) visit(v);
24
+ else if (o instanceof Map) for (const [k, v] of o) { visit(k); visit(v); }
25
+ else if (o instanceof Set) for (const v of o) visit(v);
26
+ else for (const k of Object.keys(o)) visit(o[k]);
27
+ }
28
+ return order;
29
+ }
30
+
31
+ const plain = (o) => {
32
+ const proto = Object.getPrototypeOf(o);
33
+ return (proto === Object.prototype || proto === null)
34
+ && Object.getOwnPropertySymbols(o).length === 0
35
+ && Object.getOwnPropertyNames(o).length === Object.keys(o).length;
36
+ };
37
+
38
+ // `files` lists the program's file and its resolved copybooks, in the parse's order.
39
+ export function keep(value, order, files) {
40
+ const at = new Map(order.map((o, i) => [o, i]));
41
+ const fileAt = new Map(files.map((f, i) => [f, i]));
42
+ const table = [];
43
+ const index = new Map();
44
+ const enc = (v) => {
45
+ if (typeof v === 'string') {
46
+ if (fileAt.has(v)) return { f: fileAt.get(v) };
47
+ for (const f of files) if (f && v.includes(f)) throw FAIL;
48
+ return v;
49
+ }
50
+ if (v === null || typeof v !== 'object') {
51
+ if (typeof v === 'function' || typeof v === 'symbol') throw FAIL;
52
+ return v;
53
+ }
54
+ if (at.has(v)) return { p: at.get(v) };
55
+ if (index.has(v)) return { r: index.get(v) };
56
+ const i = table.length;
57
+ index.set(v, i);
58
+ const entry = {};
59
+ table.push(entry);
60
+ if (ArrayBuffer.isView(v)) { entry.t = v; return { r: i }; }
61
+ if (Array.isArray(v)) {
62
+ if (Object.keys(v).length !== v.length || !plainArray(v)) throw FAIL;
63
+ entry.a = v.map(enc);
64
+ } else if (v instanceof Map) entry.m = [...v].map(([k, x]) => [enc(k), enc(x)]);
65
+ else if (v instanceof Set) entry.s = [...v].map(enc);
66
+ else {
67
+ if (!plain(v)) throw FAIL;
68
+ entry.o = Object.keys(v).map((k) => [k, enc(v[k])]);
69
+ if (Object.getPrototypeOf(v) === null) entry.n = true;
70
+ }
71
+ return { r: i };
72
+ };
73
+ try { return { root: enc(value), table }; } catch (e) { if (e === FAIL) return null; throw e; }
74
+ }
75
+
76
+ const plainArray = (a) => Object.getPrototypeOf(a) === Array.prototype && Object.getOwnPropertySymbols(a).length === 0;
77
+
78
+ export function restore(kept, order, files) {
79
+ const made = kept.table.map((e) => (e.t ? e.t : e.a ? [] : e.m ? new Map() : e.s ? new Set() : e.n ? Object.create(null) : {}));
80
+ const dec = (x) => {
81
+ if (x === null || typeof x !== 'object') return x;
82
+ if ('p' in x) return order[x.p];
83
+ if ('f' in x) return files[x.f];
84
+ return made[x.r];
85
+ };
86
+ kept.table.forEach((e, i) => {
87
+ const o = made[i];
88
+ if (e.a) for (const x of e.a) o.push(dec(x));
89
+ else if (e.m) for (const [k, x] of e.m) o.set(dec(k), dec(x));
90
+ else if (e.s) for (const x of e.s) o.add(dec(x));
91
+ else if (e.o) for (const [k, x] of e.o) o[k] = dec(x);
92
+ });
93
+ return dec(kept.root);
94
+ }
95
+
96
+ // Whether two analyses are the same, objects of the parse compared as themselves.
97
+ export function sameAnalysis(a, b, parse) {
98
+ const pairs = new Map();
99
+ const eq = (x, y) => {
100
+ if (x === y) return true;
101
+ if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') return Object.is(x, y);
102
+ if (parse.has(x) || parse.has(y)) return false;
103
+ if (pairs.has(x)) return pairs.get(x) === y;
104
+ pairs.set(x, y);
105
+ if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
106
+ if (ArrayBuffer.isView(x)) return x.length === y.length && x.every((v, i) => v === y[i]);
107
+ if (x instanceof Map) { if (x.size !== y.size) return false; const ys = [...y]; return [...x].every(([k, v], i) => eq(k, ys[i][0]) && eq(v, ys[i][1])); }
108
+ if (x instanceof Set) { if (x.size !== y.size) return false; const ys = [...y]; return [...x].every((v, i) => eq(v, ys[i])); }
109
+ const kx = Object.keys(x), ky = Object.keys(y);
110
+ return kx.length === ky.length && kx.every((k, i) => k === ky[i] && eq(x[k], y[k]));
111
+ };
112
+ return eq(a, b);
113
+ }