mandrel 2.23.0 → 2.24.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.
@@ -87,7 +87,8 @@
87
87
  "statusResync",
88
88
  "refCleanup",
89
89
  "baseFastForward",
90
- "tempPurge"
90
+ "tempPurge",
91
+ "leaseRelease"
91
92
  ],
92
93
  "properties": {
93
94
  "followUps": { "type": "boolean" },
@@ -98,6 +99,10 @@
98
99
  "type": "boolean",
99
100
  "description": "Story #4794 — the merged Story's spent temp artifacts (gate transcripts, validation evidence) were purged under delivery.tempRetention. A disabled policy reports true: the operator turned the purge off, so doing nothing IS the correct outcome. Only a real failure — an unreadable temp root, an undeletable artifact — reports false, and like every tail step that degrades the report, never the land."
100
101
  },
102
+ "leaseRelease": {
103
+ "type": "boolean",
104
+ "description": "Story #4860 — the operator's assignee-lease on the Story was released now that the merge is confirmed. The close deliberately no longer releases it at PR creation, so the ticket stays assigned for the whole time its PR is open; every non-merged ending (merge.unlanded block, exhausted wait budget, --no-wait-merge, --no-auto-merge) retains the claim and never reaches this step. A no-op release — the operator is no longer the recorded owner, as on a re-run or a belated manual confirm — reports true: an already-unassigned ticket is the desired end state. Only a throw reports false, and like every tail step that degrades the report, never the land."
105
+ },
101
106
  "details": {
102
107
  "type": "object",
103
108
  "description": "Per-step diagnostic detail — the reason a false step reported false.",
@@ -61,10 +61,10 @@ import { parseArgs } from 'node:util';
61
61
  import { runAsCli } from './lib/cli-utils.js';
62
62
  import { resolveConfig } from './lib/config-resolver.js';
63
63
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
64
- import { computeChangeSet } from './lib/orchestration/change-set.js';
64
+ import { resolveBackstopOutcome } from './lib/orchestration/light-backstop.js';
65
+ import { recordGateRefusal } from './lib/orchestration/light-escalation.js';
65
66
  import {
66
67
  buildReceiptStoryTicket,
67
- checkLightDiffBackstop,
68
68
  deriveLightSuitability,
69
69
  resolveLightGateOutcome,
70
70
  } from './lib/orchestration/light-suitability.js';
@@ -121,7 +121,11 @@ Gate options:
121
121
  envelope and ENDS the session (no prompt, no fallback).
122
122
 
123
123
  Backstop options:
124
- --backstop Re-check the ACTUAL diff after implementation.
124
+ --backstop Re-check the ACTUAL diff after implementation. Bounds the
125
+ change's IMPLEMENTATION half by magnitude (changed lines +
126
+ file sprawl); test/doc/baseline companions are exempt from
127
+ the counts but still matched for sensitive paths. A block
128
+ emits a nextCommand recycling the receipt through /plan.
125
129
  --story <id> Story issue number whose story-<id> branch to diff.
126
130
 
127
131
  --pretty Pretty-print the JSON envelope.
@@ -130,8 +134,6 @@ Backstop options:
130
134
 
131
135
  /** Exit code when the gate did not resolve to proceed-light. */
132
136
  const EXIT_NOT_PROCEED = 2;
133
- /** Exit code when the diff backstop blocked the land. */
134
- const EXIT_BACKSTOP_BLOCKED = 3;
135
137
 
