pi-lens 3.8.69 → 3.8.71

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.
Files changed (147) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +31 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +53 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/code-quality-warnings.js +27 -0
  9. package/dist/clients/dead-code-client.js +5 -23
  10. package/dist/clients/diagnostic-dispositions.js +298 -0
  11. package/dist/clients/diagnostics-publish.js +38 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
  13. package/dist/clients/dispatch/dispatcher.js +6 -1
  14. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  15. package/dist/clients/dispatch/integration.js +19 -4
  16. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  17. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  18. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  19. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  20. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  21. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  22. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  23. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  24. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  25. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  26. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  27. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  28. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  29. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  31. package/dist/clients/dispatch/runners/lsp.js +23 -34
  32. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  33. package/dist/clients/dispatch/suppress-writer.js +68 -0
  34. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  35. package/dist/clients/disposition-logger.js +39 -0
  36. package/dist/clients/disposition-publish.js +109 -0
  37. package/dist/clients/file-utils.js +94 -33
  38. package/dist/clients/format-events-publish.js +299 -0
  39. package/dist/clients/git-tracked-ignore.js +208 -0
  40. package/dist/clients/gitleaks-client.js +40 -5
  41. package/dist/clients/installer/index.js +123 -6
  42. package/dist/clients/instance-reaper.js +229 -2
  43. package/dist/clients/instance-registry.js +141 -2
  44. package/dist/clients/json-cache-read.js +65 -0
  45. package/dist/clients/knip-client.js +35 -38
  46. package/dist/clients/language-profile.js +11 -11
  47. package/dist/clients/lens-engine.js +12 -0
  48. package/dist/clients/lens-map.js +1079 -0
  49. package/dist/clients/log-cleanup.js +61 -19
  50. package/dist/clients/lsp/aggregation.js +69 -2
  51. package/dist/clients/lsp/cascade-tier.js +31 -19
  52. package/dist/clients/lsp/client.js +124 -14
  53. package/dist/clients/lsp/config.js +27 -1
  54. package/dist/clients/lsp/index.js +1028 -68
  55. package/dist/clients/lsp/server-strategies.js +17 -8
  56. package/dist/clients/lsp/server.js +80 -28
  57. package/dist/clients/lsp/tsserver-sync.js +145 -0
  58. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  59. package/dist/clients/lsp-budget.js +148 -0
  60. package/dist/clients/mcp/session.js +9 -0
  61. package/dist/clients/middle-man-analysis.js +260 -0
  62. package/dist/clients/module-report.js +173 -11
  63. package/dist/clients/ndjson-logger.js +24 -0
  64. package/dist/clients/opengrep-client.js +217 -0
  65. package/dist/clients/opengrep-config.js +2 -10
  66. package/dist/clients/path-utils.js +66 -0
  67. package/dist/clients/pipeline.js +5 -1
  68. package/dist/clients/project-diagnostics/cache.js +5 -12
  69. package/dist/clients/project-diagnostics/extractors.js +21 -1
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  71. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  72. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  73. package/dist/clients/project-snapshot.js +3 -6
  74. package/dist/clients/quiet-window.js +57 -2
  75. package/dist/clients/read-expansion.js +2 -3
  76. package/dist/clients/read-guard-tool-lines.js +10 -0
  77. package/dist/clients/read-guard.js +1 -1
  78. package/dist/clients/resource-sampler.js +254 -0
  79. package/dist/clients/review-graph/builder.js +322 -42
  80. package/dist/clients/review-graph/import-resolvers.js +53 -19
  81. package/dist/clients/review-graph/symbol-id.js +57 -0
  82. package/dist/clients/runtime-agent-end.js +26 -0
  83. package/dist/clients/runtime-coordinator.js +9 -1
  84. package/dist/clients/runtime-session.js +93 -17
  85. package/dist/clients/runtime-tool-call.js +789 -0
  86. package/dist/clients/runtime-tool-result.js +15 -2
  87. package/dist/clients/runtime-turn.js +62 -13
  88. package/dist/clients/safe-spawn.js +48 -2
  89. package/dist/clients/session-state-store.js +6 -9
  90. package/dist/clients/sgconfig.js +2 -9
  91. package/dist/clients/source-filter.js +17 -25
  92. package/dist/clients/source-walker.js +66 -0
  93. package/dist/clients/startup-scan.js +105 -43
  94. package/dist/clients/symbol-containment.js +24 -0
  95. package/dist/clients/test-runner-client.js +544 -29
  96. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  97. package/dist/clients/typos-config.js +2 -11
  98. package/dist/clients/widget-state.js +83 -1
  99. package/dist/clients/write-ordering-guard.js +58 -0
  100. package/dist/clients/zizmor-config.js +29 -10
  101. package/dist/index.js +25511 -19414
  102. package/dist/mcp/server.js +95 -19
  103. package/dist/tools/activate-tools.js +72 -0
  104. package/dist/tools/ast-dump.js +2 -8
  105. package/dist/tools/lens-diagnostic-mark.js +158 -0
  106. package/dist/tools/lens-diagnostics.js +327 -33
  107. package/dist/tools/lsp-diagnostics.js +525 -105
  108. package/dist/tools/module-report.js +18 -5
  109. package/docs/agent-tools.md +136 -0
  110. package/docs/ast-grep_rules_catalog.md +564 -0
  111. package/docs/astplayground.md +178 -0
  112. package/docs/audit1.md +177 -0
  113. package/docs/custom-rules.md +231 -0
  114. package/docs/dependencies.md +54 -0
  115. package/docs/dispositions.md +85 -0
  116. package/docs/env_variables.md +10 -0
  117. package/docs/environment-variables.md +83 -0
  118. package/docs/fable.md +140 -0
  119. package/docs/features.md +338 -0
  120. package/docs/globalconfig.md +79 -0
  121. package/docs/language-coverage.md +48 -0
  122. package/docs/lsp-capability-matrix.md +157 -0
  123. package/docs/lsp-latency-benchmark.md +169 -0
  124. package/docs/mcp.md +438 -0
  125. package/docs/module-report-read-symbol.md +258 -0
  126. package/docs/servercapabilities.md +118 -0
  127. package/docs/subagent-compat.md +281 -0
  128. package/docs/tools.md +16 -0
  129. package/docs/tools_improvement2.md +345 -0
  130. package/docs/tree-sitter_rules_catalog.md +296 -0
  131. package/docs/usage.md +129 -0
  132. package/docs/word-index.md +150 -0
  133. package/package.json +10 -1
  134. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  135. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  136. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  137. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  138. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  139. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  140. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  141. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  144. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  145. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  146. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  147. package/dist/clients/production-readiness.js +0 -493
