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.
- package/.agents/scripts/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
- package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
- package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
- package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
- package/.agents/scripts/lib/detect-package-manager.js +2 -2
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- package/.agents/scripts/lib/test-tiers.js +25 -5
- package/.agents/scripts/providers/github/tickets.js +1 -1
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/.agents/workflows/qa-assist.md +67 -9
- package/.agents/workflows/qa-explore.md +77 -11
- package/docs/CHANGELOG.md +23 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +2 -1
- package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
|
@@ -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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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. **
|
|
305
|
-
|
|
306
|
-
|
|
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`),
|
|
311
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
54
|
-
* configure now). Defaults to a
|
|
55
|
-
* prompt with yes as the
|
|
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
|
-
'
|
|
123
|
-
'
|
|
124
|
-
'
|
|
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 = '
|
|
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 —
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
352
|
-
answer
|
|
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) →
|
|
355
|
-
|
|
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
|
|
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
|
-
`✅
|
|
278
|
+
`✅ Installed ${payloadFiles.length} file(s) into ./.agents/ (pruned ${staleFiles.length} stale file(s))\n`,
|
|
279
279
|
);
|
|
280
280
|
} else {
|
|
281
|
-
write(`✅
|
|
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.
|
|
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
|
-
}
|