mandrel 2.25.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -40,6 +40,23 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
40
40
|
* @property {string} stderr - Trimmed stderr.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Explicit stdout ceiling for every git invocation in this module.
|
|
45
|
+
*
|
|
46
|
+
* Both child-process runners default `maxBuffer` to 1 MB, at which point the
|
|
47
|
+
* child is killed and the call fails with `ENOBUFS` for a reason unrelated to
|
|
48
|
+
* git. The push path is the sharp edge: `git push` relays the whole `pre-push`
|
|
49
|
+
* hook output, which is unbounded by design — this repo's hook emits a full
|
|
50
|
+
* `check-baselines` envelope, measured at 2,166,643 bytes, so every Story
|
|
51
|
+
* close failed at `phase: push` once hooks became reachable inside worktrees.
|
|
52
|
+
*
|
|
53
|
+
* 64 MB is the bound Story #4914 already set on `baselines/git-base.js` for
|
|
54
|
+
* the identical failure, matching `run-test-profile.js`,
|
|
55
|
+
* `audit-baselines/trend.js` and `audit-baselines/weights.js`. This module was
|
|
56
|
+
* missed by that sweep.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
59
|
+
|
|
43
60
|
let _execFileSync = execFileSync;
|
|
44
61
|
let _spawnSync = spawnSync;
|
|
45
62
|
|
|
@@ -108,38 +125,44 @@ export function __setGitRunners(exec, spawn) {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
128
|
+
* The **single** throwing git runner. Both the module-level {@link gitSync} and
|
|
129
|
+
* the interface returned by {@link createGitInterface} route through this —
|
|
130
|
+
* they differ only in which `execFileSync` they hand it.
|
|
113
131
|
*
|
|
114
|
-
* @param {
|
|
115
|
-
* @param {
|
|
132
|
+
* @param {typeof execFileSync} exec
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @param {string[]} args
|
|
116
135
|
* @returns {string} Trimmed stdout text.
|
|
117
136
|
*/
|
|
118
|
-
|
|
119
|
-
return
|
|
137
|
+
function runGitSync(exec, cwd, args) {
|
|
138
|
+
return exec('git', args, {
|
|
120
139
|
cwd,
|
|
121
140
|
encoding: 'utf8',
|
|
122
141
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
142
|
shell: false,
|
|
124
143
|
env: cleanGitEnv(),
|
|
144
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
125
145
|
}).trim();
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
149
|
+
* The **single** non-throwing git runner — the `spawnSync` counterpart of
|
|
150
|
+
* {@link runGitSync}, normalising `status`/stdout/stderr into a
|
|
151
|
+
* {@link GitResult}.
|
|
131
152
|
*
|
|
132
|
-
* @param {
|
|
133
|
-
* @param {
|
|
153
|
+
* @param {typeof spawnSync} spawn
|
|
154
|
+
* @param {string} cwd
|
|
155
|
+
* @param {string[]} args
|
|
134
156
|
* @returns {GitResult}
|
|
135
157
|
*/
|
|
136
|
-
|
|
137
|
-
const result =
|
|
158
|
+
function runGitSpawn(spawn, cwd, args) {
|
|
159
|
+
const result = spawn('git', args, {
|
|
138
160
|
cwd,
|
|
139
161
|
stdio: 'pipe',
|
|
140
162
|
encoding: 'utf-8',
|
|
141
163
|
shell: false,
|
|
142
164
|
env: cleanGitEnv(),
|
|
165
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
143
166
|
});
|
|
144
167
|
return {
|
|
145
168
|
status: result.status ?? 1,
|
|
@@ -148,6 +171,30 @@ export function gitSpawn(cwd, ...args) {
|
|
|
148
171
|
};
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Run a git command synchronously, returning trimmed stdout.
|
|
176
|
+
* Throws an Error if the command exits with a non-zero code.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} cwd - Working directory for the git process.
|
|
179
|
+
* @param {...string} args - Git sub-command and arguments.
|
|
180
|
+
* @returns {string} Trimmed stdout text.
|
|
181
|
+
*/
|
|
182
|
+
export function gitSync(cwd, ...args) {
|
|
183
|
+
return runGitSync(_execFileSync, cwd, args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run a git command synchronously, returning a result object.
|
|
188
|
+
* Never throws — callers must inspect `status` to detect failure.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} cwd - Working directory for the git process.
|
|
191
|
+
* @param {...string} args - Git sub-command and arguments.
|
|
192
|
+
* @returns {GitResult}
|
|
193
|
+
*/
|
|
194
|
+
export function gitSpawn(cwd, ...args) {
|
|
195
|
+
return runGitSpawn(_spawnSync, cwd, args);
|
|
196
|
+
}
|
|
197
|
+
|
|
151
198
|
/**
|
|
152
199
|
* Build a git interface closed over injected child-process runners. Preferred
|
|
153
200
|
* seam for callers that want explicit injection without touching the
|
|
@@ -165,57 +212,25 @@ export function gitSpawn(cwd, ...args) {
|
|
|
165
212
|
export function createGitInterface(deps = {}) {
|
|
166
213
|
const exec = deps.exec ?? execFileSync;
|
|
167
214
|
const spawn = deps.spawn ?? spawnSync;
|
|
168
|
-
const sleep =
|
|
169
|
-
deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
215
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
170
216
|
const jitterFactor = deps.jitter ?? 0.5;
|
|
171
217
|
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const result = spawn('git', args, {
|
|
183
|
-
cwd,
|
|
184
|
-
stdio: 'pipe',
|
|
185
|
-
encoding: 'utf-8',
|
|
186
|
-
shell: false,
|
|
187
|
-
env: cleanGitEnv(),
|
|
188
|
-
});
|
|
189
|
-
return {
|
|
190
|
-
status: result.status ?? 1,
|
|
191
|
-
stdout: (result.stdout ?? '').trim(),
|
|
192
|
-
stderr: (result.stderr ?? '').trim(),
|
|
193
|
-
};
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
async function runWithRetry(leadingArgs, cwd, args) {
|
|
197
|
-
const backoff = [250, 500, 1000];
|
|
198
|
-
let attempt = 0;
|
|
199
|
-
let last;
|
|
200
|
-
for (;;) {
|
|
201
|
-
attempt++;
|
|
202
|
-
last = gitSpawn(cwd, ...leadingArgs, ...args);
|
|
203
|
-
if (last.status === 0) return { ...last, attempts: attempt };
|
|
204
|
-
if (!isPackedRefsContention(last.stderr))
|
|
205
|
-
return { ...last, attempts: attempt };
|
|
206
|
-
if (attempt > backoff.length) return { ...last, attempts: attempt };
|
|
207
|
-
const base = backoff[attempt - 1];
|
|
208
|
-
const jitter = Math.floor(Math.random() * base * jitterFactor);
|
|
209
|
-
await sleep(base + jitter);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
218
|
+
const boundGitSpawn = (cwd, ...args) => runGitSpawn(spawn, cwd, args);
|
|
219
|
+
const withRetry =
|
|
220
|
+
(argvPrefix) =>
|
|
221
|
+
(cwd, ...args) =>
|
|
222
|
+
gitWithContentionRetry(
|
|
223
|
+
{ spawnGit: boundGitSpawn, sleep, jitterFactor },
|
|
224
|
+
cwd,
|
|
225
|
+
argvPrefix,
|
|
226
|
+
args,
|
|
227
|
+
);
|
|
212
228
|
|
|
213
229
|
return {
|
|
214
|
-
gitSync,
|
|
215
|
-
gitSpawn,
|
|
216
|
-
gitFetchWithRetry: (
|
|
217
|
-
gitPullWithRetry: (
|
|
218
|
-
runWithRetry(['pull', '--rebase'], cwd, args),
|
|
230
|
+
gitSync: (cwd, ...args) => runGitSync(exec, cwd, args),
|
|
231
|
+
gitSpawn: boundGitSpawn,
|
|
232
|
+
gitFetchWithRetry: withRetry(['fetch']),
|
|
233
|
+
gitPullWithRetry: withRetry(['pull', '--rebase']),
|
|
219
234
|
};
|
|
220
235
|
}
|
|
221
236
|
|
|
@@ -238,12 +253,21 @@ function isPackedRefsContention(stderr) {
|
|
|
238
253
|
}
|
|
239
254
|
|
|
240
255
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
256
|
+
* Real wall-clock sleep — the default backoff delay for both the module-level
|
|
257
|
+
* retry helpers and {@link createGitInterface}.
|
|
243
258
|
* @param {number} ms
|
|
244
259
|
* @returns {Promise<void>}
|
|
245
260
|
*/
|
|
246
|
-
|
|
261
|
+
function defaultSleep(ms) {
|
|
262
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Sleep helper for retry backoff. Overridable via `__setSleep` so tests
|
|
267
|
+
* can skip real wall-clock delays without relying on node:test timer mocks.
|
|
268
|
+
* @type {(ms: number) => Promise<void>}
|
|
269
|
+
*/
|
|
270
|
+
let _sleep = defaultSleep;
|
|
247
271
|
let _jitterFactor = 0.5;
|
|
248
272
|
|
|
249
273
|
/**
|
|
@@ -258,38 +282,65 @@ export function __setSleep(fn, opts = {}) {
|
|
|
258
282
|
}
|
|
259
283
|
|
|
260
284
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
285
|
+
* Backoff schedule for {@link gitWithContentionRetry}: 250ms, 500ms, 1000ms
|
|
286
|
+
* (3 retries → 4 attempts total).
|
|
287
|
+
*/
|
|
288
|
+
const CONTENTION_BACKOFF_MS = Object.freeze([250, 500, 1000]);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The **single** bounded retry loop for git commands that can hit packed-refs
|
|
292
|
+
* lock contention. Only contention signatures trigger a retry — non-contention
|
|
263
293
|
* failures surface immediately, and success short-circuits the loop.
|
|
264
294
|
*
|
|
265
|
-
* Backoff schedule: 250ms, 500ms, 1000ms (3 retries → 4 attempts total).
|
|
266
295
|
* Deliberately no global lock — a mutex would erase the parallelism the
|
|
267
|
-
* worktree-isolation model is designed to enable. The schedule and the
|
|
268
|
-
*
|
|
269
|
-
*
|
|
296
|
+
* worktree-isolation model is designed to enable. The schedule and the jitter
|
|
297
|
+
* policy live only here, so a backoff tuning change has a single point of
|
|
298
|
+
* application: the module-level `gitFetchWithRetry` / `gitPullWithRetry` pass
|
|
299
|
+
* the `_sleep` / `_jitterFactor` seams, and {@link createGitInterface} passes
|
|
300
|
+
* its injected equivalents.
|
|
270
301
|
*
|
|
302
|
+
* @param {{ spawnGit: (cwd: string, ...args: string[]) => GitResult,
|
|
303
|
+
* sleep: (ms: number) => Promise<void>, jitterFactor: number }} runners
|
|
271
304
|
* @param {string} cwd
|
|
272
305
|
* @param {string[]} argvPrefix - Leading git argv (e.g. `['fetch']`).
|
|
273
306
|
* @param {string[]} args - Trailing arguments (e.g. `['origin']`).
|
|
274
307
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
275
308
|
*/
|
|
276
|
-
async function gitWithContentionRetry(
|
|
277
|
-
|
|
309
|
+
async function gitWithContentionRetry(
|
|
310
|
+
{ spawnGit, sleep, jitterFactor },
|
|
311
|
+
cwd,
|
|
312
|
+
argvPrefix,
|
|
313
|
+
args,
|
|
314
|
+
) {
|
|
278
315
|
let attempt = 0;
|
|
279
|
-
let last;
|
|
280
316
|
for (;;) {
|
|
281
317
|
attempt++;
|
|
282
|
-
last =
|
|
283
|
-
|
|
284
|
-
if (
|
|
318
|
+
const last = spawnGit(cwd, ...argvPrefix, ...args);
|
|
319
|
+
const exhausted = attempt > CONTENTION_BACKOFF_MS.length;
|
|
320
|
+
if (
|
|
321
|
+
last.status === 0 ||
|
|
322
|
+
exhausted ||
|
|
323
|
+
!isPackedRefsContention(last.stderr)
|
|
324
|
+
) {
|
|
285
325
|
return { ...last, attempts: attempt };
|
|
286
|
-
|
|
287
|
-
const base =
|
|
288
|
-
|
|
289
|
-
await _sleep(base + jitter);
|
|
326
|
+
}
|
|
327
|
+
const base = CONTENTION_BACKOFF_MS[attempt - 1];
|
|
328
|
+
await sleep(base + Math.floor(Math.random() * base * jitterFactor));
|
|
290
329
|
}
|
|
291
330
|
}
|
|
292
331
|
|
|
332
|
+
/**
|
|
333
|
+
* The module-level retry runners — the `__setSleep`-overridable seams bound to
|
|
334
|
+
* the module-global {@link gitSpawn}. Read lazily so `__setSleep` and
|
|
335
|
+
* `__setGitRunners` still take effect after import.
|
|
336
|
+
*
|
|
337
|
+
* @returns {{ spawnGit: typeof gitSpawn, sleep: (ms: number) => Promise<void>,
|
|
338
|
+
* jitterFactor: number }}
|
|
339
|
+
*/
|
|
340
|
+
function moduleRetryRunners() {
|
|
341
|
+
return { spawnGit: gitSpawn, sleep: _sleep, jitterFactor: _jitterFactor };
|
|
342
|
+
}
|
|
343
|
+
|
|
293
344
|
/**
|
|
294
345
|
* Run `git fetch …` with the bounded packed-refs-contention retry loop
|
|
295
346
|
* (see `gitWithContentionRetry`).
|
|
@@ -299,7 +350,7 @@ async function gitWithContentionRetry(cwd, argvPrefix, args) {
|
|
|
299
350
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
300
351
|
*/
|
|
301
352
|
export function gitFetchWithRetry(cwd, ...args) {
|
|
302
|
-
return gitWithContentionRetry(cwd, ['fetch'], args);
|
|
353
|
+
return gitWithContentionRetry(moduleRetryRunners(), cwd, ['fetch'], args);
|
|
303
354
|
}
|
|
304
355
|
|
|
305
356
|
/**
|
|
@@ -312,7 +363,12 @@ export function gitFetchWithRetry(cwd, ...args) {
|
|
|
312
363
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
313
364
|
*/
|
|
314
365
|
export function gitPullWithRetry(cwd, ...args) {
|
|
315
|
-
return gitWithContentionRetry(
|
|
366
|
+
return gitWithContentionRetry(
|
|
367
|
+
moduleRetryRunners(),
|
|
368
|
+
cwd,
|
|
369
|
+
['pull', '--rebase'],
|
|
370
|
+
args,
|
|
371
|
+
);
|
|
316
372
|
}
|
|
317
373
|
|
|
318
374
|
/**
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* import-graph.js — the shared static-import graph seam (Story #4902).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `check-arch-cycles.js`, which owned the only
|
|
5
|
+
* import-graph builder in the repository and kept it private to its own
|
|
6
|
+
* cycle ratchet. A second consumer now needs the same graph for a very
|
|
7
|
+
* different question — `audit-baselines.js` ranks hotspot files by import
|
|
8
|
+
* in-degree — and re-deriving "which module imports which" a second time
|
|
9
|
+
* would guarantee the two answers drift.
|
|
10
|
+
*
|
|
11
|
+
* Note this is a **module** graph, not the task/DAG graph in `lib/Graph.js`;
|
|
12
|
+
* the two are unrelated despite the shared word.
|
|
13
|
+
*
|
|
14
|
+
* The extraction is behaviour-preserving: `check-arch-cycles.js` imports
|
|
15
|
+
* these helpers and re-exports them, so its public surface (and the ratchet's
|
|
16
|
+
* output) is unchanged.
|
|
17
|
+
*
|
|
18
|
+
* @module lib/import-graph
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default scan roots making up the project's distributed surface — the
|
|
26
|
+
* directories published to npm via `package.json` `files[]`. Resolving
|
|
27
|
+
* them into one graph (relativized against the repo root) means a cycle
|
|
28
|
+
* crossing two roots is visible to consumers of the graph.
|
|
29
|
+
*
|
|
30
|
+
* @type {string[]}
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively collect `.js` files under `rootDir`, skipping
|
|
36
|
+
* `node_modules`. Returns absolute paths, sorted for determinism.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} rootDir
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
export function collectJsFiles(rootDir) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const walk = (dir) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.name === 'node_modules') continue;
|
|
52
|
+
const full = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
walk(full);
|
|
55
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
56
|
+
out.push(full);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(rootDir);
|
|
61
|
+
return out.sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pure helper: extract relative static-import specifiers from source text.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} source
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function parseRelativeImports(source) {
|
|
73
|
+
const specs = [];
|
|
74
|
+
for (const m of source.matchAll(IMPORT_RE)) {
|
|
75
|
+
specs.push(m[1]);
|
|
76
|
+
}
|
|
77
|
+
return specs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a directed import graph over the given files. Node identity is the
|
|
82
|
+
* file path relative to `rootDir`, posix-separated, so the graph (and any
|
|
83
|
+
* cycles found in it) serializes identically across platforms. Edges that
|
|
84
|
+
* resolve outside the scanned file set are dropped.
|
|
85
|
+
*
|
|
86
|
+
* @param {string[]} files absolute paths
|
|
87
|
+
* @param {string} rootDir
|
|
88
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
89
|
+
* @returns {Map<string, string[]>}
|
|
90
|
+
*/
|
|
91
|
+
export function buildGraph(files, rootDir, { readFile } = {}) {
|
|
92
|
+
const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
93
|
+
const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
|
|
94
|
+
const idSet = new Set(files.map(toId));
|
|
95
|
+
const graph = new Map();
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const id = toId(file);
|
|
98
|
+
let source;
|
|
99
|
+
try {
|
|
100
|
+
source = read(file);
|
|
101
|
+
} catch {
|
|
102
|
+
graph.set(id, []);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const edges = [];
|
|
106
|
+
for (const spec of parseRelativeImports(source)) {
|
|
107
|
+
const target = path
|
|
108
|
+
.relative(rootDir, path.resolve(path.dirname(file), spec))
|
|
109
|
+
.split(path.sep)
|
|
110
|
+
.join('/');
|
|
111
|
+
if (idSet.has(target) && target !== id) edges.push(target);
|
|
112
|
+
}
|
|
113
|
+
graph.set(id, [...new Set(edges)].sort());
|
|
114
|
+
}
|
|
115
|
+
return graph;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the whole-repository import graph by scanning the roots that exist
|
|
120
|
+
* under `cwd`. Returns `null` when none of the roots is present — the
|
|
121
|
+
* "no resolvable import graph" degradation every consumer must tolerate
|
|
122
|
+
* rather than treating an absent graph as a graph with no edges.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} cwd repository root the ids are relativized against
|
|
125
|
+
* @param {{ roots?: string[] }} [opts]
|
|
126
|
+
* @returns {Map<string, string[]> | null}
|
|
127
|
+
*/
|
|
128
|
+
export function resolveRepoGraph(cwd, { roots = DEFAULT_ROOTS } = {}) {
|
|
129
|
+
const present = roots
|
|
130
|
+
.map((dir) => path.resolve(cwd, dir))
|
|
131
|
+
.filter((dir) => fs.existsSync(dir));
|
|
132
|
+
if (present.length === 0) return null;
|
|
133
|
+
const files = present.flatMap((dir) => collectJsFiles(dir));
|
|
134
|
+
if (files.length === 0) return null;
|
|
135
|
+
return buildGraph(files, path.resolve(cwd));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Count inbound edges per node. Nodes with no inbound edge are present in
|
|
140
|
+
* the result with a count of 0, so callers never have to distinguish
|
|
141
|
+
* "unknown module" from "module nothing imports".
|
|
142
|
+
*
|
|
143
|
+
* @param {Map<string, string[]> | null} graph
|
|
144
|
+
* @returns {Map<string, number>} empty when `graph` is null
|
|
145
|
+
*/
|
|
146
|
+
export function computeInDegree(graph) {
|
|
147
|
+
const degrees = new Map();
|
|
148
|
+
if (!graph) return degrees;
|
|
149
|
+
for (const node of graph.keys()) degrees.set(node, 0);
|
|
150
|
+
for (const edges of graph.values()) {
|
|
151
|
+
for (const target of edges) {
|
|
152
|
+
degrees.set(target, (degrees.get(target) ?? 0) + 1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return degrees;
|
|
156
|
+
}
|
|
@@ -534,18 +534,33 @@ function frictionForTerminal(envelope) {
|
|
|
534
534
|
* Emit the friction record (if any) implied by a terminal envelope.
|
|
535
535
|
* Best-effort; never throws.
|
|
536
536
|
*
|
|
537
|
+
* `tool` is the CALLER's name, threaded in rather than assumed: TWO CLIs emit
|
|
538
|
+
* terminal envelopes — `single-story-close.js` (plus its runner) and
|
|
539
|
+
* `single-story-confirm-merge.js` — and `retro-proposals.js` reads
|
|
540
|
+
* `emitter.tool` (via {@link normalizeGatheredSignal}) to name the surface a
|
|
541
|
+
* candidate came from, so hard-coding the close name here attributed every
|
|
542
|
+
* confirm-merge record to a CLI that never ran and misdirected the follow-up.
|
|
543
|
+
* Both callers already declare their name once via `runAsCli({ source })`;
|
|
544
|
+
* pass that same string. The default keeps the close paths — the original
|
|
545
|
+
* callers, which pass none — emitting exactly what they always did.
|
|
546
|
+
*
|
|
537
547
|
* @param {object} args
|
|
538
548
|
* @param {object} args.envelope
|
|
549
|
+
* @param {string} [args.tool] Emitting surface (default `single-story-close`).
|
|
539
550
|
* @param {object} [args.config]
|
|
540
551
|
* @returns {Promise<boolean>} true when a record was appended.
|
|
541
552
|
*/
|
|
542
|
-
export async function emitTerminalFriction({
|
|
553
|
+
export async function emitTerminalFriction({
|
|
554
|
+
envelope,
|
|
555
|
+
tool = 'single-story-close',
|
|
556
|
+
config,
|
|
557
|
+
} = {}) {
|
|
543
558
|
const verdict = frictionForTerminal(envelope);
|
|
544
559
|
if (!verdict) return false;
|
|
545
560
|
return emitRuntimeFriction({
|
|
546
561
|
storyId: envelope?.storyId,
|
|
547
562
|
category: verdict.category,
|
|
548
|
-
tool
|
|
563
|
+
tool,
|
|
549
564
|
details: verdict.details,
|
|
550
565
|
config,
|
|
551
566
|
});
|