@esneiderbravo/speclaw 2.0.8 → 2.0.10
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/README.md +38 -9
- package/dist/cli/commands/index-build.js +2 -15
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/lawbook.js +2 -0
- package/dist/cli/commands/laws.js +149 -19
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/update.js +110 -24
- package/dist/cli/index.js +24 -66
- package/dist/cli/lib/help.js +489 -0
- package/dist/cli/lib/self-update.js +107 -0
- package/dist/cli/lib/update-check.js +17 -10
- package/dist/modules/compass/affected-config.js +5 -0
- package/dist/modules/compass/affected.js +344 -27
- package/dist/modules/compass/db.js +88 -16
- package/dist/modules/compass/diff-context.js +4 -2
- package/dist/modules/compass/explore-rich.js +14 -7
- package/dist/modules/compass/extract.js +163 -4
- package/dist/modules/compass/hotspots.js +11 -6
- package/dist/modules/compass/hybrid.js +9 -4
- package/dist/modules/compass/indexer.js +365 -95
- package/dist/modules/compass/map.js +2 -2
- package/dist/modules/compass/query.js +70 -24
- package/dist/modules/compass/register.js +1 -1
- package/dist/modules/compass/visualize.js +5 -3
- package/dist/modules/cortex/brief.js +3 -1
- package/dist/modules/cortex/harness.js +51 -5
- package/dist/modules/cortex/paths.js +39 -0
- package/dist/modules/cortex/status.js +4 -1
- package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
- package/dist/modules/foundation/compile-laws.js +23 -6
- package/dist/modules/foundation/doctor.js +11 -1
- package/dist/modules/foundation/lock.js +224 -20
- package/dist/modules/foundation/scaffold.js +24 -8
- package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/engine.js +24 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/agents.js +56 -7
- package/dist/shared/output-budget.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update for `speclaw update`: re-run the command as
|
|
3
|
+
* `npx -y <pkg>@<latest> update …` so the latest release applies its own
|
|
4
|
+
* migrations instead of a stale binary applying old ones. This is the only CLI
|
|
5
|
+
* module that imports `child_process`; `update.ts` receives it through
|
|
6
|
+
* `UpdateHooks` so tests never spawn anything.
|
|
7
|
+
*/
|
|
8
|
+
// Covers: req~update-self-update~1
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
/**
|
|
13
|
+
* Flag tokens safe to forward to the child: `-x`, `--flag`, or `--flag=value`
|
|
14
|
+
* with a conservative value charset. The charset also keeps the Windows shell
|
|
15
|
+
* spawn free of injection.
|
|
16
|
+
*/
|
|
17
|
+
const SAFE_TOKEN = /^--?[A-Za-z][A-Za-z0-9-]*(=[A-Za-z0-9._/:@-]*)?$/;
|
|
18
|
+
/**
|
|
19
|
+
* A registry version that is safe to put on a command line: strict semver with
|
|
20
|
+
* an optional prerelease tag. Anything else (for example `"9.9.9 & calc"`) is
|
|
21
|
+
* never spawned — it would reach the Windows shell verbatim.
|
|
22
|
+
*/
|
|
23
|
+
const SAFE_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
24
|
+
/**
|
|
25
|
+
* Whether `version` is a strict semver string safe to spawn with.
|
|
26
|
+
*
|
|
27
|
+
* @param version - The `latest` version reported by the registry.
|
|
28
|
+
* @returns True only for `MAJOR.MINOR.PATCH` with an optional `-prerelease`.
|
|
29
|
+
*/
|
|
30
|
+
export function isSafeVersion(version) {
|
|
31
|
+
return SAFE_VERSION.test(version);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether `command` exists as a file in a directory on the env's PATH (the key
|
|
35
|
+
* is matched case-insensitively, since Windows spells it `Path`).
|
|
36
|
+
*
|
|
37
|
+
* @param command - The executable file name, e.g. `npx.cmd`.
|
|
38
|
+
* @param env - The environment whose PATH is searched.
|
|
39
|
+
* @param delimiter - The PATH separator (`;` on Windows).
|
|
40
|
+
* @returns True when some PATH entry holds `command`.
|
|
41
|
+
*/
|
|
42
|
+
export function onPath(command, env, delimiter = path.delimiter) {
|
|
43
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
|
|
44
|
+
const dirs = (key ? (env[key] ?? "") : "").split(delimiter).filter(Boolean);
|
|
45
|
+
return dirs.some((dir) => fs.existsSync(path.join(dir, command)));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Split the original `update` arguments into the ones forwarded to the child
|
|
49
|
+
* and the ones dropped. `update` takes no positional arguments, so dropping
|
|
50
|
+
* anything that is not a safe flag loses nothing in practice.
|
|
51
|
+
*
|
|
52
|
+
* @param argv - The arguments after `speclaw update`.
|
|
53
|
+
* @returns The forwarded tokens (in order) and the dropped ones.
|
|
54
|
+
*/
|
|
55
|
+
export function safeForwardArgs(argv) {
|
|
56
|
+
const forward = [];
|
|
57
|
+
const dropped = [];
|
|
58
|
+
for (const token of argv)
|
|
59
|
+
(SAFE_TOKEN.test(token) ? forward : dropped).push(token);
|
|
60
|
+
return { forward, dropped };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run `npx -y <pkg>@<version> update <args>` with inherited stdio and the loop
|
|
64
|
+
* guard `SPECLAW_SELF_UPDATED=<version>` in its environment. On Windows the
|
|
65
|
+
* `npx.cmd` shim is spawned through the shell (Node refuses `.cmd` files
|
|
66
|
+
* otherwise); `args` must already be filtered by {@link safeForwardArgs}. A
|
|
67
|
+
* version that is not strict semver is never spawned, and on Windows a missing
|
|
68
|
+
* `npx.cmd` is detected up front (the shell would turn it into exit code 1
|
|
69
|
+
* rather than an ENOENT), so both report `unavailable` and the caller migrates
|
|
70
|
+
* in process.
|
|
71
|
+
*
|
|
72
|
+
* @param opts - Package name, target version, forwarded args, the base env,
|
|
73
|
+
* and the platform (defaults to `process.platform`; tests pass `win32`).
|
|
74
|
+
* @returns `ran` with the child's exit code, or `unavailable` when it could not start.
|
|
75
|
+
*/
|
|
76
|
+
export function selfUpdate(opts) {
|
|
77
|
+
if (!isSafeVersion(opts.version)) {
|
|
78
|
+
return Promise.resolve({
|
|
79
|
+
kind: "unavailable",
|
|
80
|
+
reason: `invalid version ${JSON.stringify(opts.version)}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const windows = (opts.platform ?? process.platform) === "win32";
|
|
84
|
+
const command = windows ? "npx.cmd" : "npx";
|
|
85
|
+
const args = ["-y", `${opts.pkg}@${opts.version}`, "update", ...opts.args];
|
|
86
|
+
const env = { ...(opts.env ?? process.env), SPECLAW_SELF_UPDATED: opts.version };
|
|
87
|
+
if (windows && !onPath(command, env, ";")) {
|
|
88
|
+
return Promise.resolve({ kind: "unavailable", reason: `${command} not found on PATH` });
|
|
89
|
+
}
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const done = (outcome) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
resolve(outcome);
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
const child = spawn(command, args, { stdio: "inherit", env, shell: windows });
|
|
100
|
+
child.on("error", (err) => done({ kind: "unavailable", reason: err.message }));
|
|
101
|
+
child.on("exit", (code) => done({ kind: "ran", code: code ?? 1 }));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
done({ kind: "unavailable", reason: err.message });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -77,23 +77,28 @@ export function isNewer(latest, current) {
|
|
|
77
77
|
* cache unless `force` is set. Falls back to stale cache when offline.
|
|
78
78
|
*
|
|
79
79
|
* @param opts - `force` bypasses the cache and always queries the registry.
|
|
80
|
-
* @returns The current/latest versions
|
|
80
|
+
* @returns The current/latest versions, whether an upgrade is available, and
|
|
81
|
+
* `fresh`: true only when `latest` came from the registry during this call
|
|
82
|
+
* (a cached or offline value is not fresh — `npx` could not resolve it either).
|
|
81
83
|
*/
|
|
82
84
|
export async function checkForUpdates(opts = {}) {
|
|
83
85
|
const current = pkgVersion();
|
|
84
86
|
const cache = readCache();
|
|
85
87
|
let latest;
|
|
88
|
+
let fresh = false;
|
|
86
89
|
if (!opts.force && cache && Date.now() - cache.checkedAt < TTL_MS) {
|
|
87
90
|
latest = cache.latest;
|
|
88
91
|
}
|
|
89
92
|
else {
|
|
90
93
|
latest = await fetchLatest(pkgName());
|
|
91
|
-
if (latest)
|
|
94
|
+
if (latest) {
|
|
95
|
+
fresh = true;
|
|
92
96
|
writeCache(latest);
|
|
97
|
+
}
|
|
93
98
|
else if (cache)
|
|
94
99
|
latest = cache.latest; // offline: use whatever we last knew
|
|
95
100
|
}
|
|
96
|
-
return { current, latest, updateAvailable: !!latest && isNewer(latest, current) };
|
|
101
|
+
return { current, latest, updateAvailable: !!latest && isNewer(latest, current), fresh };
|
|
97
102
|
}
|
|
98
103
|
/** The public npm page for a package, where an upgrade can be reviewed. */
|
|
99
104
|
export function npmPackageUrl(name) {
|
|
@@ -102,8 +107,8 @@ export function npmPackageUrl(name) {
|
|
|
102
107
|
/**
|
|
103
108
|
* Build the two-line "update available" notice. The latest version is rendered
|
|
104
109
|
* as a clickable link to the package's npm page, so a capable terminal lets the
|
|
105
|
-
* user open the release with a single click.
|
|
106
|
-
*
|
|
110
|
+
* user open the release with a single click. `speclaw update` upgrades itself
|
|
111
|
+
* (it re-runs at the latest version through npx) and migrates the project.
|
|
107
112
|
*
|
|
108
113
|
* @param current - The installed version.
|
|
109
114
|
* @param latest - The newest published version.
|
|
@@ -119,16 +124,18 @@ export function upgradeNotice(current, latest) {
|
|
|
119
124
|
c.muted(" available") +
|
|
120
125
|
"\n" +
|
|
121
126
|
" " +
|
|
122
|
-
c.muted("
|
|
123
|
-
c.cyan(`npx ${name}@latest update`) +
|
|
124
|
-
c.muted(" — or ") +
|
|
127
|
+
c.muted("run ") +
|
|
125
128
|
c.cyan("speclaw update") +
|
|
126
|
-
c.muted("
|
|
129
|
+
c.muted(" (or ") +
|
|
130
|
+
c.cyan(`npx ${name}@latest update`) +
|
|
131
|
+
c.muted(") — it upgrades itself through npx and migrates this project"));
|
|
127
132
|
}
|
|
128
133
|
/**
|
|
129
134
|
* Print the "update available" notice to stderr when a newer version exists.
|
|
130
135
|
* No-op for the `mcp`/`update`/`help`/`version` commands, on non-TTY stderr, or
|
|
131
136
|
* when NO_UPDATE_NOTIFIER / SPECLAW_NO_UPDATE_NOTIFIER is set. Never throws.
|
|
137
|
+
* `SPECLAW_UPDATE_NOTIFIER=force` lifts only the TTY check, so a test with
|
|
138
|
+
* piped stderr can prove a command path does (or does not) reach the notifier.
|
|
132
139
|
*
|
|
133
140
|
* @param cmd - The command that just ran (used to skip noisy contexts).
|
|
134
141
|
*/
|
|
@@ -136,7 +143,7 @@ export async function maybeNotifyUpdate(cmd) {
|
|
|
136
143
|
try {
|
|
137
144
|
if (process.env.NO_UPDATE_NOTIFIER || process.env.SPECLAW_NO_UPDATE_NOTIFIER)
|
|
138
145
|
return;
|
|
139
|
-
if (!process.stderr.isTTY)
|
|
146
|
+
if (!process.stderr.isTTY && process.env.SPECLAW_UPDATE_NOTIFIER !== "force")
|
|
140
147
|
return;
|
|
141
148
|
// `init` shows its own prominent up-front warning and ends on the clean
|
|
142
149
|
// copy-paste prompt — don't append a second notice after it.
|
|
@@ -10,10 +10,15 @@ const DEFAULT_TARGETS = {
|
|
|
10
10
|
exclude: ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts}", "test/**", "tests/**"],
|
|
11
11
|
},
|
|
12
12
|
test: {
|
|
13
|
+
// The `**/` forms cover workspace packages (`apps/web/src/**`), whose
|
|
14
|
+
// changes would otherwise be filtered out before any test is selected.
|
|
13
15
|
include: [
|
|
14
16
|
"src/**",
|
|
15
17
|
"test/**",
|
|
16
18
|
"tests/**",
|
|
19
|
+
"**/src/**",
|
|
20
|
+
"**/test/**",
|
|
21
|
+
"**/tests/**",
|
|
17
22
|
"**/*.{test,spec}.{ts,tsx,js,jsx}",
|
|
18
23
|
"**/conftest.py",
|
|
19
24
|
],
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static affected-test selection: reverse reachability into `files.is_test = 1`,
|
|
3
|
-
* plus
|
|
3
|
+
* plus ready-to-run commands per package (runner detected from `package.json`).
|
|
4
4
|
*/
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { changedFiles, isGitRepo } from "../../shared/git.js";
|
|
8
8
|
import { openDb, indexExists } from "./db.js";
|
|
9
9
|
import { impact } from "./query.js";
|
|
10
|
-
import { loadAffectedConfig, matchGlobalFiles, matchesAny, } from "./affected-config.js";
|
|
10
|
+
import { loadAffectedConfig, matchGlobalFiles, matchesAny, matchGlob, } from "./affected-config.js";
|
|
11
11
|
/**
|
|
12
12
|
* Select a safe superset of test files affected by a change.
|
|
13
13
|
*
|
|
@@ -40,7 +40,7 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
40
40
|
reason: "no changed files",
|
|
41
41
|
tests: [],
|
|
42
42
|
skipped: { files: countTestFiles(projectPath), percent: 100 },
|
|
43
|
-
|
|
43
|
+
...planTestCommand(projectPath, [], "none"),
|
|
44
44
|
warnings,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
@@ -48,12 +48,13 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
48
48
|
const glob = matchGlobalFiles(files, cfg);
|
|
49
49
|
if (glob.matched.length > 0) {
|
|
50
50
|
const allTests = listTestFiles(projectPath);
|
|
51
|
+
const reason = `global file matched (${glob.matched.join(", ")})`;
|
|
51
52
|
return {
|
|
52
53
|
mode: "all",
|
|
53
|
-
reason
|
|
54
|
+
reason,
|
|
54
55
|
tests: allTests.map((file) => ({ file, nodes: 0, minDepth: 0 })),
|
|
55
56
|
skipped: { files: 0, percent: 0 },
|
|
56
|
-
|
|
57
|
+
...planTestCommand(projectPath, [], "all", reason),
|
|
57
58
|
warnings,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
@@ -125,7 +126,7 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
125
126
|
: "empty selection",
|
|
126
127
|
tests,
|
|
127
128
|
skipped: { files: skippedFiles, percent },
|
|
128
|
-
|
|
129
|
+
...planTestCommand(projectPath, tests.map((t) => t.file), tests.length === 0 ? "none" : "subset"),
|
|
129
130
|
warnings,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
@@ -160,43 +161,359 @@ function listTestFiles(projectPath) {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
/**
|
|
163
|
-
* Build
|
|
164
|
+
* Build the command for a selection as a single string (see {@link planTestCommand}).
|
|
164
165
|
*
|
|
165
166
|
* @param projectPath - Project root.
|
|
166
|
-
* @param tests - Selected test paths (ignored for mode `all`).
|
|
167
|
+
* @param tests - Selected repo-relative test paths (ignored for mode `all`).
|
|
167
168
|
* @param _cfg - Reserved for future runner overrides.
|
|
168
169
|
* @param mode - `all` | `subset` | `none`.
|
|
170
|
+
* @returns The command to run from the root, or null when nothing is selected.
|
|
169
171
|
*/
|
|
170
172
|
export function buildTestCommand(projectPath, tests, _cfg, mode) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
173
|
+
return planTestCommand(projectPath, tests, mode).command;
|
|
174
|
+
}
|
|
175
|
+
/** Read a `package.json`; null when absent or unparseable. */
|
|
176
|
+
function readPackage(dir) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
181
182
|
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Turn a selection into runnable commands.
|
|
186
|
+
*
|
|
187
|
+
* - `none`: `command` is null with a reason; a run-nothing command is never emitted.
|
|
188
|
+
* - `all`: the root runner's full suite.
|
|
189
|
+
* - `subset`: files are grouped by their nearest `package.json` directory
|
|
190
|
+
* (bounded by the root) and each group gets its own runner's command:
|
|
191
|
+
* `npx vitest run`, `npx jest --runTestsByPath`, the `node --test` script's
|
|
192
|
+
* flags without coverage flags (mapped onto a compiled-test glob, after
|
|
193
|
+
* `npm run pretest`), or `npm test -- <files>` for an unrecognized script.
|
|
194
|
+
* Several groups compose as `(cd a && …) && (cd b && …)` from the root.
|
|
195
|
+
*
|
|
196
|
+
* @param projectPath - Project root.
|
|
197
|
+
* @param tests - Selected repo-relative test paths (ignored unless `subset`).
|
|
198
|
+
* @param mode - `all` | `subset` | `none`.
|
|
199
|
+
* @param allReason - Why the full suite was selected (mode `all`).
|
|
200
|
+
* @returns The command plan; `commands` is empty exactly when `command` is null.
|
|
201
|
+
*/
|
|
202
|
+
export function planTestCommand(projectPath, tests, mode, allReason = "the full suite was selected") {
|
|
203
|
+
// Covers: req~affected-test-selection~1
|
|
182
204
|
if (mode === "all") {
|
|
183
|
-
|
|
205
|
+
const command = fullSuiteCommand(readPackage(projectPath));
|
|
206
|
+
return {
|
|
207
|
+
command,
|
|
208
|
+
commandReason: `${allReason}; run the full suite`,
|
|
209
|
+
commands: [{ cwd: ".", command, files: [] }],
|
|
210
|
+
};
|
|
184
211
|
}
|
|
185
212
|
if (mode === "none" || tests.length === 0) {
|
|
186
|
-
return
|
|
213
|
+
return {
|
|
214
|
+
command: null,
|
|
215
|
+
commandReason: "no test file is reachable from the change; nothing to run",
|
|
216
|
+
commands: [],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const groups = new Map();
|
|
220
|
+
const nearest = new Map();
|
|
221
|
+
for (const t of [...tests].sort()) {
|
|
222
|
+
const cwd = nearestPackageDir(projectPath, path.posix.dirname(t), nearest);
|
|
223
|
+
const rel = cwd === "." ? t : t.slice(cwd.length + 1);
|
|
224
|
+
groups.set(cwd, [...(groups.get(cwd) ?? []), rel]);
|
|
225
|
+
}
|
|
226
|
+
const commands = [];
|
|
227
|
+
const notes = [];
|
|
228
|
+
const runners = [];
|
|
229
|
+
for (const cwd of [...groups.keys()].sort()) {
|
|
230
|
+
const files = groups.get(cwd);
|
|
231
|
+
const own = readPackage(path.join(projectPath, cwd));
|
|
232
|
+
const inherited = inheritedRunner(projectPath, cwd, own);
|
|
233
|
+
const plan = groupCommand(inherited?.pkg ?? own, files);
|
|
234
|
+
if (inherited) {
|
|
235
|
+
const from = inherited.dir === "." ? "the root package.json" : `${inherited.dir}/package.json`;
|
|
236
|
+
plan.note = [`${plan.runner} inherited from ${from}`, plan.note]
|
|
237
|
+
.filter((n) => n !== undefined)
|
|
238
|
+
.join("; ");
|
|
239
|
+
}
|
|
240
|
+
if (plan.note)
|
|
241
|
+
notes.push(cwd === "." ? plan.note : `${cwd}: ${plan.note}`);
|
|
242
|
+
if (plan.command === null)
|
|
243
|
+
continue;
|
|
244
|
+
commands.push({ cwd, command: plan.command, files: plan.kept ?? files });
|
|
245
|
+
runners.push(`${cwd}: ${plan.runner}`);
|
|
246
|
+
}
|
|
247
|
+
if (commands.length === 0) {
|
|
248
|
+
return {
|
|
249
|
+
command: null,
|
|
250
|
+
commandReason: `no runnable test file is selected; ${notes.join("; ")}`,
|
|
251
|
+
commands: [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const command = commands.length === 1
|
|
255
|
+
? commands[0].cwd === "."
|
|
256
|
+
? commands[0].command
|
|
257
|
+
: `cd ${shellQuote(commands[0].cwd)} && ${commands[0].command}`
|
|
258
|
+
: commands
|
|
259
|
+
.map((c) => c.cwd === "." ? `(${c.command})` : `(cd ${shellQuote(c.cwd)} && ${c.command})`)
|
|
260
|
+
.join(" && ");
|
|
261
|
+
const head = commands.length === 1
|
|
262
|
+
? `${runners[0].split(": ")[1]} in ${commands[0].cwd} for ${tests.length} selected test file(s)`
|
|
263
|
+
: `${tests.length} selected test file(s) across ${commands.length} packages (${runners.join(", ")}); run from the repository root`;
|
|
264
|
+
return {
|
|
265
|
+
command,
|
|
266
|
+
commandReason: [head, ...notes].join("; "),
|
|
267
|
+
commands,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** Full-suite command for a package (root `all` mode). */
|
|
271
|
+
function fullSuiteCommand(pkg) {
|
|
272
|
+
if (pkg?.scripts?.test)
|
|
273
|
+
return "npm test";
|
|
274
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
275
|
+
if (deps.vitest)
|
|
276
|
+
return "npx vitest run";
|
|
277
|
+
if (deps.jest)
|
|
278
|
+
return "npx jest";
|
|
279
|
+
return "node --test";
|
|
280
|
+
}
|
|
281
|
+
/** Nearest directory at or above `dir` (repo-relative) holding a `package.json`; `.` at worst. */
|
|
282
|
+
function nearestPackageDir(root, dir, cache) {
|
|
283
|
+
const hit = cache.get(dir);
|
|
284
|
+
if (hit !== undefined)
|
|
285
|
+
return hit;
|
|
286
|
+
let found;
|
|
287
|
+
if (dir === "." || dir === "" || dir === "/")
|
|
288
|
+
found = ".";
|
|
289
|
+
else if (fs.existsSync(path.join(root, dir, "package.json")))
|
|
290
|
+
found = dir;
|
|
291
|
+
else
|
|
292
|
+
found = nearestPackageDir(root, path.posix.dirname(dir), cache);
|
|
293
|
+
cache.set(dir, found);
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
/** Whether a package declares vitest or jest as a dependency. */
|
|
297
|
+
function declaresRunner(pkg) {
|
|
298
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
299
|
+
return Boolean(deps.vitest || deps.jest);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Runner inherited by a package that declares none (no `scripts.test`, no
|
|
303
|
+
* vitest/jest dependency): the nearest ancestor `package.json`, up to the
|
|
304
|
+
* root, that declares vitest or jest — the hoisted-monorepo layout. Returns
|
|
305
|
+
* the package to plan with (the leaf with the ancestor's runner dependencies)
|
|
306
|
+
* and the ancestor's directory, or null when nothing is inherited.
|
|
307
|
+
*/
|
|
308
|
+
function inheritedRunner(root, cwd, own) {
|
|
309
|
+
if (cwd === "." || own?.scripts?.test || declaresRunner(own))
|
|
310
|
+
return null;
|
|
311
|
+
let dir = cwd;
|
|
312
|
+
while (dir !== ".") {
|
|
313
|
+
dir = path.posix.dirname(dir);
|
|
314
|
+
if (dir === "" || dir === "/")
|
|
315
|
+
dir = ".";
|
|
316
|
+
const anc = readPackage(path.join(root, dir));
|
|
317
|
+
if (!declaresRunner(anc))
|
|
318
|
+
continue;
|
|
319
|
+
const deps = { ...anc?.dependencies, ...anc?.devDependencies };
|
|
320
|
+
const runnerDeps = {};
|
|
321
|
+
if (deps.vitest)
|
|
322
|
+
runnerDeps.vitest = deps.vitest;
|
|
323
|
+
if (deps.jest)
|
|
324
|
+
runnerDeps.jest = deps.jest;
|
|
325
|
+
return {
|
|
326
|
+
pkg: { ...own, devDependencies: { ...own?.devDependencies, ...runnerDeps } },
|
|
327
|
+
dir,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
/** Flags dropped from a `node --test` script: coverage thresholds fail on a subset. */
|
|
333
|
+
function isCoverageFlag(token) {
|
|
334
|
+
return token === "--experimental-test-coverage" || token.startsWith("--test-coverage-");
|
|
335
|
+
}
|
|
336
|
+
/** Node flags whose value may follow as a separate word (`--import ./x.mjs`). */
|
|
337
|
+
const NODE_VALUE_FLAGS = new Set([
|
|
338
|
+
"--import",
|
|
339
|
+
"--require",
|
|
340
|
+
"-r",
|
|
341
|
+
"--loader",
|
|
342
|
+
"--experimental-loader",
|
|
343
|
+
"--env-file",
|
|
344
|
+
"--conditions",
|
|
345
|
+
"-C",
|
|
346
|
+
"--test-reporter",
|
|
347
|
+
"--test-reporter-destination",
|
|
348
|
+
"--test-name-pattern",
|
|
349
|
+
"--test-skip-pattern",
|
|
350
|
+
"--test-concurrency",
|
|
351
|
+
"--test-timeout",
|
|
352
|
+
"--test-shard",
|
|
353
|
+
"--test-coverage-lines",
|
|
354
|
+
"--test-coverage-branches",
|
|
355
|
+
"--test-coverage-functions",
|
|
356
|
+
"--test-coverage-include",
|
|
357
|
+
"--test-coverage-exclude",
|
|
358
|
+
]);
|
|
359
|
+
/**
|
|
360
|
+
* Split a `node --test` script's words (after `node`) into the flags to keep
|
|
361
|
+
* and the positional test globs. A value-taking flag keeps its value with it;
|
|
362
|
+
* coverage flags are dropped together with their values. A directory
|
|
363
|
+
* positional (no glob character, no file extension) becomes `<dir>/**`, so it
|
|
364
|
+
* matches every file under it.
|
|
365
|
+
*/
|
|
366
|
+
function splitNodeTestArgs(words) {
|
|
367
|
+
const flags = [];
|
|
368
|
+
const globs = [];
|
|
369
|
+
for (let i = 0; i < words.length; i++) {
|
|
370
|
+
const word = words[i];
|
|
371
|
+
if (word.startsWith("-")) {
|
|
372
|
+
const group = [word];
|
|
373
|
+
if (!word.includes("=") && NODE_VALUE_FLAGS.has(word) && i + 1 < words.length) {
|
|
374
|
+
group.push(words[++i]);
|
|
375
|
+
}
|
|
376
|
+
if (!isCoverageFlag(word))
|
|
377
|
+
flags.push(...group);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const isDir = !/[*?{[]/.test(word) && !/\.[cm]?[jt]sx?$/.test(word);
|
|
381
|
+
globs.push(isDir ? `${word.replace(/\/+$/, "")}/**` : word);
|
|
187
382
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
383
|
+
return { flags, globs };
|
|
384
|
+
}
|
|
385
|
+
/** Source extensions mapped to their compiled JavaScript counterparts. */
|
|
386
|
+
const COMPILED_EXT = [
|
|
387
|
+
[/\.tsx?$/, ".js"],
|
|
388
|
+
[/\.mts$/, ".mjs"],
|
|
389
|
+
[/\.cts$/, ".cjs"],
|
|
390
|
+
];
|
|
391
|
+
/** Source path with its TypeScript extension swapped for the compiled one. */
|
|
392
|
+
function compiledPath(file) {
|
|
393
|
+
let out = file;
|
|
394
|
+
for (const [re, ext] of COMPILED_EXT)
|
|
395
|
+
out = out.replace(re, ext);
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Map a source test path onto the script's globs: the path itself when a glob
|
|
400
|
+
* matches it, else `<first glob segment>/<compiled path>` when that matches.
|
|
401
|
+
* `helper` marks a file whose name no glob's last segment can match (e.g.
|
|
402
|
+
* `test/helpers/fixtures.ts` against `*.test.js`): the runner never executes it,
|
|
403
|
+
* not even in a full run, so it is left out rather than forcing the full suite.
|
|
404
|
+
* `null` means a test-shaped file that could not be mapped.
|
|
405
|
+
*/
|
|
406
|
+
function mapOntoGlobs(file, globs) {
|
|
407
|
+
if (globs.some((g) => matchGlob(file, g)))
|
|
408
|
+
return file;
|
|
409
|
+
const base = path.posix.basename(file);
|
|
410
|
+
const testShaped = globs.some((g) => {
|
|
411
|
+
const last = g.split("/").pop() ?? g;
|
|
412
|
+
return matchGlob(base, last) || matchGlob(compiledPath(base), last);
|
|
413
|
+
});
|
|
414
|
+
if (!testShaped)
|
|
415
|
+
return "helper";
|
|
416
|
+
for (const glob of globs) {
|
|
417
|
+
const first = glob.split("/")[0];
|
|
418
|
+
if (!first || /[*{]/.test(first))
|
|
419
|
+
continue;
|
|
420
|
+
const mapped = `${first}/${compiledPath(file)}`;
|
|
421
|
+
if (matchGlob(mapped, glob))
|
|
422
|
+
return mapped;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Command for one package group, with its detected runner and an optional note.
|
|
428
|
+
* `command` is null only when every file is a helper the runner never executes;
|
|
429
|
+
* `kept` lists the files the command runs when helpers were left out.
|
|
430
|
+
*/
|
|
431
|
+
function groupCommand(pkg, files) {
|
|
432
|
+
const script = pkg?.scripts?.test;
|
|
433
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
434
|
+
const args = files.map(shellQuote).join(" ");
|
|
435
|
+
const tokens = script ? shellSplit(script) : [];
|
|
436
|
+
const isNodeTest = tokens[0] === "node" && tokens.includes("--test");
|
|
437
|
+
if ((script && /\bvitest\b/.test(script)) || (!script && deps.vitest)) {
|
|
438
|
+
return { command: `npx vitest run ${args}`, runner: "vitest" };
|
|
439
|
+
}
|
|
440
|
+
if ((script && /\bjest\b/.test(script)) || (!isNodeTest && deps.jest)) {
|
|
441
|
+
return { command: `npx jest --runTestsByPath ${args}`, runner: "jest" };
|
|
442
|
+
}
|
|
443
|
+
if (isNodeTest) {
|
|
444
|
+
const { flags, globs } = splitNodeTestArgs(tokens.slice(1));
|
|
445
|
+
const base = ["node", ...flags].map(shellQuote).join(" ");
|
|
446
|
+
if (globs.length === 0)
|
|
447
|
+
return { command: `${base} ${args}`, runner: "node --test" };
|
|
448
|
+
const mapped = files.map((f) => mapOntoGlobs(f, globs));
|
|
449
|
+
if (mapped.some((m) => m === null)) {
|
|
450
|
+
return {
|
|
451
|
+
command: "npm test",
|
|
452
|
+
runner: "node --test",
|
|
453
|
+
note: "a selected test file does not fit the test script's glob, so the full suite runs (a safe superset)",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const runnable = mapped.filter((m) => m !== null && m !== "helper");
|
|
457
|
+
const kept = files.filter((_, i) => mapped[i] !== "helper");
|
|
458
|
+
const helpers = mapped.length - runnable.length;
|
|
459
|
+
const helperNote = helpers > 0 ? `${helpers} helper file(s) the test script never runs left out` : undefined;
|
|
460
|
+
if (runnable.length === 0)
|
|
461
|
+
return { command: null, runner: "node --test", note: helperNote };
|
|
462
|
+
const compiled = runnable.some((m) => !files.includes(m));
|
|
463
|
+
const prefix = compiled && pkg?.scripts?.pretest ? "npm run pretest && " : "";
|
|
464
|
+
const notes = [
|
|
465
|
+
compiled ? "files mapped onto the compiled test glob; coverage flags dropped" : undefined,
|
|
466
|
+
helperNote,
|
|
467
|
+
].filter((n) => n !== undefined);
|
|
468
|
+
return {
|
|
469
|
+
command: `${prefix}${base} ${runnable.map(shellQuote).join(" ")}`,
|
|
470
|
+
runner: "node --test",
|
|
471
|
+
note: notes.length > 0 ? notes.join("; ") : undefined,
|
|
472
|
+
kept,
|
|
473
|
+
};
|
|
191
474
|
}
|
|
192
475
|
if (script) {
|
|
193
|
-
|
|
194
|
-
|
|
476
|
+
return {
|
|
477
|
+
command: `npm test -- ${args}`,
|
|
478
|
+
runner: "npm test",
|
|
479
|
+
note: "unrecognized test script: paths are forwarded and it may run the full suite (a superset)",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return { command: `node --test ${args}`, runner: "node --test" };
|
|
483
|
+
}
|
|
484
|
+
/** Split a shell command line into words, honoring single and double quotes. */
|
|
485
|
+
function shellSplit(line) {
|
|
486
|
+
const out = [];
|
|
487
|
+
let cur = "";
|
|
488
|
+
let quote = null;
|
|
489
|
+
let has = false;
|
|
490
|
+
for (const ch of line) {
|
|
491
|
+
if (quote) {
|
|
492
|
+
if (ch === quote)
|
|
493
|
+
quote = null;
|
|
494
|
+
else
|
|
495
|
+
cur += ch;
|
|
496
|
+
}
|
|
497
|
+
else if (ch === "'" || ch === '"') {
|
|
498
|
+
quote = ch;
|
|
499
|
+
has = true;
|
|
500
|
+
}
|
|
501
|
+
else if (/\s/.test(ch)) {
|
|
502
|
+
if (has || cur)
|
|
503
|
+
out.push(cur);
|
|
504
|
+
cur = "";
|
|
505
|
+
has = false;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
cur += ch;
|
|
509
|
+
}
|
|
195
510
|
}
|
|
196
|
-
|
|
511
|
+
if (has || cur)
|
|
512
|
+
out.push(cur);
|
|
513
|
+
return out;
|
|
197
514
|
}
|
|
198
515
|
function shellQuote(p) {
|
|
199
|
-
if (/^[A-Za-z0-9_
|
|
516
|
+
if (/^[A-Za-z0-9_./=:,@+-]+$/.test(p))
|
|
200
517
|
return p;
|
|
201
518
|
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
202
519
|
}
|