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.
Files changed (58) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/agents/story-worker.md +12 -11
  3. package/.agents/docs/SDLC.md +6 -7
  4. package/.agents/docs/quality-gates.md +1 -1
  5. package/.agents/instructions.md +2 -3
  6. package/.agents/runtime-deps.json +7 -2
  7. package/.agents/schemas/crap-baseline.schema.json +1 -1
  8. package/.agents/schemas/crap-report.schema.json +1 -1
  9. package/.agents/scripts/evidence-gate.js +17 -1
  10. package/.agents/scripts/install-matrix-assert.js +48 -3
  11. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  12. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  13. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  14. package/.agents/scripts/lib/crap-engine.js +2 -2
  15. package/.agents/scripts/lib/crap-utils.js +21 -5
  16. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  17. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  18. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  19. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  20. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  21. package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
  22. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  23. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
  24. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
  25. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
  26. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  27. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  28. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  29. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
  30. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  32. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  34. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  35. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  36. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  37. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  38. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  39. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  40. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  41. package/.agents/scripts/lib/story-body/story-body.js +36 -2
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
  43. package/.agents/scripts/lib/test-run-credit.js +23 -12
  44. package/.agents/scripts/plan-persist.js +0 -11
  45. package/.agents/skills/skills.index.json +1 -11
  46. package/.agents/workflows/audit-to-stories.md +14 -11
  47. package/.agents/workflows/helpers/deliver-digest.md +22 -15
  48. package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
  49. package/.agents/workflows/helpers/deliver-story.md +6 -5
  50. package/.agents/workflows/helpers/plan-reference.md +53 -13
  51. package/.agents/workflows/mandrel-plan.md +19 -14
  52. package/README.md +3 -3
  53. package/docs/CHANGELOG.md +21 -0
  54. package/lib/cli/registry.js +143 -27
  55. package/package.json +7 -2
  56. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  57. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  58. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * ## The upstream defect
6
6
  *
7
- * `typhonjs-escomplex` parses with `@typhonjs/babel-parser`, so every AST it
8
- * analyses is a **Babel** AST. But `typhonjs-escomplex-commons`'
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* `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.
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 escomplex.
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 fromKernel = createRequire(requireFn.resolve('typhonjs-escomplex'));
98
- const mod = fromKernel(
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 kernel for 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 fromKernel = createRequire(require.resolve('typhonjs-escomplex'));
351
- const mod = fromKernel(
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 = escomplex.analyzeModule(sourceCode)?.maintainability;
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 = escomplex.analyzeModule(sourceCode);
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 project base branch fallback used when a caller omits
74
- * `baseRef`.
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
+ }