mandrel 1.58.0 → 1.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 +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +13 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Logger } from './Logger.js';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
|
8
|
+
|
|
9
|
+
let _ts = null;
|
|
10
|
+
let _tsLoadFailed = false;
|
|
11
|
+
|
|
12
|
+
function loadTypeScript() {
|
|
13
|
+
if (_ts) return _ts;
|
|
14
|
+
if (_tsLoadFailed) return null;
|
|
15
|
+
try {
|
|
16
|
+
_ts = require('typescript');
|
|
17
|
+
return _ts;
|
|
18
|
+
} catch {
|
|
19
|
+
_tsLoadFailed = true;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the `typescript` package version, used to stamp baselines so
|
|
26
|
+
* consumers can detect transpiler drift. Returns `'0.0.0'` when the
|
|
27
|
+
* dependency is unresolvable — callers treat that sentinel as "unknown
|
|
28
|
+
* environment" and may refuse to persist a baseline that includes TS rows.
|
|
29
|
+
*
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveTsTranspilerVersion() {
|
|
33
|
+
const ts = loadTypeScript();
|
|
34
|
+
if (ts && typeof ts.version === 'string') return ts.version;
|
|
35
|
+
return '0.0.0';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTypeScriptPath(filePath) {
|
|
39
|
+
return TS_EXTS.has(path.extname(String(filePath)).toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pre-transpile TypeScript or TSX sources to JavaScript that the
|
|
44
|
+
* Esprima-based escomplex kernel can parse. Returns the input unchanged
|
|
45
|
+
* for `.js` / `.mjs` / `.cjs` paths.
|
|
46
|
+
*
|
|
47
|
+
* Type annotations introduce no control flow, so the transpiled output
|
|
48
|
+
* scores identically to the original TS for cyclomatic complexity,
|
|
49
|
+
* Halstead volume, and the maintainability index. `.tsx` uses the
|
|
50
|
+
* `react-jsx` emit so JSX expressions become function calls escomplex
|
|
51
|
+
* can read; `.preserve` would leave JSX in the output and Esprima would
|
|
52
|
+
* choke on it.
|
|
53
|
+
*
|
|
54
|
+
* On transpile failure the helper returns `null` — callers treat that
|
|
55
|
+
* as "skip this file" rather than crashing the scan.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} filePath
|
|
58
|
+
* @param {string} source
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
export function transpileIfNeeded(filePath, source) {
|
|
62
|
+
if (!isTypeScriptPath(filePath)) return source;
|
|
63
|
+
const ts = loadTypeScript();
|
|
64
|
+
if (!ts) {
|
|
65
|
+
Logger.warn(
|
|
66
|
+
`[Maintainability] ⚠ typescript package not resolvable; cannot score ${filePath}. ` +
|
|
67
|
+
"Install with 'npm install --save-dev typescript' (peer dep, >=5.0.0).",
|
|
68
|
+
);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const result = ts.transpileModule(source, {
|
|
73
|
+
compilerOptions: {
|
|
74
|
+
target: ts.ScriptTarget.ESNext,
|
|
75
|
+
module: ts.ModuleKind.ESNext,
|
|
76
|
+
isolatedModules: true,
|
|
77
|
+
noEmitHelpers: true,
|
|
78
|
+
importHelpers: false,
|
|
79
|
+
removeComments: false,
|
|
80
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
81
|
+
sourceMap: false,
|
|
82
|
+
},
|
|
83
|
+
fileName: path.basename(filePath),
|
|
84
|
+
reportDiagnostics: false,
|
|
85
|
+
});
|
|
86
|
+
return result.outputText;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
Logger.warn(
|
|
89
|
+
`[Maintainability] ⚠ TS transpile failed for ${filePath}: ${err?.message ?? err}; skipping.`,
|
|
90
|
+
);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -36,22 +36,11 @@ import { WaveRunnerError } from './wave-runner-error.js';
|
|
|
36
36
|
* currentWave: number
|
|
37
37
|
* totalWaves: number
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* by /epic-plan) — behaviour is byte-identical to the pre-spec path.
|
|
42
|
-
* When `spec` is supplied, wave grouping is driven by `spec.stories[].wave`
|
|
43
|
-
* (the declarative SSOT from `.agents/epics/<epic-id>.yaml`) and slugs are
|
|
44
|
-
* resolved to GH issue numbers via the sibling `<epic-id>.state.json`
|
|
45
|
-
* mapping; the checkpoint is still consulted for `currentWave`,
|
|
46
|
-
* `totalWaves`, and `waves[]` history but its `plan[]` is overridden.
|
|
39
|
+
* Wave grouping comes from the checkpoint's `state.plan` (the GH-derived
|
|
40
|
+
* dependency-DAG grouping originally seeded by /epic-plan).
|
|
47
41
|
*
|
|
48
42
|
* @typedef {object} WaveTickArgs
|
|
49
43
|
* @property {number | { id: number }} epic
|
|
50
|
-
* @property {object} [spec] Parsed epic-spec (see lib/spec/loader.js). When
|
|
51
|
-
* provided, wave grouping comes from `spec.stories[].wave`.
|
|
52
|
-
* @property {object} [state] Parsed epic-state (see lib/spec/loader.js).
|
|
53
|
-
* When `spec` is provided, this must be supplied so slugs can resolve to
|
|
54
|
-
* issue numbers via `state.mapping[slug].issueNumber`.
|
|
55
44
|
* @property {{
|
|
56
45
|
* provider?: object,
|
|
57
46
|
* epicRunStateStore?: { read: () => Promise<object|null> },
|
|
@@ -74,8 +63,6 @@ export async function tick(args = {}) {
|
|
|
74
63
|
} = args.collaborators ?? {};
|
|
75
64
|
const ctx = args.ctx ?? {};
|
|
76
65
|
const provider = collabProvider ?? ctx.provider;
|
|
77
|
-
const spec = args.spec ?? null;
|
|
78
|
-
const specState = args.state ?? null;
|
|
79
66
|
if (!provider) {
|
|
80
67
|
throw new WaveRunnerError('invalid-input', 'provider is required');
|
|
81
68
|
}
|
|
@@ -104,7 +91,8 @@ export async function tick(args = {}) {
|
|
|
104
91
|
}
|
|
105
92
|
|
|
106
93
|
const currentWave = positiveIntOrZero(state.currentWave);
|
|
107
|
-
const
|
|
94
|
+
const plan = Array.isArray(state.plan) ? state.plan : [];
|
|
95
|
+
const totalWaves = positiveIntOrZero(state.totalWaves);
|
|
108
96
|
const history = Array.isArray(state.waves) ? state.waves : [];
|
|
109
97
|
|
|
110
98
|
if (totalWaves === 0 || currentWave >= totalWaves) {
|
|
@@ -460,143 +448,6 @@ function storyIdOf(s) {
|
|
|
460
448
|
return s.id ?? s.storyId ?? s.number;
|
|
461
449
|
}
|
|
462
450
|
|
|
463
|
-
/**
|
|
464
|
-
* Walk `spec.features[].stories[]` and bucket entries by their `wave`
|
|
465
|
-
* value, mapping slugs → GH issue numbers via the sibling state file.
|
|
466
|
-
* Returns `Story[][]` indexed by wave number; missing waves between 0 and
|
|
467
|
-
* the highest declared wave are emitted as empty arrays so wave N is
|
|
468
|
-
* always reachable as `plan[N]`.
|
|
469
|
-
*
|
|
470
|
-
* Each emitted entry is shaped to match the checkpoint plan's
|
|
471
|
-
* `{ id, title }` contract that the rest of `tick()` already consumes:
|
|
472
|
-
*
|
|
473
|
-
* - `id` is the GH issue number resolved from `state.mapping[slug].issueNumber`
|
|
474
|
-
* so the same provider.getTicket(id) path used by the spec-less plan
|
|
475
|
-
* keeps working unchanged.
|
|
476
|
-
* - `title` is carried through from `story.title` so the wave-start
|
|
477
|
-
* signal can include the Story's human-readable name without an
|
|
478
|
-
* extra provider round-trip.
|
|
479
|
-
* - `slug` is preserved on the entry so observability + future
|
|
480
|
-
* re-resolution paths can re-key against the spec.
|
|
481
|
-
*
|
|
482
|
-
* When a Story slug has no resolved `issueNumber` in `state.mapping`
|
|
483
|
-
* (a fresh spec entry the reconciler has not materialised yet), the entry
|
|
484
|
-
* is skipped — un-materialised Stories cannot be dispatched anyway, and
|
|
485
|
-
* including them with a `null` id would surface as a `story-fetch`
|
|
486
|
-
* failure inside `tick()`. The reconciler will close the loop on the
|
|
487
|
-
* next apply; until then, an empty wave is a faithful reflection of
|
|
488
|
-
* GitHub state.
|
|
489
|
-
*
|
|
490
|
-
* Pure function — does not read disk, does not call GH. Callers are
|
|
491
|
-
* expected to compose it with `loadSpec` + `loadState` from
|
|
492
|
-
* `lib/spec/loader.js`.
|
|
493
|
-
*
|
|
494
|
-
* @param {object} spec Parsed epic-spec (see lib/spec/loader.js).
|
|
495
|
-
* @param {{mapping?: Record<string, {issueNumber?: number}>}|null} [state]
|
|
496
|
-
* Parsed epic-state. May be omitted; if missing, no entries can be
|
|
497
|
-
* resolved and `groupByWave` returns `[]`.
|
|
498
|
-
* @returns {Array<Array<{id: number, title?: string, slug: string}>>}
|
|
499
|
-
*/
|
|
500
|
-
export function groupByWave(spec, state = null) {
|
|
501
|
-
const mapping =
|
|
502
|
-
state && typeof state.mapping === 'object' && state.mapping !== null
|
|
503
|
-
? state.mapping
|
|
504
|
-
: {};
|
|
505
|
-
const entries = extractValidStoryEntries(spec, mapping);
|
|
506
|
-
if (entries.length === 0) return [];
|
|
507
|
-
const byWave = new Map();
|
|
508
|
-
let maxWave = -1;
|
|
509
|
-
for (const { wave, entry } of entries) {
|
|
510
|
-
if (!byWave.has(wave)) byWave.set(wave, []);
|
|
511
|
-
byWave.get(wave).push(entry);
|
|
512
|
-
if (wave > maxWave) maxWave = wave;
|
|
513
|
-
}
|
|
514
|
-
if (maxWave < 0) return [];
|
|
515
|
-
const out = [];
|
|
516
|
-
for (let i = 0; i <= maxWave; i += 1) {
|
|
517
|
-
out.push(byWave.get(i) ?? []);
|
|
518
|
-
}
|
|
519
|
-
return out;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Walk every feature/story pair in `spec` and emit only the entries that
|
|
524
|
-
* survive the spec-validity cascade: the story must be a non-null object,
|
|
525
|
-
* declare a non-negative integer `wave`, declare a string `slug`, and
|
|
526
|
-
* resolve to a numeric `issueNumber` in `mapping`. Each surviving entry
|
|
527
|
-
* is returned as `{ wave, entry }` where `entry` carries the same shape
|
|
528
|
-
* (`{ id, title, slug }`) that `groupByWave` previously pushed into its
|
|
529
|
-
* per-wave bucket.
|
|
530
|
-
*
|
|
531
|
-
* Extracted from `groupByWave` so the bucketing transform stays
|
|
532
|
-
* straight-line; this predicate owns the entire defensive guard cascade
|
|
533
|
-
* and is the right place to add new validation rules going forward.
|
|
534
|
-
*
|
|
535
|
-
* @param {object|null|undefined} spec Parsed epic-spec.
|
|
536
|
-
* @param {Record<string, {issueNumber?: number}>} mapping
|
|
537
|
-
* Slug → issue-number lookup from the sibling state file.
|
|
538
|
-
* @returns {Array<{wave: number, entry: {id: number, title?: string, slug: string}}>}
|
|
539
|
-
*/
|
|
540
|
-
export function extractValidStoryEntries(spec, mapping) {
|
|
541
|
-
const out = [];
|
|
542
|
-
const features = Array.isArray(spec?.features) ? spec.features : [];
|
|
543
|
-
for (const feature of features) {
|
|
544
|
-
const stories = Array.isArray(feature?.stories) ? feature.stories : [];
|
|
545
|
-
for (const story of stories) {
|
|
546
|
-
const resolved = resolveStoryEntry(story, mapping);
|
|
547
|
-
if (resolved) out.push(resolved);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return out;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Validate a single `story` against the spec-validity cascade and return
|
|
555
|
-
* `{ wave, entry }` when every guard passes, or `null` when any guard
|
|
556
|
-
* trips. Splitting the per-story cascade out keeps both
|
|
557
|
-
* `extractValidStoryEntries` (which owns iteration) and `resolveStoryEntry`
|
|
558
|
-
* (which owns validation) below CRAP 5 even when none of the branches are
|
|
559
|
-
* exercised at runtime — the predicate's cyclomatic footprint is small
|
|
560
|
-
* enough that uncovered branches do not blow the baseline budget.
|
|
561
|
-
*
|
|
562
|
-
* @param {*} story Candidate story from `spec.features[].stories[]`.
|
|
563
|
-
* @param {Record<string, {issueNumber?: number}>} mapping Slug → issue lookup.
|
|
564
|
-
* @returns {{wave: number, entry: {id: number, title?: string, slug: string}} | null}
|
|
565
|
-
*/
|
|
566
|
-
function resolveStoryEntry(story, mapping) {
|
|
567
|
-
if (!story || typeof story !== 'object') return null;
|
|
568
|
-
if (!Number.isInteger(story.wave) || story.wave < 0) return null;
|
|
569
|
-
if (typeof story.slug !== 'string' || !story.slug) return null;
|
|
570
|
-
const mapped = mapping[story.slug];
|
|
571
|
-
if (!mapped || typeof mapped.issueNumber !== 'number') return null;
|
|
572
|
-
return {
|
|
573
|
-
wave: story.wave,
|
|
574
|
-
entry: { id: mapped.issueNumber, title: story.title, slug: story.slug },
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Resolve which plan + totalWaves drive this tick. When `spec` is
|
|
580
|
-
* supplied, the spec-derived grouping wins (and totalWaves comes from
|
|
581
|
-
* the spec since the checkpoint may lag); otherwise the checkpoint's
|
|
582
|
-
* plan is used unchanged. Extracted so `tick()`'s cyclomatic complexity
|
|
583
|
-
* stays inside its baseline budget — the route choice is now a single
|
|
584
|
-
* call, not three ternaries inline.
|
|
585
|
-
*
|
|
586
|
-
* @param {object} state Checkpoint state (already validated as object).
|
|
587
|
-
* @param {object|null} spec Parsed epic-spec or `null` when omitted.
|
|
588
|
-
* @param {object|null} specState Parsed epic-state for slug mapping.
|
|
589
|
-
* @returns {{plan: Array<Array<object>>, totalWaves: number}}
|
|
590
|
-
*/
|
|
591
|
-
function resolvePlan(state, spec, specState) {
|
|
592
|
-
if (spec) {
|
|
593
|
-
const specPlan = groupByWave(spec, specState);
|
|
594
|
-
return { plan: specPlan, totalWaves: specPlan.length };
|
|
595
|
-
}
|
|
596
|
-
const plan = Array.isArray(state.plan) ? state.plan : [];
|
|
597
|
-
return { plan, totalWaves: positiveIntOrZero(state.totalWaves) };
|
|
598
|
-
}
|
|
599
|
-
|
|
600
451
|
/**
|
|
601
452
|
* A Story is "done" when it carries `agent::done` OR its GitHub issue is
|
|
602
453
|
* `state === 'closed'`. The closed-state arm (Story #3907) is what aligns the
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import { parentPort } from 'node:worker_threads';
|
|
31
31
|
import { calculateCrapForSource } from '../crap-engine.js';
|
|
32
|
-
import { transpileIfNeeded } from '../
|
|
32
|
+
import { transpileIfNeeded } from '../transpile.js';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Pure handler for a single inbound worker message. Exported so unit
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
calculateReport,
|
|
40
40
|
calculateReportForFile,
|
|
41
41
|
} from '../maintainability-engine.js';
|
|
42
|
-
import { transpileIfNeeded } from '../
|
|
42
|
+
import { transpileIfNeeded } from '../transpile.js';
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Score a pre-sourced content string (Story #3696). Mirrors
|
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import {
|
|
13
13
|
applyNodeModulesStrategy,
|
|
14
14
|
installDependencies,
|
|
15
|
+
probeReusedInstall,
|
|
15
16
|
} from '../node-modules-strategy.js';
|
|
16
17
|
import {
|
|
17
18
|
findByPath,
|
|
@@ -41,6 +42,23 @@ export async function ensure(ctx, storyId, branch) {
|
|
|
41
42
|
if (typeof ctx.onPhase === 'function') ctx.onPhase(name);
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
// Reuse must not blindly skip the install: when the prior run's install
|
|
46
|
+
// failed (or was interrupted), `skipped/worktree-reused` defeats the
|
|
47
|
+
// retry exactly when it matters — `deriveInstallAction('skipped')` skips.
|
|
48
|
+
// Probe for a completed install; on a failed probe, retry the install
|
|
49
|
+
// here and surface its real outcome.
|
|
50
|
+
const reuseInstallStatus = () => {
|
|
51
|
+
const strategy = ctx.config?.nodeModulesStrategy ?? 'per-worktree';
|
|
52
|
+
const probe = probeReusedInstall(strategy, wtPath);
|
|
53
|
+
if (probe.status !== 'failed') return probe;
|
|
54
|
+
ctx.logger.warn(
|
|
55
|
+
`worktree.reuse install probe failed (${probe.reason}) — retrying install in ${wtPath}`,
|
|
56
|
+
);
|
|
57
|
+
// `ctx.installDependencies` is a test-injection seam; production ctx
|
|
58
|
+
// bags leave it unset and get the real installer.
|
|
59
|
+
return (ctx.installDependencies ?? installDependencies)(ctx, wtPath);
|
|
60
|
+
};
|
|
61
|
+
|
|
44
62
|
if (existing) {
|
|
45
63
|
if (existing.branch && existing.branch !== br) {
|
|
46
64
|
throw new Error(
|
|
@@ -53,7 +71,7 @@ export async function ensure(ctx, storyId, branch) {
|
|
|
53
71
|
return {
|
|
54
72
|
path: wtPath,
|
|
55
73
|
created: false,
|
|
56
|
-
installStatus:
|
|
74
|
+
installStatus: reuseInstallStatus(),
|
|
57
75
|
};
|
|
58
76
|
}
|
|
59
77
|
|
|
@@ -87,7 +105,7 @@ export async function ensure(ctx, storyId, branch) {
|
|
|
87
105
|
return {
|
|
88
106
|
path: wtPath,
|
|
89
107
|
created: false,
|
|
90
|
-
installStatus:
|
|
108
|
+
installStatus: reuseInstallStatus(),
|
|
91
109
|
};
|
|
92
110
|
}
|
|
93
111
|
}
|
|
@@ -118,10 +118,89 @@ export function findHoldersInPath(wtPath, opts = {}) {
|
|
|
118
118
|
}));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Pure: compute the set of pids that must never be killed — `selfPid` plus
|
|
123
|
+
* its full ancestor chain — from a process table of `{ pid, ppid }` rows.
|
|
124
|
+
* Cycle-guarded (a corrupt/raced table cannot loop forever). `selfPid` is
|
|
125
|
+
* always included even when the table is empty or missing its row, so the
|
|
126
|
+
* guard fails safe.
|
|
127
|
+
*
|
|
128
|
+
* @param {number} selfPid
|
|
129
|
+
* @param {Array<{ pid: number, ppid?: number }>} table
|
|
130
|
+
* @returns {Set<number>}
|
|
131
|
+
*/
|
|
132
|
+
export function computeProtectedPids(selfPid, table) {
|
|
133
|
+
const protectedPids = new Set([selfPid]);
|
|
134
|
+
if (!Array.isArray(table) || table.length === 0) return protectedPids;
|
|
135
|
+
const parentOf = new Map();
|
|
136
|
+
for (const row of table) {
|
|
137
|
+
if (row && typeof row.pid === 'number' && typeof row.ppid === 'number') {
|
|
138
|
+
parentOf.set(row.pid, row.ppid);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let cursor = selfPid;
|
|
142
|
+
while (parentOf.has(cursor)) {
|
|
143
|
+
const ppid = parentOf.get(cursor);
|
|
144
|
+
if (protectedPids.has(ppid)) break; // cycle guard
|
|
145
|
+
protectedPids.add(ppid);
|
|
146
|
+
cursor = ppid;
|
|
147
|
+
}
|
|
148
|
+
return protectedPids;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Enumerate the full Windows process table as `{ pid, ppid }` rows so the
|
|
153
|
+
* kill set can exclude the invoking shell / orchestrator ancestry.
|
|
154
|
+
* Best-effort: any failure returns `[]` (callers still protect `selfPid`).
|
|
155
|
+
*
|
|
156
|
+
* @param {object} [opts]
|
|
157
|
+
* @param {Function} [opts.spawn] Injection point for tests (default `spawnSync`).
|
|
158
|
+
* @param {string} [opts.platform] Override `process.platform` for tests.
|
|
159
|
+
* @returns {Array<{ pid: number, ppid: number }>}
|
|
160
|
+
*/
|
|
161
|
+
export function fetchProcessTable(opts = {}) {
|
|
162
|
+
const spawn = opts.spawn ?? spawnSync;
|
|
163
|
+
const platform = opts.platform ?? process.platform;
|
|
164
|
+
if (platform !== 'win32') return [];
|
|
165
|
+
|
|
166
|
+
const script =
|
|
167
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId | ConvertTo-Json -Compress';
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = spawn(
|
|
171
|
+
'powershell.exe',
|
|
172
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
173
|
+
{ encoding: 'utf8', timeout: 15_000 },
|
|
174
|
+
);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
if (!res || res.status !== 0 || !res.stdout) return [];
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(String(res.stdout).trim());
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
186
|
+
return list
|
|
187
|
+
.filter((p) => p && typeof p.ProcessId === 'number')
|
|
188
|
+
.map((p) => ({
|
|
189
|
+
pid: p.ProcessId,
|
|
190
|
+
ppid: typeof p.ParentProcessId === 'number' ? p.ParentProcessId : -1,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
121
194
|
/**
|
|
122
195
|
* `taskkill /T /F /PID <pid>` for each holder. Returns the pids reported
|
|
123
196
|
* as terminated. Per-pid failures are logged but do not throw — caller
|
|
124
197
|
* decides whether the partial kill is enough to retry.
|
|
198
|
+
*
|
|
199
|
+
* Self-preservation (Story #4018): holders are matched by command-line
|
|
200
|
+
* substring, which can select the invoking shell or the orchestrator's own
|
|
201
|
+
* ancestor chain (any ancestor whose command line mentions the worktree
|
|
202
|
+
* path). Before any `taskkill /T /F`, the kill set excludes `selfPid` and
|
|
203
|
+
* its full ancestor chain — `/T` on an ancestor would kill this process too.
|
|
125
204
|
*/
|
|
126
205
|
export function terminateHolders(holders, opts = {}) {
|
|
127
206
|
const spawn = opts.spawn ?? spawnSync;
|
|
@@ -130,9 +209,20 @@ export function terminateHolders(holders, opts = {}) {
|
|
|
130
209
|
if (platform !== 'win32') return [];
|
|
131
210
|
if (!Array.isArray(holders) || holders.length === 0) return [];
|
|
132
211
|
|
|
212
|
+
const selfPid = opts.selfPid ?? process.pid;
|
|
213
|
+
const protectedPids =
|
|
214
|
+
opts.protectedPids ??
|
|
215
|
+
computeProtectedPids(selfPid, fetchProcessTable({ spawn, platform }));
|
|
216
|
+
|
|
133
217
|
const killed = [];
|
|
134
218
|
for (const h of holders) {
|
|
135
219
|
if (!h || typeof h.pid !== 'number') continue;
|
|
220
|
+
if (protectedPids.has(h.pid)) {
|
|
221
|
+
logger.warn(
|
|
222
|
+
`force-drain: skipping pid=${h.pid} name=${h.name ?? '?'} — self/ancestor of this process (never killed)`,
|
|
223
|
+
);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
136
226
|
let res;
|
|
137
227
|
try {
|
|
138
228
|
res = spawn('taskkill.exe', ['/T', '/F', '/PID', String(h.pid)], {
|
|
@@ -134,7 +134,7 @@ async function fsRmWithRetry(
|
|
|
134
134
|
return { success: true, attempts: attempt };
|
|
135
135
|
} catch (err) {
|
|
136
136
|
lastErr = err;
|
|
137
|
-
if (attempt < maxRetries) {
|
|
137
|
+
if (attempt < maxRetries && retryDelay > 0) {
|
|
138
138
|
await new Promise((r) => setTimeout(r, retryDelay));
|
|
139
139
|
}
|
|
140
140
|
}
|
|
@@ -160,8 +160,8 @@ function handleRemoveFailure(
|
|
|
160
160
|
classification,
|
|
161
161
|
attempt,
|
|
162
162
|
maxAttempts,
|
|
163
|
+
{ retryDelaysMs = [0, 150, 350, 700, 1200, 2000], sleepFn = sleepSync } = {},
|
|
163
164
|
) {
|
|
164
|
-
const retryDelaysMs = [0, 150, 350, 700, 1200, 2000];
|
|
165
165
|
const { isLockLike, isCwdLike } = classification;
|
|
166
166
|
if ((isLockLike || isCwdLike) && attempt < maxAttempts) {
|
|
167
167
|
const delay = retryDelaysMs[attempt] ?? 300;
|
|
@@ -169,13 +169,13 @@ function handleRemoveFailure(
|
|
|
169
169
|
ctx.logger.warn(
|
|
170
170
|
`worktree.reap remove hit ${reasonClass} error; retrying in ${delay}ms (${attempt}/${maxAttempts})`,
|
|
171
171
|
);
|
|
172
|
-
|
|
172
|
+
sleepFn(delay);
|
|
173
173
|
return 'continue';
|
|
174
174
|
}
|
|
175
175
|
return 'break';
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
async function runGitWorktreeRemoveLoop(ctx, wtPath) {
|
|
178
|
+
async function runGitWorktreeRemoveLoop(ctx, wtPath, retryOpts = {}) {
|
|
179
179
|
const maxAttempts = ctx.platform === 'win32' ? 6 : 2;
|
|
180
180
|
let lastReason = 'worktree-remove-failed';
|
|
181
181
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -193,6 +193,7 @@ async function runGitWorktreeRemoveLoop(ctx, wtPath) {
|
|
|
193
193
|
classification,
|
|
194
194
|
attempt,
|
|
195
195
|
maxAttempts,
|
|
196
|
+
retryOpts,
|
|
196
197
|
);
|
|
197
198
|
if (action === 'break') break;
|
|
198
199
|
}
|
|
@@ -205,6 +206,7 @@ function tryForceRemoveFallback(
|
|
|
205
206
|
lastReason,
|
|
206
207
|
forceRemoveBackoffMs,
|
|
207
208
|
maxAttempts,
|
|
209
|
+
sleepFn = sleepSync,
|
|
208
210
|
) {
|
|
209
211
|
if (!(WINDOWS_LOCK_RE.test(lastReason) || WINDOWS_CWD_RE.test(lastReason))) {
|
|
210
212
|
return { handled: false, lastReason };
|
|
@@ -212,7 +214,7 @@ function tryForceRemoveFallback(
|
|
|
212
214
|
ctx.logger.warn(
|
|
213
215
|
`worktree.reap remove exhausted Windows lock retry; retrying with --force in ${forceRemoveBackoffMs}ms path=${wtPath}`,
|
|
214
216
|
);
|
|
215
|
-
|
|
217
|
+
sleepFn(forceRemoveBackoffMs);
|
|
216
218
|
const forced = ctx.git.gitSpawn(
|
|
217
219
|
ctx.repoRoot,
|
|
218
220
|
'worktree',
|
|
@@ -248,8 +250,9 @@ async function tryStage15WindowsFsRm({
|
|
|
248
250
|
push,
|
|
249
251
|
lastReason,
|
|
250
252
|
priorAttempts,
|
|
253
|
+
sleepFn = sleepSync,
|
|
251
254
|
}) {
|
|
252
|
-
|
|
255
|
+
sleepFn(forceRemoveBackoffMs);
|
|
253
256
|
try {
|
|
254
257
|
await fsRm(wtPath, {
|
|
255
258
|
recursive: true,
|
|
@@ -304,6 +307,7 @@ async function handleFsRmFailure({
|
|
|
304
307
|
lastReason,
|
|
305
308
|
forceRemoveBackoffMs,
|
|
306
309
|
fsRm,
|
|
310
|
+
sleepFn,
|
|
307
311
|
}) {
|
|
308
312
|
// Stage 1.5 — coverage-leak quiesce + extended fs.rm budget (Windows only).
|
|
309
313
|
if (ctx.platform === 'win32') {
|
|
@@ -316,6 +320,7 @@ async function handleFsRmFailure({
|
|
|
316
320
|
push,
|
|
317
321
|
lastReason,
|
|
318
322
|
priorAttempts: rmResult.attempts,
|
|
323
|
+
sleepFn,
|
|
319
324
|
});
|
|
320
325
|
if (stage15) return stage15;
|
|
321
326
|
}
|
|
@@ -347,7 +352,12 @@ async function handleFsRmFailure({
|
|
|
347
352
|
export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
|
|
348
353
|
const { storyId = null, branch = null, push = false } = opts;
|
|
349
354
|
const forceRemoveBackoffMs = opts.forceRemoveBackoffMs ?? 3000;
|
|
350
|
-
const
|
|
355
|
+
const retryDelay = opts.retryDelay ?? 200;
|
|
356
|
+
const { retryDelaysMs, sleepFn } = opts;
|
|
357
|
+
const removeLoop = await runGitWorktreeRemoveLoop(ctx, wtPath, {
|
|
358
|
+
...(retryDelaysMs ? { retryDelaysMs } : {}),
|
|
359
|
+
...(sleepFn ? { sleepFn } : {}),
|
|
360
|
+
});
|
|
351
361
|
if (removeLoop.removed) return { removed: true };
|
|
352
362
|
let { lastReason } = removeLoop;
|
|
353
363
|
if (ctx.platform === 'win32' && opts.forceRemoveFallback !== false) {
|
|
@@ -357,6 +367,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
|
|
|
357
367
|
lastReason,
|
|
358
368
|
forceRemoveBackoffMs,
|
|
359
369
|
removeLoop.maxAttempts,
|
|
370
|
+
sleepFn,
|
|
360
371
|
);
|
|
361
372
|
if (fallback.handled) return fallback.result;
|
|
362
373
|
lastReason = fallback.lastReason;
|
|
@@ -367,7 +378,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
|
|
|
367
378
|
const fsRm = ctx.fsRm ?? fsPromisesRm;
|
|
368
379
|
const rmResult = await fsRmWithRetry(fsRm, wtPath, {
|
|
369
380
|
maxRetries: 5,
|
|
370
|
-
retryDelay
|
|
381
|
+
retryDelay,
|
|
371
382
|
});
|
|
372
383
|
if (!rmResult.success) {
|
|
373
384
|
return handleFsRmFailure({
|
|
@@ -380,6 +391,7 @@ export async function removeWorktreeWithRecovery(ctx, wtPath, opts = {}) {
|
|
|
380
391
|
lastReason,
|
|
381
392
|
forceRemoveBackoffMs,
|
|
382
393
|
fsRm,
|
|
394
|
+
sleepFn,
|
|
383
395
|
});
|
|
384
396
|
}
|
|
385
397
|
finalizeGitWorktreeRemove(ctx);
|
|
@@ -571,6 +583,12 @@ export async function reap(ctx, storyId, opts = {}) {
|
|
|
571
583
|
storyId: storyIdN,
|
|
572
584
|
branch,
|
|
573
585
|
push: opts.push === true,
|
|
586
|
+
...(opts.retryDelaysMs ? { retryDelaysMs: opts.retryDelaysMs } : {}),
|
|
587
|
+
...(opts.retryDelay !== undefined ? { retryDelay: opts.retryDelay } : {}),
|
|
588
|
+
...(opts.sleepFn ? { sleepFn: opts.sleepFn } : {}),
|
|
589
|
+
...(opts.forceRemoveBackoffMs !== undefined
|
|
590
|
+
? { forceRemoveBackoffMs: opts.forceRemoveBackoffMs }
|
|
591
|
+
: {}),
|
|
574
592
|
});
|
|
575
593
|
if (!removeResult.removed) {
|
|
576
594
|
return {
|
|
@@ -119,6 +119,80 @@ export function selectInstallCommand(strategy, wtPath, fsLike = fs) {
|
|
|
119
119
|
return { cmd: 'npm', args: ['ci'] };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Per-package-manager "install completed" marker files written into
|
|
124
|
+
* `node_modules/` by the install command itself. Their presence (and
|
|
125
|
+
* freshness relative to the lockfile) is the cheapest reliable signal that a
|
|
126
|
+
* prior install ran to completion — a failed/interrupted install leaves
|
|
127
|
+
* `node_modules` partially populated without (or with a stale) marker.
|
|
128
|
+
*/
|
|
129
|
+
const INSTALL_MARKERS = [
|
|
130
|
+
'.package-lock.json', // npm ci / npm install
|
|
131
|
+
'.modules.yaml', // pnpm
|
|
132
|
+
'.yarn-state.yml', // yarn berry (node-modules linker)
|
|
133
|
+
'.yarn-integrity', // yarn classic
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
137
|
+
|
|
138
|
+
function safeMtimeMs(fsLike, p) {
|
|
139
|
+
try {
|
|
140
|
+
return fsLike.statSync(p).mtimeMs;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pure: probe whether a **reused** worktree already carries a completed,
|
|
148
|
+
* up-to-date install. Worktree reuse must not blindly report
|
|
149
|
+
* `skipped/worktree-reused` — when the prior run's install *failed*, that
|
|
150
|
+
* status defeats the install retry exactly when it matters
|
|
151
|
+
* (`deriveInstallAction('skipped')` treats it as "nothing to do").
|
|
152
|
+
*
|
|
153
|
+
* Returns the same shape as `installDependencies`:
|
|
154
|
+
* - `{ status: 'skipped', reason: 'worktree-reused' }` — a completed
|
|
155
|
+
* install was detected (or the strategy never installs per-tree);
|
|
156
|
+
* safe to skip.
|
|
157
|
+
* - `{ status: 'failed', reason }` — missing/incomplete/stale install
|
|
158
|
+
* detected; callers should retry the install.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
|
|
161
|
+
* @param {string} wtPath Absolute worktree path.
|
|
162
|
+
* @param {{ existsSync: Function, statSync: Function }} [fsLike] Injectable for tests.
|
|
163
|
+
* @returns {{ status: 'skipped' | 'failed', reason: string }}
|
|
164
|
+
*/
|
|
165
|
+
export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
|
|
166
|
+
// `symlink` re-points node_modules at a donor — no per-tree install to probe.
|
|
167
|
+
if (strategy === 'symlink') {
|
|
168
|
+
return { status: 'skipped', reason: 'worktree-reused' };
|
|
169
|
+
}
|
|
170
|
+
if (!fsLike.existsSync(path.join(wtPath, 'package.json'))) {
|
|
171
|
+
return { status: 'skipped', reason: 'no-package-json' };
|
|
172
|
+
}
|
|
173
|
+
const nmPath = path.join(wtPath, 'node_modules');
|
|
174
|
+
if (!fsLike.existsSync(nmPath)) {
|
|
175
|
+
return { status: 'failed', reason: 'reuse-node-modules-missing' };
|
|
176
|
+
}
|
|
177
|
+
const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
|
|
178
|
+
fsLike.existsSync(p),
|
|
179
|
+
);
|
|
180
|
+
if (!marker) {
|
|
181
|
+
return { status: 'failed', reason: 'reuse-install-incomplete' };
|
|
182
|
+
}
|
|
183
|
+
const markerMtime = safeMtimeMs(fsLike, marker);
|
|
184
|
+
const lockfile = LOCKFILES.map((l) => path.join(wtPath, l)).find((p) =>
|
|
185
|
+
fsLike.existsSync(p),
|
|
186
|
+
);
|
|
187
|
+
if (lockfile && markerMtime !== null) {
|
|
188
|
+
const lockMtime = safeMtimeMs(fsLike, lockfile);
|
|
189
|
+
if (lockMtime !== null && lockMtime > markerMtime) {
|
|
190
|
+
return { status: 'failed', reason: 'reuse-node-modules-stale' };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { status: 'skipped', reason: 'worktree-reused' };
|
|
194
|
+
}
|
|
195
|
+
|
|
122
196
|
/** Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min. */
|
|
123
197
|
export function installRetryPolicy(cmd) {
|
|
124
198
|
const isPnpm = cmd === 'pnpm';
|