pi-lens 4.1.2 → 4.1.4
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/CHANGELOG.md +1308 -0
- package/README.md +3 -0
- package/config/biome/core.jsonc +11 -2
- package/config/dependency-cruiser-eager-allowlist.json +91 -0
- package/dist/clients/actionable-warnings-logger.js +18 -3
- package/dist/clients/actionable-warnings.js +927 -80
- package/dist/clients/agent-behavior-client.js +13 -4
- package/dist/clients/ast-grep-client.js +136 -9
- package/dist/clients/ast-grep-rule-manager.js +60 -5
- package/dist/clients/ast-grep-tool-logger.js +2 -2
- package/dist/clients/availability-probe-flight.js +12 -0
- package/dist/clients/bash-file-access.js +1 -3
- package/dist/clients/biome-client.js +16 -2
- package/dist/clients/blocker-freshness.js +34 -0
- package/dist/clients/bootstrap.js +509 -73
- package/dist/clients/bounded-cache.js +152 -12
- package/dist/clients/bounded-telemetry.js +115 -23
- package/dist/clients/build-identity.js +95 -0
- package/dist/clients/bundled-resource-health.js +113 -0
- package/dist/clients/bus-events-logger.js +2 -2
- package/dist/clients/bus-publish.js +3 -0
- package/dist/clients/cache/rule-cache.js +33 -5
- package/dist/clients/cache-manager.js +105 -6
- package/dist/clients/cache-observability.js +46 -19
- package/dist/clients/cargo-manifest.js +422 -0
- package/dist/clients/cascade-logger.js +17 -3
- package/dist/clients/code-quality-warnings.js +13 -3
- package/dist/clients/complexity-client.js +16 -5
- package/dist/clients/config-core/deny.js +221 -0
- package/dist/clients/config-core/index.js +47 -0
- package/dist/clients/config-core/merge.js +357 -0
- package/dist/clients/config-core/normalize.js +340 -0
- package/dist/clients/config-core/process-spec.js +248 -0
- package/dist/clients/config-core/provenance.js +171 -0
- package/dist/clients/config-core/records.js +218 -0
- package/dist/clients/config-core/resolve.js +125 -0
- package/dist/clients/config-core/safe-object.js +78 -0
- package/dist/clients/config-core/schema.js +167 -0
- package/dist/clients/config-diagnostic-codes.js +281 -0
- package/dist/clients/config-locations.js +160 -0
- package/dist/clients/config-resolve.js +789 -0
- package/dist/clients/config-schema.js +197 -0
- package/dist/clients/config-warn.js +407 -0
- package/dist/clients/dead-code-client.js +33 -25
- package/dist/clients/dead-code-logger.js +2 -2
- package/dist/clients/deadline-utils.js +178 -0
- package/dist/clients/debug-handles.js +2 -2
- package/dist/clients/debug-heap.js +3 -3
- package/dist/clients/deferred-lsp-work.js +106 -0
- package/dist/clients/degradation-ledger.js +166 -8
- package/dist/clients/demoted-finding-render.js +14 -0
- package/dist/clients/dependency-checker.js +28 -22
- package/dist/clients/deps/minimatch.js +1 -1
- package/dist/clients/diagnostic-line-freshness.js +12 -9
- package/dist/clients/diagnostic-logger.js +14 -4
- package/dist/clients/dispatch/collect-later-tier.js +39 -0
- package/dist/clients/dispatch/dispatcher.js +304 -19
- package/dist/clients/dispatch/fact-store.js +328 -9
- package/dist/clients/dispatch/integration.js +214 -107
- package/dist/clients/dispatch/pending-runner-findings.js +87 -0
- package/dist/clients/dispatch/plan.js +21 -5
- package/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
- package/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/dist/clients/dispatch/runners/cpp-check.js +0 -1
- package/dist/clients/dispatch/runners/credo.js +0 -1
- package/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/dist/clients/dispatch/runners/detekt.js +0 -1
- package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/dist/clients/dispatch/runners/eslint.js +5 -2
- package/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/dist/clients/dispatch/runners/helm-lint.js +16 -4
- package/dist/clients/dispatch/runners/helm-render.js +54 -10
- package/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/dist/clients/dispatch/runners/index.js +1 -3
- package/dist/clients/dispatch/runners/javac.js +0 -1
- package/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/dist/clients/dispatch/runners/lsp.js +37 -2
- package/dist/clients/dispatch/runners/markdownlint.js +4 -2
- package/dist/clients/dispatch/runners/mypy.js +0 -1
- package/dist/clients/dispatch/runners/oxlint.js +24 -22
- package/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
- package/dist/clients/dispatch/runners/pyright.js +7 -4
- package/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/dist/clients/dispatch/runners/ruff.js +0 -1
- package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
- package/dist/clients/dispatch/runners/shellcheck.js +0 -1
- package/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/dist/clients/dispatch/runners/spellcheck.js +0 -1
- package/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/dist/clients/dispatch/runners/taplo.js +0 -1
- package/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/dist/clients/dispatch/runners/tflint.js +0 -1
- package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
- package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
- package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
- package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
- package/dist/clients/dispatch/runners/vale.js +0 -1
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
- package/dist/clients/dispatch/runners/yamllint.js +0 -1
- package/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/dist/clients/disposition-logger.js +3 -3
- package/dist/clients/effective-config.js +403 -0
- package/dist/clients/error-class.js +23 -0
- package/dist/clients/event-loop-hold.js +274 -0
- package/dist/clients/event-loop-monitor.js +66 -1
- package/dist/clients/extension-log.js +2 -2
- package/dist/clients/feature-hints.js +2 -1
- package/dist/clients/file-time.js +6 -1
- package/dist/clients/file-utils.js +266 -13
- package/dist/clients/finding-delivery-gate.js +55 -13
- package/dist/clients/format-service.js +6 -1
- package/dist/clients/formatters.js +303 -104
- package/dist/clients/freshness-cadence.js +17 -0
- package/dist/clients/generated-artifacts.js +114 -19
- package/dist/clients/generation-guard.js +4 -9
- package/dist/clients/git-guard.js +198 -48
- package/dist/clients/git-tracked-ignore.js +32 -1
- package/dist/clients/go-client.js +39 -0
- package/dist/clients/govulncheck-client.js +5 -1
- package/dist/clients/gradle-ktfmt-style.js +252 -0
- package/dist/clients/gzip-stage-write.js +7 -0
- package/dist/clients/hashline-anchor.js +424 -0
- package/dist/clients/hook-budgets.js +76 -0
- package/dist/clients/host-edit-normalize.js +5 -2
- package/dist/clients/host-ports.js +1 -1
- package/dist/clients/installer/index.js +200 -84
- package/dist/clients/installer/managed-tool-refresh.js +3 -1
- package/dist/clients/instance-reaper.js +108 -243
- package/dist/clients/instance-registry-lock.js +145 -0
- package/dist/clients/instance-registry.js +435 -122
- package/dist/clients/jscpd-client.js +6 -1
- package/dist/clients/language-profile.js +4 -2
- package/dist/clients/language-registry.js +595 -0
- package/dist/clients/latency-logger.js +179 -3
- package/dist/clients/ledger-bounds.js +13 -0
- package/dist/clients/lens-config.js +141 -30
- package/dist/clients/lens-engine.js +30 -37
- package/dist/clients/lens-flag-registry.js +67 -6
- package/dist/clients/lens-map.js +3 -1
- package/dist/clients/log-cleanup.js +2 -2
- package/dist/clients/lsp/aggregation.js +3 -1
- package/dist/clients/lsp/cascade-tier.js +4 -0
- package/dist/clients/lsp/client.js +672 -49
- package/dist/clients/lsp/config.js +504 -156
- package/dist/clients/lsp/diagnostic-binding.js +10 -5
- package/dist/clients/lsp/edits.js +15 -2
- package/dist/clients/lsp/index.js +1179 -247
- package/dist/clients/lsp/jvm-runtime.js +5 -0
- package/dist/clients/lsp/language.js +18 -169
- package/dist/clients/lsp/launch.js +38 -9
- package/dist/clients/lsp/pending-aux-coverage.js +113 -12
- package/dist/clients/lsp/server.js +426 -160
- package/dist/clients/lsp/session-roots.js +192 -0
- package/dist/clients/lsp/spawn-history.js +2 -7
- package/dist/clients/lsp/tsserver-sync.js +9 -2
- package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
- package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
- package/dist/clients/lsp-mutation.js +179 -23
- package/dist/clients/map-with-concurrency.js +36 -0
- package/dist/clients/mcp/analyze.js +45 -10
- package/dist/clients/mcp/session.js +19 -17
- package/dist/clients/memory-sampler.js +28 -1
- package/dist/clients/message-end-attribution.js +30 -0
- package/dist/clients/metrics-history.js +21 -4
- package/dist/clients/middle-man-analysis.js +2 -4
- package/dist/clients/module-report.js +20 -45
- package/dist/clients/mutating-tool.js +651 -0
- package/dist/clients/mutation-attribution.js +368 -0
- package/dist/clients/mutation-bridge.js +240 -0
- package/dist/clients/ndjson-logger.js +247 -20
- package/dist/clients/observed-mutation-sources.js +101 -0
- package/dist/clients/observed-mutation.js +1215 -0
- package/dist/clients/opaque-mutation-scan.js +300 -40
- package/dist/clients/opengrep-config.js +4 -0
- package/dist/clients/package-manager.js +201 -44
- package/dist/clients/partial-edit-apply.js +359 -80
- package/dist/clients/path-attribution-telemetry.js +23 -7
- package/dist/clients/path-keyed-map.js +35 -3
- package/dist/clients/path-utils.js +460 -13
- package/dist/clients/persist-debounce.js +8 -1
- package/dist/clients/php-cs-fixer-config.js +114 -0
- package/dist/clients/pipeline.js +77 -24
- package/dist/clients/probe-home-state.js +228 -0
- package/dist/clients/process-bridge.js +66 -0
- package/dist/clients/process-singletons.js +210 -0
- package/dist/clients/process-snapshot.js +68 -0
- package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
- package/dist/clients/project-diagnostics/scanner.js +61 -24
- package/dist/clients/project-lens-config.js +393 -122
- package/dist/clients/project-snapshot.js +85 -35
- package/dist/clients/python-environment.js +217 -0
- package/dist/clients/python-provenance.js +639 -0
- package/dist/clients/quiet-window.js +6 -1
- package/dist/clients/read-bridge.js +13 -21
- package/dist/clients/read-expansion.js +9 -46
- package/dist/clients/read-guard-logger.js +39 -6
- package/dist/clients/read-guard-tool-lines.js +364 -212
- package/dist/clients/read-guard.js +95 -7
- package/dist/clients/recent-touches.js +17 -5
- package/dist/clients/resource-sampler.js +195 -96
- package/dist/clients/review-graph/builder.js +752 -132
- package/dist/clients/review-graph/service.js +55 -3
- package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/dist/clients/review-graph/tsconfig-paths.js +5 -1
- package/dist/clients/review-graph/workspace-modules.js +10 -74
- package/dist/clients/review-graph-logger.js +30 -4
- package/dist/clients/runtime-agent-end.js +18 -2
- package/dist/clients/runtime-context.js +49 -16
- package/dist/clients/runtime-coordinator.js +93 -6
- package/dist/clients/runtime-session.js +169 -34
- package/dist/clients/runtime-tool-call.js +183 -35
- package/dist/clients/runtime-tool-result.js +894 -241
- package/dist/clients/runtime-turn.js +1264 -74
- package/dist/clients/rust-client.js +31 -0
- package/dist/clients/safe-spawn.js +191 -28
- package/dist/clients/sanitize.js +7 -1
- package/dist/clients/scratch-tree-policy.js +1 -3
- package/dist/clients/security-scan-client.js +51 -17
- package/dist/clients/session-lifecycle.js +238 -42
- package/dist/clients/session-start-observability.js +79 -0
- package/dist/clients/sessionstart-logger.js +12 -3
- package/dist/clients/sg-runner.js +27 -15
- package/dist/clients/sgconfig.js +6 -1
- package/dist/clients/shared-checkout-guard.js +338 -0
- package/dist/clients/skills-resolver.js +105 -0
- package/dist/clients/smells-rollup.js +2 -2
- package/dist/clients/spawn-output-cap.js +37 -0
- package/dist/clients/spawn-timeout-cooldown.js +15 -1
- package/dist/clients/startup-scan.js +6 -7
- package/dist/clients/startup-timing.js +69 -4
- package/dist/clients/string-utils.js +33 -0
- package/dist/clients/subagent-mode.js +17 -4
- package/dist/clients/test-runner-client.js +586 -118
- package/dist/clients/test-runner-delivery.js +239 -0
- package/dist/clients/tool-definition.js +41 -1
- package/dist/clients/tool-policy.js +92 -22
- package/dist/clients/tree-sitter-cache.js +40 -40
- package/dist/clients/tree-sitter-client.js +97 -53
- package/dist/clients/tree-sitter-logger.js +19 -3
- package/dist/clients/tree-sitter-query-loader.js +81 -1
- package/dist/clients/tree-sitter-shared.js +51 -46
- package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/dist/clients/typos-config.js +5 -0
- package/dist/clients/user-notify.js +6 -2
- package/dist/clients/warm-attach.js +8 -10
- package/dist/clients/widget-state.js +191 -26
- package/dist/clients/word-index-logger.js +15 -3
- package/dist/clients/word-index-store.js +463 -0
- package/dist/clients/word-index.js +815 -125
- package/dist/clients/workspace-topology.js +25 -1
- package/dist/clients/zizmor-config.js +29 -6
- package/dist/index.js +68577 -53950
- package/dist/mcp/analyze-cli.js +3 -1
- package/dist/mcp/build-staleness.js +8 -7
- package/dist/mcp/cli.js +3 -1
- package/dist/mcp/server.js +133 -6
- package/dist/scripts/lib/process-scan.mjs +583 -0
- package/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/dist/tools/effective-config.js +89 -0
- package/dist/tools/lens-diagnostics.js +99 -24
- package/dist/tools/lsp-diagnostics.js +81 -85
- package/dist/tools/lsp-navigation.js +40 -17
- package/dist/tools/shared.js +0 -1
- package/docs/agent-guide.md +68 -3
- package/docs/ast-grep_rules_catalog.md +41 -10
- package/docs/astplayground.md +26 -17
- package/docs/configuration.md +222 -0
- package/docs/dependencies.md +3 -3
- package/docs/features.md +44 -5
- package/docs/language-coverage.md +2 -2
- package/docs/pi-lens-fixer.md +25 -0
- package/docs/pi-lens-investigator.md +25 -0
- package/docs/pi-lens-reviewer.md +27 -0
- package/docs/pi-lens-subagent.md +38 -0
- package/docs/pi-lens-warden.md +55 -0
- package/docs/public-api-stability.md +359 -0
- package/docs/release-qa-baseline.md +182 -0
- package/docs/subagent-compat.md +110 -30
- package/docs/tree-sitter_rules_catalog.md +2 -2
- package/package.json +17 -21
- package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
- package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
- package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
- package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
- package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
- package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
- package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
- package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
- package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
- package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
- package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
- package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
- package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
- package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
- package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
- package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
- package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
- package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
- package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
- package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
- package/rules/ast-grep-rules/rules/no-important.yml +21 -0
- package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
- package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
- package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
- package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
- package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
- package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
- package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
- package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
- package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/scripts/install-selftest.mjs +58 -2
- package/scripts/lib/skills-predicate.mjs +129 -0
- package/scripts/rpc-load-check.mjs +3 -1
|
@@ -10,14 +10,57 @@
|
|
|
10
10
|
* Design: File-level targeted testing — only runs tests for the
|
|
11
11
|
* specific file being edited, not the entire suite.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import { BoundedFifoMap } from "./bounded-cache.js";
|
|
17
|
+
import { emitBounded } from "./bounded-telemetry.js";
|
|
18
|
+
import { LEDGER_FIELD_MAX } from "./degradation-ledger.js";
|
|
16
19
|
import { minimatch } from "./deps/minimatch.js";
|
|
20
|
+
import { createSubsystemLogger } from "./extension-log.js";
|
|
17
21
|
import { detectFileRole } from "./file-role.js";
|
|
18
22
|
import { findGlobalBinary } from "./package-manager.js";
|
|
23
|
+
import { PathKeyedMap } from "./path-keyed-map.js";
|
|
24
|
+
import { augmentPythonEnvironment, detectPythonEnvironment, } from "./python-environment.js";
|
|
25
|
+
import { normalizeEphemeralMapKey, normalizeMapKey, toPosix, } from "./path-utils.js";
|
|
19
26
|
import { isMeasuredDuration, toMeasuredDurationMs } from "./run-duration.js";
|
|
20
27
|
import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
|
|
28
|
+
import { stripAnsi } from "./sanitize.js";
|
|
29
|
+
/**
|
|
30
|
+
* #2532: the ONE classification seam for "this `TestResult` carries NO
|
|
31
|
+
* counted failure the agent needs to fix, even though the runner also
|
|
32
|
+
* reported an `error`" — i.e. `failed === 0 && !!error`. This is NOT "the
|
|
33
|
+
* suite never started": a runner can report BOTH counted failures and an
|
|
34
|
+
* error (pytest exit 2 "Interrupted" after `2 failed, 1 passed` already
|
|
35
|
+
* printed — `parsePytestOutput` sets `error` from the exit code
|
|
36
|
+
* independently of the parsed counts, review round 1 S2). The rule is
|
|
37
|
+
* exactly `failed === 0`, nothing about `error`'s presence or cause: a
|
|
38
|
+
* result with counted failures stays blocking even when `error` is also
|
|
39
|
+
* set (`testResultToProjectDiagnostics`/`formatResult` still mention the
|
|
40
|
+
* error in that case, they just don't let it downgrade the verdict).
|
|
41
|
+
*
|
|
42
|
+
* `hasRealFailure`/`runnerErrorOnly` (`runtime-turn.ts`, #2522) key off the
|
|
43
|
+
* exact same fact for the turn-end delivery framing — a batch made
|
|
44
|
+
* entirely of `isRunnerErrorResult` results is advisory, one containing
|
|
45
|
+
* even one counted failure keeps "fix before continuing". Three more
|
|
46
|
+
* surfaces route through this SAME predicate instead of re-deriving it
|
|
47
|
+
* (review round 1 S1's class sweep), so the identical `TestResult` cannot
|
|
48
|
+
* classify differently across them: `testResultToProjectDiagnostics`
|
|
49
|
+
* (`lens_diagnostics mode=full`), the `--lens-guard` merge call in
|
|
50
|
+
* `handleTurnEnd`, and `TestRunnerClient.formatResult`/the turn-end dbg
|
|
51
|
+
* summary (both in this file / `runtime-turn.ts`).
|
|
52
|
+
*
|
|
53
|
+
* Two call sites intentionally test the COMPLEMENT (a truly clean result,
|
|
54
|
+
* no counted failure AND no error) rather than this predicate, and are not
|
|
55
|
+
* a missed fourth spelling: `runtime-turn.ts`'s `cleanFiles` filter
|
|
56
|
+
* (`--lens-guard`'s clear-blocker list) and `testResultToProjectDiagnostics`'s
|
|
57
|
+
* own early "nothing to report" return. Both need "genuinely clean", which
|
|
58
|
+
* `!isRunnerErrorResult(result)` cannot express (it is also true for a
|
|
59
|
+
* counted failure).
|
|
60
|
+
*/
|
|
61
|
+
export function isRunnerErrorResult(result) {
|
|
62
|
+
return result.failed === 0 && !!result.error;
|
|
63
|
+
}
|
|
21
64
|
// Source file → test file patterns (reverse lookup)
|
|
22
65
|
const SOURCE_TO_TEST_PATTERNS = [
|
|
23
66
|
{
|
|
@@ -65,6 +108,57 @@ const MAX_NODE_MODULES_WALK_UP = 5;
|
|
|
65
108
|
// test type rather than mirroring source layout) — capped depth, never an
|
|
66
109
|
// unbounded walk of the whole tests tree.
|
|
67
110
|
const MAX_PYTEST_RECURSE_DEPTH = 3;
|
|
111
|
+
/**
|
|
112
|
+
* #2522: built-in exclusion list for the turn-end auto-fired test selection.
|
|
113
|
+
*
|
|
114
|
+
* `getTestRunTarget`'s three strategies (failed-first / related / self) will
|
|
115
|
+
* happily resolve to ANY test file on disk, including integration/e2e suites
|
|
116
|
+
* that spawn external processes. A plegma dogfooding turn resolved
|
|
117
|
+
* `tests/integration/opencode-delegate.test.ts` this way — it spawns
|
|
118
|
+
* `opencode` and needs a configured provider, so on a box without one it took
|
|
119
|
+
* 17s to fail and was reported to the agent as "3/3 failed, fix before
|
|
120
|
+
* proceeding" on an unrelated model-switch turn (#2522, refs #2504/#2509).
|
|
121
|
+
*
|
|
122
|
+
* One hard-coded list, no per-project config knob (maintainer decision
|
|
123
|
+
* 2026-09-03) — this is a safety bound on what turn_end may auto-fire, not a
|
|
124
|
+
* project preference. Documented in AGENTS.md and docs/. Matched against the
|
|
125
|
+
* resolved test file's project-relative, POSIX-folded path so it applies
|
|
126
|
+
* uniformly to whichever strategy produced the target.
|
|
127
|
+
*/
|
|
128
|
+
export const TURN_END_EXCLUDED_TEST_GLOBS = [
|
|
129
|
+
"**/integration/**",
|
|
130
|
+
"**/e2e/**",
|
|
131
|
+
"**/*.integration.*",
|
|
132
|
+
"**/*.e2e.*",
|
|
133
|
+
];
|
|
134
|
+
/**
|
|
135
|
+
* Whether a resolved test target falls under the built-in turn-end
|
|
136
|
+
* exclusion list (#2522). `testFilePath` may be absolute or relative, and
|
|
137
|
+
* may use either path-separator form — folded through `toPosix` after being
|
|
138
|
+
* made cwd-relative so `\`- and `/`-separated inputs match identically
|
|
139
|
+
* (AGENTS.md cross-form-path screen).
|
|
140
|
+
*/
|
|
141
|
+
export function isExcludedTestTarget(testFilePath, cwd) {
|
|
142
|
+
const rel = toPosix(path.relative(cwd, path.resolve(cwd, testFilePath)));
|
|
143
|
+
// #2522 review round 2, F6: a target that resolves OUTSIDE the project root
|
|
144
|
+
// yields a `..`-leading relative path (or, across Windows drives, a still
|
|
145
|
+
// absolute one), which matches none of the globs — so the bare `some()`
|
|
146
|
+
// below reported it as "not excluded" and turn_end would have auto-spawned a
|
|
147
|
+
// runner against a file outside the project the turn is running in. An
|
|
148
|
+
// out-of-tree target, and the project root itself, fail CLOSED.
|
|
149
|
+
if (rel === "" ||
|
|
150
|
+
rel === ".." ||
|
|
151
|
+
rel.startsWith("../") ||
|
|
152
|
+
path.isAbsolute(rel))
|
|
153
|
+
return true;
|
|
154
|
+
// #2522 review round 2, F5: case-INSENSITIVE. `tests/Integration/`,
|
|
155
|
+
// `tests/E2E/` and `foo.E2E.test.ts` are the same hazard as their lowercase
|
|
156
|
+
// spellings, and on the case-insensitive filesystems Windows and macOS ship
|
|
157
|
+
// by default they are literally the same files — so a case-sensitive match
|
|
158
|
+
// made the SAME repo excluded on one box and unbounded on another. A safety
|
|
159
|
+
// bound a capital letter defeats is not a bound.
|
|
160
|
+
return TURN_END_EXCLUDED_TEST_GLOBS.some((glob) => minimatch(rel, glob, { dot: true, nocase: true }));
|
|
161
|
+
}
|
|
68
162
|
// --- Runner Detection ---
|
|
69
163
|
export const RUNNERS = {
|
|
70
164
|
vitest: {
|
|
@@ -194,21 +288,158 @@ export function stripWrapperArgs(binName, args) {
|
|
|
194
288
|
return args;
|
|
195
289
|
}
|
|
196
290
|
// --- Client ---
|
|
291
|
+
const MAX_FAILED_TARGETS_PER_RUNNER = 32;
|
|
292
|
+
const MAX_FAILED_TARGET_CHECKS_PER_SELECTION = 8;
|
|
293
|
+
const FAILED_TARGET_DETAIL_CAP_PER_TURN = 8;
|
|
294
|
+
function canonicalFailedPath(filePath) {
|
|
295
|
+
const absolute = path.resolve(filePath);
|
|
296
|
+
try {
|
|
297
|
+
return normalizeMapKey(fs.realpathSync.native(absolute));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return normalizeMapKey(absolute);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function canonicalProjectRoot(cwd) {
|
|
304
|
+
const absolute = path.resolve(cwd);
|
|
305
|
+
try {
|
|
306
|
+
return {
|
|
307
|
+
key: normalizeEphemeralMapKey(fs.realpathSync.native(absolute)),
|
|
308
|
+
resolved: true,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return { key: normalizeEphemeralMapKey(absolute), resolved: false };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export const MAX_CANONICAL_ROOT_MEMO_ENTRIES = 512;
|
|
316
|
+
function filesystemErrorCode(error) {
|
|
317
|
+
if (error !== null &&
|
|
318
|
+
typeof error === "object" &&
|
|
319
|
+
"code" in error &&
|
|
320
|
+
typeof error.code === "string") {
|
|
321
|
+
return error.code;
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
const PYTEST_SUMMARY_OUTCOME = "(?:passed|failed|skipped|reruns?|errors?|warnings?|deselected|xfailed|xpassed)";
|
|
326
|
+
const PYTEST_SUMMARY_LINE = new RegExp(`^\\s*(?:=+\\s*)?\\d+\\s+${PYTEST_SUMMARY_OUTCOME}` +
|
|
327
|
+
`(?:\\s*,\\s*\\d+\\s+${PYTEST_SUMMARY_OUTCOME})*` +
|
|
328
|
+
`\\s+in\\s+[\\d.]+s(?:\\s*=+)?\\s*$`, "i");
|
|
329
|
+
/** Extract aggregate values from pytest's final outcome line only. */
|
|
330
|
+
function parsePytestSummary(output) {
|
|
331
|
+
// Pytest may color the whole summary or individual tokens. Strip ANSI before
|
|
332
|
+
// selecting and extracting so formatting cannot turn the first count into 0.
|
|
333
|
+
const normalizedOutput = stripAnsi(output);
|
|
334
|
+
const summaryLine = normalizedOutput
|
|
335
|
+
.split(/\r?\n/)
|
|
336
|
+
.reverse()
|
|
337
|
+
.find((line) => PYTEST_SUMMARY_LINE.test(line));
|
|
338
|
+
let passed = 0;
|
|
339
|
+
let failed = 0;
|
|
340
|
+
let skipped = 0;
|
|
341
|
+
if (!summaryLine)
|
|
342
|
+
return { passed, failed, skipped };
|
|
343
|
+
const summaryBody = summaryLine.replace(/^=+\s*|\s*=+\s*$/g, "");
|
|
344
|
+
for (const field of summaryBody.split(",")) {
|
|
345
|
+
const [countText, outcome] = field.trim().split(/\s+/, 2);
|
|
346
|
+
const count = Number.parseInt(countText, 10);
|
|
347
|
+
if (!Number.isFinite(count))
|
|
348
|
+
continue;
|
|
349
|
+
switch (outcome) {
|
|
350
|
+
case "passed":
|
|
351
|
+
passed = count;
|
|
352
|
+
break;
|
|
353
|
+
case "failed":
|
|
354
|
+
failed = count;
|
|
355
|
+
break;
|
|
356
|
+
case "skipped":
|
|
357
|
+
skipped = count;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const durationMatch = /in\s+([\d.]+)s/.exec(summaryLine);
|
|
362
|
+
// Rounded, like `jsonRunDurationMs` and PHPUnit's legacy path:
|
|
363
|
+
// `in 2.01s` is 2009.9999999999998 in binary floating point.
|
|
364
|
+
const duration = durationMatch
|
|
365
|
+
? Math.round(Number.parseFloat(durationMatch[1]) * 1000)
|
|
366
|
+
: undefined;
|
|
367
|
+
return { passed, failed, skipped, duration };
|
|
368
|
+
}
|
|
197
369
|
export class TestRunnerClient {
|
|
198
370
|
log;
|
|
199
|
-
|
|
371
|
+
// This is an instance-lifetime memo of RESOLVED spellings only, which leaves
|
|
372
|
+
// two temporal edges. A symlink retargeted mid-session keeps its old
|
|
373
|
+
// resolution until a new client instance — acceptable because round 2's
|
|
374
|
+
// evidence re-validation already handles verdict-level staleness (positive
|
|
375
|
+
// verdicts re-stat their config file). A spelling that did NOT resolve is
|
|
376
|
+
// never memoized (#2077): the fallback key is a guess about a path that does
|
|
377
|
+
// not exist yet, so memoizing it would pin an alias probed before its
|
|
378
|
+
// symlink was created to a stale verdict for the instance's life. Re-probing
|
|
379
|
+
// costs one failing realpath per call, and only for a cwd that does not
|
|
380
|
+
// resolve — a state where `detectRunner` already walks node_modules on every
|
|
381
|
+
// call. Keep the memo bounded so pathological spelling churn cannot grow it
|
|
382
|
+
// without limit.
|
|
383
|
+
canonicalRootMemo = new BoundedFifoMap(MAX_CANONICAL_ROOT_MEMO_ENTRIES);
|
|
384
|
+
availableRunners = new PathKeyedMap(normalizeEphemeralMapKey);
|
|
200
385
|
failedTestsByRunner = new Map();
|
|
386
|
+
failedTargetSequence = 0;
|
|
387
|
+
statFailedTarget;
|
|
201
388
|
// Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
|
|
202
389
|
// plain text (never executed) and cached per cwd so the config file is
|
|
203
390
|
// only read/parsed once, not on every edit. `null` means "no config found
|
|
204
391
|
// or it couldn't be parsed in the simple shape we look for" — callers
|
|
205
392
|
// treat that as "no additional signal" and fall back to naming-convention
|
|
206
393
|
// detection only.
|
|
207
|
-
vitestTestGlobsCache = new
|
|
208
|
-
constructor(verbose = false) {
|
|
209
|
-
this.log = verbose
|
|
210
|
-
|
|
211
|
-
|
|
394
|
+
vitestTestGlobsCache = new PathKeyedMap(normalizeEphemeralMapKey);
|
|
395
|
+
constructor(verbose = false, options = {}) {
|
|
396
|
+
this.log = verbose ? createSubsystemLogger("test-runner") : () => { };
|
|
397
|
+
this.statFailedTarget =
|
|
398
|
+
options.statFailedTarget ?? ((filePath) => void fs.statSync(filePath));
|
|
399
|
+
}
|
|
400
|
+
getCanonicalProjectRoot(cwd) {
|
|
401
|
+
const cached = this.canonicalRootMemo.get(cwd);
|
|
402
|
+
if (cached !== undefined)
|
|
403
|
+
return cached;
|
|
404
|
+
const { key, resolved } = canonicalProjectRoot(cwd);
|
|
405
|
+
if (!resolved)
|
|
406
|
+
return key;
|
|
407
|
+
this.canonicalRootMemo.set(cwd, key);
|
|
408
|
+
return key;
|
|
409
|
+
}
|
|
410
|
+
/** #2442 test-only: exercise canonicalRootMemo's bounded eviction directly. */
|
|
411
|
+
_getCanonicalProjectRootForTests(cwd) {
|
|
412
|
+
return this.getCanonicalProjectRoot(cwd);
|
|
413
|
+
}
|
|
414
|
+
getRunnerAvailability(byRunner, runner) {
|
|
415
|
+
const cached = byRunner?.get(runner);
|
|
416
|
+
if (!cached)
|
|
417
|
+
return undefined;
|
|
418
|
+
if (cached.available &&
|
|
419
|
+
cached.evidencePath !== undefined &&
|
|
420
|
+
!fs.existsSync(cached.evidencePath)) {
|
|
421
|
+
byRunner?.delete(runner);
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
return cached.available;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* #2252: only a POSITIVE verdict is memoized. A negative one has no
|
|
428
|
+
* `evidencePath` to re-stat, so `getRunnerAvailability` had no way to tell
|
|
429
|
+
* "still absent" from "a config file just appeared" and served the first
|
|
430
|
+
* miss for the client's whole process lifetime — measured live: probe an
|
|
431
|
+
* empty directory, add `vitest.config.ts`, and the SAME client kept
|
|
432
|
+
* answering "no runner". Same precedent as #2242's alias-canonicalization
|
|
433
|
+
* fix (`clients/test-runner-client.ts`'s `getCanonicalProjectRoot`): drop
|
|
434
|
+
* the memo write on the failure branch rather than adding a TTL or a
|
|
435
|
+
* re-arm signal. The cost is bounded and already paid today — a cache miss
|
|
436
|
+
* re-runs the exact same `configFiles.some(fs.existsSync)` walk this
|
|
437
|
+
* method's caller already does on every FIRST probe of a runner.
|
|
438
|
+
*/
|
|
439
|
+
setRunnerAvailability(byRunner, runner, available, evidencePath) {
|
|
440
|
+
if (!available)
|
|
441
|
+
return;
|
|
442
|
+
byRunner.set(runner, { available, evidencePath });
|
|
212
443
|
}
|
|
213
444
|
/**
|
|
214
445
|
* Check if a test runner is available in the project
|
|
@@ -218,15 +449,22 @@ export class TestRunnerClient {
|
|
|
218
449
|
* 3. node_modules presence
|
|
219
450
|
*/
|
|
220
451
|
detectRunner(cwd, sourceFilePath) {
|
|
452
|
+
const rootKey = this.getCanonicalProjectRoot(cwd);
|
|
453
|
+
let byRunner = this.availableRunners.get(rootKey);
|
|
454
|
+
if (!byRunner) {
|
|
455
|
+
byRunner = new Map();
|
|
456
|
+
this.availableRunners.set(rootKey, byRunner);
|
|
457
|
+
}
|
|
221
458
|
// Priority 1: Config files
|
|
222
459
|
for (const [name, config] of Object.entries(RUNNERS)) {
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
225
|
-
if (
|
|
460
|
+
const cached = this.getRunnerAvailability(byRunner, name);
|
|
461
|
+
if (cached !== undefined) {
|
|
462
|
+
if (cached) {
|
|
226
463
|
return { runner: name, config };
|
|
227
464
|
}
|
|
228
465
|
continue;
|
|
229
466
|
}
|
|
467
|
+
let configEvidencePath;
|
|
230
468
|
const found = config.configFiles.some((cf) => {
|
|
231
469
|
if (name === "pytest" && cf === "pyproject.toml") {
|
|
232
470
|
const pyprojectPath = path.join(cwd, cf);
|
|
@@ -234,7 +472,10 @@ export class TestRunnerClient {
|
|
|
234
472
|
return false;
|
|
235
473
|
try {
|
|
236
474
|
const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
|
|
237
|
-
|
|
475
|
+
const matches = pyproject.includes("[tool.pytest.ini_options]");
|
|
476
|
+
if (matches)
|
|
477
|
+
configEvidencePath = pyprojectPath;
|
|
478
|
+
return matches;
|
|
238
479
|
}
|
|
239
480
|
catch {
|
|
240
481
|
return false;
|
|
@@ -250,15 +491,22 @@ export class TestRunnerClient {
|
|
|
250
491
|
...composer.require,
|
|
251
492
|
...composer["require-dev"],
|
|
252
493
|
};
|
|
253
|
-
|
|
494
|
+
const matches = Boolean(allDeps["phpunit/phpunit"]);
|
|
495
|
+
if (matches)
|
|
496
|
+
configEvidencePath = composerPath;
|
|
497
|
+
return matches;
|
|
254
498
|
}
|
|
255
499
|
catch {
|
|
256
500
|
return false;
|
|
257
501
|
}
|
|
258
502
|
}
|
|
259
|
-
|
|
503
|
+
const candidate = path.join(cwd, cf);
|
|
504
|
+
const matches = fs.existsSync(candidate);
|
|
505
|
+
if (matches)
|
|
506
|
+
configEvidencePath = candidate;
|
|
507
|
+
return matches;
|
|
260
508
|
});
|
|
261
|
-
this.
|
|
509
|
+
this.setRunnerAvailability(byRunner, name, found, configEvidencePath);
|
|
262
510
|
if (found) {
|
|
263
511
|
this.log(`Detected runner via config: ${name}`);
|
|
264
512
|
return { runner: name, config };
|
|
@@ -274,17 +522,17 @@ export class TestRunnerClient {
|
|
|
274
522
|
// Check for vitest first (more specific than jest)
|
|
275
523
|
if (allDeps.vitest) {
|
|
276
524
|
this.log("Detected vitest in package.json");
|
|
277
|
-
this.
|
|
525
|
+
this.setRunnerAvailability(byRunner, "vitest", true, packageJsonPath);
|
|
278
526
|
return { runner: "vitest", config: RUNNERS.vitest };
|
|
279
527
|
}
|
|
280
528
|
if (allDeps.jest) {
|
|
281
529
|
this.log("Detected jest in package.json");
|
|
282
|
-
this.
|
|
530
|
+
this.setRunnerAvailability(byRunner, "jest", true, packageJsonPath);
|
|
283
531
|
return { runner: "jest", config: RUNNERS.jest };
|
|
284
532
|
}
|
|
285
533
|
if (allDeps.pytest || allDeps["pytest-cov"]) {
|
|
286
534
|
this.log("Detected pytest in package.json (unusual)");
|
|
287
|
-
this.
|
|
535
|
+
this.setRunnerAvailability(byRunner, "pytest", true, packageJsonPath);
|
|
288
536
|
return { runner: "pytest", config: RUNNERS.pytest };
|
|
289
537
|
}
|
|
290
538
|
}
|
|
@@ -428,7 +676,10 @@ export class TestRunnerClient {
|
|
|
428
676
|
relativeSourceDir(sourceFilePath, cwd) {
|
|
429
677
|
const dir = path.dirname(sourceFilePath);
|
|
430
678
|
const relDir = path.relative(cwd, path.resolve(cwd, dir));
|
|
431
|
-
if (!relDir ||
|
|
679
|
+
if (!relDir ||
|
|
680
|
+
relDir === "." ||
|
|
681
|
+
relDir.startsWith("..") ||
|
|
682
|
+
path.isAbsolute(relDir)) {
|
|
432
683
|
return null;
|
|
433
684
|
}
|
|
434
685
|
return relDir;
|
|
@@ -447,21 +698,40 @@ export class TestRunnerClient {
|
|
|
447
698
|
* template expression) — anything more dynamic than that is out of
|
|
448
699
|
* scope for this heuristic.
|
|
449
700
|
*
|
|
450
|
-
* Cached per `cwd
|
|
451
|
-
*
|
|
701
|
+
* Cached per `cwd`, including a `null` result — #2252 F2: a project with
|
|
702
|
+
* no vitest config, or one this heuristic can't scrape, is a COMMON shape
|
|
703
|
+
* (every non-vitest project pays this on every edit otherwise; measured
|
|
704
|
+
* ~1500x — 0.4µs cached vs. 598.7µs re-reading the candidate list every
|
|
705
|
+
* call). The cache entry is revalidated on every read, the same shape
|
|
706
|
+
* `getRunnerAvailability` uses for a positive verdict: bounded
|
|
707
|
+
* `fs.existsSync` checks against the config file the result was derived
|
|
708
|
+
* from (or, when none existed, against the candidate list itself), never
|
|
709
|
+
* a full re-read/re-parse on a cache hit. So a config file appearing (or
|
|
710
|
+
* a broken one being fixed) still converges — only the FULL parse is
|
|
711
|
+
* paid once, not the existence check.
|
|
452
712
|
*/
|
|
453
713
|
parseVitestTestGlobs(cwd) {
|
|
454
|
-
|
|
455
|
-
return this.vitestTestGlobsCache.get(cwd) ?? null;
|
|
456
|
-
}
|
|
714
|
+
const rootKey = this.getCanonicalProjectRoot(cwd);
|
|
457
715
|
// .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
|
|
458
716
|
// *detection* priority) but is a legal vitest config extension, so it's
|
|
459
717
|
// included here for the scrape even though detectRunner doesn't check it.
|
|
460
718
|
const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
|
|
719
|
+
const cached = this.vitestTestGlobsCache.get(rootKey);
|
|
720
|
+
if (cached !== undefined) {
|
|
721
|
+
const stillValid = cached.evidencePath !== undefined
|
|
722
|
+
? fs.existsSync(cached.evidencePath)
|
|
723
|
+
: !candidates.some((cf) => fs.existsSync(path.join(cwd, cf)));
|
|
724
|
+
if (stillValid)
|
|
725
|
+
return cached.result;
|
|
726
|
+
this.vitestTestGlobsCache.delete(rootKey);
|
|
727
|
+
}
|
|
461
728
|
let content = null;
|
|
729
|
+
let foundPath;
|
|
462
730
|
for (const cf of candidates) {
|
|
731
|
+
const candidatePath = path.join(cwd, cf);
|
|
463
732
|
try {
|
|
464
|
-
content = fs.readFileSync(
|
|
733
|
+
content = fs.readFileSync(candidatePath, "utf-8");
|
|
734
|
+
foundPath = candidatePath;
|
|
465
735
|
break;
|
|
466
736
|
}
|
|
467
737
|
catch {
|
|
@@ -480,7 +750,7 @@ export class TestRunnerClient {
|
|
|
480
750
|
result.exclude = exclude;
|
|
481
751
|
}
|
|
482
752
|
}
|
|
483
|
-
this.vitestTestGlobsCache.set(
|
|
753
|
+
this.vitestTestGlobsCache.set(rootKey, { result, evidencePath: foundPath });
|
|
484
754
|
return result;
|
|
485
755
|
}
|
|
486
756
|
/**
|
|
@@ -706,12 +976,11 @@ export class TestRunnerClient {
|
|
|
706
976
|
* 1) If there are known failing tests, rerun those first (fast feedback loop).
|
|
707
977
|
* 2) Otherwise run related tests for the edited file.
|
|
708
978
|
*/
|
|
709
|
-
getTestRunTarget(sourceFilePath, cwd) {
|
|
979
|
+
getTestRunTarget(sourceFilePath, cwd, turnIndex) {
|
|
710
980
|
const detected = this.detectRunner(cwd, sourceFilePath);
|
|
711
981
|
if (!detected)
|
|
712
982
|
return null;
|
|
713
|
-
const
|
|
714
|
-
const failedSet = this.failedTestsByRunner.get(key);
|
|
983
|
+
const failedSet = this.getFailedTargets(cwd, detected.runner);
|
|
715
984
|
// If the edited file is itself a test file, there's no "related test"
|
|
716
985
|
// to discover — running findTestFile on it would strip its own
|
|
717
986
|
// extension and search for nonsense like foo.test.test.ts. Skip
|
|
@@ -721,34 +990,22 @@ export class TestRunnerClient {
|
|
|
721
990
|
? null
|
|
722
991
|
: this.findTestFile(sourceFilePath, cwd, detected.runner);
|
|
723
992
|
if (failedSet && failedSet.size > 0) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (failedSet.has(relatedAbs)) {
|
|
727
|
-
return {
|
|
728
|
-
testFile: relatedAbs,
|
|
729
|
-
runner: detected.runner,
|
|
730
|
-
config: detected.config,
|
|
731
|
-
strategy: "failed-first",
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
if (selfIsTest) {
|
|
736
|
-
const selfAbs = path.resolve(sourceFilePath);
|
|
737
|
-
if (failedSet.has(selfAbs)) {
|
|
738
|
-
return {
|
|
739
|
-
testFile: selfAbs,
|
|
740
|
-
runner: detected.runner,
|
|
741
|
-
config: detected.config,
|
|
742
|
-
strategy: "failed-first",
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
return {
|
|
747
|
-
testFile: [...failedSet][0],
|
|
993
|
+
const failedFirst = this.retireMissingFailedTargets({
|
|
994
|
+
cwd,
|
|
748
995
|
runner: detected.runner,
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
996
|
+
failedTargets: failedSet,
|
|
997
|
+
relatedAbs: related ? path.resolve(related.testFile) : undefined,
|
|
998
|
+
selfAbs: selfIsTest ? path.resolve(sourceFilePath) : undefined,
|
|
999
|
+
turnIndex,
|
|
1000
|
+
});
|
|
1001
|
+
if (failedFirst) {
|
|
1002
|
+
return {
|
|
1003
|
+
testFile: failedFirst,
|
|
1004
|
+
runner: detected.runner,
|
|
1005
|
+
config: detected.config,
|
|
1006
|
+
strategy: "failed-first",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
752
1009
|
}
|
|
753
1010
|
if (selfIsTest) {
|
|
754
1011
|
return {
|
|
@@ -767,22 +1024,32 @@ export class TestRunnerClient {
|
|
|
767
1024
|
strategy: "related",
|
|
768
1025
|
};
|
|
769
1026
|
}
|
|
770
|
-
|
|
771
|
-
* Run tests for a specific file without blocking the event loop, so LSP
|
|
772
|
-
* messages, other file writes, and all async operations continue while
|
|
773
|
-
* tests run.
|
|
774
|
-
*/
|
|
775
|
-
async runTestFileAsync(testFile, cwd, runner, config) {
|
|
1027
|
+
async runTestFileAsync(testFile, cwd, runnerOrRequest, legacyConfig) {
|
|
776
1028
|
const absoluteTestFile = path.resolve(testFile);
|
|
1029
|
+
let request;
|
|
1030
|
+
if (typeof runnerOrRequest === "string") {
|
|
1031
|
+
if (!legacyConfig) {
|
|
1032
|
+
return this.emptyResult(absoluteTestFile, "", runnerOrRequest, "Runner configuration missing");
|
|
1033
|
+
}
|
|
1034
|
+
request = { runner: runnerOrRequest, config: legacyConfig };
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
request = runnerOrRequest;
|
|
1038
|
+
}
|
|
1039
|
+
const { runner, config, turnIndex, signal } = request;
|
|
777
1040
|
if (!fs.existsSync(absoluteTestFile)) {
|
|
778
1041
|
return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
|
|
779
1042
|
}
|
|
780
1043
|
try {
|
|
781
|
-
const { command, args } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
|
|
1044
|
+
const { command, args, env } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
|
|
782
1045
|
this.log(`Running (async): ${command} ${args.join(" ")}`);
|
|
783
1046
|
const result = await safeSpawnAsync(command, args, {
|
|
784
1047
|
cwd,
|
|
785
1048
|
timeout: 60000,
|
|
1049
|
+
env,
|
|
1050
|
+
// #2522 R2 F1. `safeSpawnAsync` resolves `options.signal ?? ambient`,
|
|
1051
|
+
// so an absent batch signal keeps the pre-#2522 ambient behaviour.
|
|
1052
|
+
signal,
|
|
786
1053
|
});
|
|
787
1054
|
const stdout = result.stdout || "";
|
|
788
1055
|
const stderr = result.stderr || "";
|
|
@@ -811,7 +1078,13 @@ export class TestRunnerClient {
|
|
|
811
1078
|
parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
|
|
812
1079
|
break;
|
|
813
1080
|
}
|
|
814
|
-
this.recordResult(
|
|
1081
|
+
this.recordResult({
|
|
1082
|
+
cwd,
|
|
1083
|
+
runner,
|
|
1084
|
+
testFile: absoluteTestFile,
|
|
1085
|
+
result: parsed,
|
|
1086
|
+
turnIndex,
|
|
1087
|
+
});
|
|
815
1088
|
return parsed;
|
|
816
1089
|
}
|
|
817
1090
|
catch (err) {
|
|
@@ -819,6 +1092,172 @@ export class TestRunnerClient {
|
|
|
819
1092
|
return this.emptyResult(absoluteTestFile, "", runner, err.message);
|
|
820
1093
|
}
|
|
821
1094
|
}
|
|
1095
|
+
getFailedTargets(cwd, runner, create = false) {
|
|
1096
|
+
let roots = this.failedTestsByRunner.get(runner);
|
|
1097
|
+
if (!roots && create) {
|
|
1098
|
+
roots = new PathKeyedMap(canonicalFailedPath);
|
|
1099
|
+
this.failedTestsByRunner.set(runner, roots);
|
|
1100
|
+
}
|
|
1101
|
+
if (!roots)
|
|
1102
|
+
return undefined;
|
|
1103
|
+
const root = canonicalFailedPath(cwd);
|
|
1104
|
+
let targets = roots.get(root);
|
|
1105
|
+
if (!targets && create) {
|
|
1106
|
+
targets = new PathKeyedMap(canonicalFailedPath);
|
|
1107
|
+
roots.set(root, targets);
|
|
1108
|
+
}
|
|
1109
|
+
return targets;
|
|
1110
|
+
}
|
|
1111
|
+
deleteFailedRoot(cwd, runner) {
|
|
1112
|
+
const roots = this.failedTestsByRunner.get(runner);
|
|
1113
|
+
if (!roots)
|
|
1114
|
+
return;
|
|
1115
|
+
roots.delete(canonicalFailedPath(cwd));
|
|
1116
|
+
if (roots.size === 0)
|
|
1117
|
+
this.failedTestsByRunner.delete(runner);
|
|
1118
|
+
}
|
|
1119
|
+
failedTargetCount(runner) {
|
|
1120
|
+
const roots = this.failedTestsByRunner.get(runner);
|
|
1121
|
+
if (!roots)
|
|
1122
|
+
return 0;
|
|
1123
|
+
let count = 0;
|
|
1124
|
+
for (const targets of roots.values())
|
|
1125
|
+
count += targets.size;
|
|
1126
|
+
return count;
|
|
1127
|
+
}
|
|
1128
|
+
evictOldestFailedTarget(runner) {
|
|
1129
|
+
const roots = this.failedTestsByRunner.get(runner);
|
|
1130
|
+
if (!roots)
|
|
1131
|
+
return undefined;
|
|
1132
|
+
let oldest;
|
|
1133
|
+
for (const [root, targets] of roots) {
|
|
1134
|
+
for (const [identity, entry] of targets) {
|
|
1135
|
+
if (!oldest || entry.sequence < oldest.entry.sequence) {
|
|
1136
|
+
oldest = { root, identity, entry };
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (!oldest)
|
|
1141
|
+
return undefined;
|
|
1142
|
+
const targets = roots.get(oldest.root);
|
|
1143
|
+
targets?.delete(oldest.identity);
|
|
1144
|
+
if (targets?.size === 0)
|
|
1145
|
+
roots.delete(oldest.root);
|
|
1146
|
+
if (roots.size === 0)
|
|
1147
|
+
this.failedTestsByRunner.delete(runner);
|
|
1148
|
+
return oldest.entry.displayPath;
|
|
1149
|
+
}
|
|
1150
|
+
classifyFailedTarget(candidate) {
|
|
1151
|
+
try {
|
|
1152
|
+
this.statFailedTarget(candidate);
|
|
1153
|
+
return { status: "present" };
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
const errorCode = filesystemErrorCode(error);
|
|
1157
|
+
if (errorCode === "ENOENT" || errorCode === "ENOTDIR") {
|
|
1158
|
+
return { status: "missing", errorCode };
|
|
1159
|
+
}
|
|
1160
|
+
return { status: "indeterminate", errorCode };
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
recordFailedTargetState(record) {
|
|
1164
|
+
const { outcome, runner, candidate, errorCode, turnIndex } = record;
|
|
1165
|
+
const boundedTarget = candidate.length <= LEDGER_FIELD_MAX
|
|
1166
|
+
? candidate
|
|
1167
|
+
: `…${candidate.slice(1 - LEDGER_FIELD_MAX)}`;
|
|
1168
|
+
const targetIdentity = createHash("sha256")
|
|
1169
|
+
.update(runner)
|
|
1170
|
+
.update("\0")
|
|
1171
|
+
.update(candidate)
|
|
1172
|
+
.digest("hex");
|
|
1173
|
+
const identity = `${outcome}:${targetIdentity}`;
|
|
1174
|
+
const payload = {
|
|
1175
|
+
durationMs: 0,
|
|
1176
|
+
filePath: boundedTarget,
|
|
1177
|
+
metadata: {
|
|
1178
|
+
outcome,
|
|
1179
|
+
runner,
|
|
1180
|
+
errorCode: errorCode ?? "unknown",
|
|
1181
|
+
},
|
|
1182
|
+
};
|
|
1183
|
+
const options = {
|
|
1184
|
+
ledgerKind: "test-runner-failed-target-state",
|
|
1185
|
+
risingEdgePer: "identity",
|
|
1186
|
+
reason: `${outcome}: ${boundedTarget}`,
|
|
1187
|
+
};
|
|
1188
|
+
emitBounded("test_runner_failed_target_state", identity, payload, turnIndex === undefined
|
|
1189
|
+
? options
|
|
1190
|
+
: {
|
|
1191
|
+
...options,
|
|
1192
|
+
capPerTurn: {
|
|
1193
|
+
limit: FAILED_TARGET_DETAIL_CAP_PER_TURN,
|
|
1194
|
+
turnIndex,
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Retire only confirmed-missing failed-first paths, then return one usable
|
|
1200
|
+
* target. A bounded prefix is checked per selection; remaining candidates
|
|
1201
|
+
* carry over to the next selection instead of adding unbounded synchronous
|
|
1202
|
+
* filesystem work to turn_end. Related/self targets keep priority.
|
|
1203
|
+
*/
|
|
1204
|
+
retireMissingFailedTargets(selection) {
|
|
1205
|
+
const { cwd, runner, failedTargets, relatedAbs, selfAbs, turnIndex } = selection;
|
|
1206
|
+
let checked = 0;
|
|
1207
|
+
const inspected = new Set();
|
|
1208
|
+
const inspect = (identity, entry) => {
|
|
1209
|
+
if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
|
|
1210
|
+
return undefined;
|
|
1211
|
+
checked += 1;
|
|
1212
|
+
inspected.add(identity);
|
|
1213
|
+
const candidate = entry.displayPath;
|
|
1214
|
+
const verdict = this.classifyFailedTarget(candidate);
|
|
1215
|
+
if (verdict.status === "missing") {
|
|
1216
|
+
failedTargets.delete(identity);
|
|
1217
|
+
this.recordFailedTargetState({
|
|
1218
|
+
outcome: "retired-missing",
|
|
1219
|
+
runner,
|
|
1220
|
+
candidate,
|
|
1221
|
+
errorCode: verdict.errorCode,
|
|
1222
|
+
turnIndex,
|
|
1223
|
+
});
|
|
1224
|
+
return undefined;
|
|
1225
|
+
}
|
|
1226
|
+
if (verdict.status === "indeterminate") {
|
|
1227
|
+
this.recordFailedTargetState({
|
|
1228
|
+
outcome: "retained-indeterminate",
|
|
1229
|
+
runner,
|
|
1230
|
+
candidate,
|
|
1231
|
+
errorCode: verdict.errorCode,
|
|
1232
|
+
turnIndex,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
return candidate;
|
|
1236
|
+
};
|
|
1237
|
+
for (const preferred of [relatedAbs, selfAbs]) {
|
|
1238
|
+
if (!preferred)
|
|
1239
|
+
continue;
|
|
1240
|
+
const identity = canonicalFailedPath(preferred);
|
|
1241
|
+
const entry = failedTargets.get(identity);
|
|
1242
|
+
if (!entry)
|
|
1243
|
+
continue;
|
|
1244
|
+
const selected = inspect(identity, entry);
|
|
1245
|
+
if (selected)
|
|
1246
|
+
return selected;
|
|
1247
|
+
}
|
|
1248
|
+
for (const [identity, entry] of failedTargets) {
|
|
1249
|
+
if (inspected.has(identity))
|
|
1250
|
+
continue;
|
|
1251
|
+
if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
|
|
1252
|
+
break;
|
|
1253
|
+
const selected = inspect(identity, entry);
|
|
1254
|
+
if (selected)
|
|
1255
|
+
return selected;
|
|
1256
|
+
}
|
|
1257
|
+
if (failedTargets.size === 0)
|
|
1258
|
+
this.deleteFailedRoot(cwd, runner);
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
822
1261
|
/**
|
|
823
1262
|
* Check if a source file has corresponding tests (without running them)
|
|
824
1263
|
*/
|
|
@@ -970,31 +1409,9 @@ export class TestRunnerClient {
|
|
|
970
1409
|
parsePytestOutput(stdout, stderr, exitCode, testFile, _cwd, runner) {
|
|
971
1410
|
const failures = [];
|
|
972
1411
|
const output = `${stdout}\n${stderr}`;
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
let passed = 0;
|
|
977
|
-
let failed = 0;
|
|
978
|
-
let skipped = 0;
|
|
979
|
-
// #1479: undefined until pytest's own `in N.NNs` is read. `in 0.00s` is
|
|
980
|
-
// a real pytest summary, so 0 has to stay available as a measurement.
|
|
981
|
-
let duration;
|
|
982
|
-
if (summaryMatch) {
|
|
983
|
-
// Extract numbers from various patterns
|
|
984
|
-
const passedMatch = output.match(/(\d+)\s+passed/);
|
|
985
|
-
const failedMatch = output.match(/(\d+)\s+failed/);
|
|
986
|
-
const skippedMatch = output.match(/(\d+)\s+skipped/);
|
|
987
|
-
const durationMatch = output.match(/in\s+([\d.]+)s/);
|
|
988
|
-
passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
|
|
989
|
-
failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
|
|
990
|
-
skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
|
|
991
|
-
// Rounded, like `jsonRunDurationMs` and PHPUnit's legacy path:
|
|
992
|
-
// `in 2.01s` is 2009.9999999999998 in binary floating point, and
|
|
993
|
-
// the turn-end log prints the number as it stands.
|
|
994
|
-
// (Routing this through `toMeasuredDurationMs` is #1484, not this.)
|
|
995
|
-
if (durationMatch)
|
|
996
|
-
duration = Math.round(parseFloat(durationMatch[1]) * 1000);
|
|
997
|
-
}
|
|
1412
|
+
// #1479: `duration` stays undefined unless pytest emits its own `in N.NNs`
|
|
1413
|
+
// summary. A measured `in 0.00s` remains a real zero.
|
|
1414
|
+
const { passed, failed, skipped, duration } = parsePytestSummary(output);
|
|
998
1415
|
// Parse individual failures: "FAILED tests/test_foo.py::test_something - AssertionError: ..."
|
|
999
1416
|
const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
|
|
1000
1417
|
let match;
|
|
@@ -1022,7 +1439,11 @@ export class TestRunnerClient {
|
|
|
1022
1439
|
skipped,
|
|
1023
1440
|
failures,
|
|
1024
1441
|
duration,
|
|
1025
|
-
error: exitCode ===
|
|
1442
|
+
error: exitCode === 4
|
|
1443
|
+
? "Pytest configuration error"
|
|
1444
|
+
: exitCode === 2
|
|
1445
|
+
? "Pytest interrupted"
|
|
1446
|
+
: undefined,
|
|
1026
1447
|
};
|
|
1027
1448
|
}
|
|
1028
1449
|
// --- PHPUnit Parser (text-based, default CLI output) ---
|
|
@@ -1043,7 +1464,9 @@ export class TestRunnerClient {
|
|
|
1043
1464
|
const errorsMatch = output.match(/Errors:\s*(\d+)/i);
|
|
1044
1465
|
const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
|
|
1045
1466
|
const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
|
|
1046
|
-
const failures = failuresMatch
|
|
1467
|
+
const failures = failuresMatch
|
|
1468
|
+
? Number.parseInt(failuresMatch[1], 10)
|
|
1469
|
+
: 0;
|
|
1047
1470
|
const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
|
|
1048
1471
|
skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
|
|
1049
1472
|
failed = failures + errors;
|
|
@@ -1123,7 +1546,9 @@ export class TestRunnerClient {
|
|
|
1123
1546
|
if (summaryMatch) {
|
|
1124
1547
|
const total = Number.parseInt(summaryMatch[1], 10);
|
|
1125
1548
|
failed = Number.parseInt(summaryMatch[2], 10);
|
|
1126
|
-
const excluded = summaryMatch[3]
|
|
1549
|
+
const excluded = summaryMatch[3]
|
|
1550
|
+
? Number.parseInt(summaryMatch[3], 10)
|
|
1551
|
+
: 0;
|
|
1127
1552
|
const skippedCount = summaryMatch[4]
|
|
1128
1553
|
? Number.parseInt(summaryMatch[4], 10)
|
|
1129
1554
|
: 0;
|
|
@@ -1499,9 +1924,7 @@ export class TestRunnerClient {
|
|
|
1499
1924
|
// Package-line counts, like the go duration probe above, take the
|
|
1500
1925
|
// FIRST package summary only — the same known limit already
|
|
1501
1926
|
// documented on `parseGenericRunnerDuration`.
|
|
1502
|
-
const goFailNames = [
|
|
1503
|
-
...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g),
|
|
1504
|
-
];
|
|
1927
|
+
const goFailNames = [...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g)];
|
|
1505
1928
|
// #1524-r4: only COUNT unparented failures. go prints a `--- FAIL:`
|
|
1506
1929
|
// line for every level of a failing subtest tree — `TestA` AND
|
|
1507
1930
|
// `TestA/sub` each get their own line for what is really one
|
|
@@ -1595,7 +2018,10 @@ export class TestRunnerClient {
|
|
|
1595
2018
|
// runner error they actually are). They still label a name onto
|
|
1596
2019
|
// `failures` above when `matched` or `goFailNames` already settled
|
|
1597
2020
|
// the question some other way, but they no longer settle it alone.
|
|
1598
|
-
const runnerError = exitCode !== 0 &&
|
|
2021
|
+
const runnerError = exitCode !== 0 &&
|
|
2022
|
+
!matched &&
|
|
2023
|
+
goFailNames.length === 0 &&
|
|
2024
|
+
lower.includes("error")
|
|
1599
2025
|
? `Runner ${runner} exited with ${exitCode}`
|
|
1600
2026
|
: undefined;
|
|
1601
2027
|
// #1524-r4 (tidy): a runner-error result reports through
|
|
@@ -1653,9 +2079,15 @@ export class TestRunnerClient {
|
|
|
1653
2079
|
* Format test result for LLM consumption
|
|
1654
2080
|
*/
|
|
1655
2081
|
formatResult(result) {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
2082
|
+
// #2532 review S1: folded onto `isRunnerErrorResult` instead of the old
|
|
2083
|
+
// local `error && passed === 0 && failed === 0` spelling — that missed a
|
|
2084
|
+
// runner error reported alongside partial passes (pytest `Interrupted`
|
|
2085
|
+
// after some tests already ran clean), which fell through to the normal
|
|
2086
|
+
// "N/N passed" branch below and silently dropped the interruption.
|
|
2087
|
+
if (isRunnerErrorResult(result)) {
|
|
2088
|
+
return result.passed > 0
|
|
2089
|
+
? `[Tests] ⚠ Could not complete tests: ${result.error} (${result.passed} passed before)`
|
|
2090
|
+
: `[Tests] ⚠ Could not run tests: ${result.error}`;
|
|
1659
2091
|
}
|
|
1660
2092
|
const total = result.passed + result.failed + result.skipped;
|
|
1661
2093
|
if (total === 0) {
|
|
@@ -1720,9 +2152,7 @@ export class TestRunnerClient {
|
|
|
1720
2152
|
const relDir = path.relative(cwd, dir);
|
|
1721
2153
|
const segments = relDir.split(path.sep).filter(Boolean);
|
|
1722
2154
|
if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
|
|
1723
|
-
return [
|
|
1724
|
-
path.join(cwd, testDir, ...segments.slice(1), testFilename),
|
|
1725
|
-
];
|
|
2155
|
+
return [path.join(cwd, testDir, ...segments.slice(1), testFilename)];
|
|
1726
2156
|
}
|
|
1727
2157
|
return [];
|
|
1728
2158
|
}
|
|
@@ -1788,6 +2218,19 @@ export class TestRunnerClient {
|
|
|
1788
2218
|
* runners like vitest/jest/pytest).
|
|
1789
2219
|
*/
|
|
1790
2220
|
async resolveExec(runner, config, testFile, cwd) {
|
|
2221
|
+
// Run pytest through the project interpreter itself, not a generic `python`
|
|
2222
|
+
// resolved from the host PATH. The child-only environment also keeps tools
|
|
2223
|
+
// spawned by tests inside the same project environment.
|
|
2224
|
+
if (runner === "pytest") {
|
|
2225
|
+
const pythonEnvironment = await detectPythonEnvironment(cwd);
|
|
2226
|
+
if (pythonEnvironment) {
|
|
2227
|
+
return {
|
|
2228
|
+
command: pythonEnvironment.pythonPath,
|
|
2229
|
+
args: config.args(testFile, cwd),
|
|
2230
|
+
env: augmentPythonEnvironment(process.env, pythonEnvironment),
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
1791
2234
|
// PHPUnit has no npx-style automatic local-binary resolution — Composer's
|
|
1792
2235
|
// standard local-install location is vendor/bin/phpunit, so check that
|
|
1793
2236
|
// explicitly before falling back to a global `phpunit` on PATH.
|
|
@@ -1806,12 +2249,18 @@ export class TestRunnerClient {
|
|
|
1806
2249
|
// itself, so the leading wrapper-name arg(s) that named it (e.g. "vitest",
|
|
1807
2250
|
// or "-m pytest") are stripped from args() — see stripWrapperArgs.
|
|
1808
2251
|
if (fs.existsSync(localBin)) {
|
|
1809
|
-
return {
|
|
2252
|
+
return {
|
|
2253
|
+
command: localBin,
|
|
2254
|
+
args: stripWrapperArgs(binName, config.args(testFile, cwd)),
|
|
2255
|
+
};
|
|
1810
2256
|
}
|
|
1811
2257
|
// Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx (#375).
|
|
1812
2258
|
const globalBin = await findGlobalBinary(binName);
|
|
1813
2259
|
if (globalBin) {
|
|
1814
|
-
return {
|
|
2260
|
+
return {
|
|
2261
|
+
command: globalBin,
|
|
2262
|
+
args: stripWrapperArgs(binName, config.args(testFile, cwd)),
|
|
2263
|
+
};
|
|
1815
2264
|
}
|
|
1816
2265
|
return { command: config.command, args: config.args(testFile, cwd) };
|
|
1817
2266
|
}
|
|
@@ -1836,24 +2285,43 @@ export class TestRunnerClient {
|
|
|
1836
2285
|
const lines = stack.split("\n").slice(0, 3);
|
|
1837
2286
|
return lines.join("\n").slice(0, 500);
|
|
1838
2287
|
}
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2288
|
+
recordResult(record) {
|
|
2289
|
+
const { cwd, runner, testFile, result, turnIndex } = record;
|
|
2290
|
+
const targetPath = path.resolve(testFile);
|
|
2291
|
+
const target = canonicalFailedPath(targetPath);
|
|
2292
|
+
let failedTargets = this.getFailedTargets(cwd, runner, result.failed > 0);
|
|
2293
|
+
if (!failedTargets)
|
|
2294
|
+
return;
|
|
1846
2295
|
if (result.failed > 0) {
|
|
1847
|
-
|
|
1848
|
-
|
|
2296
|
+
const alreadyRecorded = failedTargets.has(target);
|
|
2297
|
+
if (!alreadyRecorded &&
|
|
2298
|
+
this.failedTargetCount(runner) >= MAX_FAILED_TARGETS_PER_RUNNER) {
|
|
2299
|
+
const evicted = this.evictOldestFailedTarget(runner);
|
|
2300
|
+
if (evicted) {
|
|
2301
|
+
this.recordFailedTargetState({
|
|
2302
|
+
outcome: "capacity-evicted",
|
|
2303
|
+
runner,
|
|
2304
|
+
candidate: evicted,
|
|
2305
|
+
turnIndex,
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
// Eviction can remove this root's final prior target. Reacquire the
|
|
2309
|
+
// root map before inserting so the newest failure never lands in a
|
|
2310
|
+
// detached PathKeyedMap.
|
|
2311
|
+
failedTargets = this.getFailedTargets(cwd, runner, true);
|
|
2312
|
+
if (!failedTargets)
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
// Sequence is global across roots, so a refreshed target becomes the
|
|
2316
|
+
// newest failure without relying on one root map's insertion order.
|
|
2317
|
+
failedTargets.set(target, {
|
|
2318
|
+
displayPath: targetPath,
|
|
2319
|
+
sequence: ++this.failedTargetSequence,
|
|
2320
|
+
});
|
|
1849
2321
|
return;
|
|
1850
2322
|
}
|
|
1851
|
-
if (
|
|
1852
|
-
|
|
1853
|
-
if (set.size === 0)
|
|
1854
|
-
this.failedTestsByRunner.delete(key);
|
|
1855
|
-
else
|
|
1856
|
-
this.failedTestsByRunner.set(key, set);
|
|
2323
|
+
if (failedTargets.delete(target) && failedTargets.size === 0) {
|
|
2324
|
+
this.deleteFailedRoot(cwd, runner);
|
|
1857
2325
|
}
|
|
1858
2326
|
}
|
|
1859
2327
|
}
|