mandrel 1.62.0 → 1.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.agents/scripts/agents-bootstrap-github.js +40 -48
  2. package/.agents/scripts/bootstrap.js +74 -60
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
  8. package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
  9. package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
  10. package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
  11. package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
  12. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
  13. package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
  14. package/.agents/scripts/lib/detect-package-manager.js +2 -2
  15. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  16. package/.agents/scripts/lib/onboard/init-tail.js +60 -69
  17. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  18. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  19. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  21. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  22. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  23. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  24. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  25. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  26. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  27. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  28. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  29. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  30. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  31. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  32. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  33. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  34. package/.agents/scripts/providers/github/tickets.js +1 -1
  35. package/.agents/scripts/single-story-init.js +16 -3
  36. package/.agents/workflows/audit-architecture.md +9 -0
  37. package/.agents/workflows/helpers/deliver-stories.md +24 -2
  38. package/.agents/workflows/helpers/single-story-deliver.md +84 -1
  39. package/README.md +1 -1
  40. package/docs/CHANGELOG.md +43 -0
  41. package/lib/cli/init.js +66 -21
  42. package/lib/cli/sync.js +3 -3
  43. package/package.json +1 -1
  44. package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.64.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.63.0...mandrel-v1.64.0) (2026-06-14)
6
+
7
+
8
+ ### Added
9
+
10
+ * **bootstrap:** polish init/bootstrap preflight + UX copy, drop unused stack detection ([#4113](https://github.com/dsj1984/mandrel/issues/4113)) ([c775418](https://github.com/dsj1984/mandrel/commit/c77541846508047f1101158e67ac2a59c8577101))
11
+ * **cli:** mandrel init banner + Welcome prompt, sync 'Installed' wording ([#4109](https://github.com/dsj1984/mandrel/issues/4109)) ([c516d39](https://github.com/dsj1984/mandrel/commit/c516d3903080c5e6838e833dbddf20ab0d4af1cd))
12
+
13
+
14
+ ### Fixed
15
+
16
+ * 2-tier Stories un-initializable: composeStoryBody omits `Epic: #N` when epicId === parentId, blocking the /deliver wave loop ([#4102](https://github.com/dsj1984/mandrel/issues/4102)) ([#4103](https://github.com/dsj1984/mandrel/issues/4103)) ([2431cf6](https://github.com/dsj1984/mandrel/commit/2431cf6e7330b9cf98a7f54b60ba8d3df46f635c))
17
+ * **init:** set terminal:false on readline confirms so the prompt is not erased (refs [#4106](https://github.com/dsj1984/mandrel/issues/4106)) ([#4108](https://github.com/dsj1984/mandrel/issues/4108)) ([e31e767](https://github.com/dsj1984/mandrel/commit/e31e767a6d69519faf120bf08ce55d0272a46416))
18
+ * **init:** use node:readline for confirm prompts so init does not hang (refs [#4106](https://github.com/dsj1984/mandrel/issues/4106)) ([#4107](https://github.com/dsj1984/mandrel/issues/4107)) ([2b56d08](https://github.com/dsj1984/mandrel/commit/2b56d0851f403fd97c86e6097fd9d62446ee2257))
19
+
20
+ ## [1.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.62.0...mandrel-v1.63.0) (2026-06-13)
21
+
22
+
23
+ ### Added
24
+
25
+ * **decompose:** set native GitHub blocked-by dependencies from Story depends_on graph (refs [#4067](https://github.com/dsj1984/mandrel/issues/4067)) ([#4068](https://github.com/dsj1984/mandrel/issues/4068)) ([1799659](https://github.com/dsj1984/mandrel/commit/1799659a84b06555c9caa1193aec59113c943b78))
26
+
27
+
28
+ ### Fixed
29
+
30
+ * **audit-architecture:** add Impact severity field to Detailed Findings (refs [#4085](https://github.com/dsj1984/mandrel/issues/4085)) ([#4096](https://github.com/dsj1984/mandrel/issues/4096)) ([d72c109](https://github.com/dsj1984/mandrel/commit/d72c109195955eff70d7d926abb36a88e32d3f6e))
31
+ * fix stale architecture.md/README docs and broaden the import-cycle gate scan root ([#4071](https://github.com/dsj1984/mandrel/issues/4071)) ([#4090](https://github.com/dsj1984/mandrel/issues/4090)) ([2e97b45](https://github.com/dsj1984/mandrel/commit/2e97b45f9ed43398dc703dd735394c7483329033))
32
+
33
+
34
+ ### Performance
35
+
36
+ * **decompose:** parallelize blocked-by edge creation with bounded concurrentMap (refs [#4082](https://github.com/dsj1984/mandrel/issues/4082)) ([#4094](https://github.com/dsj1984/mandrel/issues/4094)) ([a9f38da](https://github.com/dsj1984/mandrel/commit/a9f38da2d44941a164865a6ad291c2da01ee4ed6))
37
+
38
+
39
+ ### Changed
40
+
41
+ * `parseProviderFindings` is a CC-30 ternary thicket ([#4074](https://github.com/dsj1984/mandrel/issues/4074)) ([#4089](https://github.com/dsj1984/mandrel/issues/4089)) ([5ed693c](https://github.com/dsj1984/mandrel/commit/5ed693c3325c05a36251b859be5873ab0378031c))
42
+ * extract shared createDriftDetector skeleton for CRAP and MI drift detectors (refs [#4076](https://github.com/dsj1984/mandrel/issues/4076)) ([#4091](https://github.com/dsj1984/mandrel/issues/4091)) ([e8a81a8](https://github.com/dsj1984/mandrel/commit/e8a81a82307a12c3d6e58275d4c4554b07659f4f))
43
+ * **scripts:** reduce CC of CLI orchestration bodies below the must-fix band (refs [#4075](https://github.com/dsj1984/mandrel/issues/4075)) ([#4093](https://github.com/dsj1984/mandrel/issues/4093)) ([53519de](https://github.com/dsj1984/mandrel/commit/53519de7968e59c08479c886ca3713f53f5c039d))
44
+ * **signals:** hoist shared detector validation preamble into common.js (refs [#4077](https://github.com/dsj1984/mandrel/issues/4077)) ([#4092](https://github.com/dsj1984/mandrel/issues/4092)) ([2f6fe7b](https://github.com/dsj1984/mandrel/commit/2f6fe7b103c208fbd3ea4cfedf63d2a7aa412e24))
45
+ * **single-story-init:** inject spawnSync boundary into makeGhRunner (refs [#4073](https://github.com/dsj1984/mandrel/issues/4073)) ([#4087](https://github.com/dsj1984/mandrel/issues/4087)) ([8fae355](https://github.com/dsj1984/mandrel/commit/8fae35568523e3997d8f7e79580c0cc8e0625072))
46
+ * **story-body:** extract per-section sub-parsers from parse() (refs [#4072](https://github.com/dsj1984/mandrel/issues/4072)) ([#4088](https://github.com/dsj1984/mandrel/issues/4088)) ([aa9e472](https://github.com/dsj1984/mandrel/commit/aa9e47204728da0ab4f547927af251fce6a85b69))
47
+
5
48
  ## [1.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.61.0...mandrel-v1.62.0) (2026-06-12)
6
49
 
7
50
 
package/lib/cli/init.js CHANGED
@@ -50,9 +50,10 @@
50
50
  * for `./.agents/` in the cwd
51
51
  * - `runStep` — `(cmd, args) => { status }`; runs one install/sync/bootstrap
52
52
  * step. Defaults to a `spawnSync` runner with `stdio: inherit`.
53
- * - `confirm` — `() => boolean`; reads the operator's yes/no answer (true =
54
- * configure now). Defaults to a synchronous stdin readline
55
- * prompt with yes as the default.
53
+ * - `confirm` — `() => boolean | Promise<boolean>`; reads the operator's
54
+ * yes/no answer (true = configure now). Defaults to a
55
+ * `node:readline` stdin prompt (awaited) with yes as the
56
+ * default.
56
57
  * - `stdout` — `(s) => void`; defaults to `process.stdout.write`.
57
58
  * - `isTTY` — boolean; defaults to `process.stdin.isTTY`.
58
59
  * - `exit` — `(code) => void`; defaults to `process.exit`.
@@ -66,6 +67,7 @@
66
67
  import { spawnSync } from 'node:child_process';
67
68
  import fs from 'node:fs';
68
69
  import path from 'node:path';
70
+ import readline from 'node:readline/promises';
69
71
  import { pathToFileURL } from 'node:url';
70
72
 
71
73
  // Lazily resolved at runtime so cold-start `npx mandrel init` (where
@@ -119,11 +121,29 @@ const BOOTSTRAP_SCRIPT = path.join('.agents', 'scripts', 'bootstrap.js');
119
121
  const SYNC_BIN = path.join('node_modules', PACKAGE_NAME, 'bin', 'mandrel.js');
120
122
 
121
123
  const PROMPT_TEXT =
122
- 'The Mandrel .agents package has been copied to your directory.\n' +
123
- 'Would you like to begin the interactive process to setup your local and ' +
124
- 'github environments now? [Y/n]: ';
124
+ '\n' +
125
+ 'Welcome to Mandrel!\n\n' +
126
+ 'Check .agents/README.md for more quick start instructions, flag options, and documentation.\n\n' +
127
+ 'Begin interactive setup? [Y/n]: ';
125
128
 
126
- const FILES_ONLY_HINT = 'Configure any time with: npx mandrel init\n';
129
+ const FILES_ONLY_HINT = 'Setup any time with: npx mandrel init\n';
130
+
131
+ // ASCII banner printed once at the very top of `mandrel init`, before any
132
+ // install/sync output streams to the terminal. Paste multi-line ASCII art
133
+ // between the fences below. `String.raw` keeps backslashes literal so the art
134
+ // renders exactly as pasted — the only characters to avoid inside are a
135
+ // literal backtick and the `${` sequence. The leading/trailing blank lines
136
+ // frame the art in the terminal.
137
+ const BANNER = String.raw`
138
+
139
+ ______ ___ _________ ______
140
+ ___ |/ /_____ _____________ /______________ /
141
+ __ /|_/ /_ __ '/_ __ \ __ /__ ___/ _ \_ /
142
+ _ / / / / /_/ /_ / / / /_/ / _ / / __/ /
143
+ /_/ /_/ \__,_/ /_/ /_/\__,_/ /_/ \___//_/
144
+ ____________________________________________________
145
+
146
+ `;
127
147
 
128
148
  // On win32, `npm` resolves to a `.cmd` shim that Node refuses to spawn without
129
149
  // a shell after the CVE-2024-27980 hardening; mirror update.js and set
@@ -175,7 +195,7 @@ function buildBootstrapArgs(argv, assumeYes) {
175
195
  * argv?: string[],
176
196
  * exists?: (relPath: string) => boolean,
177
197
  * runStep?: (cmd: string, args: string[]) => { status: number | null },
178
- * confirm?: () => boolean,
198
+ * confirm?: () => boolean | Promise<boolean>,
179
199
  * stdout?: (s: string) => void,
180
200
  * isTTY?: boolean,
181
201
  * afterBootstrap?: (root: string) => Promise<{ ok?: boolean } | void> | { ok?: boolean } | void,
@@ -260,7 +280,7 @@ export async function planInit({
260
280
  proceed = false;
261
281
  } else {
262
282
  stdout(PROMPT_TEXT);
263
- proceed = confirm();
283
+ proceed = await confirm();
264
284
  }
265
285
 
266
286
  if (proceed) {
@@ -338,23 +358,44 @@ function defaultRunStep(cmd, args) {
338
358
  }
339
359
 
340
360
  /**
341
- * Default `confirm` seam — synchronous yes/no prompt. Reads one line from stdin
342
- * and normalizes it to a boolean; any input other than an explicit "no"
343
- * (`n`/`no`, case-insensitive) including bare Enterdefaults to `true`
344
- * (configure), matching the `[Y/n]` convention where yes is the default.
361
+ * Default `confirm` seam — yes/no prompt read via `node:readline` (mirrors the
362
+ * prompt mechanism in `bootstrap.js`). Returns on Enter and never blocks
363
+ * waiting for EOF the way `fs.readFileSync(0)` did that EOF-blocking read hung
364
+ * `mandrel init` on an interactive TTY. Any input other than an explicit "no"
365
+ * (`n`/`no`, case-insensitive) — including bare Enter — resolves to `true`
366
+ * (configure), matching the `[Y/n]` convention where yes is the default. The
367
+ * prompt text is written by `planInit` via `stdout`, so the question string
368
+ * passed here is empty.
345
369
  *
346
- * @returns {boolean}
370
+ * `terminal: false` is **load-bearing**, not cosmetic: with terminal mode on
371
+ * (the default when stdout is a TTY) readline emits cursor-control escapes
372
+ * (`\x1b[1G\x1b[0J` — column-1 + erase-to-end-of-screen) when it takes over the
373
+ * line, which **erases the `[Y/n]:` prompt already written via `stdout`** — the
374
+ * operator then sees only the first prompt line and a dead-looking cursor.
375
+ * Disabling terminal mode leaves the pre-written prompt intact and reads the
376
+ * line via the TTY's own cooked-mode echo. `createInterface` is injectable so a
377
+ * test can assert this option is set (regression guard).
378
+ *
379
+ * @param {{ createInterface?: typeof readline.createInterface }} [opts]
380
+ * @returns {Promise<boolean>}
347
381
  */
348
- function defaultConfirm() {
349
- let answer = '';
382
+ export async function defaultConfirm({
383
+ createInterface = readline.createInterface,
384
+ } = {}) {
385
+ const rl = createInterface({
386
+ input: process.stdin,
387
+ output: process.stdout,
388
+ terminal: false,
389
+ });
350
390
  try {
351
- const buf = fs.readFileSync(0, 'utf8');
352
- answer = buf.split('\n', 1)[0].trim().toLowerCase();
391
+ const answer = (await rl.question('')).trim().toLowerCase();
392
+ return answer !== 'n' && answer !== 'no';
353
393
  } catch {
354
- // No readable line (e.g. stdin closed) → fall through to the default.
355
- answer = '';
394
+ // No readable line (e.g. stdin closed) → default to yes (configure).
395
+ return true;
396
+ } finally {
397
+ rl.close();
356
398
  }
357
- return answer !== 'n' && answer !== 'no';
358
399
  }
359
400
 
360
401
  /**
@@ -376,6 +417,10 @@ export default async function run(argv = []) {
376
417
  return;
377
418
  }
378
419
 
420
+ // Banner is the very first output — before the install + sync steps that
421
+ // planInit kicks off — so it greets the operator on a cold start.
422
+ process.stdout.write(BANNER);
423
+
379
424
  const result = await planInit({
380
425
  argv,
381
426
  exists: defaultExists,
package/lib/cli/sync.js CHANGED
@@ -242,7 +242,7 @@ export function runSync({
242
242
  write(`would prune ${path.join('.agents', rel)}\n`);
243
243
  }
244
244
  write(
245
- `✅ Dry run: ${payloadFiles.length} file(s) would be materialized, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
245
+ `✅ Dry run: ${payloadFiles.length} file(s) would be installed, ${stale.length} stale file(s) would be pruned from ./.agents/\n`,
246
246
  );
247
247
  return {
248
248
  copied: 0,
@@ -275,10 +275,10 @@ export function runSync({
275
275
 
276
276
  if (staleFiles.length > 0) {
277
277
  write(
278
- `✅ Materialized ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
278
+ `✅ Installed ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
279
279
  );
280
280
  } else {
281
- write(`✅ Materialized ${payloadFiles.length} file(s) into ./.agents/\n`);
281
+ write(`✅ Installed ${payloadFiles.length} file(s) into ./.agents/\n`);
282
282
  }
283
283
  return {
284
284
  copied: payloadFiles.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.62.0",
3
+ "version": "1.64.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",
@@ -1,300 +0,0 @@
1
- /**
2
- * detect-stack.js — Consumer stack detection for `mandrel init`
3
- *
4
- * Inspects a consumer repository root and reports the package manager,
5
- * test runner, and primary language it can infer from on-disk signals
6
- * (lockfiles, `package.json` contents, and source-file extensions). The
7
- * `mandrel init` configure-path tail (Feature #3514, Story #4045) uses
8
- * this to tell the operator what it found before scaffolding missing
9
- * `docsContextFiles`.
10
- *
11
- * The detection functions are seam-injectable: each takes an injected
12
- * filesystem facade (`exists` / `readFile` / `listExtensions`) so they
13
- * are unit-testable in isolation against an in-memory fixture, mirroring
14
- * the style of `lib/runtime-deps/preflight.js#detectPackageManager`. The
15
- * default facade reads the real filesystem so callers can point it at a
16
- * sample-repo fixture directory.
17
- *
18
- * Story #3520 (refs #3520).
19
- */
20
-
21
- import fs from 'node:fs';
22
- import path from 'node:path';
23
- import { detectPackageManager as detectPm } from '../detect-package-manager.js';
24
-
25
- /**
26
- * Filesystem facade. Pure detection logic talks to disk only through
27
- * this seam so tests can drive it with an in-memory fixture.
28
- *
29
- * @typedef {object} FsFacade
30
- * @property {(p: string) => boolean} exists - Path existence probe.
31
- * @property {(p: string) => string|null} readFile - UTF-8 read; null when absent/unreadable.
32
- * @property {(root: string) => string[]} listExtensions - Lowercased source-file extensions (with leading dot) found under root.
33
- */
34
-
35
- const SOURCE_EXTENSIONS = new Set([
36
- '.ts',
37
- '.tsx',
38
- '.js',
39
- '.jsx',
40
- '.mjs',
41
- '.cjs',
42
- '.py',
43
- '.go',
44
- '.rs',
45
- '.rb',
46
- '.java',
47
- '.kt',
48
- '.php',
49
- '.cs',
50
- '.swift',
51
- ]);
52
-
53
- const IGNORED_DIRS = new Set([
54
- 'node_modules',
55
- '.git',
56
- 'dist',
57
- 'build',
58
- 'coverage',
59
- '.next',
60
- '.nuxt',
61
- 'vendor',
62
- 'target',
63
- '__pycache__',
64
- '.venv',
65
- 'venv',
66
- ]);
67
-
68
- /**
69
- * Map a source-file extension to a primary-language label.
70
- *
71
- * @param {string} ext - Lowercased extension including the leading dot.
72
- * @returns {string|null} Language label, or null when the extension is not a recognized source type.
73
- */
74
- function extensionToLanguage(ext) {
75
- switch (ext) {
76
- case '.ts':
77
- case '.tsx':
78
- return 'typescript';
79
- case '.js':
80
- case '.jsx':
81
- case '.mjs':
82
- case '.cjs':
83
- return 'javascript';
84
- case '.py':
85
- return 'python';
86
- case '.go':
87
- return 'go';
88
- case '.rs':
89
- return 'rust';
90
- case '.rb':
91
- return 'ruby';
92
- case '.java':
93
- return 'java';
94
- case '.kt':
95
- return 'kotlin';
96
- case '.php':
97
- return 'php';
98
- case '.cs':
99
- return 'csharp';
100
- case '.swift':
101
- return 'swift';
102
- default:
103
- return null;
104
- }
105
- }
106
-
107
- /**
108
- * Recursively collect lowercased source-file extensions under `root`,
109
- * skipping vendored / build / VCS directories. Used by the default
110
- * filesystem facade; tests inject their own `listExtensions`.
111
- *
112
- * @param {string} root - Absolute repository root.
113
- * @returns {string[]} Extensions (with leading dot, possibly repeated) in traversal order.
114
- */
115
- function listExtensionsOnDisk(root) {
116
- /** @type {string[]} */
117
- const extensions = [];
118
- /** @type {string[]} */
119
- const stack = [root];
120
-
121
- while (stack.length > 0) {
122
- const dir = stack.pop();
123
- let entries;
124
- try {
125
- entries = fs.readdirSync(dir, { withFileTypes: true });
126
- } catch {
127
- continue;
128
- }
129
- for (const entry of entries) {
130
- if (entry.isDirectory()) {
131
- if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) {
132
- continue;
133
- }
134
- stack.push(path.join(dir, entry.name));
135
- } else if (entry.isFile()) {
136
- const ext = path.extname(entry.name).toLowerCase();
137
- if (ext) extensions.push(ext);
138
- }
139
- }
140
- }
141
-
142
- return extensions;
143
- }
144
-
145
- /**
146
- * Default filesystem facade backed by `node:fs`. Reads the real disk so
147
- * callers can point detection at a sample-repo fixture directory.
148
- *
149
- * @type {FsFacade}
150
- */
151
- export const defaultFsFacade = {
152
- exists: (p) => fs.existsSync(p),
153
- readFile: (p) => {
154
- try {
155
- return fs.readFileSync(p, 'utf8');
156
- } catch {
157
- return null;
158
- }
159
- },
160
- listExtensions: (root) => listExtensionsOnDisk(root),
161
- };
162
-
163
- /**
164
- * Detect the package manager from lockfile presence. Defaults to `npm`
165
- * when no lockfile is found but a `package.json` exists, and `null` when
166
- * the repo has no Node manifest at all.
167
- *
168
- * Delegates to the shared `detectPackageManager` helper
169
- * (Story #4048 B3 — one implementation per concept). The `fsFacade.exists`
170
- * seam is forwarded directly.
171
- *
172
- * @param {string} root - Repository root.
173
- * @param {FsFacade} [fsFacade=defaultFsFacade]
174
- * @returns {'pnpm'|'yarn'|'bun'|'npm'|null}
175
- */
176
- export function detectPackageManager(root, fsFacade = defaultFsFacade) {
177
- return detectPm(root, fsFacade.exists);
178
- }
179
-
180
- /**
181
- * Parse `package.json` into an object, returning `null` when it is
182
- * absent or unparseable.
183
- *
184
- * @param {string} root - Repository root.
185
- * @param {FsFacade} fsFacade
186
- * @returns {Record<string, unknown>|null}
187
- */
188
- function readPackageJson(root, fsFacade) {
189
- const raw = fsFacade.readFile(path.join(root, 'package.json'));
190
- if (!raw) return null;
191
- try {
192
- return JSON.parse(raw);
193
- } catch {
194
- return null;
195
- }
196
- }
197
-
198
- /**
199
- * Detect the test runner from `package.json` dependency declarations and
200
- * the `test` script. Recognizes vitest, jest, mocha, ava, and the
201
- * Node.js built-in test runner (`node --test`). Returns `null` when no
202
- * runner can be inferred.
203
- *
204
- * @param {string} root - Repository root.
205
- * @param {FsFacade} [fsFacade=defaultFsFacade]
206
- * @returns {'vitest'|'jest'|'mocha'|'ava'|'node-test'|null}
207
- */
208
- export function detectTestRunner(root, fsFacade = defaultFsFacade) {
209
- const pkg = readPackageJson(root, fsFacade);
210
- if (!pkg) return null;
211
-
212
- const deps = {
213
- .../** @type {Record<string, unknown>} */ (pkg.dependencies ?? {}),
214
- .../** @type {Record<string, unknown>} */ (pkg.devDependencies ?? {}),
215
- };
216
-
217
- if (deps.vitest) return 'vitest';
218
- if (deps.jest) return 'jest';
219
- if (deps.mocha) return 'mocha';
220
- if (deps.ava) return 'ava';
221
-
222
- const scripts = /** @type {Record<string, unknown>} */ (pkg.scripts ?? {});
223
- const testScript =
224
- typeof scripts.test === 'string' ? scripts.test.toLowerCase() : '';
225
- if (testScript) {
226
- if (testScript.includes('vitest')) return 'vitest';
227
- if (testScript.includes('jest')) return 'jest';
228
- if (testScript.includes('mocha')) return 'mocha';
229
- if (testScript.includes('ava')) return 'ava';
230
- if (
231
- testScript.includes('node --test') ||
232
- testScript.includes('node:test')
233
- ) {
234
- return 'node-test';
235
- }
236
- }
237
-
238
- return null;
239
- }
240
-
241
- /**
242
- * Detect the primary language by tallying source-file extensions and
243
- * picking the most frequent recognized language. A `tsconfig.json`
244
- * breaks ties toward TypeScript. Returns `null` when no recognized
245
- * source files are found.
246
- *
247
- * @param {string} root - Repository root.
248
- * @param {FsFacade} [fsFacade=defaultFsFacade]
249
- * @returns {string|null}
250
- */
251
- export function detectPrimaryLanguage(root, fsFacade = defaultFsFacade) {
252
- const extensions = fsFacade.listExtensions(root) ?? [];
253
- /** @type {Map<string, number>} */
254
- const tally = new Map();
255
-
256
- for (const ext of extensions) {
257
- if (!SOURCE_EXTENSIONS.has(ext)) continue;
258
- const language = extensionToLanguage(ext);
259
- if (!language) continue;
260
- tally.set(language, (tally.get(language) ?? 0) + 1);
261
- }
262
-
263
- if (tally.size === 0) return null;
264
-
265
- // tsconfig.json is a strong TypeScript signal: nudge the tally so a
266
- // mixed JS/TS repo resolves to typescript when the config is present.
267
- if (fsFacade.exists(path.join(root, 'tsconfig.json'))) {
268
- tally.set('typescript', (tally.get('typescript') ?? 0) + 1);
269
- }
270
-
271
- let best = null;
272
- let bestCount = -1;
273
- for (const [language, count] of tally) {
274
- if (count > bestCount) {
275
- best = language;
276
- bestCount = count;
277
- }
278
- }
279
-
280
- return best;
281
- }
282
-
283
- /**
284
- * Inspect a consumer repository and report the inferred stack.
285
- *
286
- * @param {string} root - Absolute repository root to inspect.
287
- * @param {FsFacade} [fsFacade=defaultFsFacade] - Filesystem seam (defaults to real disk).
288
- * @returns {{ packageManager: 'pnpm'|'yarn'|'bun'|'npm'|null, testRunner: 'vitest'|'jest'|'mocha'|'ava'|'node-test'|null, primaryLanguage: string|null }}
289
- */
290
- export function detectStack(root, fsFacade = defaultFsFacade) {
291
- if (!root || typeof root !== 'string') {
292
- throw new Error('detectStack: root must be a non-empty string path');
293
- }
294
-
295
- return {
296
- packageManager: detectPackageManager(root, fsFacade),
297
- testRunner: detectTestRunner(root, fsFacade),
298
- primaryLanguage: detectPrimaryLanguage(root, fsFacade),
299
- };
300
- }