@esneiderbravo/speclaw 2.0.9 → 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 CHANGED
@@ -116,7 +116,7 @@ gh attestation verify <tarball> --owner esneiderbravo
116
116
  | Module | What it does |
117
117
  | :-- | :-- |
118
118
  | **Foundation** | The project's constitution: `LAWS.md` binding granular standards under `docs/standards/`, plus `CLAUDE.md` / `AGENTS.md`. **Enforced** via agent hooks (`speclaw check`), deterministic graph laws (`speclaw verify` / `laws verify`), multidialect compile (`laws compile`), and committed **`speclaw.lock`** digests + injection scan (`laws lock` / `accept` / `scan`). |
119
- | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
119
+ | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **11**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
120
120
  | **Lawbook** | Spec-driven workflow with **adaptive ceremony** (levels 0–3), EARS linting, requirement **coverage**, sealed **drift** anchors, bugfix + `lawbook_investigate`. Artifacts under `lawbook/`. |
121
121
  | **Cortex** | **One brain. Many agents.** The multi-agent loop: durable `harness.json`, MCP tool `cortex` (`status`/`start`/`advance`/`rework`/`brief`), CLI `speclaw cortex`, skill `/lawbook/cortex`. Dispatches explorer → planner → implementer → reviewer → tester → archiver. |
122
122
  | **Team** | Declare `team.owners` in `lawbook/config.yaml`; `speclaw owners --write` compiles a managed trailing block in `.github/CODEOWNERS` (GitHub: last match wins). Doctor checks the posture. CLI-only — no MCP tool. |
@@ -47,8 +47,11 @@ export async function runQuery(cmd, flags) {
47
47
  console.log(s.source);
48
48
  ui.heading("Callees");
49
49
  r.callees?.forEach((c) => ui.info(`${c.name}${c.file ? ` (${c.file}:${c.line})` : ""}`));
50
+ if (r.unresolvedCallees && r.unresolvedCallees.count > 0) {
51
+ ui.info(`+ ${r.unresolvedCallees.count} unresolved call(s): ${r.unresolvedCallees.sample.join(", ")}`);
52
+ }
50
53
  ui.heading("Callers");
51
- r.callers?.forEach((c) => ui.info(`${c.name} (${c.file}:${c.line})`));
54
+ r.callers?.forEach((c) => ui.info(`${c.kind === "file" ? "(file) " : ""}${c.name} (${c.file}:${c.line})`));
52
55
  return;
53
56
  }
54
57
  case "search":
@@ -147,7 +150,14 @@ export async function runQuery(cmd, flags) {
147
150
  ui.info(result.reason);
148
151
  result.tests.forEach((t) => ui.info(t.file));
149
152
  ui.heading("Command");
150
- console.log(result.command);
153
+ // Covers: req~affected-test-selection~1
154
+ if (result.command === null) {
155
+ console.log(`no command: ${result.commandReason}`);
156
+ }
157
+ else {
158
+ console.log(result.command);
159
+ ui.info(result.commandReason);
160
+ }
151
161
  result.warnings.forEach((w) => ui.warn(w));
152
162
  return;
153
163
  }
@@ -237,6 +237,23 @@ const MIGRATIONS = [
237
237
  "`speclaw.lock`.\n" +
238
238
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
239
239
  },
240
+ {
241
+ version: "2.0.10",
242
+ describe: "Compass schema 11 — truthful callers, callees, and affected-test commands",
243
+ agentPrompt: "- Compass is now **schema 11**: the first `speclaw index` after upgrading migrates " +
244
+ "10→11 and re-extracts every file once (embeddings are reused). Where the project " +
245
+ 'mentions "Schema **10**" or "Compass (schema 10)", change it to schema 11 ' +
246
+ "(10→11 forces a reindex; embeddings reused).\n" +
247
+ "- speclaw 2.0.9 or older (including an MCP entry pinned at `@2.0.9` or a stale " +
248
+ "global CLI) treats a schema-11 index as incompatible and rebuilds it from scratch, " +
249
+ "dropping the embedding cache. Where the project documents the index or the MCP entry, " +
250
+ "add: run `speclaw update` so the pinned MCP entry moves to 2.0.10; an older speclaw " +
251
+ "opening this index rebuilds it from scratch.\n" +
252
+ "- Affected-test `command` may now be `null` (with `commandReason`) when no test is " +
253
+ "reachable, and `commands[]` lists one `{ cwd, command, files }` per package. If the " +
254
+ "project's docs or scripts run `command` blindly, make them handle `null`.\n" +
255
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
256
+ },
240
257
  ];
241
258
  /**
242
259
  * Bring the current project up to date without a full re-init. When the npm
@@ -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 a ready-to-run command string.
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
- command: buildTestCommand(projectPath, [], cfg, "none"),
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: `global file matched (${glob.matched.join(", ")})`,
54
+ reason,
54
55
  tests: allTests.map((file) => ({ file, nodes: 0, minDepth: 0 })),
55
56
  skipped: { files: 0, percent: 0 },
56
- command: buildTestCommand(projectPath, [], cfg, "all"),
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
- command: buildTestCommand(projectPath, tests.map((t) => t.file), cfg, tests.length === 0 ? "none" : "subset"),
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 an executable test command from package.json scripts.test when present.
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
- const pkgPath = path.join(projectPath, "package.json");
172
- let script;
173
- if (fs.existsSync(pkgPath)) {
174
- try {
175
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
176
- script = pkg.scripts?.test;
177
- }
178
- catch {
179
- /* ignore */
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
- return script ? "npm test" : "node --test";
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 script ? "npm test -- --test-name-pattern=^$" : "node --test --test-name-pattern=^$";
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
- const args = tests.map(shellQuote).join(" ");
189
- if (script && /\bnode\s+--test\b/.test(script)) {
190
- return `node --test ${args}`;
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
- // Pass paths after `--` for npm/vitest/jest-style scripts.
194
- return `npm test -- ${args}`;
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
- return `node --test ${args}`;
511
+ if (has || cur)
512
+ out.push(cur);
513
+ return out;
197
514
  }
198
515
  function shellQuote(p) {
199
- if (/^[A-Za-z0-9_./-]+$/.test(p))
516
+ if (/^[A-Za-z0-9_./=:,@+-]+$/.test(p))
200
517
  return p;
201
518
  return `'${p.replace(/'/g, `'\\''`)}'`;
202
519
  }