mandrel 1.63.0 → 1.65.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.
@@ -296,19 +296,62 @@ For each untriaged ledger item:
296
296
  `searchIssues` port to the GitHub provider (querying both open and closed
297
297
  Issues).
298
298
 
299
- 3. **Decide the disposition** with the operator: `file` (promote to a
300
- follow-up ticket with the classified labels + fingerprint footer), `defer`
301
- (carry forward to a later session as backlog), or `dismiss` (non-actionable).
302
- Record the chosen `disposition` back onto the ledger item.
299
+ 3. **Decide the disposition** with the operator: `file` (promote through
300
+ `/plan` never a raw GitHub Issue), `defer` (carry forward to a later
301
+ session as backlog), or `dismiss` (non-actionable). Record the chosen
302
+ `disposition` back onto the ledger item.
303
303
 
304
- 4. **Gate:** any ticket-filing or label mutation is a write — confirm each one
305
- with the operator before it happens. Capture stayed read-only precisely so
306
- that every state change lands here, deliberately and confirmed.
304
+ 4. **Promote the `file`-dispositioned findings through `/plan`** via
305
+ [`promote-finding.js`](../scripts/lib/findings/promote-finding.js) the
306
+ same cluster/size/route/file path `/qa-assist` and `/audit-to-stories`
307
+ consume. Never hand-roll the clustering, sizing, or promotion in prose:
308
+
309
+ ```js
310
+ import { promoteFindings } from '../scripts/lib/findings/promote-finding.js';
311
+ const { promotions } = await promoteFindings(ledgerItems, {
312
+ searchIssues, // GitHub provider, open + closed
313
+ createStory, // tight cluster (≤2 surfaces): render seed → /plan --from-notes
314
+ createEpic, // broad cluster (>2 surfaces): render seed → /plan --idea
315
+ });
316
+ ```
317
+
318
+ - **Sizing is delegated, not decided in prose.** `promoteFindings` runs
319
+ `clusterLedgerItems` + `targetForCluster`: a cluster spanning **≤2**
320
+ distinct coverage surfaces routes to `createStory`; **>2** routes to
321
+ `createEpic`. The workflow introduces no new sizing, clustering, or dedup
322
+ logic — `route-finding.js` / `promote-finding.js` remain the single
323
+ implementation.
324
+ - **`createStory` (`/plan --from-notes`)** — render a **redacted**
325
+ `--from-notes` seed from the cluster (reuse the `/audit-to-stories`
326
+ Phase 5b notes shape; redaction already ran in Capture), **stamp the
327
+ cluster's `fingerprintFooter(sha)` verbatim into the seed body**, then
328
+ chain `/plan --from-notes <seed>`. The footer must survive into the issue
329
+ body the Story create path writes — it round-trips through
330
+ `story-plan.js --body <file> --dry-run` unchanged (asserted by the
331
+ deterministic round-trip test under `tests/`) so a later `routeFinding`
332
+ dedups the same finding instead of re-filing it.
333
+ - **`createEpic` (`/plan --idea`)** — carry the cluster's
334
+ `fingerprintFooter(sha)` into the `/plan --idea` seed, then chain
335
+ `/plan --idea <seed>`. **Known limitation (not solved here):**
336
+ per-child-Story fingerprint propagation through full Epic decomposition is
337
+ *not* guaranteed — the fingerprint is carried in the Epic seed only; the
338
+ child Stories `/plan` spawns from that seed are not individually
339
+ footer-stamped.
340
+ - **A `file` disposition never opens a raw GitHub Issue.** Every `file`
341
+ finding flows through `promoteFindings` → `/plan`; only `defer` and
342
+ `dismiss` skip the `/plan` handoff.
343
+
344
+ 5. **Gate:** any ticket-filing, seed write, `/plan` invocation, or label
345
+ mutation is a write — confirm each one with the operator before it happens.
346
+ Capture stayed read-only precisely so that every state change lands here,
347
+ deliberately and confirmed. The plan→deliver hard stop is preserved: each
348
+ `/plan` chain pauses at its own HITL gates and never auto-delivers.
307
349
 
308
350
  After triage, write the updated dispositions back to the ledger (still under
309
351
  `temp/qa/`), and summarize: items captured, the driving method used, classes,
310
- routes (`new`/`update-existing`/`duplicate`/`regression-of-closed`), filed
311
- tickets, and the deferred rolling backlog that a resumed session will pick up.
352
+ routes (`new`/`update-existing`/`duplicate`/`regression-of-closed`), the
353
+ Stories (`/plan --from-notes`) and Epics (`/plan --idea`) promoted, and the
354
+ deferred rolling backlog that a resumed session will pick up.
312
355
 