@@ -4,13 +4,16 @@
4
4
  * Detects test files and runs them on write/edit to provide
5
5
  * immediate test feedback to the AI agent.
6
6
  *
7
- * Supports: vitest, jest, pytest (extensible to more)
7
+ * Supports: vitest, jest, pytest, go, cargo, dotnet, gradle, maven, rspec,
8
+ * minitest, phpunit, mix (extensible to more)
8
9
  *
9
10
  * Design: File-level targeted testing — only runs tests for the
10
11
  * specific file being edited, not the entire suite.
11
12
  */
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
15
+ import { minimatch } from "./deps/minimatch.js";
16
+ import { detectFileRole } from "./file-role.js";
14
17
  import { findGlobalBinary } from "./package-manager.js";
15
18
  import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
16
19
  // Source file → test file patterns (reverse lookup)
@@ -42,7 +45,24 @@ const SOURCE_TO_TEST_PATTERNS = [
42
45
  },
43
46
  { ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] }, // Go tests are co-located
44
47
  { ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] }, // Rust: tests/ or #[test] in src
48
+ // PHPUnit convention: tests/ mirrors src/ with ClassNameTest.php naming
49
+ // (e.g. src/Foo/Bar.php -> tests/Foo/BarTest.php). Basename is already the
50
+ // class name (PHP files are named after their class), so no case transform
51
+ // is needed — the mirrored-directory search below handles the tests/ root.
52
+ { ext: ".php", testExts: ["Test.php"], dirs: ["tests"] },
53
+ // ExUnit convention: test/ mirrors lib/ with a _test.exs suffix on the same
54
+ // basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
55
+ { ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] },
45
56
  ];
57
+ // Bound for walking up parent directories to find a hoisted node_modules
58
+ // (monorepo workspaces) — deep enough for realistic nesting
59
+ // (repo/packages/scope/pkg-name), never unbounded to the filesystem root.
60
+ const MAX_NODE_MODULES_WALK_UP = 5;
61
+ // Bound for recursive descent into a Python test directory when the exact
62
+ // same-relative-subdir mirror doesn't match (e.g. tests/unit/ grouping by
63
+ // test type rather than mirroring source layout) — capped depth, never an
64
+ // unbounded walk of the whole tests tree.
65
+ const MAX_PYTEST_RECURSE_DEPTH = 3;
46
66
  // --- Runner Detection ---