136
138
  /**
137
139
  * Split a comma-separated path list into trimmed, non-empty entries.
@@ -292,33 +294,6 @@ export function buildNextCommands(storyId) {
292
294
  };
293
295
  }
294
296
 
295
- /**
296
- * Run the diff backstop against a Story branch's actual change set.
297
- *
298
- * @param {{
299
- * storyId: number,
300
- * baseRef?: string,
301
- * cwd?: string,
302
- * computeFn?: typeof computeChangeSet,
303
- * injectedRules?: object,
304
- * }} args
305
- * @returns {ReturnType<typeof checkLightDiffBackstop>}
306
- */
307
- export function runDiffBackstop({
308
- storyId,
309
- baseRef = 'main',
310
- cwd = process.cwd(),
311
- computeFn = computeChangeSet,
312
- injectedRules,
313
- } = {}) {
314
- const { files } = computeFn({
315
- baseRef,
316
- headRef: `story-${storyId}`,
317
- cwd,
318
- });
319
- return checkLightDiffBackstop({ changedFiles: files, injectedRules });
320
- }
321
-
322
297
  /**
323
298
  * Was a non-blank `--operator-proceed-light` supplied? The gate core decides
324
299
  * whether it *applies*; this only asks whether the operator typed one, so the
@@ -348,27 +323,28 @@ function emit(envelope, pretty) {
348
323
  }
349
324
 
350
325
  /**
351
- * Backstop mode — re-check the actual diff.
326
+ * Backstop mode — re-check the actual diff. The decision lives in
327
+ * {@link module:lib/orchestration/light-backstop}; this branches and prints.
352
328
  *
353
- * @param {{ story?: string, pretty: boolean }} values
329
+ * @param {object} values Parsed CLI values.
330
+ * @param {{ resolveFn?: typeof resolveBackstopOutcome }} [deps]
354
331
  * @returns {Promise<number>}
355
332
  */