313
356
  ---
314
357
 
@@ -343,8 +386,31 @@ tickets, and the deferred rolling backlog that a resumed session will pick up.
343
386
  ([`coverage-verdict.js`](../scripts/lib/qa/coverage-verdict.js)),
344
387
  missing-test ([`propose-missing-test.js`](../scripts/lib/qa/propose-missing-test.js)),
345
388
  classification ([`classify-finding.js`](../scripts/lib/findings/classify-finding.js)),
346
- and dedup/route ([`route-finding.js`](../scripts/lib/findings/route-finding.js))
347
- are deterministic — never re-derive them in prose.
389
+ dedup/route ([`route-finding.js`](../scripts/lib/findings/route-finding.js)),
390
+ and cluster/size/promote
391
+ ([`promote-finding.js`](../scripts/lib/findings/promote-finding.js)) are
392
+ deterministic — never re-derive them in prose.
393
+ - **Promote through `/plan`, never a raw Issue.** A `file`-dispositioned
394
+ finding is promoted via `promoteFindings`, which chains into
395
+ [`/plan`](plan.md) (`--from-notes` for a tight cluster, `--idea` for a broad
396
+ one) — mirroring [`/audit-to-stories`](audit-to-stories.md). `/qa-explore`
397
+ never opens a bare GitHub Issue for a `file` finding. The cluster's
398
+ `fingerprintFooter(sha)` is stamped verbatim into the seed so a future
399
+ `routeFinding` dedups it.
348
400
  - **Resume safely.** A reused session appends and carries the un-triaged
349
401
  backlog forward via [`qa-session.js`](../scripts/lib/qa/qa-session.js); it
350
402
  never overwrites a prior ledger.
403
+
404
+ ## See also
405
+
406
+ - [`/plan`](plan.md) — the planning pipeline `/qa-explore` Triage chains into
407
+ for a `file`-dispositioned finding (`--from-notes` for a Story, `--idea` for
408
+ an Epic). The plan→deliver hard stop is preserved across the handoff.
409
+ - [`/qa-assist`](qa-assist.md) — the human-led sibling that enriches a single
410
+ operator observation and triages through the same `/plan` handoff.
411
+ - [`/audit-to-stories`](audit-to-stories.md) — the precedent for the
412
+ findings → `/plan` handoff and the shared fingerprint-footer dedup contract.
413
+ - [`promote-finding.js`](../scripts/lib/findings/promote-finding.js) /
414
+ [`route-finding.js`](../scripts/lib/findings/route-finding.js) — the shared
415
+ cluster/size/promote and dedup/route/fingerprint-footer helpers. There is no
416
+ second clustering, sizing, or dedup implementation.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.65.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.64.0...mandrel-v1.65.0) (2026-06-14)
6
+
7
+
8
+ ### Added
9
+
10
+ * Epic [#4118](https://github.com/dsj1984/mandrel/issues/4118) ([#4127](https://github.com/dsj1984/mandrel/issues/4127)) ([d24a1f7](https://github.com/dsj1984/mandrel/commit/d24a1f7d3fc36d20015890fbadd7d08caa1d506b))
11
+ * **qa:** route /qa-assist and /qa-explore triage into /plan (refs [#4115](https://github.com/dsj1984/mandrel/issues/4115)) ([#4116](https://github.com/dsj1984/mandrel/issues/4116)) ([de2a211](https://github.com/dsj1984/mandrel/commit/de2a211089104edd1cb76f77d668b0219a041c3e))
12
+
13
+ ## [1.64.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.63.0...mandrel-v1.64.0) (2026-06-14)
14
+
15
+
16
+ ### Added
17
+
18
+ * **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))
19
+ * **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))
20
+
21
+
22
+ ### Fixed
23
+
24
+ * 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))
25
+ * **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))
26
+ * **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))
27
+
5
28
  ## [1.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.62.0...mandrel-v1.63.0) (2026-06-13)
6
29
 
7
30
 
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.63.0",
3
+ "version": "1.65.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/",
@@ -85,6 +85,7 @@
85
85
  "lint-staged": "^17.0.4",
86
86
  "markdownlint-cli2": "^0.18.1",
87
87
  "memfs": "^4.57.2",
88
+ "node-pty": "^1.0.0",
88
89
  "typescript": ">=5.0.0"
89
90
  },
90
91
  "dependencies": {
@@ -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
- }