mandrel 2.57.0 → 2.59.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/README.md +6 -3
- package/.agents/agents/story-worker.md +12 -11
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/scripts/evidence-gate.js +17 -1
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/code-review.js +7 -3
- package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
- package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
- package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/story-body.js +36 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
- package/.agents/scripts/lib/test-run-credit.js +23 -12
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/deliver-digest.md +22 -15
- package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
- package/.agents/workflows/helpers/deliver-story.md +6 -5
- package/.agents/workflows/helpers/plan-reference.md +53 -13
- package/.agents/workflows/mandrel-plan.md +19 -14
- package/README.md +3 -3
- package/docs/CHANGELOG.md +21 -0
- package/lib/cli/registry.js +143 -27
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- package/.agents/skills/core/scope-triage/SKILL.md +0 -48
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ## The upstream defect
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The kernel parses with `@babel/parser`, so every AST it analyses is a
|
|
8
|
+
* **Babel** AST. But `typhonjs-escomplex-commons`'
|
|
9
9
|
* `utils/ast/astSyntax.js` — the code generator that `ASTGenerator` drives —
|
|
10
10
|
* was written against **ESTree**. The two disagree on node names
|
|
11
11
|
* (`OptionalMemberExpression` vs a `MemberExpression` with `optional: true`)
|
|
@@ -62,6 +62,21 @@ import { createRequire } from 'node:module';
|
|
|
62
62
|
|
|
63
63
|
const require = createRequire(import.meta.url);
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The package whose resolution anchors every `typhonjs-escomplex-commons` deep
|
|
67
|
+
* import in this module.
|
|
68
|
+
*
|
|
69
|
+
* `escomplex-plugin-syntax-babylon` is the package that actually reads the
|
|
70
|
+
* `astSyntax` table during a metric traversal, so its copy of `commons` is the
|
|
71
|
+
* only one worth patching.
|
|
72
|
+
*
|
|
73
|
+
* Module-local on purpose. A test proves the binding by resolving from this
|
|
74
|
+
* package's own root itself — which is the assertion worth making, since our
|
|
75
|
+
* copy and the plugin's coincide under a hoisting installer and an
|
|
76
|
+
* "ours === theirs" check would pass vacuously.
|
|
77
|
+
*/
|
|
78
|
+
const ANCHOR_PACKAGE = 'escomplex-plugin-syntax-babylon';
|
|
79
|
+
|
|
65
80
|
/** Marker set on every function this module installs, for idempotency. */
|
|
66
81
|
const PATCH_MARKER = Symbol.for('mandrel.escomplexAstCompat');
|
|
67
82
|
|
|
@@ -77,25 +92,32 @@ let installResult = null;
|
|
|
77
92
|
* back to today's behaviour (unscorable files, now reported explicitly by
|
|
78
93
|
* the engine rather than silently scored 0).
|
|
79
94
|
*
|
|
80
|
-
* The patch must land on the *same* `
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
95
|
+
* The patch must land on the *same* `astSyntax` table the metric traversal
|
|
96
|
+
* reads, and that table is resolved by **`escomplex-plugin-syntax-babylon`**,
|
|
97
|
+
* from its own location — `PluginSyntaxBabylon` requires
|
|
98
|
+
* `typhonjs-escomplex-commons/dist/utils/ast/ASTGenerator` and `ASTState` binds
|
|
99
|
+
* the table it finds. So `commons` is anchored through the syntax plugin's
|
|
100
|
+
* resolution, not through this module's and not through the kernel's.
|
|
101
|
+
*
|
|
102
|
+
* Resolving it from here, or from the kernel, would be a coin flip: under a
|
|
103
|
+
* hoisting installer every copy usually coincides, but under pnpm's isolated
|
|
104
|
+
* layout — or as soon as anything declares `commons` at a different version —
|
|
105
|
+
* the plugin can read a *different* physical copy, and the patch then lands on
|
|
106
|
+
* a table nobody reads while `install()` cheerfully reports success. Anchoring
|
|
107
|
+
* on the reader makes that unreachable. Note this is why a test asserting
|
|
108
|
+
* "our copy === the patched copy" proves nothing: under hoisting it passes
|
|
109
|
+
* vacuously — a test must resolve from the plugin's own root instead.
|
|
88
110
|
*
|
|
89
111
|
* `requireFn` is the test seam: a cross-checkout verification harness passes
|
|
90
|
-
* its own `createRequire` so the anchor starts from that checkout's
|
|
112
|
+
* its own `createRequire` so the anchor starts from that checkout's plugin.
|
|
91
113
|
*
|
|
92
114
|
* @param {NodeJS.Require} [requireFn]
|
|
93
115
|
* @returns {Record<string, Function>|null}
|
|
94
116
|
*/
|
|
95
117
|
function resolveSyntaxTable(requireFn = require) {
|
|
96
118
|
try {
|
|
97
|
-
const
|
|
98
|
-
const mod =
|
|
119
|
+
const fromReader = createRequire(requireFn.resolve(ANCHOR_PACKAGE));
|
|
120
|
+
const mod = fromReader(
|
|
99
121
|
'typhonjs-escomplex-commons/dist/utils/ast/astSyntax.js',
|
|
100
122
|
);
|
|
101
123
|
const table = mod?.default ?? mod;
|
|
@@ -337,8 +359,8 @@ export function install(options = {}) {
|
|
|
337
359
|
/**
|
|
338
360
|
* `ASTUtil` is only needed by the `OptionalCallExpression` handler, and only at
|
|
339
361
|
* call time — resolving it lazily keeps `install()` free of a second deep
|
|
340
|
-
* import that could fail at module load. Anchored through the
|
|
341
|
-
* same reason as {@link resolveSyntaxTable}.
|
|
362
|
+
* import that could fail at module load. Anchored through the syntax plugin
|
|
363
|
+
* for the same reason as {@link resolveSyntaxTable}.
|
|
342
364
|
*
|
|
343
365
|
* `formatSequence` is a pure helper that takes the traveler and state as
|
|
344
366
|
* arguments, so which copy answers is immaterial — but resolving it the same
|
|
@@ -347,8 +369,8 @@ export function install(options = {}) {
|
|
|
347
369
|
* @returns {{ formatSequence: Function }}
|
|
348
370
|
*/
|
|
349
371
|
function ASTUtil() {
|
|
350
|
-
const
|
|
351
|
-
const mod =
|
|
372
|
+
const fromReader = createRequire(require.resolve(ANCHOR_PACKAGE));
|
|
373
|
+
const mod = fromReader(
|
|
352
374
|
'typhonjs-escomplex-commons/dist/utils/ast/ASTUtil.js',
|
|
353
375
|
);
|
|
354
376
|
return mod?.default ?? mod;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escomplex-kernel.js — the complexity kernel's parse and dispatch layers,
|
|
3
|
+
* in-repo.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists
|
|
6
|
+
*
|
|
7
|
+
* `typhonjs-escomplex` was a thin shell around four packages that do all the
|
|
8
|
+
* actual work. The shell contributed two things: a parser front-end
|
|
9
|
+
* (`@typhonjs/babel-parser`, a ~100-LOC shim over `@babel/parser`) and a
|
|
10
|
+
* generic plugin bus (`typhonjs-plugin-manager`, used as a hardcoded
|
|
11
|
+
* two-plugin synchronous dispatcher). Nine packages of plumbing hang off
|
|
12
|
+
* those two, and none of it computes anything.
|
|
13
|
+
*
|
|
14
|
+
* What this does **not** do is remove `core-js@2`. Four of the retained
|
|
15
|
+
* metric-core packages `require('babel-runtime/core-js/*')` themselves, so it
|
|
16
|
+
* is load-bearing for the code that stays; a change that claimed otherwise
|
|
17
|
+
* would be unshippable. What it buys instead is an honest closure —
|
|
18
|
+
* `babel-runtime` is required by those packages and declared by none of them,
|
|
19
|
+
* so today it resolves only because the removed plumbing hoists it. Declaring
|
|
20
|
+
* it turns an accident into a contract.
|
|
21
|
+
*
|
|
22
|
+
* This module reimplements exactly those two layers over the retained metric
|
|
23
|
+
* core — `typhonjs-escomplex-commons`, `escomplex-plugin-metrics-module`,
|
|
24
|
+
* `escomplex-plugin-syntax-babylon`, `typhonjs-ast-walker` — which compute
|
|
25
|
+
* every score. Nothing here computes a metric; the scores come from the same
|
|
26
|
+
* packages as before, which is why they do not move.
|
|
27
|
+
*
|
|
28
|
+
* ## The equivalence contract
|
|
29
|
+
*
|
|
30
|
+
* Reproducing the displaced shell's *behaviour* means reproducing three
|
|
31
|
+
* details it never documented:
|
|
32
|
+
*
|
|
33
|
+
* 1. **The parser's fixed plugin list**, verbatim and in order, with
|
|
34
|
+
* `sourceType: 'unambiguous'` — see `PARSER_PLUGINS` below. The list is
|
|
35
|
+
* what makes a `.ts` file, a decorator or a pipeline operator parse at
|
|
36
|
+
* all, and `unambiguous` is what lets a CommonJS script and an ES module
|
|
37
|
+
* both score.
|
|
38
|
+
* 2. **Both plugin instances, in registration order** — syntax first, then
|
|
39
|
+
* metrics. The metrics plugin reads trait tables the syntax plugin put on
|
|
40
|
+
* the event.
|
|
41
|
+
* 3. **One mutable event object per dispatch**, threaded through every plugin
|
|
42
|
+
* in turn, with the caller reading the mutations back off it. The displaced
|
|
43
|
+
* bus also stamped `$$plugin_invoke_count` / `$$plugin_invoke_names` onto
|
|
44
|
+
* every event's data; no plugin and no report reads them, so they are not
|
|
45
|
+
* reproduced.
|
|
46
|
+
*
|
|
47
|
+
* Equivalence is not asserted by reasoning: `tests/lib/escomplex-kernel.test.js`
|
|
48
|
+
* replays a corpus captured under the displaced kernel *before* it left the
|
|
49
|
+
* tree (`tests/fixtures/escomplex-kernel-parity/`), because afterwards there is
|
|
50
|
+
* nothing left to compare against.
|
|
51
|
+
*
|
|
52
|
+
* ## The one uncontrolled input
|
|
53
|
+
*
|
|
54
|
+
* `.agents/` materializes into a consumer's repository root, so `@babel/parser`
|
|
55
|
+
* resolves from **their** `node_modules`. The preflight guard checks presence,
|
|
56
|
+
* not range, and `@babel/parser@8` is GA and rejects several names in the
|
|
57
|
+
* fixed plugin list. A manifest range documents the requirement; it does not
|
|
58
|
+
* enforce it. So the resolved major is asserted here, at load, with an error
|
|
59
|
+
* that names the problem — rather than surfacing as an opaque plugin-list
|
|
60
|
+
* syntax error partway through a scan.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
// Every metric-core package is reached by a STATIC import specifier with an
|
|
64
|
+
// explicit `.js` extension. That is not style: `tests/scripts/
|
|
65
|
+
// runtime-deps-drift.test.js` scans for literal `import`/`require` callees, so
|
|
66
|
+
// a package reached through an aliased `createRequire` would be a runtime
|
|
67
|
+
// dependency that is neither declared nor preflighted. The extensions are
|
|
68
|
+
// mandatory because `typhonjs-escomplex-commons` ships an empty
|
|
69
|
+
// `package.json` — no `main`, no `exports` — so a deep path is the only door.
|
|
70
|
+
import { parse as babelParse } from '@babel/parser';
|
|
71
|
+
import PluginMetricsModule from 'escomplex-plugin-metrics-module/dist/PluginMetricsModule.js';
|
|
72
|
+
import PluginSyntaxBabylon from 'escomplex-plugin-syntax-babylon/dist/PluginSyntaxBabylon.js';
|
|
73
|
+
import ASTWalker from 'typhonjs-ast-walker/dist/ASTWalker.js';
|
|
74
|
+
import ModuleScopeControl from 'typhonjs-escomplex-commons/dist/module/report/control/ModuleScopeControl.js';
|
|
75
|
+
import ModuleReport from 'typhonjs-escomplex-commons/dist/module/report/ModuleReport.js';
|
|
76
|
+
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
77
|
+
import { describeParserMajorError } from './runtime-deps/parser-major.js';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The displaced parser shim's plugin list, verbatim and in order.
|
|
81
|
+
*
|
|
82
|
+
* Order is preserved because it is cheap to preserve, not because a
|
|
83
|
+
* reordering is known to matter. The two entries with options
|
|
84
|
+
* (`decorators`, `pipelineOperator`) carry the shim's exact settings —
|
|
85
|
+
* `decoratorsBeforeExport: false` and the `minimal` pipeline proposal — which
|
|
86
|
+
* decide whether decorated classes and `|>` parse. Re-cloned per parse so a
|
|
87
|
+
* parser that mutated its options could not poison a later call.
|
|
88
|
+
*/
|
|
89
|
+
const PARSER_PLUGINS = [
|
|
90
|
+
'asyncGenerators',
|
|
91
|
+
'bigInt',
|
|
92
|
+
'classProperties',
|
|
93
|
+
'classPrivateProperties',
|
|
94
|
+
'classPrivateMethods',
|
|
95
|
+
['decorators', { decoratorsBeforeExport: false }],
|
|
96
|
+
'doExpressions',
|
|
97
|
+
'dynamicImport',
|
|
98
|
+
'exportDefaultFrom',
|
|
99
|
+
'exportNamespaceFrom',
|
|
100
|
+
'functionBind',
|
|
101
|
+
'functionSent',
|
|
102
|
+
'importMeta',
|
|
103
|
+
'jsx',
|
|
104
|
+
'logicalAssignment',
|
|
105
|
+
'nullishCoalescingOperator',
|
|
106
|
+
'numericSeparator',
|
|
107
|
+
'objectRestSpread',
|
|
108
|
+
'optionalCatchBinding',
|
|
109
|
+
'optionalChaining',
|
|
110
|
+
['pipelineOperator', { proposal: 'minimal' }],
|
|
111
|
+
'throwExpressions',
|
|
112
|
+
'typescript',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two plugins, in registration order: syntax populates the trait tables
|
|
117
|
+
* the metrics plugin then reads. Neither defines `onPluginLoad`, and the
|
|
118
|
+
* displaced bus was constructed without an eventbus, so there is no plugin
|
|
119
|
+
* lifecycle or eventbus coupling to reproduce — only this list.
|
|
120
|
+
*/
|
|
121
|
+
const PLUGINS = [
|
|
122
|
+
['escomplex-plugin-syntax-babylon', new PluginSyntaxBabylon()],
|
|
123
|
+
['escomplex-plugin-metrics-module', new PluginMetricsModule()],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// The kernel's code generator predates the Babel AST its own parser emits, so
|
|
127
|
+
// ordinary modern syntax aborts a WHOLE module — see `escomplex-ast-compat.js`
|
|
128
|
+
// for the defect and the upstream status. Installing at the kernel rather than
|
|
129
|
+
// at each caller makes the next scoring entrypoint correct by construction.
|
|
130
|
+
installAstCompat();
|
|
131
|
+
|
|
132
|
+
const parserProblem = describeParserMajorError();
|
|
133
|
+
if (parserProblem !== null) {
|
|
134
|
+
throw new Error(`[escomplex-kernel] ${parserProblem}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run one synchronous plugin dispatch.
|
|
139
|
+
*
|
|
140
|
+
* Reproduces the displaced bus's `invokeSyncEvents` for the degenerate shape
|
|
141
|
+
* this kernel uses: no `copyProps` (the shell passed `void 0` at every call
|
|
142
|
+
* site, so the merge base was always `{}`), no eventbus, and the caller
|
|
143
|
+
* reading its results back off the same mutated `data` object every plugin
|
|
144
|
+
* saw.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} method Plugin method name, e.g. `onEnterNode`.
|
|
147
|
+
* @param {object} passthru Properties placed on the event's `data`.
|
|
148
|
+
* @returns {object} The event `data`, after every plugin has mutated it.
|
|
149
|
+
*/
|
|
150
|
+
function dispatch(method, passthru) {
|
|
151
|
+
const event = {
|
|
152
|
+
data: { ...passthru },
|
|
153
|
+
extra: undefined,
|
|
154
|
+
eventbus: undefined,
|
|
155
|
+
pluginName: undefined,
|
|
156
|
+
pluginOptions: undefined,
|
|
157
|
+
};
|
|
158
|
+
for (const [name, instance] of PLUGINS) {
|
|
159
|
+
if (typeof instance[method] !== 'function') continue;
|
|
160
|
+
event.pluginName = name;
|
|
161
|
+
instance[method](event);
|
|
162
|
+
}
|
|
163
|
+
return event.data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The `ignoreKeys` a syntax trait wants withheld from the walker, if any.
|
|
168
|
+
*
|
|
169
|
+
* @param {object|undefined} syntax The trait entry for this node type.
|
|
170
|
+
* @param {object} node
|
|
171
|
+
* @param {object} parent
|
|
172
|
+
* @returns {string[]}
|
|
173
|
+
*/
|
|
174
|
+
function traitIgnoreKeys(syntax, node, parent) {
|
|
175
|
+
return typeof syntax === 'object' && syntax?.ignoreKeys
|
|
176
|
+
? syntax.ignoreKeys.valueOf(node, parent)
|
|
177
|
+
: [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The new scope a syntax trait opens at this node, if any.
|
|
182
|
+
*
|
|
183
|
+
* @param {object|undefined} syntax The trait entry for this node type.
|
|
184
|
+
* @param {object} node
|
|
185
|
+
* @param {object} parent
|
|
186
|
+
* @returns {object|null}
|
|
187
|
+
*/
|
|
188
|
+
function traitNewScope(syntax, node, parent) {
|
|
189
|
+
if (typeof syntax !== 'object' || !syntax?.newScope) return null;
|
|
190
|
+
return syntax.newScope.valueOf(node, parent) ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the walker visitor for one module traversal.
|
|
195
|
+
*
|
|
196
|
+
* Split out of {@link analyzeModule} so the enter/exit symmetry is readable
|
|
197
|
+
* side by side: each resolves the trait's scope, brackets the `scopeControl`
|
|
198
|
+
* mutation with a pre/post dispatch, and straddles it with the node dispatch
|
|
199
|
+
* in opposite order on the way in and out.
|
|
200
|
+
*
|
|
201
|
+
* The two event shapes are not interchangeable, and the difference is the
|
|
202
|
+
* displaced shell's, not a simplification available here: node events carry
|
|
203
|
+
* `syntaxes` and scope events do not, and a scope event names its scope
|
|
204
|
+
* `newScope` on the way in but `scope` on the way out.
|
|
205
|
+
*
|
|
206
|
+
* @param {{
|
|
207
|
+
* moduleReport: object,
|
|
208
|
+
* scopeControl: object,
|
|
209
|
+
* syntaxes: Record<string, object>,
|
|
210
|
+
* settings: object,
|
|
211
|
+
* }} context
|
|
212
|
+
* @returns {{enterNode: Function, exitNode: Function}}
|
|
213
|
+
*/
|
|
214
|
+
function buildVisitor({ moduleReport, scopeControl, syntaxes, settings }) {
|
|
215
|
+
const nodeBase = { moduleReport, scopeControl, syntaxes, settings };
|
|
216
|
+
const scopeBase = { moduleReport, scopeControl, settings };
|
|
217
|
+
return {
|
|
218
|
+
enterNode(node, parent) {
|
|
219
|
+
const syntax = syntaxes[node.type];
|
|
220
|
+
const event = dispatch('onEnterNode', {
|
|
221
|
+
...nodeBase,
|
|
222
|
+
ignoreKeys: traitIgnoreKeys(syntax, node, parent),
|
|
223
|
+
node,
|
|
224
|
+
parent,
|
|
225
|
+
});
|
|
226
|
+
const ignoreKeys = event !== null ? event.ignoreKeys : [];
|
|
227
|
+
const newScope = traitNewScope(syntax, node, parent);
|
|
228
|
+
if (newScope) {
|
|
229
|
+
const scoped = { ...scopeBase, newScope, node, parent };
|
|
230
|
+
dispatch('onModulePreScopeCreated', scoped);
|
|
231
|
+
scopeControl.createScope(newScope);
|
|
232
|
+
dispatch('onModulePostScopeCreated', scoped);
|
|
233
|
+
}
|
|
234
|
+
return ignoreKeys;
|
|
235
|
+
},
|
|
236
|
+
exitNode(node, parent) {
|
|
237
|
+
const syntax = syntaxes[node.type];
|
|
238
|
+
const newScope = traitNewScope(syntax, node, parent);
|
|
239
|
+
if (newScope) {
|
|
240
|
+
const scoped = { ...scopeBase, scope: newScope, node, parent };
|
|
241
|
+
dispatch('onModulePreScopePopped', scoped);
|
|
242
|
+
scopeControl.popScope(newScope);
|
|
243
|
+
dispatch('onModulePostScopePopped', scoped);
|
|
244
|
+
}
|
|
245
|
+
dispatch('onExitNode', { ...nodeBase, node, parent });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse and score one module.
|
|
252
|
+
*
|
|
253
|
+
* Drop-in replacement for the displaced `escomplex.analyzeModule(source)`:
|
|
254
|
+
* same report object, same `finalize()` shape, same thrown errors for source
|
|
255
|
+
* the kernel cannot handle.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} source JavaScript (or TypeScript) source text.
|
|
258
|
+
* @param {object} [options] Passed to the plugins' `onConfigure`, as before.
|
|
259
|
+
* @returns {object} The finalized module report.
|
|
260
|
+
* @throws {SyntaxError} Propagated from the parser, as before.
|
|
261
|
+
*/
|
|
262
|
+
export function analyzeModule(source, options = {}) {
|
|
263
|
+
const ast = babelParse(source, {
|
|
264
|
+
plugins: structuredClone(PARSER_PLUGINS),
|
|
265
|
+
sourceType: 'unambiguous',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const settings = dispatch('onConfigure', { options, settings: {} }).settings;
|
|
269
|
+
Object.freeze(settings);
|
|
270
|
+
const syntaxes = dispatch('onLoadSyntax', {
|
|
271
|
+
settings,
|
|
272
|
+
syntaxes: {},
|
|
273
|
+
}).syntaxes;
|
|
274
|
+
|
|
275
|
+
const moduleReport = new ModuleReport(
|
|
276
|
+
ast.loc.start.line,
|
|
277
|
+
ast.loc.end.line,
|
|
278
|
+
settings,
|
|
279
|
+
);
|
|
280
|
+
dispatch('onModuleStart', { ast, moduleReport, syntaxes, settings });
|
|
281
|
+
|
|
282
|
+
const scopeControl = new ModuleScopeControl(moduleReport);
|
|
283
|
+
new ASTWalker().traverse(
|
|
284
|
+
ast,
|
|
285
|
+
buildVisitor({ moduleReport, scopeControl, syntaxes, settings }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
for (const phase of [
|
|
289
|
+
'onModuleCalculate',
|
|
290
|
+
'onModuleAverage',
|
|
291
|
+
'onModulePostAverage',
|
|
292
|
+
'onModuleEnd',
|
|
293
|
+
]) {
|
|
294
|
+
dispatch(phase, { moduleReport, syntaxes, settings });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return moduleReport.finalize();
|
|
298
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import escomplex from 'typhonjs-escomplex';
|
|
3
2
|
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
3
|
+
import { analyzeModule } from './escomplex-kernel.js';
|
|
4
4
|
import { transpileIfNeeded } from './transpile.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -45,7 +45,7 @@ const UNSCORABLE = 0;
|
|
|
45
45
|
*/
|
|
46
46
|
export function scoreSource(sourceCode) {
|
|
47
47
|
try {
|
|
48
|
-
const score =
|
|
48
|
+
const score = analyzeModule(sourceCode)?.maintainability;
|
|
49
49
|
return Number.isFinite(score)
|
|
50
50
|
? { score, unscorable: false, reason: null }
|
|
51
51
|
: unscorable(`kernel returned a non-finite index (${String(score)})`);
|
|
@@ -141,7 +141,7 @@ export function scoreFile(filePath) {
|
|
|
141
141
|
*/
|
|
142
142
|
export function calculateReport(sourceCode) {
|
|
143
143
|
try {
|
|
144
|
-
const result =
|
|
144
|
+
const result = analyzeModule(sourceCode);
|
|
145
145
|
const methods = (result.methods ?? []).map((m) => ({
|
|
146
146
|
name: m.name,
|
|
147
147
|
maintainability:
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { hasSurvivingCritical } from '../audit-suite/findings.js';
|
|
43
43
|
import { resolveConfig } from '../config-resolver.js';
|
|
44
44
|
import { computeChangeSet } from './change-set.js';
|
|
45
|
+
import { remoteBaseRef } from './review-base-ref.js';
|
|
45
46
|
import { deriveChangeLevel, resolveDepth } from './review-depth.js';
|
|
46
47
|
import {
|
|
47
48
|
collectProviderDegradations,
|
|
@@ -70,11 +71,14 @@ import { upsertStructuredComment } from './ticketing.js';
|
|
|
70
71
|
*/
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
|
-
* Resolve the
|
|
74
|
-
*
|
|
74
|
+
* Resolve the base ref used when a caller omits `baseRef`. Remote-qualified,
|
|
75
|
+
* never the bare branch name — a caller that does not name a base must not
|
|
76
|
+
* silently inherit the local ref's drift (Story #5325). An unfetched remote
|
|
77
|
+
* then yields an unenumerable diff, which every downstream consumer already
|
|
78
|
+
* fails safe on, instead of a confidently-wrong wide one.
|
|
75
79
|
*/
|
|
76
80
|
function resolveConfigBase(config) {
|
|
77
|
-
return config?.project?.baseBranch ?? 'main';
|
|
81
|
+
return remoteBaseRef(config?.project?.baseBranch ?? 'main');
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Positive-integer override, else the supplied default. */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pinned-identifier-lint.js — the `pinned-identifier` advisory lint over a
|
|
3
|
+
* draft Story's `acceptance[]` (Story #5323).
|
|
4
|
+
*
|
|
5
|
+
* `acceptance[]` is the Story's **binding** contract and `changes[]` only an
|
|
6
|
+
* advisory sketch the deliverer may revise, so an acceptance item naming an
|
|
7
|
+
* internal symbol pins something the executor is free to rename out from
|
|
8
|
+
* under it. The story-author prompt has always said so; nothing surfaced a
|
|
9
|
+
* violation, which left the rule enforced only by the authoring model
|
|
10
|
+
* remembering it.
|
|
11
|
+
*
|
|
12
|
+
* The classifier lives in its own module because its vocabulary is its own:
|
|
13
|
+
* four token grammars and a call-suffix strip that the sibling prose lint in
|
|
14
|
+
* `plan-text-hygiene.js` shares nothing with.
|
|
15
|
+
*
|
|
16
|
+
* Advisory by contract: findings are deterministic text for the persist
|
|
17
|
+
* dry-run's warning list. They never gate persist and spawn nothing.
|
|
18
|
+
*
|
|
19
|
+
* Pure, synchronous, no I/O.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/pinned-identifier-lint
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An inline code span carrying one of these is naming something other than a
|
|
26
|
+
* source identifier, and is never a pinned identifier:
|
|
27
|
+
*
|
|
28
|
+
* - `/` — a file path or a glob (`src/app.js`, `tests/x/*.spec.ts`);
|
|
29
|
+
* - `.` — a dotted path, a filename, or a config key
|
|
30
|
+
* (`delivery.routing.closeAndLand`, `story-body.js`);
|
|
31
|
+
* - `:` — a label (`agent::ready`, `type::story`);
|
|
32
|
+
* - `-` — a kebab token: a `data-testid` value, a slug, a package name, or
|
|
33
|
+
* a CLI flag (`--dry-run`);
|
|
34
|
+
* - whitespace — an argv shape, so a command (`npm run lint`);
|
|
35
|
+
* - `[` / `]` — a field reference (`acceptance[]`).
|
|
36
|
+
*/
|
|
37
|
+
const NON_IDENTIFIER_MARKERS = /[/.:\-\s[\]]/;
|
|
38
|
+
|
|
39
|
+
/** A bare source identifier, with an optional call suffix stripped. */
|
|
40
|
+
const BARE_IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An UPPER_SNAKE token. Exempt by contract: an environment variable and an
|
|
44
|
+
* internal constant are the same shape, and warning on every `DATABASE_URL`
|
|
45
|
+
* to catch the occasional pinned constant trades a real class of false
|
|
46
|
+
* positives for a marginal gain.
|
|
47
|
+
*/
|
|
48
|
+
const UPPER_SNAKE_RE = /^[A-Z0-9_$]+$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A case transition (`staffCan`, `MyCalendarBoard`, `TimeGrid`) — the
|
|
52
|
+
* signature that separates a source identifier from a prose word a criterion
|
|
53
|
+
* legitimately quotes (`landed`, `pending`, `main`).
|
|
54
|
+
*/
|
|
55
|
+
const CASE_TRANSITION_RE = /[a-z][A-Z]/;
|
|
56
|
+
|
|
57
|
+
/** The remedy, either side of the identifiers a finding names. */
|
|
58
|
+
const MESSAGE_HEAD = 'Acceptance item pins the internal identifier(s) ';
|
|
59
|
+
const MESSAGE_TAIL =
|
|
60
|
+
'; `changes[]` is an advisory sketch the deliverer may reshape, so assert ' +
|
|
61
|
+
'the observable behaviour instead of the symbol that implements it.';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Collect the inline code spans of one acceptance item.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} item
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
function codeSpans(item) {
|
|
70
|
+
return [...String(item ?? '').matchAll(/`([^`\n]+)`/g)].map((m) => m[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide whether one code span pins a source identifier: a bare token
|
|
75
|
+
* carrying a case transition, with no separator that would make it a path, a
|
|
76
|
+
* label, a kebab token, a flag or a command, and not an UPPER_SNAKE name.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately conservative in one direction only: a false positive costs a
|
|
79
|
+
* warning line the author dismisses, while a false negative on a path, a
|
|
80
|
+
* testid or a command would train the author to ignore the lint.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} span
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function isPinnedIdentifier(span) {
|
|
86
|
+
const token = span.trim().replace(/\(\s*\)$/, '');
|
|
87
|
+
return (
|
|
88
|
+
!NON_IDENTIFIER_MARKERS.test(token) &&
|
|
89
|
+
BARE_IDENTIFIER_RE.test(token) &&
|
|
90
|
+
!UPPER_SNAKE_RE.test(token) &&
|
|
91
|
+
CASE_TRANSITION_RE.test(token)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The authored `acceptance[]` of one draft Story.
|
|
97
|
+
*
|
|
98
|
+
* It is authored at the ticket's top level — the machine contract the
|
|
99
|
+
* validators read — and synced into the body only at assembly, so the top
|
|
100
|
+
* level wins; the parsed body covers a draft that carries it inline.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} story The raw draft ticket.
|
|
103
|
+
* @param {object} body Its parsed body.
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
function resolveAcceptance(story, body) {
|
|
107
|
+
if (Array.isArray(story?.acceptance)) return story.acceptance;
|
|
108
|
+
return Array.isArray(body?.acceptance) ? body.acceptance : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Evaluate the lint over one draft Story — one finding per offending
|
|
113
|
+
* acceptance item, naming every identifier it pinned.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} story The raw draft ticket.
|
|
116
|
+
* @param {object} body Its parsed body.
|
|
117
|
+
* @param {string} slug
|
|
118
|
+
* @param {(text: string) => string} excerpt Evidence truncator, shared with
|
|
119
|
+
* the sibling lints so every finding excerpts alike.
|
|
120
|
+
* @returns {Array<{ kind: 'pinned-identifier', slug: string, evidence: string, message: string }>}
|
|
121
|
+
*/
|
|
122
|
+
export function findPinnedIdentifiers(story, body, slug, excerpt) {
|
|
123
|
+
const acceptance = resolveAcceptance(story, body);
|
|
124
|
+
const findings = [];
|
|
125
|
+
for (const item of acceptance) {
|
|
126
|
+
const pinned = codeSpans(item).filter(isPinnedIdentifier);
|
|
127
|
+
if (pinned.length === 0) continue;
|
|
128
|
+
const named = pinned.map((name) => `\`${name}\``).join(', ');
|
|
129
|
+
findings.push({
|
|
130
|
+
kind: 'pinned-identifier',
|
|
131
|
+
slug,
|
|
132
|
+
evidence: excerpt(String(item ?? '')),
|
|
133
|
+
message: `${MESSAGE_HEAD}${named}${MESSAGE_TAIL}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return findings;
|
|
137
|
+
}
|