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.
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/docs/CHANGELOG.md +9 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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 {
|
|
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 =
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|