47
67
  const RUNNERS = {
48
68
  vitest: {
@@ -129,12 +149,35 @@ const RUNNERS = {
129
149
  args: (testFile, _cwd) => ["-Itest", testFile],
130
150
  parseJson: false,
131
151
  },
152
+ phpunit: {
153
+ // phpunit.xml(.dist) is the strong signal; composer.json is checked for
154
+ // a require-dev dependency on phpunit/phpunit (see the special case in
155
+ // detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
156
+ // handling above).
157
+ configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
158
+ command: "phpunit",
159
+ args: (testFile, _cwd) => [testFile],
160
+ parseJson: false, // PHPUnit's default CLI output is text-based
161
+ },
162
+ mix: {
163
+ configFiles: ["mix.exs"],
164
+ command: "mix",
165
+ args: (testFile, _cwd) => ["test", testFile],
166
+ parseJson: false, // mix test's default output is text-based
167
+ },
132
168
  };
133
169
  // --- Client ---
134
170
  export class TestRunnerClient {
135
171
  log;
136
172
  availableRunners = new Map();
137
173
  failedTestsByRunner = new Map();
174
+ // Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
175
+ // plain text (never executed) and cached per cwd so the config file is
176
+ // only read/parsed once, not on every edit. `null` means "no config found
177
+ // or it couldn't be parsed in the simple shape we look for" — callers
178
+ // treat that as "no additional signal" and fall back to naming-convention
179
+ // detection only.
180
+ vitestTestGlobsCache = new Map();
138
181
  constructor(verbose = false) {
139
182
  this.log = verbose
140
183
  ? (msg) => console.error(`[test-runner] ${msg}`)
@@ -170,6 +213,22 @@ export class TestRunnerClient {
170
213
  return false;
171
214
  }
172
215
  }
216
+ if (name === "phpunit" && cf === "composer.json") {
217
+ const composerPath = path.join(cwd, cf);
218
+ if (!fs.existsSync(composerPath))
219
+ return false;
220
+ try {
221
+ const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8"));
222
+ const allDeps = {
223
+ ...composer.require,
224
+ ...composer["require-dev"],
225
+ };
226
+ return Boolean(allDeps["phpunit/phpunit"]);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
173
232
  return fs.existsSync(path.join(cwd, cf));
174
233
  });
175
234
  this.availableRunners.set(cacheKey, found);
@@ -206,17 +265,22 @@ export class TestRunnerClient {
206
265
  void err;
207
266
  // package.json parse error or file not found
208
267
  }
209
- // Priority 3: Check node_modules for installed packages
210
- const nodeModulesPath = path.join(cwd, "node_modules");
211
- if (fs.existsSync(nodeModulesPath)) {
212
- if (fs.existsSync(path.join(nodeModulesPath, "vitest"))) {
213
- this.log("Detected vitest in node_modules");
214
- return { runner: "vitest", config: RUNNERS.vitest };
215
- }
216
- if (fs.existsSync(path.join(nodeModulesPath, "jest"))) {
217
- this.log("Detected jest in node_modules");
218
- return { runner: "jest", config: RUNNERS.jest };
219
- }
268
+ // Priority 3: Check node_modules for installed packages, including a
269
+ // hoisted monorepo layout where cwd is a workspace package (e.g.
270
+ // packages/foo) but the runner only lives in node_modules at the
271
+ // workspace root (npm/yarn/pnpm workspace hoisting). Walk up a
272
+ // bounded number of parent directories looking for a node_modules
273
+ // containing the package — never an unbounded walk to the
274
+ // filesystem root.
275
+ const hoistedVitest = this.findHoistedNodeModulesPackage(cwd, "vitest");
276
+ if (hoistedVitest) {
277
+ this.log(`Detected vitest in node_modules (${hoistedVitest})`);
278
+ return { runner: "vitest", config: RUNNERS.vitest };
279
+ }
280
+ const hoistedJest = this.findHoistedNodeModulesPackage(cwd, "jest");
281
+ if (hoistedJest) {
282
+ this.log(`Detected jest in node_modules (${hoistedJest})`);
283
+ return { runner: "jest", config: RUNNERS.jest };
220
284
  }
221
285
  for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
222
286
  const config = RUNNERS[name];
@@ -257,6 +321,252 @@ export class TestRunnerClient {
257
321
  }
258
322
  return null;
259
323
  }
324
+ /**
325
+ * Walk up from `cwd` through parent directories looking for a
326
+ * `node_modules/<packageName>` — handles monorepo workspace hoisting
327
+ * (npm/yarn/pnpm), where a workspace package's own `node_modules` may
328
+ * not exist at all, with dependencies hoisted to the workspace root
329
+ * several directories up. Bounded by `MAX_NODE_MODULES_WALK_UP` levels
330
+ * and stops at the filesystem root — never an unbounded walk.
331
+ * Returns the `node_modules` directory where the package was found, or
332
+ * null if not found within the bound.
333
+ */
334
+ findHoistedNodeModulesPackage(cwd, packageName) {
335
+ let dir = path.resolve(cwd);
336
+ for (let level = 0; level <= MAX_NODE_MODULES_WALK_UP; level++) {
337
+ const nodeModulesPath = path.join(dir, "node_modules");
338
+ if (fs.existsSync(path.join(nodeModulesPath, packageName))) {
339
+ return nodeModulesPath;
340
+ }
341
+ const parent = path.dirname(dir);
342
+ if (parent === dir)
343
+ break; // reached filesystem root
344
+ dir = parent;
345
+ }
346
+ return null;
347
+ }
348
+ /**
349
+ * Depth-bounded breadth-first search under `rootDir` for a pytest-style
350
+ * test file matching `pattern` (exact, e.g. `test_foo.py`) or the
351
+ * looser `test_*<basename>*.py` convention. Used as a last-resort
352
+ * fallback when a Python test suite groups tests by kind
353
+ * (`tests/unit/`, `tests/integration/`) instead of mirroring the
354
+ * source directory layout, so the exact-mirror candidates in
355
+ * `findTestFile` don't match. Bounded by `maxDepth` levels below
356
+ * `rootDir` and skips hidden directories and `__pycache__` — never an
357
+ * unbounded walk of the whole tests tree.
358
+ */
359
+ findPytestMatchRecursive(rootDir, pattern, basename, maxDepth) {
360
+ const queue = [
361
+ { dir: rootDir, depth: 0 },
362
+ ];
363
+ while (queue.length > 0) {
364
+ const next = queue.shift();
365
+ if (!next)
366
+ break;
367
+ const { dir, depth } = next;
368
+ let entries;
369
+ try {
370
+ entries = fs.readdirSync(dir, { withFileTypes: true });
371
+ }
372
+ catch {
373
+ continue;
374
+ }
375
+ for (const entry of entries) {
376
+ const fullPath = path.join(dir, entry.name);
377
+ if (entry.isFile()) {
378
+ if (entry.name === pattern ||
379
+ (entry.name.startsWith("test_") &&
380
+ entry.name.endsWith(".py") &&
381
+ entry.name.includes(basename))) {
382
+ return fullPath;
383
+ }
384
+ }
385
+ else if (entry.isDirectory() && depth < maxDepth) {
386
+ if (entry.name === "__pycache__" || entry.name.startsWith("."))
387
+ continue;
388
+ queue.push({ dir: fullPath, depth: depth + 1 });
389
+ }
390
+ }
391
+ }
392
+ return null;
393
+ }
394
+ /**
395
+ * Path of `dir` relative to `cwd`, using forward slashes, or null if `dir`
396
+ * is not inside `cwd` (e.g. resolves to `..` or an absolute path).
397
+ * Used to compute a mirrored test-tree subdirectory (e.g. `clients` for
398
+ * `clients/knip-client.ts`, so `tests/clients/knip-client.test.ts` is
399
+ * checked alongside the flat `tests/knip-client.test.ts` candidate).
400
+ */
401
+ relativeSourceDir(sourceFilePath, cwd) {
402
+ const dir = path.dirname(sourceFilePath);
403
+ const relDir = path.relative(cwd, path.resolve(cwd, dir));
404
+ if (!relDir || relDir === "." || relDir.startsWith("..") || path.isAbsolute(relDir)) {
405
+ return null;
406
+ }
407
+ return relDir;
408
+ }
409
+ /**
410
+ * Best-effort, text-only scrape of a vitest config's `test.include` /
411
+ * `test.exclude` arrays. This deliberately does NOT execute the config
412
+ * file (that would mean loading arbitrary ESM/TS via Vite's config
413
+ * loader — too heavy for a per-edit hot path). It just looks for a
414
+ * simple `include: [ ... ]` / `exclude: [ ... ]` shape with string
415
+ * literals inside and pulls those out with a regex.
416
+ *
417
+ * Returns `null` (never throws) when there's no vitest config file, it
418
+ * can't be read, or the include/exclude shape isn't a plain array of
419
+ * string literals (e.g. it's built from a function call, spread, or
420
+ * template expression) — anything more dynamic than that is out of
421
+ * scope for this heuristic.
422
+ *
423
+ * Cached per `cwd` so the file is only read/parsed once per project,
424
+ * not on every edit.
425
+ */
426
+ parseVitestTestGlobs(cwd) {
427
+ if (this.vitestTestGlobsCache.has(cwd)) {
428
+ return this.vitestTestGlobsCache.get(cwd) ?? null;
429
+ }
430
+ // .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
431
+ // *detection* priority) but is a legal vitest config extension, so it's
432
+ // included here for the scrape even though detectRunner doesn't check it.
433
+ const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
434
+ let content = null;
435
+ for (const cf of candidates) {
436
+ try {
437
+ content = fs.readFileSync(path.join(cwd, cf), "utf-8");
438
+ break;
439
+ }
440
+ catch {
441
+ continue;
442
+ }
443
+ }
444
+ let result = null;
445
+ if (content !== null) {
446
+ const include = this.extractGlobArrayLiteral(content, "include");
447
+ const exclude = this.extractGlobArrayLiteral(content, "exclude");
448
+ if (include || exclude) {
449
+ result = {};
450
+ if (include)
451
+ result.include = include;
452
+ if (exclude)
453
+ result.exclude = exclude;
454
+ }
455
+ }
456
+ this.vitestTestGlobsCache.set(cwd, result);
457
+ return result;
458
+ }
459
+ /**
460
+ * Extract `<key>: [ 'a', "b", `c` ]` as a plain string array from raw
461
+ * config text. Returns undefined if the key isn't present, or if the
462
+ * array body contains anything besides string literals and commas/
463
+ * whitespace (a function call, spread, variable reference, etc.) —
464
+ * that's a sign the value is dynamic and this best-effort scrape can't
465
+ * safely interpret it.
466
+ */
467
+ extractGlobArrayLiteral(content, key) {
468
+ const arrayMatch = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
469
+ if (!arrayMatch)
470
+ return undefined;
471
+ const body = arrayMatch[1];
472
+ const literalPattern = /'([^'\\]*)'|"([^"\\]*)"|`([^`\\]*)`/g;
473
+ const literals = [];
474
+ let lastEnd = 0;
475
+ let match;
476
+ while ((match = literalPattern.exec(body)) !== null) {
477
+ const between = body.slice(lastEnd, match.index).trim();
478
+ // Only whitespace/commas may appear between literals — anything
479
+ // else (identifiers, parens, spreads) means the array isn't a
480
+ // plain list of string literals.
481
+ if (between !== "" && !/^,$/.test(between))
482
+ return undefined;
483
+ literals.push(match[1] ?? match[2] ?? match[3] ?? "");
484
+ lastEnd = literalPattern.lastIndex;
485
+ }
486
+ const trailing = body.slice(lastEnd).trim();
487
+ if (trailing !== "" && trailing !== ",")
488
+ return undefined;
489
+ return literals.length > 0 ? literals : undefined;
490
+ }
491
+ /**
492
+ * Whether `sourceFilePath` is itself a test file (as opposed to a source
493
+ * file whose *related* test file needs to be discovered).
494
+ *
495
+ * Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
496
+ * basenames, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
497
+ * directories — shared with the rest of the codebase, not a second
498
+ * parallel detector).
499
+ *
500
+ * Secondary signal (vitest only): the project's own `test.include` /
501
+ * `test.exclude` globs, best-effort scraped by `parseVitestTestGlobs`.
502
+ * This can correct the naming-convention answer in both directions —
503
+ * an `exclude` glob can rule out a path that looks like a test by name,
504
+ * and an `include` glob can catch a project that puts tests somewhere
505
+ * unconventional. When no config is found or it can't be parsed, this
506
+ * is a no-op and behavior is unchanged.
507
+ *
508
+ * #628: a positive `include` override is only trusted when the glob is a
509
+ * *narrow* test signal (see `isNarrowTestGlob`) — a bare "any file with
510
+ * this extension" include (e.g. `src/**\/*.ts`) is common in real vitest
511
+ * configs and matches ordinary source files, so treating any match as
512
+ * "this is a test" produced vacuous `0p/0f` self-runs on plain source
513
+ * files (background-review.ts, index.ts, …). The `exclude` direction is
514
+ * left as a plain match: over-excluding only causes discovery to run on a
515
+ * file that's actually a test (falls back to `findTestFile`, not a false
516
+ * "self" positive), which is the safe failure mode.
517
+ */
518
+ isTestFile(sourceFilePath, cwd, runner) {
519
+ let result = detectFileRole(sourceFilePath) === "test";
520
+ if (runner === "vitest") {
521
+ const globs = this.parseVitestTestGlobs(cwd);
522
+ if (globs) {
523
+ const rel = path
524
+ .relative(cwd, path.resolve(cwd, sourceFilePath))
525
+ .replace(/\\/g, "/");
526
+ const matches = (globs_, filter) => !!globs_?.some((g) => (!filter || filter(g)) && minimatch(rel, g, { dot: true }));
527
+ if (matches(globs.exclude)) {
528
+ result = false;
529
+ }
530
+ else if (!result &&
531
+ matches(globs.include, (g) => this.isNarrowTestGlob(g))) {
532
+ result = true;
533
+ }
534
+ }
535
+ }
536
+ return result;
537
+ }
538
+ /**
539
+ * Whether an `include` glob is a specific enough signal to override a
540
+ * plain "this is source, not a test" naming-convention verdict (#628).
541
+ *
542
+ * Trusted when either:
543
+ * - a literal (non-wildcard) path segment before the first wildcard
544
+ * names a conventional test location (`tests/`, `test/`, `spec/`,
545
+ * `specs/`, `__tests__/`) — the real case this override exists for:
546
+ * a project whose test files live in such a directory without a
547
+ * `.test.`/`.spec.` name (e.g. `tests/**\/*.ts`).
548
+ * - the static suffix after the last wildcard encodes more than the
549
+ * bare language extension (e.g. `.check.ts`, `.flow.ts`) — an explicit
550
+ * project-specific naming convention, not "any file with this
551
+ * extension" (e.g. `**\/*.check.ts`).
552
+ *
553
+ * Rejected for a bare extension glob with no test-ish directory (e.g.
554
+ * `src/**\/*.ts`, `**\/*.ts`) — that shape matches every source file in
555
+ * the tree and is exactly what produced vacuous self-runs in practice.
556
+ */
557
+ isNarrowTestGlob(glob) {
558
+ const testDirPattern = /^(tests?|specs?|__tests__)$/i;
559
+ for (const segment of glob.split("/")) {
560
+ if (segment.includes("*") || segment.includes("?"))
561
+ break;
562
+ if (testDirPattern.test(segment))
563
+ return true;
564
+ }
565
+ const lastWildcard = Math.max(glob.lastIndexOf("*"), glob.lastIndexOf("?"));
566
+ const suffix = lastWildcard >= 0 ? glob.slice(lastWildcard + 1) : glob;
567
+ const dotSegments = suffix.split(".").filter(Boolean);
568
+ return dotSegments.length >= 2;
569
+ }
260
570
  /**
261
571
  * Find test file for a given source file
262
572
  * Returns the test file path if it exists, null otherwise
@@ -273,6 +583,12 @@ export class TestRunnerClient {
273
583
  : this.detectRunner(cwd, sourceFilePath);
274
584
  if (!detected)
275
585
  return null;
586
+ // Relative subdirectory of the source file, used to check a mirrored
587
+ // test-tree layout (tests/<same-subdir>/<basename><testExt>), on top of
588
+ // the flat tests/<basename><testExt> layout already checked below.
589
+ // Null when the source file sits at the project root (dir === ".") or
590
+ // falls outside cwd — in that case there is no subdir to mirror.
591
+ const relDir = this.relativeSourceDir(sourceFilePath, cwd);
276
592
  // Check each potential test file location
277
593
  for (let i = 0; i < patterns.testExts.length; i++) {
278
594
  const testExt = patterns.testExts[i];
@@ -280,23 +596,43 @@ export class TestRunnerClient {
280
596
  // Handle glob patterns (pytest style: test_*.py)
281
597
  if (testExt.includes("*")) {
282
598
  const pattern = testExt.replace(/\*/g, basename);
