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.
- package/.agents/scripts/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- 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/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/providers/github/tickets.js +1 -1
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +1 -1
- 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
|
|
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,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
|
-
}
|