mandrel 2.23.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -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/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
|
|
33
|
+
import { fingerprintSeverity } from './severity.js';
|
|
34
|
+
|
|
33
35
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
34
36
|
const MARKER = 'audit-fingerprints:';
|
|
35
37
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
@@ -65,6 +67,20 @@ function normaliseLabels(labels) {
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* Compute the stable identity payload for a finding.
|
|
70
|
+
*
|
|
71
|
+
* **Severity is projected, not raw (Story #4877).** The severity vocabulary was
|
|
72
|
+
* normalised onto the canonical five-level scale in the same change that wrote
|
|
73
|
+
* this comment, and severity is an identity field — so a naive
|
|
74
|
+
* `normaliseField(finding.severity)` here would have re-minted the fingerprint
|
|
75
|
+
* of every finding whose spelling the normalisation touched, silently breaking
|
|
76
|
+
* dedup against every Issue already filed. {@link fingerprintSeverity} is the
|
|
77
|
+
* projection that makes the hash **invariant** under that normalisation: it
|
|
78
|
+
* resolves aliases onto their canonical level, keeps an absent severity as the
|
|
79
|
+
* empty string (what the raw call produced), and passes an unrecognised value
|
|
80
|
+
* through verbatim. Labels are deliberately left on the raw
|
|
81
|
+
* lower-case/trim/sort path for the same reason — order- and case-insensitive
|
|
82
|
+
* already, and any further folding would move existing shas.
|
|
83
|
+
*
|
|
68
84
|
* @param {object} finding
|
|
69
85
|
* @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string }}
|
|
70
86
|
*/
|
|
@@ -73,7 +89,7 @@ function fingerprintComponents(finding) {
|
|
|
73
89
|
title: normaliseField(finding?.title),
|
|
74
90
|
area: normaliseField(finding?.area),
|
|
75
91
|
primaryFile: normaliseField(finding?.primaryFile),
|
|
76
|
-
severity:
|
|
92
|
+
severity: fingerprintSeverity(finding?.severity),
|
|
77
93
|
labels: normaliseLabels(finding?.labels),
|
|
78
94
|
};
|
|
79
95
|
}
|
|
@@ -142,20 +158,18 @@ export function semanticKeyFooter(keys) {
|
|
|
142
158
|
|
|
143
159
|
/**
|
|
144
160
|
* Extract semantic keys from an Issue body carrying the semantic-key footer.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
161
|
+
* The audit filers stamp the footer via {@link semanticKeyFooter}; the
|
|
162
|
+
* confirmation path here and {@link carryProvenanceFooters} read it back.
|
|
147
163
|
*
|
|
148
164
|
* @param {string} body
|
|
149
165
|
* @returns {string[]}
|
|
150
166
|
*/
|
|
151
167
|
function parseSemanticKeyFooter(body) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.map((s) => s.trim())
|
|
158
|
-
.filter((s) => s.length > 0);
|
|
168
|
+
return parseAllFooterValues(
|
|
169
|
+
body,
|
|
170
|
+
/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
|
|
171
|
+
(s) => s.length > 0,
|
|
172
|
+
);
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/**
|
|
@@ -190,13 +204,104 @@ export function fingerprintFooter(shas) {
|
|
|
190
204
|
* @returns {string[]}
|
|
191
205
|
*/
|
|
192
206
|
export function parseFingerprintFooter(body) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
return parseAllFooterValues(
|
|
208
|
+
body,
|
|
209
|
+
/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/g,
|
|
210
|
+
(s) => SHA1_RE.test(s),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Collect the comma-separated values out of **every** occurrence of a footer
|
|
216
|
+
* marker in `text`, de-duplicated, in first-seen order.
|
|
217
|
+
*
|
|
218
|
+
* Scanning every occurrence rather than only the first matters for the
|
|
219
|
+
* provenance carry (Story #4877): the audit Single-plan seed stamps one footer
|
|
220
|
+
* pair per MVP Scope bullet, so a multi-group seed carries several. A
|
|
221
|
+
* first-match-only parse silently dropped every group but the first, which
|
|
222
|
+
* would have made the carry look wired while leaking most of the provenance.
|
|
223
|
+
* Reading all footers is also strictly more correct for issue-body confirmation
|
|
224
|
+
* — a body that accumulated two footer lines confirms against either.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} text
|
|
227
|
+
* @param {RegExp} pattern — a global regex whose first capture group is the
|
|
228
|
+
* comma-separated value list.
|
|
229
|
+
* @param {(value: string) => boolean} isValid
|
|
230
|
+
* @returns {string[]}
|
|
231
|
+
*/
|
|
232
|
+
function parseAllFooterValues(text, pattern, isValid) {
|
|
233
|
+
if (typeof text !== 'string') return [];
|
|
234
|
+
const out = [];
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
for (const match of text.matchAll(pattern)) {
|
|
237
|
+
for (const raw of match[1].split(',')) {
|
|
238
|
+
const value = raw.trim();
|
|
239
|
+
if (!isValid(value) || seen.has(value)) continue;
|
|
240
|
+
seen.add(value);
|
|
241
|
+
out.push(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Carry audit dedup provenance from a source document into a target body
|
|
249
|
+
* (Story #4877).
|
|
250
|
+
*
|
|
251
|
+
* The audit sweep's Single-plan path emits a `/plan` seed whose MVP Scope
|
|
252
|
+
* bullets already carry the `audit-fingerprints` / `audit-semantic-keys`
|
|
253
|
+
* footers (Story #4626). Nothing then copied them into the Story `/plan`
|
|
254
|
+
* actually persisted, so the recommended path filed Stories that the next
|
|
255
|
+
* sweep could not recognise and re-filed as new. It was left to the authoring
|
|
256
|
+
* agent to notice HTML comments in a one-pager and hand-carry them — a
|
|
257
|
+
* remembered step, which is to say no step at all.
|
|
258
|
+
*
|
|
259
|
+
* This is that carry, as a function: harvest both footers out of `from`, and
|
|
260
|
+
* append whichever provenance `into` is missing. It is deliberately:
|
|
261
|
+
*
|
|
262
|
+
* - **Additive.** Shas and keys already present in `into` are never duplicated,
|
|
263
|
+
* and a footer `into` already carries is left exactly as authored.
|
|
264
|
+
* - **Union-preserving.** When both sides carry footers the result carries the
|
|
265
|
+
* union, so a hand-authored fingerprint is not dropped in favour of the seed's.
|
|
266
|
+
* - **Idempotent.** Re-running over its own output is a no-op, so a resumed
|
|
267
|
+
* persist cannot stack footers.
|
|
268
|
+
* - **Silent on nothing-to-do.** No provenance in `from` returns `into`
|
|
269
|
+
* unchanged with `carried: false`, so a non-audit plan run is untouched.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ from?: string, into?: string }} args — `from` is the provenance
|
|
272
|
+
* source (the seed markdown); `into` is the body being persisted.
|
|
273
|
+
* @returns {{ body: string, carried: boolean, fingerprints: string[], semanticKeys: string[] }}
|
|
274
|
+
* `body` is the augmented text; `fingerprints` / `semanticKeys` are the values
|
|
275
|
+
* newly carried (empty when there was nothing to carry).
|
|
276
|
+
*/
|
|
277
|
+
export function carryProvenanceFooters({ from = '', into = '' } = {}) {
|
|
278
|
+
const body = typeof into === 'string' ? into : '';
|
|
279
|
+
const source = typeof from === 'string' ? from : '';
|
|
280
|
+
|
|
281
|
+
const have = new Set(parseFingerprintFooter(body));
|
|
282
|
+
const haveKeys = new Set(parseSemanticKeyFooter(body));
|
|
283
|
+
const fingerprints = parseFingerprintFooter(source).filter(
|
|
284
|
+
(sha) => !have.has(sha),
|
|
285
|
+
);
|
|
286
|
+
const semanticKeys = parseSemanticKeyFooter(source).filter(
|
|
287
|
+
(key) => !haveKeys.has(key),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (fingerprints.length === 0 && semanticKeys.length === 0) {
|
|
291
|
+
return { body, carried: false, fingerprints: [], semanticKeys: [] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const appended = [];
|
|
295
|
+
if (fingerprints.length > 0) appended.push(fingerprintFooter(fingerprints));
|
|
296
|
+
if (semanticKeys.length > 0) appended.push(semanticKeyFooter(semanticKeys));
|
|
297
|
+
|
|
298
|
+
const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
299
|
+
return {
|
|
300
|
+
body: `${body}${separator}\n${appended.join('\n')}\n`,
|
|
301
|
+
carried: true,
|
|
302
|
+
fingerprints,
|
|
303
|
+
semanticKeys,
|
|
304
|
+
};
|
|
200
305
|
}
|
|
201
306
|
|
|
202
307
|
/**
|
|
@@ -378,4 +483,5 @@ export const __testing = {
|
|
|
378
483
|
confirmCandidates,
|
|
379
484
|
decideFromConfirmed,
|
|
380
485
|
issueCarriesSemanticKey,
|
|
486
|
+
parseSemanticKeyFooter,
|
|
381
487
|
};
|
|
@@ -40,10 +40,38 @@ export const SEVERITIES = Object.freeze([
|
|
|
40
40
|
* malformed input — and because both the classify and promote paths share it,
|
|
41
41
|
* malformed input still fingerprints identically across the two paths.
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
const DEFAULT_SEVERITY = 'info';
|
|
44
44
|
|
|
45
45
|
const SEVERITY_SET = new Set(SEVERITIES);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Non-canonical spellings that resolve onto a canonical level (Story #4877).
|
|
49
|
+
*
|
|
50
|
+
* The vocabulary drifted because four modules each carried their own partial
|
|
51
|
+
* copy of it: `audit-to-stories/parse-audit-md.js` recognised
|
|
52
|
+
* `critical|high|medium|mod|moderate|low` and nothing else, `audit-to-stories.js`
|
|
53
|
+
* ranked `critical|high|medium|low`, and `audit-to-stories/seed-from-findings.js`
|
|
54
|
+
* ordered the same four. None of them knew `info` — the canonical floor — so an
|
|
55
|
+
* `Info` / `Informational` finding parsed to `null`, tallied as `unknown`, and
|
|
56
|
+
* was dropped by EVERY severity-filtered run (including `--severity low`).
|
|
57
|
+
* Folding the alias table in here makes this module the only place the
|
|
58
|
+
* vocabulary is written down.
|
|
59
|
+
*
|
|
60
|
+
* Keys are already lower-cased and trimmed by {@link normalizeSeverity}.
|
|
61
|
+
*
|
|
62
|
+
* @type {Readonly<Record<string, string>>}
|
|
63
|
+
*/
|
|
64
|
+
const SEVERITY_ALIASES = Object.freeze({
|
|
65
|
+
blocker: 'critical',
|
|
66
|
+
major: 'high',
|
|
67
|
+
mod: 'medium',
|
|
68
|
+
moderate: 'medium',
|
|
69
|
+
minor: 'low',
|
|
70
|
+
informational: 'info',
|
|
71
|
+
nit: 'info',
|
|
72
|
+
trivial: 'info',
|
|
73
|
+
});
|
|
74
|
+
|
|
47
75
|
/**
|
|
48
76
|
* Numeric rank for "highest severity wins" comparisons, derived from the
|
|
49
77
|
* canonical order so the ranking has exactly one source. `critical` is the
|
|
@@ -74,7 +102,44 @@ export const SEVERITY_RANK = Object.freeze(
|
|
|
74
102
|
export function normalizeSeverity(value, fallback = DEFAULT_SEVERITY) {
|
|
75
103
|
if (typeof value !== 'string') return fallback;
|
|
76
104
|
const normalized = value.trim().toLowerCase();
|
|
77
|
-
|
|
105
|
+
if (SEVERITY_SET.has(normalized)) return normalized;
|
|
106
|
+
return SEVERITY_ALIASES[normalized] ?? fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The **identity projection** of a severity, for use inside a finding
|
|
111
|
+
* fingerprint — and nowhere else (Story #4877).
|
|
112
|
+
*
|
|
113
|
+
* `severity` is a `fingerprintFinding` identity field
|
|
114
|
+
* (`route-finding.js`), so whatever this returns is folded into the sha that
|
|
115
|
+
* deduplicates findings against already-filed Issues. That makes the severity
|
|
116
|
+
* pipeline and the severity *identity* two different jobs with two different
|
|
117
|
+
* failure modes:
|
|
118
|
+
*
|
|
119
|
+
* - {@link normalizeSeverity} resolves an absent or unrecognised severity to
|
|
120
|
+
* `info` so downstream filtering and tallies have a level to work with.
|
|
121
|
+
* - This function must NOT. Folding `info` in where the previous
|
|
122
|
+
* implementation folded the empty string would re-mint the fingerprint of
|
|
123
|
+
* every finding that carries no severity, silently breaking dedup for all of
|
|
124
|
+
* them. An absent severity therefore stays the empty string, exactly as the
|
|
125
|
+
* raw `String(value).toLowerCase().trim()` it replaces produced.
|
|
126
|
+
*
|
|
127
|
+
* What it *does* change is alias resolution: `Informational` and `info` project
|
|
128
|
+
* onto the same `info`, so the fingerprint is **invariant** under the
|
|
129
|
+
* normalization this Story introduces — a finding hashes the same whether it is
|
|
130
|
+
* fingerprinted before or after {@link normalizeSeverity} has run over it. An
|
|
131
|
+
* unrecognised non-empty value is passed through verbatim rather than collapsed,
|
|
132
|
+
* again so no already-filed fingerprint moves.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} value — the raw severity field off a finding.
|
|
135
|
+
* @returns {string} a canonical level, the empty string when absent, or the
|
|
136
|
+
* lower-cased raw value when it is neither canonical nor a known alias.
|
|
137
|
+
*/
|
|
138
|
+
export function fingerprintSeverity(value) {
|
|
139
|
+
if (typeof value !== 'string') return '';
|
|
140
|
+
const raw = value.trim().toLowerCase();
|
|
141
|
+
if (raw.length === 0) return '';
|
|
142
|
+
return normalizeSeverity(raw, raw);
|
|
78
143
|
}
|
|
79
144
|
|
|
80
145
|
/**
|
|
@@ -99,3 +164,16 @@ export function highestSeverity(values) {
|
|
|
99
164
|
}
|
|
100
165
|
return best;
|
|
101
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Internals reached by the unit tests only. The floor and the alias table are
|
|
170
|
+
* consumed exclusively by this module's own exported functions, so they are
|
|
171
|
+
* not public API — but the alias table carries a structural invariant (no
|
|
172
|
+
* alias shadows a canonical level, every target IS one) that cannot be proven
|
|
173
|
+
* through `normalizeSeverity` alone, since that function returns a canonical
|
|
174
|
+
* value by construction.
|
|
175
|
+
*/
|
|
176
|
+
export const __testing = {
|
|
177
|
+
DEFAULT_SEVERITY,
|
|
178
|
+
SEVERITY_ALIASES,
|
|
179
|
+
};
|
|
Binary file
|
|
@@ -111,6 +111,22 @@ export const META_LABELS = {
|
|
|
111
111
|
CONSUMER_IMPROVEMENT: 'meta::consumer-improvement',
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Risk-axis labels. Planning/audit metadata only — `risk::high` carries no
|
|
116
|
+
* runtime behaviour (the single runtime pause point is `agent::blocked`, see
|
|
117
|
+
* `.agents/instructions.md` § 1.J); it marks a ticket a human should look at
|
|
118
|
+
* first. Applied by the audit filer to a Story whose merge contains a Critical
|
|
119
|
+
* finding.
|
|
120
|
+
*
|
|
121
|
+
* Named here because it was a bare `'risk::high'` string literal in
|
|
122
|
+
* `audit-to-stories/build-story-body.js` and defined by no taxonomy at all —
|
|
123
|
+
* neither `LABEL_TAXONOMY` nor the audit bootstrap created it, so the audit
|
|
124
|
+
* filer generated a label the repo did not have (Story #4877).
|
|
125
|
+
*/
|
|
126
|
+
export const RISK_LABELS = {
|
|
127
|
+
HIGH: 'risk::high',
|
|
128
|
+
};
|
|
129
|
+
|
|
114
130
|
/**
|
|
115
131
|
* Planning-axis labels (Epic #2880 F7). Currently scoped to the
|
|
116
132
|
* `planning::healthcheck-waived` operator-applied waiver — a historical
|
|
@@ -147,6 +163,7 @@ export const PLANNING_HEALTHCHECK_WAIVED = 'planning::healthcheck-waived';
|
|
|
147
163
|
*/
|
|
148
164
|
export const LABEL_COLORS = {
|
|
149
165
|
TYPE: '#7057FF',
|
|
166
|
+
RISK_HIGH: '#B60205',
|
|
150
167
|
AGENT: '#0E8A16',
|
|
151
168
|
STATUS_BLOCKED: '#D93F0B',
|
|
152
169
|
ACCEPTANCE: '#FBCA04',
|