356
- async function runBackstopMode(values) {
333
+ async function runBackstopMode(values, deps = {}) {
334
+ const { resolveFn = resolveBackstopOutcome } = deps;
357
335
  const storyId = Number.parseInt(String(values.story ?? ''), 10);
358
336
  if (!Number.isInteger(storyId) || storyId <= 0) {
359
337
  process.stderr.write(HELP);
360
338
  throw new Error('[deliver-light] --backstop requires --story <id>');
361
339
  }
362
- const result = runDiffBackstop({ storyId });
363
- emit({ mode: 'backstop', storyId, ...result }, values.pretty);
364
- if (result.blocked) {
365
- Logger.warn(
366
- `[deliver-light] diff backstop BLOCKED Story #${storyId}: ${result.reasons.join('; ')}`,
367
- );
368
- return EXIT_BACKSTOP_BLOCKED;
369
- }
370
- Logger.info(`[deliver-light] diff backstop clean for Story #${storyId}.`);
371
- return 0;
340
+ const { result, nextCommand, exitCode, message } = await resolveFn({
341
+ storyId,
342
+ });
343
+ const extra = nextCommand === null ? {} : { nextCommand };
344
+ emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
345
+ if (result.blocked) Logger.warn(message);
346
+ else Logger.info(message);
347
+ return exitCode;
372
348
  }
373
349
 
374
350
  /**
@@ -407,6 +383,7 @@ export async function runGateMode(values, deps = {}) {
407
383
  createReceiptFn = createLightReceipt,
408
384
  emitFn = emit,
409
385
  emitTerminalFn = emitTerminalEnvelope,
386
+ recordRefusalFn = recordGateRefusal,
410
387
  } = deps;
411
388
 
412
389
  if (!values.prompt || String(values.prompt).trim() === '') {
@@ -457,6 +434,7 @@ export async function runGateMode(values, deps = {}) {
457
434
  { mode: 'gate', action: gate.action, outcome: gate.outcome },
458
435
  values.pretty,
459
436
  );
437
+ await recordRefusalFn({ gate, amends: values.amends });
460
438
  Logger.warn(
461
439
  `[deliver-light] gate did not proceed light (${gate.action}): ${gate.outcome.reasons.join('; ')}`,
462
440
  );
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { gitSpawn } from '../git-utils.js';
31
+ import { readNumstatRows } from '../orchestration/diff-magnitude.js';
31
32
  import { selectSensitivePathClasses } from './selector.js';
32
33
 
33
34
  /**
@@ -58,6 +59,12 @@ export function resolveLensDiffFloor(config) {
58
59
  * Count the changed lines (additions + deletions) in the
59
60
  * `baseRef...headRef` diff via `git diff --numstat`.
60
61
  *
62
+ * The read and the parse are shared with the light path's magnitude backstop
63
+ * ({@link module:lib/orchestration/diff-magnitude.readNumstatRows}) so the two
64
+ * cannot disagree about how a diff is measured. This one keeps a whole-diff
65
+ * total: the lens floor asks "is this diff small", not "is its implementation
66
+ * half small", so it deliberately does **not** apply the companion exemption.
67
+ *
61
68
  * Total — never throws. Returns `null` (the neutral "count unknown" signal
62
69
  * the floor fails open on) for any git failure or unparseable output, and
63
70
  * `0` for a genuinely empty diff. Binary rows (`-\t-\tpath`) contribute 0
@@ -77,31 +84,9 @@ export function countChangedLines({
77
84
  cwd = process.cwd(),
78
85
  gitSpawnFn = gitSpawn,
79
86
  } = {}) {
80
- if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
81
- if (typeof headRef !== 'string' || headRef.length === 0) return null;
82
- try {
83
- const result = gitSpawnFn(
84
- cwd,
85
- 'diff',
86
- '--numstat',
87
- `${baseRef}...${headRef}`,
88
- );
89
- if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
90
- return null;
91
- }
92
- let total = 0;
93
- for (const line of result.stdout.split('\n')) {
94
- const trimmedEnd = line.replace(/\s+$/, '');
95
- if (trimmedEnd.length === 0) continue;
96
- const match = /^(\d+|-)\t(\d+|-)\t/.exec(trimmedEnd);
97
- if (!match) return null; // Unexpected format — the count is not trustworthy.
98
- if (match[1] !== '-') total += Number(match[1]);
99
- if (match[2] !== '-') total += Number(match[2]);
100
- }
101
- return total;
102
- } catch {
103
- return null;
104
- }
87
+ const rows = readNumstatRows({ baseRef, headRef, cwd, gitSpawnFn });
88
+ if (rows === null) return null;
89
+ return rows.reduce((total, row) => total + row.additions + row.deletions, 0);
105
90
  }
106
91
 
107
92
  /**
@@ -30,10 +30,25 @@ export const name = 'maintainability';
30
30
  export const keyField = 'path';
31
31
 
32
32
  /**
33
- * Files the maintainability scorer cannot measure because `typhonjs-escomplex`
34
- * (the upstream kernel) parse-fails on syntax the runtime supports but the
35
- * library has never been updated for. Each entry must carry a one-line reason
36
- * so a future engine bump can audit the list and drop the exclusion.
33
+ * Files the maintainability scorer cannot measure because the upstream kernel
34
+ * throws on them. Each entry must carry a one-line reason so a future engine
35
+ * bump can audit the list and drop the exclusion.
36
+ *
37
+ * **This list is empty, and the goal is to keep it that way.** It previously
38
+ * held seven paths, all attributed to escomplex "choking on modern
39
+ * destructuring / regex-property patterns". That diagnosis was wrong. The real
40
+ * defect is that `typhonjs-escomplex`'s code generator is written against
41
+ * ESTree while its own parser emits Babel, so a regex literal in a loop head,
42
+ * an `await` in a loop head, a `?.`, a dynamic `import()`, or an object spread
43
+ * in a default parameter aborted the whole file's analysis. That is now
44
+ * repaired at the kernel boundary by `lib/escomplex-ast-compat.js`, which makes
45
+ * all four surviving entries scorable — and the audit incidentally showed the
46
+ * other three had been pointing at deleted files, because nothing audits an
47
+ * allowlist whose only effect is to suppress output.
48
+ *
49
+ * Before adding a path here, check whether `escomplex-ast-compat.js` can handle
50
+ * the construct instead. An entry here is a file nobody measures; a handler
51
+ * there gets it measured for every consumer.
37
52
  *
38
53
  * Story #2467 / Task #2494 — the prior behaviour stored these files with
39
54
  * `mi: 0` in `baselines/maintainability.json`, which corrupted the global
@@ -44,34 +59,7 @@ export const keyField = 'path';
44
59
  *
45
60
  * Canonicalised POSIX repo-relative paths.
46
61
  */
47
- export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(
48
- new Set([
49
- // escomplex: "Cannot read properties of undefined (reading 'pattern')" —
50
- // chokes on modern destructuring / regex-property patterns used in the
51
- // reconciler's spec walker.
52
- '.agents/scripts/acceptance-spec-reconciler.js',
53
- // escomplex: same "pattern" parse failure as acceptance-spec-reconciler;
54
- // both files share the lifecycle-lint regex-driven scan helpers.
55
- '.agents/scripts/check-lifecycle-lint.js',
56
- // escomplex: "this[node.callee.type] is not a function" — the cyclomatic
57
- // visitor lacks a handler for one of the quality-watch CLI's call shapes.
58
- '.agents/scripts/quality-watch.js',
59
- // escomplex: "Cannot read properties of undefined (reading 'pattern')" —
60
- // the audit-to-stories parser reuses the same regex-property scan
61
- // patterns as acceptance-spec-reconciler.
62
- '.agents/scripts/lib/audit-to-stories/parse-audit-md.js',
63
- // escomplex: same "pattern" parse failure family — the BDD scanner walks
64
- // source trees with regex visitors that hit the upstream destructuring
65
- // bug.
66
- '.agents/scripts/lib/bdd-scenario-scanner.js',
67
- // escomplex: same "pattern" parse failure — the wave-runner tick uses
68
- // the regex-property destructuring escomplex chokes on.
69
- '.agents/scripts/lib/wave-runner/tick.js',
70
- // escomplex: same "pattern" parse failure — exercises the same
71
- // destructuring shape in a test fixture.
72
- 'tests/scripts/story-close-merge-subject.test.js',
73
- ]),
74
- );
62
+ export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(new Set());
75
63
 
76
64
  /**
77
65
  * Filter parse-unscorable files out of a rows array. Used by the scorer
@@ -0,0 +1,360 @@
1
+ /**
2
+ * escomplex-ast-compat.js — reconcile the `typhonjs-escomplex` code generator
3
+ * with the Babel AST that `typhonjs-escomplex`'s own default parser emits.
4
+ *
5
+ * ## The upstream defect
6
+ *
7
+ * `typhonjs-escomplex` parses with `@typhonjs/babel-parser`, so every AST it
8
+ * analyses is a **Babel** AST. But `typhonjs-escomplex-commons`'
9
+ * `utils/ast/astSyntax.js` — the code generator that `ASTGenerator` drives —
10
+ * was written against **ESTree**. The two disagree on node names
11
+ * (`OptionalMemberExpression` vs a `MemberExpression` with `optional: true`)
12
+ * and on node shapes (Babel's `RegExpLiteral` carries `pattern`/`flags`
13
+ * directly; ESTree wraps them in a `regex` object on a `Literal`).
14
+ *
15
+ * That mismatch is invisible for most code because the metric traversal runs
16
+ * off the syntax plugin's trait tables, not off `astSyntax`. `astSyntax` is
17
+ * only reached where a trait re-serialises a **sub-AST** to synthesise a
18
+ * Halstead operand or a function signature — nine traits do this:
19
+ * `For`/`ForIn`/`ForOf` heads, `Function`/`FunctionExpression`/
20
+ * `ArrowFunctionExpression` parameter lists, `Class`/`ClassExpression`
21
+ * bodies, and `YieldExpression` arguments.
22
+ *
23
+ * Reach one of those with a Babel-only node and `analyzeModule()` throws —
24
+ * aborting the whole module, not just the sub-expression. The constructs that
25
+ * trip it are ordinary modern JavaScript:
26
+ *
27
+ * ```js
28
+ * for (const t of s.split(/[^a-z]+/)) {} // TypeError: …reading 'pattern'
29
+ * for (const t of await xs()) {} // …generator[node.type] is not a function
30
+ * for (const t of a?.b) {} // …generator[node.type] is not a function
31
+ * function f(a = () => import('x')) {} // …this[node.callee.type] is not a function
32
+ * function f(a = { ...b }) {} // TypeError: …reading 'type'
33
+ * function f(a = { m() {} }) {} // TypeError: …reading 'generator'
34
+ * function f(a = class { m() {} }) {} // …this[statement.type] is not a function
35
+ * ```
36
+ *
37
+ * The `{ ...b }` case is upstream issue #24, open and untouched since
38
+ * 2020-12-16 with a byte-identical stack trace. The package last published in
39
+ * June 2022 and the repo that holds the defective file has issues *disabled*,
40
+ * so waiting for an upstream release is not a plan. Consumers of this kernel
41
+ * previously absorbed the breakage as an allowlist of "unscorable" files that
42
+ * grew every time someone wrote a `?.` in a loop head.
43
+ *
44
+ * ## What this module does
45
+ *
46
+ * Installs the missing handlers and repairs the two shape assumptions, on the
47
+ * shared `astSyntax` table, once per process. Every patch is **conditional**:
48
+ * a handler is only installed where the table lacks one, and the two repairs
49
+ * wrap the original rather than replacing its behaviour. If a future kernel
50
+ * bump fixes any of this upstream, the corresponding patch silently stops
51
+ * applying and `install()` reports a shorter list — which
52
+ * `tests/lib/escomplex-ast-compat.test.js` asserts on, so the shrinkage is
53
+ * visible rather than silent.
54
+ *
55
+ * Nothing here changes the score of a file that already parses: the patched
56
+ * paths are exactly the paths that previously threw.
57
+ *
58
+ * @see https://github.com/typhonjs-node-escomplex/typhonjs-escomplex/issues/24
59
+ */
60
+
61
+ import { createRequire } from 'node:module';
62
+
63
+ const require = createRequire(import.meta.url);
64
+
65
+ /** Marker set on every function this module installs, for idempotency. */
66
+ const PATCH_MARKER = Symbol.for('mandrel.escomplexAstCompat');
67
+
68
+ /** Memoised result of the one-time install. */
69
+ let installResult = null;
70
+
71
+ /**
72
+ * Resolve the shared `astSyntax` generator table.
73
+ *
74
+ * This is a deep import into a transitive dependency's `dist/`, which is
75
+ * exactly as fragile as it looks — hence the soft failure. If upstream ever
76
+ * restructures, `install()` reports `available: false` and the engine falls
77
+ * back to today's behaviour (unscorable files, now reported explicitly by
78
+ * the engine rather than silently scored 0).
79
+ *
80
+ * The patch must land on the *same* `typhonjs-escomplex-commons` instance the
81
+ * kernel loads, so `commons` is resolved **through `typhonjs-escomplex`'s own
82
+ * resolution** rather than from here. Resolving it directly would be a coin
83
+ * flip: under a hoisting installer it usually finds the same copy, but under
84
+ * pnpm's isolated layout — or as soon as anything declares `commons` directly —
85
+ * it can find a *different* physical copy, and the patch then lands on a table
86
+ * nobody reads while `install()` cheerfully reports success. Anchoring makes
87
+ * that failure mode unreachable.
88
+ *
89
+ * `requireFn` is the test seam: a cross-checkout verification harness passes
90
+ * its own `createRequire` so the anchor starts from that checkout's escomplex.
91
+ *
92
+ * @param {NodeJS.Require} [requireFn]
93
+ * @returns {Record<string, Function>|null}
94
+ */
95
+ function resolveSyntaxTable(requireFn = require) {
96
+ try {
97
+ const fromKernel = createRequire(requireFn.resolve('typhonjs-escomplex'));
98
+ const mod = fromKernel(
99
+ 'typhonjs-escomplex-commons/dist/utils/ast/astSyntax.js',
100
+ );
101
+ const table = mod?.default ?? mod;
102
+ if (!table || typeof table !== 'object') return null;
103
+ // Sanity-check that this is the table we think it is before mutating it.
104
+ if (typeof table.MemberExpression !== 'function') return null;
105
+ if (Object.isFrozen(table)) return null;
106
+ return table;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Tag a handler as ours so a second `install()` is a no-op.
114
+ *
115
+ * @template {Function} F
116
+ * @param {F} fn
117
+ * @returns {F}
118
+ */
119
+ function mark(fn) {
120
+ fn[PATCH_MARKER] = true;
121
+ return fn;
122
+ }
123
+
124
+ /**
125
+ * Install `name` only if the table has no handler for it. Returns whether the
126
+ * install happened, so the caller can report the applied surface.
127
+ *
128
+ * @param {Record<string, Function>} table
129
+ * @param {string} name
130
+ * @param {Function} handler
131
+ * @returns {boolean}
132
+ */
133
+ function addMissing(table, name, handler) {
134
+ if (typeof table[name] === 'function') return false;
135
+ table[name] = mark(handler);
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Build an ESTree-shaped stand-in for a Babel `ObjectMethod` / `ClassMethod`,
141
+ * whose function bits sit on the node itself rather than under `value`.
142
+ * `MethodDefinition` reads `node.value.{generator,params,body}`, so handing it
143
+ * a synthesized `value` lets the original handler do the work unchanged.
144
+ *
145
+ * @param {object} node
146
+ * @returns {object}
147
+ */
148
+ function asMethodDefinition(node) {
149
+ return {
150
+ ...node,
151
+ kind:
152
+ typeof node.kind === 'string' && node.kind.length > 0
153
+ ? node.kind
154
+ : 'init',
155
+ value: {
156
+ type: 'FunctionExpression',
157
+ generator: Boolean(node.generator),
158
+ async: Boolean(node.async),
159
+ params: node.params ?? [],
160
+ body: node.body,
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Patch the shared `astSyntax` table. Idempotent; safe to call from every
167
+ * entry point that is about to run `analyzeModule`.
168
+ *
169
+ * @param {{ requireFn?: NodeJS.Require, memoise?: boolean }} [options] Test
170
+ * seam only — see {@link resolveSyntaxTable}. Production callers pass
171
+ * nothing.
172
+ * @returns {{ available: boolean, applied: string[] }} `available` is false
173
+ * when the upstream internals could not be resolved. `applied` names each
174
+ * patch that was actually needed, so an upstream fix shows up as a
175
+ * shorter list.
176
+ */
177
+ export function install(options = {}) {
178
+ const { requireFn, memoise = true } = options;
179
+ if (memoise && installResult !== null) return installResult;
180
+
181
+ const table = resolveSyntaxTable(requireFn);
182
+ if (table === null) {
183
+ const unavailable = { available: false, applied: [] };
184
+ if (memoise) installResult = unavailable;
185
+ return unavailable;
186
+ }
187
+
188
+ const applied = [];
189
+ const add = (name, handler) => {
190
+ if (addMissing(table, name, handler)) applied.push(name);
191
+ };
192
+
193
+ // ---- Repair 1: Babel's RegExpLiteral shape -------------------------------
194
+ // Upstream reads `node.regex.pattern`, which only exists on an ESTree
195
+ // `Literal`. Babel puts `pattern`/`flags` on the node. Wrapped rather than
196
+ // replaced so the ESTree delegate path (`Literal` → `RegExpLiteral`) keeps
197
+ // its exact original output.
198
+ if (
199
+ typeof table.RegExpLiteral === 'function' &&
200
+ !table.RegExpLiteral[PATCH_MARKER]
201
+ ) {
202
+ const original = table.RegExpLiteral;
203
+ table.RegExpLiteral = mark(function RegExpLiteral(node, state) {
204
+ if (node?.regex === undefined) {
205
+ state.output.write(
206
+ `new RegExp(${JSON.stringify(node?.pattern ?? '')}, ` +
207
+ `${JSON.stringify(node?.flags ?? '')})`,
208
+ );
209
+ return;
210
+ }
211
+ return original.call(this, node, state);
212
+ });
213
+ applied.push('RegExpLiteral');
214
+ }
215
+
216
+ // ---- Repair 2: ObjectExpression's hard `this.Property()` dispatch --------
217
+ // `ObjectExpression` calls `this.Property(el)` on every element regardless
218
+ // of the element's actual type. Two Babel shapes get mis-routed by that:
219
+ //
220
+ // `{ ...b }` → a `SpreadElement` lands in `Property`, which reads
221
+ // `node.key.type` and throws. Upstream issue #24.
222
+ // `{ m() {} }` → an `ObjectMethod` has `kind: 'method'`, so `Property`'s
223
+ // `node.kind[0] !== 'i'` test sends the raw Babel node to
224
+ // `MethodDefinition`, which reads `node.value.generator`
225
+ // and throws.
226
+ //
227
+ // Patching `Property` rather than `ObjectExpression` keeps the patch small
228
+ // and leaves the indentation-sensitive object serialisation untouched:
229
+ // anything arriving here that is not actually a property node gets
230
+ // re-dispatched on its real type.
231
+ //
232
+ // `Property` and `ObjectProperty` are excluded from re-dispatch because the
233
+ // table aliases `ObjectProperty` to `Property` — re-dispatching either would
234
+ // recurse into this wrapper forever.
235
+ if (typeof table.Property === 'function' && !table.Property[PATCH_MARKER]) {
236
+ const original = table.Property;
237
+ const PROPERTY_TYPES = new Set(['Property', 'ObjectProperty']);
238
+ table.Property = mark(function Property(node, state) {
239
+ const type = node?.type;
240
+ if (
241
+ typeof type === 'string' &&
242
+ !PROPERTY_TYPES.has(type) &&
243
+ typeof this[type] === 'function'
244
+ ) {
245
+ return this[type](node, state);
246
+ }
247
+ return original.call(this, node, state);
248
+ });
249
+ applied.push('Property');
250
+ }
251
+
252
+ // ---- Missing handlers: Babel-only node types ----------------------------
253
+
254
+ // `await x` — mirrors upstream's `YieldExpression`.
255
+ add('AwaitExpression', function AwaitExpression(node, state) {
256
+ const output = state.output;
257
+ output.write('await ');
258
+ output.operators.push('await');
259
+ if (node.argument) this[node.argument.type](node.argument, state);
260
+ });
261
+
262
+ // `a?.b` / `a?.[b]` — mirrors upstream's `MemberExpression`, with `?.`
263
+ // recorded as its own operator so optional access is not counted as plain
264
+ // member access.
265
+ add(
266
+ 'OptionalMemberExpression',
267
+ function OptionalMemberExpression(node, state) {
268
+ const output = state.output;
269
+ this[node.object.type](node.object, state);
270
+ if (node.computed) {
271
+ output.write('?.[');
272
+ this[node.property.type](node.property, state);
273
+ output.write(']');
274
+ output.operators.push('?.[]');
275
+ } else {
276
+ output.write('?.');
277
+ output.operators.push('?.');
278
+ this[node.property.type](node.property, state);
279
+ }
280
+ },
281
+ );
282
+
283
+ // `a?.()` — mirrors upstream's `CallExpression`.
284
+ add('OptionalCallExpression', function OptionalCallExpression(node, state) {
285
+ this[node.callee.type](node.callee, state);
286
+ state.output.write('?.');
287
+ state.output.operators.push('?.()');
288
+ ASTUtil().formatSequence(node.arguments ?? [], state, this);
289
+ });
290
+
291
+ // The callee node of a dynamic `import(...)`. Babel models the `import`
292
+ // keyword as its own node type; ESTree has no equivalent.
293
+ add('Import', function Import(_node, state) {
294
+ state.output.write('import');
295
+ state.output.operators.push('import()');
296
+ });
297
+
298
+ // `{ m() {} }` / `{ get m() {} }` — Babel's ObjectMethod.
299
+ add('ObjectMethod', function ObjectMethod(node, state) {
300
+ return this.MethodDefinition(asMethodDefinition(node), state);
301
+ });
302
+
303
+ // `class { m() {} }` — Babel's ClassMethod (ESTree: MethodDefinition).
304
+ add('ClassMethod', function ClassMethod(node, state) {
305
+ return this.MethodDefinition(asMethodDefinition(node), state);
306
+ });
307
+
308
+ // `class { p = 1 }` — Babel's ClassProperty (ESTree: PropertyDefinition).
309
+ const classProperty = function ClassProperty(node, state) {
310
+ const output = state.output;
311
+ if (node.static) {
312
+ output.write('static ');
313
+ output.operators.push('static');
314
+ }
315
+ if (node.computed) {
316
+ output.write('[');
317
+ this[node.key.type](node.key, state);
318
+ output.write(']');
319
+ } else {
320
+ this[node.key.type](node.key, state);
321
+ }
322
+ if (node.value) {
323
+ output.write(' = ');
324
+ output.operators.push('=');
325
+ this[node.value.type](node.value, state);
326
+ }
327
+ output.write(';');
328
+ };
329
+ add('ClassProperty', classProperty);
330
+ add('PropertyDefinition', classProperty);
331
+
332
+ const result = { available: true, applied };
333
+ if (memoise) installResult = result;
334
+ return result;
335
+ }
336
+
337
+ /**
338
+ * `ASTUtil` is only needed by the `OptionalCallExpression` handler, and only at
339
+ * call time — resolving it lazily keeps `install()` free of a second deep
340
+ * import that could fail at module load. Anchored through the kernel for the
341
+ * same reason as {@link resolveSyntaxTable}.
342
+ *
343
+ * `formatSequence` is a pure helper that takes the traveler and state as
344
+ * arguments, so which copy answers is immaterial — but resolving it the same
345
+ * way keeps one rule in this file rather than two.
346
+ *
347
+ * @returns {{ formatSequence: Function }}
348
+ */
349
+ function ASTUtil() {
350
+ const fromKernel = createRequire(require.resolve('typhonjs-escomplex'));
351
+ const mod = fromKernel(
352
+ 'typhonjs-escomplex-commons/dist/utils/ast/ASTUtil.js',
353
+ );
354
+ return mod?.default ?? mod;
355
+ }
356
+
357
+ // A test that needs to re-run the install against the already-patched table
358
+ // passes `{ memoise: false }` rather than resetting module state — the patches
359
+ // are self-detecting via PATCH_MARKER, so a non-memoised re-run is exactly the
360
+ // idempotency assertion worth making.