mandrel 2.56.0 → 2.57.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/plan-critic.md +13 -18
- package/.agents/agents/story-worker.md +25 -34
- package/.agents/docs/agentrc-reference.json +0 -30
- package/.agents/docs/configuration.md +8 -28
- package/.agents/docs/execution-reference.md +5 -5
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/instructions.md +9 -10
- package/.agents/schemas/agentrc.schema.json +9 -185
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +107 -17
- package/.agents/scripts/ceremony-derive.js +191 -0
- package/.agents/scripts/check-context-budget.js +28 -33
- package/.agents/scripts/check-cyclomatic.js +4 -3
- package/.agents/scripts/deliver-light.js +31 -94
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
- package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
- package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
- package/.agents/scripts/lib/close-validation/gates.js +52 -1
- package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
- package/.agents/scripts/lib/config/delivery-routing.js +7 -33
- package/.agents/scripts/lib/config/explain.js +0 -19
- package/.agents/scripts/lib/config/limits.js +18 -78
- package/.agents/scripts/lib/config/quality.js +6 -3
- package/.agents/scripts/lib/config/runners.js +3 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
- package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
- package/.agents/scripts/lib/config-settings-schema.js +16 -143
- package/.agents/scripts/lib/crap-engine.js +35 -4
- package/.agents/scripts/lib/crap-utils.js +17 -1
- package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
- package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
- package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
- package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
- package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +118 -297
- package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
- package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
- package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
- package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
- package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
- package/.agents/scripts/lib/story-body/story-body.js +17 -237
- package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
- package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
- package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
- package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
- package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
- package/.agents/scripts/lib/test-run-credit.js +266 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
- package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
- package/.agents/scripts/lib/workers/crap-worker.js +32 -41
- package/.agents/scripts/plan-context.js +7 -9
- package/.agents/scripts/plan-critics.js +28 -54
- package/.agents/scripts/plan-persist.js +25 -68
- package/.agents/scripts/quality-preview.js +51 -0
- package/.agents/scripts/run-tests.js +12 -0
- package/.agents/scripts/stories-wave-tick.js +23 -45
- package/.agents/scripts/test-isolate.js +13 -180
- package/.agents/scripts/update-coverage-baseline.js +25 -70
- package/.agents/scripts/update-crap-baseline.js +19 -123
- package/.agents/skills/core/scope-triage/SKILL.md +3 -3
- package/.agents/workflows/audit-clean-code.md +4 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
- package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
- package/.agents/workflows/helpers/code-review.md +2 -3
- package/.agents/workflows/helpers/deliver-digest.md +41 -57
- package/.agents/workflows/helpers/deliver-light.md +40 -105
- package/.agents/workflows/helpers/deliver-reference.md +1 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +37 -58
- package/.agents/workflows/helpers/deliver-story.md +9 -13
- package/.agents/workflows/helpers/plan-reference.md +132 -219
- package/.agents/workflows/mandrel-plan.md +27 -40
- package/.agents/workflows/memory-consolidate.md +9 -13
- package/docs/CHANGELOG.md +23 -0
- package/lib/migrations/index.js +4 -0
- package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
- package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/framework-version.js +0 -39
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
- package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
- package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { resolveListValue } from '../config/shared.js';
|
|
2
1
|
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
3
2
|
import { collectStoryAssumptionEntries } from './file-assumptions.js';
|
|
4
3
|
import { computeStoryReachability } from './story-reachability.js';
|
|
@@ -10,12 +9,11 @@ import { computeStoryReachability } from './story-reachability.js';
|
|
|
10
9
|
* `resolveStoryBody` (Story #4271).
|
|
11
10
|
*
|
|
12
11
|
* The decomposer emits `body` as the canonical serialized **string**, but
|
|
13
|
-
* the conflict passes (`indexConsumers`, `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
* findings emitted nothing. Parsing the body once at the entry point and
|
|
12
|
+
* the conflict passes (`indexConsumers`, `computeMissingBddScaffoldFindings`,
|
|
13
|
+
* and the producer path scan in `collectStoryProducerPaths`) historically
|
|
14
|
+
* read `story.body` only when it was already an object — so on the
|
|
15
|
+
* production string shape the `implicit-cross-story-dep` and
|
|
16
|
+
* `missing-bdd-scaffold` findings emitted nothing. Parsing the body once at the entry point and
|
|
19
17
|
* threading the normalized Story through every pass restores parity.
|
|
20
18
|
*
|
|
21
19
|
* `collectStoryAssumptionEntries` already parses string bodies itself, so a
|
|
@@ -88,54 +86,14 @@ function normalizeStoryBody(story) {
|
|
|
88
86
|
* @typedef {SharedEditorFinding | ImplicitCrossStoryDepFinding} ConflictFinding
|
|
89
87
|
*/
|
|
90
88
|
|
|
91
|
-
const DEFAULT_POLICY = Object.freeze({
|
|
92
|
-
failOnSharedEditors: false,
|
|
93
|
-
requireExplicitCrossStoryDeps: false,
|
|
94
|
-
failOnRegistryConflicts: false,
|
|
95
|
-
failOnMissingBddScaffold: false,
|
|
96
|
-
largeFanOutThreshold: 10,
|
|
97
|
-
registries: null, // null = use DEFAULT_REGISTRY_PATTERNS
|
|
98
|
-
fanOutCounter: null, // null = no fan-out probe (skip)
|
|
99
|
-
});
|
|
100
|
-
|
|
101
89
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* close after the first.
|
|
108
|
-
*
|
|
109
|
-
* Patterns support two shapes:
|
|
110
|
-
* - exact path — `lib/orchestration/lifecycle/listeners/index.js`
|
|
111
|
-
* - `**` suffix — `**\/listeners/index.js` (matches any depth)
|
|
112
|
-
*
|
|
113
|
-
* Module-private since `resolveConflictPolicy` became the one production
|
|
114
|
-
* reader; tests reach it through `_internal`.
|
|
90
|
+
* Every conflict class is advisory (`'soft'`) since Story #5312: the
|
|
91
|
+
* `planning.failOnSharedEditors` / `requireExplicitCrossStoryDeps` /
|
|
92
|
+
* `failOnRegistryConflicts` / `failOnLargeFanOut` upgrade knobs are gone,
|
|
93
|
+
* along with the registry and fan-out findings they gated. A finding is a
|
|
94
|
+
* line in the plan summary the operator reads, never a refusal.
|
|
115
95
|
*/
|
|
116
|
-
const
|
|
117
|
-
'lib/orchestration/lifecycle/listeners/index.js',
|
|
118
|
-
'**/listeners/index.js',
|
|
119
|
-
'**/handlers/index.js',
|
|
120
|
-
]);
|
|
121
|
-
|
|
122
|
-
function matchRegistryPattern(path, pattern) {
|
|
123
|
-
if (pattern.startsWith('**/')) {
|
|
124
|
-
const tail = pattern.slice(3);
|
|
125
|
-
return path === tail || path.endsWith(`/${tail}`);
|
|
126
|
-
}
|
|
127
|
-
return path === pattern;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function isRegistryPath(path, patterns) {
|
|
131
|
-
for (const p of patterns) if (matchRegistryPattern(path, p)) return true;
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function parentDirOf(path) {
|
|
136
|
-
const idx = path.lastIndexOf('/');
|
|
137
|
-
return idx <= 0 ? '' : path.slice(0, idx);
|
|
138
|
-
}
|
|
96
|
+
const SOFT = 'soft';
|
|
139
97
|
|
|
140
98
|
/**
|
|
141
99
|
* Assumptions that imply a *write* to a path — and therefore make the Story
|
|
@@ -321,36 +279,6 @@ function computeImplicitDepFindings(consumers, producers, reach, severity) {
|
|
|
321
279
|
return findings;
|
|
322
280
|
}
|
|
323
281
|
|
|
324
|
-
/**
|
|
325
|
-
* Index every object-form `body.changes` entry by `{ path, assumption }`
|
|
326
|
-
* along with its parent Task/Story so the registry-and-fan-out passes can
|
|
327
|
-
* reason about creates/deletes without re-walking the ticket array.
|
|
328
|
-
*/
|
|
329
|
-
function indexAssumptionEntries(stories) {
|
|
330
|
-
const entries = [];
|
|
331
|
-
for (const story of stories) {
|
|
332
|
-
const body = story?.body;
|
|
333
|
-
if (!body || typeof body !== 'object') continue;
|
|
334
|
-
const changes = Array.isArray(body.changes) ? body.changes : [];
|
|
335
|
-
for (const change of changes) {
|
|
336
|
-
if (
|
|
337
|
-
change === null ||
|
|
338
|
-
typeof change !== 'object' ||
|
|
339
|
-
typeof change.path !== 'string' ||
|
|
340
|
-
change.path.length === 0
|
|
341
|
-
)
|
|
342
|
-
continue;
|
|
343
|
-
entries.push({
|
|
344
|
-
path: change.path,
|
|
345
|
-
assumption: change.assumption ?? null,
|
|
346
|
-
storySlug: storySlugOf(story),
|
|
347
|
-
taskSlug: story.slug,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return entries;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
282
|
/**
|
|
355
283
|
* Compute `missing-bdd-scaffold` findings (Story #3857).
|
|
356
284
|
*
|
|
@@ -439,287 +367,17 @@ function computeMissingBddScaffoldFindings(stories, reach, severity) {
|
|
|
439
367
|
return findings;
|
|
440
368
|
}
|
|
441
369
|
|
|
442
|
-
/**
|
|
443
|
-
* Compute `cross-cutting-registries` findings (Story #2962).
|
|
444
|
-
*
|
|
445
|
-
* A registry/barrel file (e.g. `lib/orchestration/lifecycle/listeners/index.js`)
|
|
446
|
-
* collides whenever two or more concurrent Stories either
|
|
447
|
-
*
|
|
448
|
-
* (a) directly edit the registry file, OR
|
|
449
|
-
* (b) create a new sibling file in the same directory that the registry
|
|
450
|
-
* would have to wire up.
|
|
451
|
-
*
|
|
452
|
-
* For each known registry pattern (`patterns`), we collect every Story whose
|
|
453
|
-
* Tasks satisfy (a) or (b). When ≥2 such Stories sit in the same wave (no
|
|
454
|
-
* transitive `depends_on` between them), emit a single finding keyed by the
|
|
455
|
-
* registry path.
|
|
456
|
-
*
|
|
457
|
-
* Reached from the module's `_internal` named export. The pass is pure — it
|
|
458
|
-
* performs no filesystem I/O and spawns no process — so its optional final
|
|
459
|
-
* `deps` parameter seams the three collaborating predicates rather than a
|
|
460
|
-
* built-in; each entry defaults to the real implementation
|
|
461
|
-
* (`.agents/rules/test-seams.md` rules 1-3: the defaults live on the function,
|
|
462
|
-
* never on a module-level mutable variable).
|
|
463
|
-
*
|
|
464
|
-
* @param {object} input
|
|
465
|
-
* @param {{
|
|
466
|
-
* isRegistryPathImpl?: typeof isRegistryPath,
|
|
467
|
-
* inSameWaveImpl?: typeof inSameWave,
|
|
468
|
-
* registryRegistryImpl?: typeof registryRegistry,
|
|
469
|
-
* }} [deps]
|
|
470
|
-
*/
|
|
471
|
-
function computeRegistryFindings(
|
|
472
|
-
{ stories, reach, patterns, producers, assumptionEntries, severity },
|
|
473
|
-
{
|
|
474
|
-
isRegistryPathImpl = isRegistryPath,
|
|
475
|
-
inSameWaveImpl = inSameWave,
|
|
476
|
-
registryRegistryImpl = registryRegistry,
|
|
477
|
-
} = {},
|
|
478
|
-
) {
|
|
479
|
-
const findings = [];
|
|
480
|
-
// Build the matching registry path set from producer & creator paths.
|
|
481
|
-
const registryHits = new Map(); // registryPath -> Map<storySlug, producers[]>
|
|
482
|
-
function bump(registryPath, entry) {
|
|
483
|
-
let perStory = registryHits.get(registryPath);
|
|
484
|
-
if (!perStory) {
|
|
485
|
-
perStory = new Map();
|
|
486
|
-
registryHits.set(registryPath, perStory);
|
|
487
|
-
}
|
|
488
|
-
const existing = perStory.get(entry.storySlug) ?? [];
|
|
489
|
-
existing.push(entry);
|
|
490
|
-
perStory.set(entry.storySlug, existing);
|
|
491
|
-
}
|
|
492
|
-
// (a) direct registry edits — object-form `{ path, assumption }` entries
|
|
493
|
-
// from `indexAssumptionEntries` (and the producer index built from them).
|
|
494
|
-
for (const [path, entries] of producers.entries()) {
|
|
495
|
-
if (!isRegistryPathImpl(path, patterns)) continue;
|
|
496
|
-
for (const e of entries) {
|
|
497
|
-
bump(path, {
|
|
498
|
-
storySlug: e.storySlug,
|
|
499
|
-
taskSlug: e.taskSlug,
|
|
500
|
-
path,
|
|
501
|
-
reason: 'edits-registry',
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
for (const e of assumptionEntries) {
|
|
506
|
-
if (!isRegistryPathImpl(e.path, patterns)) continue;
|
|
507
|
-
bump(e.path, {
|
|
508
|
-
storySlug: e.storySlug,
|
|
509
|
-
taskSlug: e.taskSlug,
|
|
510
|
-
path: e.path,
|
|
511
|
-
reason: 'edits-registry',
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
// (b) sibling creates that would require registration in a registry.
|
|
515
|
-
// A registry path's parent dir defines its "registration scope" — any
|
|
516
|
-
// new file in that scope is a wiring candidate.
|
|
517
|
-
const scopeByRegistry = new Map();
|
|
518
|
-
for (const story of stories) {
|
|
519
|
-
const body = story?.body;
|
|
520
|
-
if (!body || typeof body !== 'object') continue;
|
|
521
|
-
for (const change of body.changes ?? []) {
|
|
522
|
-
if (
|
|
523
|
-
change === null ||
|
|
524
|
-
typeof change !== 'object' ||
|
|
525
|
-
change.assumption !== 'creates' ||
|
|
526
|
-
typeof change.path !== 'string'
|
|
527
|
-
)
|
|
528
|
-
continue;
|
|
529
|
-
const childParent = parentDirOf(change.path);
|
|
530
|
-
if (!childParent) continue;
|
|
531
|
-
for (const reg of registryRegistryImpl(
|
|
532
|
-
producers,
|
|
533
|
-
assumptionEntries,
|
|
534
|
-
patterns,
|
|
535
|
-
scopeByRegistry,
|
|
536
|
-
)) {
|
|
537
|
-
if (reg.parentDir !== childParent) continue;
|
|
538
|
-
bump(reg.path, {
|
|
539
|
-
storySlug: storySlugOf(story),
|
|
540
|
-
taskSlug: story.slug,
|
|
541
|
-
path: change.path,
|
|
542
|
-
reason: 'creates-sibling',
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
for (const [registryPath, perStory] of registryHits.entries()) {
|
|
548
|
-
const stories = Array.from(perStory.keys());
|
|
549
|
-
if (stories.length < 2) continue;
|
|
550
|
-
const cluster = new Set();
|
|
551
|
-
for (let i = 0; i < stories.length; i += 1) {
|
|
552
|
-
for (let j = i + 1; j < stories.length; j += 1) {
|
|
553
|
-
if (inSameWaveImpl(reach, stories[i], stories[j])) {
|
|
554
|
-
cluster.add(stories[i]);
|
|
555
|
-
cluster.add(stories[j]);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
if (cluster.size === 0) continue;
|
|
560
|
-
const clusterSlugs = Array.from(cluster).sort();
|
|
561
|
-
const producerList = [];
|
|
562
|
-
for (const slug of clusterSlugs) {
|
|
563
|
-
for (const p of perStory.get(slug) ?? []) producerList.push(p);
|
|
564
|
-
}
|
|
565
|
-
findings.push({
|
|
566
|
-
kind: 'cross-cutting-registries',
|
|
567
|
-
severity,
|
|
568
|
-
registryPath,
|
|
569
|
-
storySlugs: clusterSlugs,
|
|
570
|
-
producers: producerList,
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
return findings;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Resolve the set of registry paths that should be considered in scope for
|
|
578
|
-
* the sibling-create check. We treat any path that already matches a
|
|
579
|
-
* registry pattern (whether produced by a Task or not — the path exists in
|
|
580
|
-
* the project) as in-scope. To stay path-knowledge-free at plan time, we
|
|
581
|
-
* only consider patterns that are explicit paths (no `**`) or that match a
|
|
582
|
-
* path produced by some Task in the spec.
|
|
583
|
-
*/
|
|
584
|
-
function registryRegistry(producers, assumptionEntries, patterns, cache) {
|
|
585
|
-
if (cache.size > 0) return cache.values();
|
|
586
|
-
// Explicit (no-glob) patterns: always in scope as their own path.
|
|
587
|
-
for (const pat of patterns) {
|
|
588
|
-
if (pat.startsWith('**/')) continue;
|
|
589
|
-
cache.set(pat, { path: pat, parentDir: parentDirOf(pat) });
|
|
590
|
-
}
|
|
591
|
-
// Glob patterns: in scope iff some Task in the spec references a matching
|
|
592
|
-
// path via changes (edits or creates). Avoids false positives when a
|
|
593
|
-
// glob pattern doesn't apply to this repo at all.
|
|
594
|
-
for (const path of producers.keys()) {
|
|
595
|
-
if (cache.has(path)) continue;
|
|
596
|
-
if (isRegistryPath(path, patterns)) {
|
|
597
|
-
cache.set(path, { path, parentDir: parentDirOf(path) });
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
for (const e of assumptionEntries) {
|
|
601
|
-
if (cache.has(e.path)) continue;
|
|
602
|
-
if (isRegistryPath(e.path, patterns)) {
|
|
603
|
-
cache.set(e.path, { path: e.path, parentDir: parentDirOf(e.path) });
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
return cache.values();
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Normalize a fan-out probe result into `{ count, files, probe }`.
|
|
611
|
-
*
|
|
612
|
-
* The production probe reports its referencing files and the exact command
|
|
613
|
-
* that found them so an operator can reproduce the figure (Story #4547).
|
|
614
|
-
* A bare number stays valid — injected test counters and any consumer
|
|
615
|
-
* counter written against the Story #2962 contract keep working, they just
|
|
616
|
-
* carry no audit trail.
|
|
617
|
-
*/
|
|
618
|
-
function normalizeFanOutProbe(result) {
|
|
619
|
-
if (typeof result === 'number') {
|
|
620
|
-
return { count: result, files: [], probe: null };
|
|
621
|
-
}
|
|
622
|
-
if (result === null || typeof result !== 'object') {
|
|
623
|
-
return { count: 0, files: [], probe: null };
|
|
624
|
-
}
|
|
625
|
-
const files = Array.isArray(result.files) ? result.files : [];
|
|
626
|
-
const count = Number.isFinite(result.count) ? result.count : files.length;
|
|
627
|
-
return { count, files, probe: result.probe ?? null };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Index the basenames this spec *creates*, so a deletion that is really one
|
|
632
|
-
* half of a move can be told apart from a genuine wide-coupling removal.
|
|
633
|
-
*/
|
|
634
|
-
function indexCreatedBasenames(assumptionEntries) {
|
|
635
|
-
const byBasename = new Map();
|
|
636
|
-
for (const entry of assumptionEntries) {
|
|
637
|
-
if (entry.assumption !== 'creates') continue;
|
|
638
|
-
const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
|
639
|
-
if (!byBasename.has(base)) byBasename.set(base, entry.path);
|
|
640
|
-
}
|
|
641
|
-
return byBasename;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Compute `fan-out-warning` findings (Story #2962, reworked in #4547).
|
|
646
|
-
*
|
|
647
|
-
* For each `body.changes` entry whose `assumption` is `"deletes"`, probe the
|
|
648
|
-
* files at the base branch that genuinely import or require the deleted
|
|
649
|
-
* module. When that count exceeds the configured `largeFanOutThreshold`,
|
|
650
|
-
* emit a finding carrying the referencing files and the probe that produced
|
|
651
|
-
* them.
|
|
652
|
-
*
|
|
653
|
-
* The finding also records whether the deletion is **rename-shaped** — the
|
|
654
|
-
* same spec creates a file with the deleted module's basename elsewhere —
|
|
655
|
-
* because the remedy diverges: a move wants its importers repointed in one
|
|
656
|
-
* Story, not a subsystem-by-subsystem migration split across several.
|
|
657
|
-
*
|
|
658
|
-
* The default severity is always `'soft'` — the persist gate enforces a
|
|
659
|
-
* hard refusal via the `--allow-large-fan-out` operator flag, since the
|
|
660
|
-
* planner cannot reduce call sites by re-prompting. Severity may still be
|
|
661
|
-
* upgraded to `'hard'` via `failOnLargeFanOut` for callers that want the
|
|
662
|
-
* standard `errors[]` path (e.g. CI dry-runs).
|
|
663
|
-
*/
|
|
664
|
-
function computeFanOutFindings({
|
|
665
|
-
assumptionEntries,
|
|
666
|
-
threshold,
|
|
667
|
-
counter,
|
|
668
|
-
severity,
|
|
669
|
-
}) {
|
|
670
|
-
if (typeof counter !== 'function') return [];
|
|
671
|
-
if (!Number.isFinite(threshold) || threshold < 0) return [];
|
|
672
|
-
const findings = [];
|
|
673
|
-
const cache = new Map();
|
|
674
|
-
const createdBasenames = indexCreatedBasenames(assumptionEntries);
|
|
675
|
-
for (const entry of assumptionEntries) {
|
|
676
|
-
if (entry.assumption !== 'deletes') continue;
|
|
677
|
-
let probed = cache.get(entry.path);
|
|
678
|
-
if (probed === undefined) {
|
|
679
|
-
probed = normalizeFanOutProbe(counter({ path: entry.path }));
|
|
680
|
-
cache.set(entry.path, probed);
|
|
681
|
-
}
|
|
682
|
-
if (probed.count <= threshold) continue;
|
|
683
|
-
const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
|
684
|
-
const renameTarget = createdBasenames.get(base);
|
|
685
|
-
findings.push({
|
|
686
|
-
kind: 'fan-out-warning',
|
|
687
|
-
severity,
|
|
688
|
-
taskSlug: entry.taskSlug,
|
|
689
|
-
storySlug: entry.storySlug,
|
|
690
|
-
path: entry.path,
|
|
691
|
-
callSiteCount: probed.count,
|
|
692
|
-
callSites: probed.files,
|
|
693
|
-
probe: probed.probe,
|
|
694
|
-
renameShaped: renameTarget !== undefined && renameTarget !== entry.path,
|
|
695
|
-
renameTarget: renameTarget === entry.path ? null : (renameTarget ?? null),
|
|
696
|
-
threshold,
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
return findings;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
370
|
/**
|
|
703
371
|
* Public entry point. Walks the normalized ticket spec once and returns
|
|
704
|
-
* the structured cross-Story findings array.
|
|
705
|
-
*
|
|
706
|
-
*
|
|
372
|
+
* the structured cross-Story findings array. Every finding is `'soft'`
|
|
373
|
+
* (Story #5312) — an advisory line for the plan summary, never an
|
|
374
|
+
* `errors[]` entry.
|
|
707
375
|
*
|
|
708
376
|
* @param {object} input
|
|
709
377
|
* @param {object[]} input.stories
|
|
710
|
-
* @param {object} [input.policy]
|
|
711
|
-
* @param {boolean} [input.policy.failOnSharedEditors=false]
|
|
712
|
-
* @param {boolean} [input.policy.requireExplicitCrossStoryDeps=false]
|
|
713
|
-
* @param {boolean} [input.policy.failOnRegistryConflicts=false]
|
|
714
|
-
* @param {boolean} [input.policy.failOnMissingBddScaffold=false]
|
|
715
|
-
* @param {boolean} [input.policy.failOnLargeFanOut=false]
|
|
716
|
-
* @param {number} [input.policy.largeFanOutThreshold=10]
|
|
717
|
-
* @param {string[]} [input.policy.registries] Registry patterns (defaults to DEFAULT_REGISTRY_PATTERNS).
|
|
718
|
-
* @param {(arg: { path: string }) => number} [input.policy.fanOutCounter] Optional probe; when omitted the fan-out pass is skipped.
|
|
719
378
|
* @returns {ConflictFinding[]}
|
|
720
379
|
*/
|
|
721
|
-
export function computeConflictFindings({ stories
|
|
722
|
-
const merged = { ...DEFAULT_POLICY, ...(policy ?? {}) };
|
|
380
|
+
export function computeConflictFindings({ stories } = {}) {
|
|
723
381
|
// Story #4271: normalize every Story's body to its structured object form
|
|
724
382
|
// once, up front, so the canonical serialized **string** shape the
|
|
725
383
|
// decomposer emits is scanned at parity with the pre-serialize object
|
|
@@ -728,87 +386,13 @@ export function computeConflictFindings({ stories, policy } = {}) {
|
|
|
728
386
|
const producers = indexProducers(storyList);
|
|
729
387
|
const consumers = indexConsumers(storyList, producers);
|
|
730
388
|
const reach = computeStoryReachability(storyList);
|
|
731
|
-
const assumptionEntries = indexAssumptionEntries(storyList);
|
|
732
|
-
const sharedSeverity = merged.failOnSharedEditors ? 'hard' : 'soft';
|
|
733
|
-
const implicitSeverity = merged.requireExplicitCrossStoryDeps
|
|
734
|
-
? 'hard'
|
|
735
|
-
: 'soft';
|
|
736
|
-
const registrySeverity = merged.failOnRegistryConflicts ? 'hard' : 'soft';
|
|
737
|
-
const fanOutSeverity = merged.failOnLargeFanOut ? 'hard' : 'soft';
|
|
738
|
-
const bddScaffoldSeverity = merged.failOnMissingBddScaffold ? 'hard' : 'soft';
|
|
739
|
-
const patterns =
|
|
740
|
-
Array.isArray(merged.registries) && merged.registries.length > 0
|
|
741
|
-
? merged.registries
|
|
742
|
-
: DEFAULT_REGISTRY_PATTERNS;
|
|
743
389
|
return [
|
|
744
|
-
...computeSharedEditorFindings(producers, reach,
|
|
745
|
-
...computeImplicitDepFindings(
|
|
746
|
-
|
|
747
|
-
producers,
|
|
748
|
-
reach,
|
|
749
|
-
implicitSeverity,
|
|
750
|
-
),
|
|
751
|
-
...computeRegistryFindings({
|
|
752
|
-
stories: storyList,
|
|
753
|
-
reach,
|
|
754
|
-
patterns,
|
|
755
|
-
producers,
|
|
756
|
-
assumptionEntries,
|
|
757
|
-
severity: registrySeverity,
|
|
758
|
-
}),
|
|
759
|
-
...computeFanOutFindings({
|
|
760
|
-
assumptionEntries,
|
|
761
|
-
threshold: merged.largeFanOutThreshold,
|
|
762
|
-
counter: merged.fanOutCounter,
|
|
763
|
-
severity: fanOutSeverity,
|
|
764
|
-
}),
|
|
765
|
-
...computeMissingBddScaffoldFindings(storyList, reach, bddScaffoldSeverity),
|
|
390
|
+
...computeSharedEditorFindings(producers, reach, SOFT),
|
|
391
|
+
...computeImplicitDepFindings(consumers, producers, reach, SOFT),
|
|
392
|
+
...computeMissingBddScaffoldFindings(storyList, reach, SOFT),
|
|
766
393
|
];
|
|
767
394
|
}
|
|
768
395
|
|
|
769
|
-
/**
|
|
770
|
-
* Resolve the config-derived half of the conflict policy from
|
|
771
|
-
* `config.planning` — the severity flags, the fan-out threshold, and the
|
|
772
|
-
* registry patterns, in one place.
|
|
773
|
-
*
|
|
774
|
-
* Two passes consume `planning.*` and must not disagree: the raw
|
|
775
|
-
* pre-assembly pass (`persist-helpers.validateTickets`, which attaches its
|
|
776
|
-
* production `fanOutCounter` on top of this) and the post-assembly pass
|
|
777
|
-
* (`computeAssembledConflictFindings`, which forces `fanOutCounter: null`).
|
|
778
|
-
* Under Story #5045 each resolved its own copy, and the copies had already
|
|
779
|
-
* drifted — `failOnMissingBddScaffold` reached only the assembled pass,
|
|
780
|
-
* `failOnLargeFanOut` / `largeFanOutThreshold` / `crossCuttingRegistries`
|
|
781
|
-
* only the raw one — so a knob set in config silently applied on one of the
|
|
782
|
-
* two passes. A knob read here reaches both; that is the contract.
|
|
783
|
-
*
|
|
784
|
-
* `fanOutCounter` is deliberately absent: it is probe machinery, not
|
|
785
|
-
* config, and each caller owns its own.
|
|
786
|
-
*
|
|
787
|
-
* @param {object} [config] Resolved config carrying `planning.*`.
|
|
788
|
-
* @returns {object} A `computeConflictFindings` policy (no `fanOutCounter`).
|
|
789
|
-
*/
|
|
790
|
-
export function resolveConflictPolicy(config) {
|
|
791
|
-
const planning = config?.planning;
|
|
792
|
-
const policy = {
|
|
793
|
-
failOnSharedEditors: planning?.failOnSharedEditors === true,
|
|
794
|
-
requireExplicitCrossStoryDeps:
|
|
795
|
-
planning?.requireExplicitCrossStoryDeps === true,
|
|
796
|
-
failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
|
|
797
|
-
failOnLargeFanOut: planning?.failOnLargeFanOut === true,
|
|
798
|
-
failOnMissingBddScaffold: planning?.failOnMissingBddScaffold === true,
|
|
799
|
-
};
|
|
800
|
-
if (Number.isFinite(planning?.largeFanOutThreshold)) {
|
|
801
|
-
policy.largeFanOutThreshold = planning.largeFanOutThreshold;
|
|
802
|
-
}
|
|
803
|
-
if (planning?.crossCuttingRegistries !== undefined) {
|
|
804
|
-
policy.registries = resolveListValue(
|
|
805
|
-
DEFAULT_REGISTRY_PATTERNS,
|
|
806
|
-
planning.crossCuttingRegistries,
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
return policy;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
396
|
/**
|
|
813
397
|
* Re-run the cross-Story conflict passes over the **assembled** Story bodies —
|
|
814
398
|
* the artifact persist actually writes (Story #5045).
|
|
@@ -824,20 +408,10 @@ export function resolveConflictPolicy(config) {
|
|
|
824
408
|
* unreachable. Running the passes again over the serialized bodies restores
|
|
825
409
|
* them.
|
|
826
410
|
*
|
|
827
|
-
*
|
|
828
|
-
* deleted path and its inputs (`changes[]` deletes) are identical on both
|
|
829
|
-
* sides, so re-probing would double the git cost for a byte-identical answer;
|
|
830
|
-
* `enforceFanOutGate` already owns that class over the raw payload.
|
|
831
|
-
*
|
|
832
|
-
* @param {object} args
|
|
833
|
-
* @param {Array<{ slug: string, title?: string, body: string, depends_on?: string[] }>} args.stories
|
|
834
|
-
* Assembled Stories — `body` is the serialized, footer-stamped markdown.
|
|
835
|
-
* @param {object} [args.config] Resolved config; `planning.*` supplies the
|
|
836
|
-
* policy via {@link resolveConflictPolicy} — the same resolver the raw
|
|
837
|
-
* pass uses, so a knob cannot apply on only one of the two passes.
|
|
411
|
+
* @param {{ stories: Array<{ slug: string, title: string, body: string, depends_on?: string[] }> }} args
|
|
838
412
|
* @returns {ConflictFinding[]}
|
|
839
413
|
*/
|
|
840
|
-
export function computeAssembledConflictFindings({ stories
|
|
414
|
+
export function computeAssembledConflictFindings({ stories } = {}) {
|
|
841
415
|
return computeConflictFindings({
|
|
842
416
|
stories: (Array.isArray(stories) ? stories : []).map((story) => ({
|
|
843
417
|
slug: story.slug,
|
|
@@ -845,10 +419,6 @@ export function computeAssembledConflictFindings({ stories, config } = {}) {
|
|
|
845
419
|
body: story.body,
|
|
846
420
|
depends_on: Array.isArray(story.depends_on) ? story.depends_on : [],
|
|
847
421
|
})),
|
|
848
|
-
policy: {
|
|
849
|
-
...resolveConflictPolicy(config),
|
|
850
|
-
fanOutCounter: null,
|
|
851
|
-
},
|
|
852
422
|
});
|
|
853
423
|
}
|
|
854
424
|
|
|
@@ -867,7 +437,7 @@ export function computeAssembledConflictFindings({ stories, config } = {}) {
|
|
|
867
437
|
export function conflictFindingKey(finding) {
|
|
868
438
|
return [
|
|
869
439
|
finding?.kind ?? '',
|
|
870
|
-
finding?.path ??
|
|
440
|
+
finding?.path ?? '',
|
|
871
441
|
Array.isArray(finding?.storySlugs)
|
|
872
442
|
? [...finding.storySlugs].sort().join(',')
|
|
873
443
|
: (finding?.storySlug ?? ''),
|
|
@@ -877,86 +447,29 @@ export function conflictFindingKey(finding) {
|
|
|
877
447
|
].join('\u0000');
|
|
878
448
|
}
|
|
879
449
|
|
|
880
|
-
/**
|
|
881
|
-
* Render the audit trail behind a fan-out finding's number, so an operator
|
|
882
|
-
* can check the figure rather than trust it (Story #4547).
|
|
883
|
-
*
|
|
884
|
-
* Every importer is named — the list is deliberately **not** truncated. A
|
|
885
|
-
* gate that fires at 100 importers is precisely when the operator needs the
|
|
886
|
-
* list, and a `…and 109 more` tail would leave the figure uncheckable in
|
|
887
|
-
* exactly the case the gate exists for. This message is a fail-closed stop,
|
|
888
|
-
* not a log line; its length is the point.
|
|
889
|
-
*
|
|
890
|
-
* The probe is reported as what it is — the *candidate* net, which each hit
|
|
891
|
-
* is then re-resolved against. It will report at least as many lines as the
|
|
892
|
-
* gate counts files, so labelling it as the thing that produced the number
|
|
893
|
-
* would send an operator chasing a discrepancy that is by design.
|
|
894
|
-
*
|
|
895
|
-
* Returns `''` for a bare-number counter, which carries no audit trail.
|
|
896
|
-
*/
|
|
897
|
-
export function renderFanOutEvidence(finding) {
|
|
898
|
-
const files = Array.isArray(finding.callSites) ? finding.callSites : [];
|
|
899
|
-
const parts = [];
|
|
900
|
-
if (files.length > 0) {
|
|
901
|
-
parts.push(` Importers (${files.length}):`);
|
|
902
|
-
for (const file of files) parts.push(` ${file}`);
|
|
903
|
-
}
|
|
904
|
-
if (finding.probe) {
|
|
905
|
-
parts.push(
|
|
906
|
-
` Candidate probe (each hit re-resolved against its importer's directory):`,
|
|
907
|
-
` ${finding.probe}`,
|
|
908
|
-
);
|
|
909
|
-
}
|
|
910
|
-
return parts.length > 0 ? `\n${parts.join('\n')}` : '';
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* Render the remedy that actually fits the finding. A rename-shaped
|
|
915
|
-
* deletion has nowhere to split to — the importers just need repointing at
|
|
916
|
-
* the path the same plan creates — so telling the operator to split it
|
|
917
|
-
* across Stories leaves the override as the only exit, which is exactly the
|
|
918
|
-
* habit that defeats the gate (Story #4547).
|
|
919
|
-
*/
|
|
920
|
-
export function renderFanOutRemedy(finding) {
|
|
921
|
-
if (finding.renameShaped && finding.renameTarget) {
|
|
922
|
-
return (
|
|
923
|
-
`This deletion is rename-shaped: the same plan creates "${finding.renameTarget}" under the same basename. ` +
|
|
924
|
-
`Repoint the importer(s) at the new path inside this Story — a move has no subsystems to split across — ` +
|
|
925
|
-
`then rerun --allow-large-fan-out.`
|
|
926
|
-
);
|
|
927
|
-
}
|
|
928
|
-
return (
|
|
929
|
-
`Split the deletion into a subsystem-by-subsystem migration across multiple Stories, ` +
|
|
930
|
-
`or rerun --allow-large-fan-out after confirming the deletion is intentional.`
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
450
|
/**
|
|
935
451
|
* The finding kinds that are genuinely **cross-Story conflicts** — the SSOT
|
|
936
452
|
* for that question (Story #4907).
|
|
937
453
|
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* advisory it is. A second copy of this list is how the two drift back apart,
|
|
944
|
-
* so it is defined exactly once and imported.
|
|
454
|
+
* The persist soft-finding surface announces a conflict as a conflict and
|
|
455
|
+
* every other soft kind as the advisory it is, and the summary comment
|
|
456
|
+
* renders only the shared-editor class beside the wave table. A second copy
|
|
457
|
+
* of this list is how readers drift apart, so it is defined exactly once and
|
|
458
|
+
* imported.
|
|
945
459
|
*/
|
|
946
460
|
export const CONFLICT_KINDS = Object.freeze(
|
|
947
461
|
new Set([
|
|
948
462
|
'shared-editor',
|
|
949
463
|
'implicit-cross-story-dep',
|
|
950
|
-
'cross-cutting-registries',
|
|
951
|
-
'fan-out-warning',
|
|
952
464
|
'missing-bdd-scaffold',
|
|
953
465
|
]),
|
|
954
466
|
);
|
|
955
467
|
|
|
956
468
|
/**
|
|
957
|
-
* Render a
|
|
958
|
-
*
|
|
959
|
-
*
|
|
469
|
+
* Render a conflict finding as a human-readable line. Every finding is soft
|
|
470
|
+
* since Story #5312, so this feeds the dry-run warning list and the plan
|
|
471
|
+
* summary rather than an `errors[]` channel; the name survives because every
|
|
472
|
+
* caller imports it.
|
|
960
473
|
*/
|
|
961
474
|
export function renderHardConflictError(finding) {
|
|
962
475
|
if (finding.kind === 'shared-editor') {
|
|
@@ -966,23 +479,12 @@ export function renderHardConflictError(finding) {
|
|
|
966
479
|
if (finding.kind === 'implicit-cross-story-dep') {
|
|
967
480
|
return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
|
|
968
481
|
}
|
|
969
|
-
if (finding.kind === 'cross-cutting-registries') {
|
|
970
|
-
const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
|
|
971
|
-
return `Cross-cutting registry conflict: ${finding.storySlugs.length} concurrent Stories (${stories}) edit or register into "${finding.registryPath}". Add depends_on chains between them so the registry updates serialize, or split the registration into a dedicated late-wave wiring Story.`;
|
|
972
|
-
}
|
|
973
|
-
if (finding.kind === 'fan-out-warning') {
|
|
974
|
-
return (
|
|
975
|
-
`Large fan-out: Story "${finding.storySlug}" deletes "${finding.path}" ` +
|
|
976
|
-
`with ${finding.callSiteCount} importer(s) on the base branch (threshold ${finding.threshold}). ` +
|
|
977
|
-
`${renderFanOutRemedy(finding)}${renderFanOutEvidence(finding)}`
|
|
978
|
-
);
|
|
979
|
-
}
|
|
980
482
|
if (finding.kind === 'missing-bdd-scaffold') {
|
|
981
483
|
return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
|
|
982
484
|
}
|
|
983
|
-
// Findings from other passes
|
|
984
|
-
//
|
|
985
|
-
//
|
|
485
|
+
// Findings from other passes carry their own message — render it rather
|
|
486
|
+
// than a shape-blind generic line, so the soft surface
|
|
487
|
+
// (`surfaceSoftConflictFindings`) stays legible for every kind.
|
|
986
488
|
if (typeof finding.message === 'string' && finding.message.length > 0) {
|
|
987
489
|
return finding.message;
|
|
988
490
|
}
|
|
@@ -1000,12 +502,4 @@ export const _internal = {
|
|
|
1000
502
|
computeSharedEditorFindings,
|
|
1001
503
|
computeImplicitDepFindings,
|
|
1002
504
|
computeMissingBddScaffoldFindings,
|
|
1003
|
-
indexAssumptionEntries,
|
|
1004
|
-
computeRegistryFindings,
|
|
1005
|
-
computeFanOutFindings,
|
|
1006
|
-
matchRegistryPattern,
|
|
1007
|
-
isRegistryPath,
|
|
1008
|
-
parentDirOf,
|
|
1009
|
-
DEFAULT_POLICY,
|
|
1010
|
-
DEFAULT_REGISTRY_PATTERNS,
|
|
1011
505
|
};
|