283
- const searchDir = testDir === "." ? dir : path.join(cwd, testDir);
284
- let files;
285
- try {
286
- files = fs.readdirSync(searchDir);
287
- }
288
- catch (err) {
289
- void err;
290
- continue;
599
+ const searchDirs = testDir === "."
600
+ ? [dir]
601
+ : relDir
602
+ ? [path.join(cwd, testDir, relDir), path.join(cwd, testDir)]
603
+ : [path.join(cwd, testDir)];
604
+ for (const searchDir of searchDirs) {
605
+ let files;
606
+ try {
607
+ files = fs.readdirSync(searchDir);
608
+ }
609
+ catch (err) {
610
+ void err;
611
+ continue;
612
+ }
613
+ const match = files.find((f) => f === pattern ||
614
+ (f.startsWith("test_") &&
615
+ f.endsWith(".py") &&
616
+ f.includes(basename)));
617
+ if (match) {
618
+ const testPath = path.join(searchDir, match);
619
+ this.log(`Found test file: ${testPath}`);
620
+ return { testFile: testPath, runner: detected.runner };
621
+ }
291
622
  }
292
- const match = files.find((f) => f === pattern ||
293
- (f.startsWith("test_") &&
294
- f.endsWith(".py") &&
295
- f.includes(basename)));
296
- if (match) {
297
- const testPath = path.join(searchDir, match);
298
- this.log(`Found test file: ${testPath}`);
299
- return { testFile: testPath, runner: detected.runner };
623
+ // None of the exact-mirror candidates matched. Python test
624
+ // suites commonly group tests by kind (tests/unit/,
625
+ // tests/integration/) rather than mirroring the source tree,
626
+ // so do a depth-bounded recursive search under the test
627
+ // root as a last resort before falling back to import
628
+ // scanning — bounded so a large repo can't turn this into
629
+ // an unbounded directory walk.
630
+ if (testDir !== ".") {
631
+ const recursiveMatch = this.findPytestMatchRecursive(path.join(cwd, testDir), pattern, basename, MAX_PYTEST_RECURSE_DEPTH);
632
+ if (recursiveMatch) {
633
+ this.log(`Found test file (recursive): ${recursiveMatch}`);
634
+ return { testFile: recursiveMatch, runner: detected.runner };
635
+ }
300
636
  }
301
637
  }
302
638
  else {
@@ -305,8 +641,21 @@ export class TestRunnerClient {
305
641
  const searchPaths = [
306
642
  path.join(dir, testFilename), // same directory
307
643
  path.join(dir, "__tests__", testFilename), // __tests__ subdirectory
644
+ ...(relDir
645
+ ? [
646
+ path.join(cwd, "tests", relDir, testFilename), // mirrored tests/<subdir>/
647
+ path.join(cwd, "__tests__", relDir, testFilename), // mirrored __tests__/<subdir>/
648
+ ]
649
+ : []),
308
650
  path.join(cwd, "tests", testFilename), // top-level tests/
309
651
  path.join(cwd, "__tests__", testFilename), // top-level __tests__/
652
+ // PHP/Elixir-style source-root mirroring (e.g. src/Foo/Bar.php ->
653
+ // tests/Foo/BarTest.php, lib/accounts/user.ex ->
654
+ // test/accounts/user_test.exs): strips a conventional source-root
655
+ // segment and mirrors under this pattern's OWN configured test
656
+ // root (testDir), not the hardcoded "tests"/"__tests__" above —
657
+ // ExUnit's root is "test" (singular), which those don't cover.
658
+ ...this.sourceRootMirroredCandidates(dir, cwd, testDir, testFilename),
310
659
  ];
311
660
  for (const testPath of searchPaths) {
312
661
  if (fs.existsSync(testPath)) {
@@ -336,7 +685,14 @@ export class TestRunnerClient {
336
685
  return null;
337
686
  const key = this.failedKey(cwd, detected.runner);
338
687
  const failedSet = this.failedTestsByRunner.get(key);
339
- const related = this.findTestFile(sourceFilePath, cwd, detected.runner);
688
+ // If the edited file is itself a test file, there's no "related test"
689
+ // to discover — running findTestFile on it would strip its own
690
+ // extension and search for nonsense like foo.test.test.ts. Skip
691
+ // discovery entirely and treat the file as its own target.
692
+ const selfIsTest = this.isTestFile(sourceFilePath, cwd, detected.runner);
693
+ const related = selfIsTest
694
+ ? null
695
+ : this.findTestFile(sourceFilePath, cwd, detected.runner);
340
696
  if (failedSet && failedSet.size > 0) {
341
697
  if (related) {
342
698
  const relatedAbs = path.resolve(related.testFile);
@@ -349,6 +705,17 @@ export class TestRunnerClient {
349
705
  };
350
706
  }
351
707
  }
708
+ if (selfIsTest) {
709
+ const selfAbs = path.resolve(sourceFilePath);
710
+ if (failedSet.has(selfAbs)) {
711
+ return {
712
+ testFile: selfAbs,
713
+ runner: detected.runner,
714
+ config: detected.config,
715
+ strategy: "failed-first",
716
+ };
717
+ }
718
+ }
352
719
  return {
353
720
  testFile: [...failedSet][0],
354
721
  runner: detected.runner,
@@ -356,6 +723,14 @@ export class TestRunnerClient {
356
723
  strategy: "failed-first",
357
724
  };
358
725
  }
726
+ if (selfIsTest) {
727
+ return {
728
+ testFile: path.resolve(sourceFilePath),
729
+ runner: detected.runner,
730
+ config: detected.config,
731
+ strategy: "self",
732
+ };
733
+ }
359
734
  if (!related)
360
735
  return null;
361
736
  return {
@@ -399,6 +774,12 @@ export class TestRunnerClient {
399
774
  case "pytest":
400
775
  parsed = this.parsePytestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, cwd, runner);
401
776
  break;
777
+ case "phpunit":
778
+ parsed = this.parsePhpunitOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
779
+ break;
780
+ case "mix":
781
+ parsed = this.parseMixTestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
782
+ break;
402
783
  default:
403
784
  parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
404
785
  break;
@@ -534,6 +915,99 @@ export class TestRunnerClient {
534
915
  error: exitCode === 2 ? "Pytest configuration error" : undefined,
535
916
  };
536
917
  }
918
+ // --- PHPUnit Parser (text-based, default CLI output) ---
919
+ parsePhpunitOutput(stdout, stderr, exitCode, testFile, runner) {
920
+ const output = `${stdout}\n${stderr}`;
921
+ let passed = 0;
922
+ let failed = 0;
923
+ let skipped = 0;
924
+ // Success (or success-with-incomplete/skipped): "OK (12 tests, 34 assertions)"
925
+ const okMatch = output.match(/OK\s*\((\d+)\s+tests?,\s*\d+\s+assertions?\)/i);
926
+ if (okMatch) {
927
+ passed = Number.parseInt(okMatch[1], 10);
928
+ }
929
+ else {
930
+ // Failure summary: "Tests: 12, Assertions: 34, Errors: 1, Failures: 2, Skipped: 1."
931
+ const testsMatch = output.match(/Tests:\s*(\d+)/i);
932
+ const failuresMatch = output.match(/Failures:\s*(\d+)/i);
933
+ const errorsMatch = output.match(/Errors:\s*(\d+)/i);
934
+ const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
935
+ const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
936
+ const failures = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
937
+ const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
938
+ skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
939
+ failed = failures + errors;
940
+ passed = Math.max(0, total - failed - skipped);
941
+ }
942
+ // Individual failures: "1) Foo\BarTest::testSomething"
943
+ const failures = [];
944
+ const failureRegex = /^\d+\)\s+(\S+)/gm;
945
+ let match;
946
+ while ((match = failureRegex.exec(output)) !== null) {
947
+ failures.push({ name: match[1], message: match[1] });
948
+ }
949
+ return {
950
+ file: testFile,
951
+ sourceFile: "",
952
+ runner,
953
+ passed,
954
+ failed,
955
+ skipped,
956
+ failures,
957
+ duration: 0,
958
+ error: exitCode !== 0 && passed === 0 && failed === 0
959
+ ? "PHPUnit runner error"
960
+ : undefined,
961
+ };
962
+ }
963
+ // --- mix test Parser (ExUnit, text-based, default CLI output) ---
964
+ parseMixTestOutput(stdout, stderr, exitCode, testFile, runner) {
965
+ const output = `${stdout}\n${stderr}`;
966
+ let passed = 0;
967
+ let failed = 0;
968
+ let skipped = 0;
969
+ let duration = 0;
970
+ // Summary: "3 tests, 1 failure" (optionally ", N excluded" / ", N skipped")
971
+ const summaryMatch = output.match(/(\d+)\s+tests?,\s*(\d+)\s+failures?(?:,\s*(\d+)\s+excluded)?(?:,\s*(\d+)\s+skipped)?/i);
972
+ if (summaryMatch) {
973
+ const total = Number.parseInt(summaryMatch[1], 10);
974
+ failed = Number.parseInt(summaryMatch[2], 10);
975
+ const excluded = summaryMatch[3] ? Number.parseInt(summaryMatch[3], 10) : 0;
976
+ const skippedCount = summaryMatch[4]
977
+ ? Number.parseInt(summaryMatch[4], 10)
978
+ : 0;
979
+ skipped = excluded + skippedCount;
980
+ passed = Math.max(0, total - failed - skipped);
981
+ }
982
+ const durationMatch = output.match(/Finished in\s+([\d.]+)\s+seconds?/i);
983
+ if (durationMatch) {
984
+ duration = Number.parseFloat(durationMatch[1]) * 1000;
985
+ }
986
+ // Individual failures: " 1) test some behavior (MyModuleTest)"
987
+ const failures = [];
988
+ const failureRegex = /^\s*\d+\)\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
989
+ let match;
990
+ while ((match = failureRegex.exec(output)) !== null) {
991
+ failures.push({
992
+ name: match[1].trim(),
993
+ message: match[1].trim(),
994
+ location: match[2].trim(),
995
+ });
996
+ }
997
+ return {
998
+ file: testFile,
999
+ sourceFile: "",
1000
+ runner,
1001
+ passed,
1002
+ failed,
1003
+ skipped,
1004
+ failures,
1005
+ duration,
1006
+ error: exitCode !== 0 && passed === 0 && failed === 0
1007
+ ? "mix test runner error"
1008
+ : undefined,
1009
+ };
1010
+ }
537
1011
  // --- Generic text parser for non-JSON runners ---
538
1012
  parseGenericRunnerOutput(stdout, stderr, exitCode, testFile, runner) {
539
1013
  const output = `${stdout}\n${stderr}`;
@@ -655,6 +1129,36 @@ export class TestRunnerClient {
655
1129
  return output.trimEnd();
656
1130
  }
657
1131
  // --- Helpers ---
1132
+ /**
1133
+ * Additional mirrored-directory candidate for source trees whose test
1134
+ * tree mirrors the source tree under a *different*, conventional
1135
+ * source-root segment rather than the source file's full relative
1136
+ * directory — e.g. PHPUnit's `src/Foo/Bar.php` -> `tests/Foo/BarTest.php`
1137
+ * (strips `src`) or ExUnit's `lib/accounts/user.ex` ->
1138
+ * `test/accounts/user_test.exs` (strips `lib`).
1139
+ *
1140
+ * Unlike the `relDir`-based candidates above (which mirror under the
1141
+ * hardcoded "tests"/"__tests__" roots), this uses `testDir` — the
1142
+ * pattern's own configured test root from `SOURCE_TO_TEST_PATTERNS`
1143
+ * (e.g. "tests" for PHP, "test" for Elixir) — since ExUnit's root is
1144
+ * singular and wouldn't otherwise be checked.
1145
+ *
1146
+ * Returns an empty array when the source directory doesn't start with a
1147
+ * known source-root segment (src/lib/app) followed by at least one more
1148
+ * path segment — i.e. this is a no-op for languages/layouts that don't
1149
+ * use this convention.
1150
+ */
1151
+ sourceRootMirroredCandidates(dir, cwd, testDir, testFilename) {
1152
+ const knownSourceRoots = new Set(["src", "lib", "app"]);
1153
+ const relDir = path.relative(cwd, dir);
1154
+ const segments = relDir.split(path.sep).filter(Boolean);
1155
+ if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
1156
+ return [
1157
+ path.join(cwd, testDir, ...segments.slice(1), testFilename),
1158
+ ];
1159
+ }
1160
+ return [];
1161
+ }
658
1162
  /**
659
1163
  * Fallback discovery: scan known test directories for a file that imports
660
1164
  * the source module. Catches cases where the test file name doesn't match
@@ -713,6 +1217,17 @@ export class TestRunnerClient {
713
1217
  * is dropped since it becomes the command itself.
714
1218
  */
715
1219
  async resolveExec(runner, config, testFile, cwd) {
1220
+ // PHPUnit has no npx-style automatic local-binary resolution — Composer's
1221
+ // standard local-install location is vendor/bin/phpunit, so check that
1222
+ // explicitly before falling back to a global `phpunit` on PATH.
1223
+ if (runner === "phpunit") {
1224
+ const suffix = process.platform === "win32" ? ".bat" : "";
1225
+ const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix}`);
1226
+ if (fs.existsSync(vendorBin)) {
1227
+ return { command: vendorBin, args: config.args(testFile, cwd) };
1228
+ }
1229
+ return { command: "phpunit", args: config.args(testFile, cwd) };
1230
+ }
716
1231
  const binName = config.binName ?? runner;
717
1232
  const suffix = process.platform === "win32" ? ".cmd" : "";
718
1233
  const localBin = path.join(cwd, "node_modules", ".bin", binName + suffix);