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
|
@@ -13,12 +13,18 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
13
13
|
import * as fs from "node:fs/promises";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { BoundedLruCache } from "./bounded-cache.js";
|
|
16
|
+
import { createGenerationSource } from "./generation-guard.js";
|
|
16
17
|
import { normalizeMapKey } from "./path-utils.js";
|
|
18
|
+
import { resolveCargoPackageEdition } from "./cargo-manifest.js";
|
|
19
|
+
import { resolveKtfmtGradleStyle } from "./gradle-ktfmt-style.js";
|
|
20
|
+
import { resolvePhpCsFixerConfig } from "./php-cs-fixer-config.js";
|
|
17
21
|
import { TERRAGRUNT_FILENAMES } from "./file-kinds.js";
|
|
22
|
+
import { stripAnsi } from "./sanitize.js";
|
|
18
23
|
import { detectIndentation, hasDetectableIndentation, } from "./dispatch/indent-detect.js";
|
|
19
24
|
import { logLatency } from "./latency-logger.js";
|
|
25
|
+
import { compareOrdinal } from "./string-utils.js";
|
|
20
26
|
import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
|
|
21
|
-
import { findGlobalBinary, findLocalBinUpwards } from "./package-manager.js";
|
|
27
|
+
import { findGlobalBinary, findLocalBinUpwards, VENDOR_BIN_DIRS, VENV_BIN_DIRS, } from "./package-manager.js";
|
|
22
28
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
23
29
|
import { assertInstallAllowed } from "./project-trust.js";
|
|
24
30
|
import { tryLazyInstallForFormatter } from "./dispatch/runners/utils/lazy-installer.js";
|
|
@@ -48,6 +54,19 @@ export async function tryLazyInstallFormatterTool(tool, cwd) {
|
|
|
48
54
|
* stock command there is exactly the stock-style imposition the fix bans.
|
|
49
55
|
*/
|
|
50
56
|
export const SKIP_FORMATTING = "skip-formatting";
|
|
57
|
+
/**
|
|
58
|
+
* Sentinel a `resolveCommand` returns when it has PROVEN the selected
|
|
59
|
+
* formatter has no executable — the binary is absent from every place the
|
|
60
|
+
* resolver probed (node_modules/.bin, a venv, a managed install dir, PATH) and
|
|
61
|
+
* no alternative resolved (#2413). Distinct from `null` ("no override, fall
|
|
62
|
+
* back to the static `command`") and from SKIP_FORMATTING ("style-preserving
|
|
63
|
+
* refusal"): the static fallback for these formatters is the SAME bare binary
|
|
64
|
+
* the resolver just proved missing, so spawning it only re-triggers the ENOENT
|
|
65
|
+
* the resolver already observed — and reporting that as a formatting failure is
|
|
66
|
+
* the exact defect this fixes (recurring defect shape 10: an unavailable
|
|
67
|
+
* producer collapsed into an ordinary failed result).
|
|
68
|
+
*/
|
|
69
|
+
export const FORMATTER_UNAVAILABLE = "formatter-unavailable";
|
|
51
70
|
// --- Utility Functions ---
|
|
52
71
|
async function fileExists(filePath) {
|
|
53
72
|
try {
|
|
@@ -62,12 +81,47 @@ async function findUp(targets, startDir, stopDir = path.parse(startDir).root) {
|
|
|
62
81
|
const found = [];
|
|
63
82
|
let currentDir = startDir;
|
|
64
83
|
while (currentDir !== stopDir) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
// One `readdir` per directory instead of one `access` per target: the
|
|
85
|
+
// per-target probe made this loop's cost O(targets), so a long target
|
|
86
|
+
// list (e.g. `FORMATTER_CONFIG_FILES`) paid for every entry at every
|
|
87
|
+
// ancestor directory even when almost none of them exist (#1603).
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = await fs.readdir(currentDir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
entries = [];
|
|
94
|
+
}
|
|
95
|
+
if (entries.length > 0) {
|
|
96
|
+
// On default-case-insensitive platforms the old `fs.access` probe
|
|
97
|
+
// matched a config file regardless of on-disk casing; fold so the
|
|
98
|
+
// readdir membership check keeps that behavior.
|
|
99
|
+
const foldCase = process.platform === "win32" || process.platform === "darwin";
|
|
100
|
+
const entrySet = new Set(foldCase ? entries.map((e) => e.toLowerCase()) : entries);
|
|
101
|
+
for (const target of targets) {
|
|
102
|
+
// Nested candidates (for example node_modules/.bin/biome) do
|
|
103
|
+
// not appear as direct readdir members. They retain the old
|
|
104
|
+
// bounded candidate probe; direct names use the O(depth + matches)
|
|
105
|
+
// directory-membership path above.
|
|
106
|
+
if (target.includes("/") || target.includes("\\"))
|
|
107
|
+
continue;
|
|
108
|
+
if (entrySet.has(foldCase ? target.toLowerCase() : target)) {
|
|
109
|
+
const candidate = path.join(currentDir, target);
|
|
110
|
+
// Directory membership is only a cheap candidate filter. Keep the
|
|
111
|
+
// old access check for matched entries so dangling links and entries
|
|
112
|
+
// that cannot be read do not become config evidence (#1603 R2).
|
|
113
|
+
if (await fileExists(candidate))
|
|
114
|
+
found.push(candidate);
|
|
115
|
+
}
|
|
69
116
|
}
|
|
70
117
|
}
|
|
118
|
+
for (const target of targets) {
|
|
119
|
+
if (!target.includes("/") && !target.includes("\\"))
|
|
120
|
+
continue;
|
|
121
|
+
const candidate = path.join(currentDir, target);
|
|
122
|
+
if (await fileExists(candidate))
|
|
123
|
+
found.push(candidate);
|
|
124
|
+
}
|
|
71
125
|
const parent = path.dirname(currentDir);
|
|
72
126
|
if (parent === currentDir)
|
|
73
127
|
break;
|
|
@@ -75,6 +129,10 @@ async function findUp(targets, startDir, stopDir = path.parse(startDir).root) {
|
|
|
75
129
|
}
|
|
76
130
|
return found;
|
|
77
131
|
}
|
|
132
|
+
/** Test-only access to the real filesystem walker and its candidate filter. */
|
|
133
|
+
export function _findUpForTests(targets, startDir, stopDir) {
|
|
134
|
+
return findUp(targets, startDir, stopDir);
|
|
135
|
+
}
|
|
78
136
|
const WHICH_BUDGET_MS = 5000;
|
|
79
137
|
/**
|
|
80
138
|
* PATH lookups, governed by the shared availability policy (#1495).
|
|
@@ -224,6 +282,9 @@ function noteCooldownServedVerdict(command, latch) {
|
|
|
224
282
|
retryAfterMs: Math.max(1, retryAtMs - Date.now()),
|
|
225
283
|
budgetMs: WHICH_BUDGET_MS,
|
|
226
284
|
servedFromCooldown: true,
|
|
285
|
+
// No probe ran here: the latch's own remembered cause is replayed
|
|
286
|
+
// as-is, so the call site is the one asserting it (#2209).
|
|
287
|
+
classifiedBy: "caller",
|
|
227
288
|
});
|
|
228
289
|
}
|
|
229
290
|
async function which(command) {
|
|
@@ -261,6 +322,7 @@ async function which(command) {
|
|
|
261
322
|
latched: true,
|
|
262
323
|
hostStallMs,
|
|
263
324
|
budgetMs: WHICH_BUDGET_MS,
|
|
325
|
+
classifiedBy: "probe",
|
|
264
326
|
});
|
|
265
327
|
return resolved;
|
|
266
328
|
}
|
|
@@ -281,9 +343,15 @@ async function which(command) {
|
|
|
281
343
|
...(proberRan && { unclassifiedFailureOutcome: "missing" }),
|
|
282
344
|
});
|
|
283
345
|
let { outcome, cause } = classified;
|
|
346
|
+
// This call site OVERRIDES classifyProbeFailure's own answer below, so a
|
|
347
|
+
// row it forced is a caller assertion, not a probe classification — the
|
|
348
|
+
// scrutiny availability-policy.ts:253-258 reserves for "caller" rows must
|
|
349
|
+
// not be laundered away by mislabeling this one "probe" (#2226 review F1).
|
|
350
|
+
let outcomeForced = false;
|
|
284
351
|
if (!proberRan && outcome !== "transient") {
|
|
285
352
|
outcome = "transient";
|
|
286
353
|
cause = "probe-rejected";
|
|
354
|
+
outcomeForced = true;
|
|
287
355
|
}
|
|
288
356
|
const retryAfterMs = entry.latch.noteUnavailable(outcome, cause);
|
|
289
357
|
if (outcome === "transient")
|
|
@@ -303,6 +371,7 @@ async function which(command) {
|
|
|
303
371
|
hostStallMs,
|
|
304
372
|
...(retryAfterMs > 0 && { retryAfterMs }),
|
|
305
373
|
budgetMs: WHICH_BUDGET_MS,
|
|
374
|
+
classifiedBy: outcomeForced ? "caller" : "probe",
|
|
306
375
|
});
|
|
307
376
|
return null;
|
|
308
377
|
}
|
|
@@ -325,79 +394,58 @@ async function resolveGoFmtBinary() {
|
|
|
325
394
|
/**
|
|
326
395
|
* Walk up from cwd looking for a binary in .venv or venv.
|
|
327
396
|
* Returns the absolute path if found, null otherwise.
|
|
397
|
+
*
|
|
398
|
+
* Tool-resolution walker, not a config lookup (#2514/#2517 policy): escaping
|
|
399
|
+
* the project upward past HOME means STOP, not keep reading — a `.venv`/`venv`
|
|
400
|
+
* bin found at or above HOME can never be THIS project's own virtualenv. The
|
|
401
|
+
* ceiling lives in `findLocalBinUpwards` and is default-on there; the tests
|
|
402
|
+
* that pin a fake HOME drive that walker directly rather than threading a
|
|
403
|
+
* `homeDir` no production caller ever passed (#2544 review F3).
|
|
404
|
+
*
|
|
405
|
+
* On Windows the candidate order is EXT-OUTER, DIR-INNER — `.venv/Scripts/x.exe`,
|
|
406
|
+
* `venv/Scripts/x.exe`, then the bare names — which is what the pre-fold copy
|
|
407
|
+
* did and what `findLocalBinUpwards` preserves.
|
|
328
408
|
*/
|
|
329
409
|
async function findInVenv(binary, cwd) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`venv/Scripts/${binary}.exe`,
|
|
335
|
-
`.venv/Scripts/${binary}`,
|
|
336
|
-
`venv/Scripts/${binary}`,
|
|
337
|
-
]
|
|
338
|
-
: [`.venv/bin/${binary}`, `venv/bin/${binary}`];
|
|
339
|
-
let dir = cwd;
|
|
340
|
-
const root = path.parse(dir).root;
|
|
341
|
-
while (dir !== root) {
|
|
342
|
-
for (const candidate of candidates) {
|
|
343
|
-
const full = path.join(dir, candidate);
|
|
344
|
-
if (await fileExists(full))
|
|
345
|
-
return full;
|
|
346
|
-
}
|
|
347
|
-
const parent = path.dirname(dir);
|
|
348
|
-
if (parent === dir)
|
|
349
|
-
break;
|
|
350
|
-
dir = parent;
|
|
351
|
-
}
|
|
352
|
-
return null;
|
|
410
|
+
return (findLocalBinUpwards(binary, cwd, {
|
|
411
|
+
windowsExt: ".exe",
|
|
412
|
+
binDirs: VENV_BIN_DIRS,
|
|
413
|
+
}) ?? null);
|
|
353
414
|
}
|
|
354
415
|
/**
|
|
355
416
|
* Check vendor/bin for PHP Composer-managed tools.
|
|
356
417
|
* Walks up from cwd to find vendor/bin/<binary>.
|
|
418
|
+
*
|
|
419
|
+
* Tool-resolution walker, not a config lookup (#2514/#2517 policy): escaping
|
|
420
|
+
* the project upward past HOME means STOP, not keep reading — a `vendor/bin`
|
|
421
|
+
* match found at or above HOME can never be THIS project's own Composer
|
|
422
|
+
* install. Same single walker as `findInVenv` above; only `binDirs` and the
|
|
423
|
+
* Windows extension differ.
|
|
357
424
|
*/
|
|
358
425
|
async function findInVendorBin(binary, cwd) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
while (dir !== root) {
|
|
364
|
-
for (const name of names) {
|
|
365
|
-
const full = path.join(dir, "vendor", "bin", name);
|
|
366
|
-
if (await fileExists(full))
|
|
367
|
-
return full;
|
|
368
|
-
}
|
|
369
|
-
const parent = path.dirname(dir);
|
|
370
|
-
if (parent === dir)
|
|
371
|
-
break;
|
|
372
|
-
dir = parent;
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
426
|
+
return (findLocalBinUpwards(binary, cwd, {
|
|
427
|
+
windowsExt: ".bat",
|
|
428
|
+
binDirs: VENDOR_BIN_DIRS,
|
|
429
|
+
}) ?? null);
|
|
375
430
|
}
|
|
376
431
|
/**
|
|
377
432
|
* Check node_modules/.bin for locally installed Node tools.
|
|
378
433
|
* Walks up from cwd to find node_modules/.bin/<binary>.
|
|
434
|
+
*
|
|
435
|
+
* #2514: this WAS a private, byte-for-byte duplicate of
|
|
436
|
+
* `findLocalBinUpwards` (`package-manager.ts`) — the same walk `stylua`
|
|
437
|
+
* below, `taplo.ts`, `knip-client.ts`, and (via `findNodeToolBinary`)
|
|
438
|
+
* `dependency-checker.ts`/`jscpd-client.ts` already reuse. A stray
|
|
439
|
+
* `~/node_modules/.bin/oxfmt.cmd` (the home-level pi-extensions manifest
|
|
440
|
+
* installs its own bins) was picked up as the project's formatter because
|
|
441
|
+
* this copy had no HOME ceiling; fixing only this copy would have left every
|
|
442
|
+
* other caller of `findLocalBinUpwards` with the same defect. Delegating
|
|
443
|
+
* here instead of hand-rolling a second ceiling keeps there being exactly one
|
|
444
|
+
* `node_modules/.bin` walker — see that function for the tool-resolution
|
|
445
|
+
* "escaping the project means STOP" policy (#2517).
|
|
379
446
|
*/
|
|
380
447
|
async function findInNodeModules(binary, cwd) {
|
|
381
|
-
|
|
382
|
-
let dir = cwd;
|
|
383
|
-
const root = path.parse(dir).root;
|
|
384
|
-
while (dir !== root) {
|
|
385
|
-
const candidates = isWin
|
|
386
|
-
? [
|
|
387
|
-
path.join(dir, "node_modules", ".bin", `${binary}.cmd`),
|
|
388
|
-
path.join(dir, "node_modules", ".bin", binary),
|
|
389
|
-
]
|
|
390
|
-
: [path.join(dir, "node_modules", ".bin", binary)];
|
|
391
|
-
for (const full of candidates) {
|
|
392
|
-
if (await fileExists(full))
|
|
393
|
-
return full;
|
|
394
|
-
}
|
|
395
|
-
const parent = path.dirname(dir);
|
|
396
|
-
if (parent === dir)
|
|
397
|
-
break;
|
|
398
|
-
dir = parent;
|
|
399
|
-
}
|
|
400
|
-
return null;
|
|
448
|
+
return findLocalBinUpwards(binary, cwd) ?? null;
|
|
401
449
|
}
|
|
402
450
|
/**
|
|
403
451
|
* Returns true if `bundle exec <gem>` should be used:
|
|
@@ -735,7 +783,11 @@ export const oxfmtFormatter = {
|
|
|
735
783
|
const found = await which("oxfmt");
|
|
736
784
|
if (found)
|
|
737
785
|
return [found, OXFMT_NO_ERROR_ON_UNMATCHED, filePath];
|
|
738
|
-
|
|
786
|
+
// #2413: neither node_modules/.bin nor PATH has oxfmt, and `detect()` is
|
|
787
|
+
// config-only (it never probes the binary), so selection can reach here
|
|
788
|
+
// with nothing installed. The static command is bare `oxfmt` — spawning it
|
|
789
|
+
// only reproduces the reported `spawn oxfmt ENOENT`. Prove it unavailable.
|
|
790
|
+
return FORMATTER_UNAVAILABLE;
|
|
739
791
|
},
|
|
740
792
|
// Single source of truth: OXFMT_SUPPORTED_EXTENSIONS in tool-policy.ts.
|
|
741
793
|
// Do not hand-maintain a second copy of this list (#1134 — previously two
|
|
@@ -772,7 +824,12 @@ export const ruffFormatter = {
|
|
|
772
824
|
const installed = await ensureTool(toolId);
|
|
773
825
|
if (installed)
|
|
774
826
|
return [installed, ...args, filePath];
|
|
775
|
-
|
|
827
|
+
// #2413: `ensureTool` already resolves PATH, global bins and the managed
|
|
828
|
+
// dir (and attempts an install) before returning falsy, so reaching here
|
|
829
|
+
// means ruff is genuinely nowhere. `detect()`'s `hasRuffConfig` branch has
|
|
830
|
+
// no binary check, so a config-only project selects ruff with nothing
|
|
831
|
+
// installed; the static bare `ruff` would then ENOENT. Prove it unavailable.
|
|
832
|
+
return FORMATTER_UNAVAILABLE;
|
|
776
833
|
},
|
|
777
834
|
async detect(cwd) {
|
|
778
835
|
if (hasRuffConfig(cwd))
|
|
@@ -835,6 +892,18 @@ export const rustfmtFormatter = {
|
|
|
835
892
|
name: "rustfmt",
|
|
836
893
|
command: ["rustfmt", "$FILE"],
|
|
837
894
|
extensions: [".rs"],
|
|
895
|
+
// #2466: rustfmt defaults to an OLDER edition than the file's actual Cargo
|
|
896
|
+
// package when invoked bare, so it can reject valid newer-edition syntax
|
|
897
|
+
// (e.g. Rust 2024). Carry the nearest package's `edition` (honoring
|
|
898
|
+
// `edition.workspace = true` inheritance) through `--edition`; `undefined`
|
|
899
|
+
// (unreadable/unparseable manifest) falls back to the static command
|
|
900
|
+
// above, unchanged from pre-#2466 behavior.
|
|
901
|
+
async resolveCommand(filePath, _cwd) {
|
|
902
|
+
const edition = await resolveCargoPackageEdition(filePath);
|
|
903
|
+
if (edition === undefined)
|
|
904
|
+
return null;
|
|
905
|
+
return ["rustfmt", "--edition", edition, filePath];
|
|
906
|
+
},
|
|
838
907
|
async detect(cwd) {
|
|
839
908
|
if ((await which("rustfmt")) !== null)
|
|
840
909
|
return true;
|
|
@@ -959,13 +1028,27 @@ export const ktfmtFormatter = {
|
|
|
959
1028
|
// ktfmt formats in place when given a file path (no flag needed).
|
|
960
1029
|
command: ["ktfmt", "$FILE"],
|
|
961
1030
|
extensions: [".kt", ".kts"],
|
|
1031
|
+
// #2468: ktfmt's CLI never reads a project's Gradle `ktfmt { googleStyle()
|
|
1032
|
+
// | kotlinLangStyle() }` selection — style is CLI-flag-only
|
|
1033
|
+
// (`--google-style`/`--kotlinlang-style`, verified against ktfmt v0.63's
|
|
1034
|
+
// own arg parser). Carry the nearest module's declared style through;
|
|
1035
|
+
// `undefined` (no declaration, unreadable/unparseable manifest, or an
|
|
1036
|
+
// unsupported style like the removed `dropboxStyle()`) falls back to the
|
|
1037
|
+
// bare invocation, unchanged from pre-#2468 behavior.
|
|
962
1038
|
async resolveCommand(filePath, _cwd) {
|
|
1039
|
+
const styleFlag = await resolveKtfmtGradleStyle(filePath);
|
|
963
1040
|
const inPath = await which("ktfmt");
|
|
964
1041
|
if (inPath)
|
|
965
|
-
return [inPath, filePath];
|
|
1042
|
+
return styleFlag ? [inPath, styleFlag, filePath] : [inPath, filePath];
|
|
966
1043
|
const { ensureTool } = await import("./installer/index.js");
|
|
967
1044
|
const installed = await ensureTool("ktfmt");
|
|
968
|
-
|
|
1045
|
+
// #2413: which() and ensureTool (PATH/global/managed + install) both
|
|
1046
|
+
// failed, and `detect()` gates only on hasKtfmtConfig — no binary probe —
|
|
1047
|
+
// so a config-only project reaches here with ktfmt absent. The static
|
|
1048
|
+
// command is bare `ktfmt`; spawning it reproduces the ENOENT class.
|
|
1049
|
+
if (!installed)
|
|
1050
|
+
return FORMATTER_UNAVAILABLE;
|
|
1051
|
+
return styleFlag ? [installed, styleFlag, filePath] : [installed, filePath];
|
|
969
1052
|
},
|
|
970
1053
|
async detect(cwd) {
|
|
971
1054
|
// Opt-in only: ktfmt becomes the formatter when the project elects it,
|
|
@@ -1051,18 +1134,49 @@ export const phpCsFixerFormatter = {
|
|
|
1051
1134
|
name: "php-cs-fixer",
|
|
1052
1135
|
command: ["php-cs-fixer", "fix", "$FILE"],
|
|
1053
1136
|
extensions: [".php"],
|
|
1137
|
+
// #2472: php-cs-fixer does NOT walk up parent directories looking for its
|
|
1138
|
+
// own config the way prettier/biome/eslint do (verified against upstream
|
|
1139
|
+
// `computeConfigFiles()` — see `resolvePhpCsFixerConfig`'s doc comment),
|
|
1140
|
+
// and `formatFile` spawns with cwd = the FILE's own directory, which is
|
|
1141
|
+
// not necessarily where the ancestor config `detect()` found actually
|
|
1142
|
+
// lives. Always resolve the binary explicitly here (vendor/bin first,
|
|
1143
|
+
// then global) rather than falling through to the static `command` above
|
|
1144
|
+
// — that static command can never carry `--config`, so a config found at
|
|
1145
|
+
// an ancestor would silently be dropped whenever the vendor lookup here
|
|
1146
|
+
// missed but a global binary still resolved. `--config` is attached
|
|
1147
|
+
// whenever a config resolves, even when it sits in the file's own
|
|
1148
|
+
// directory (AC3): unlike the pre-#2472 code, correctness no longer
|
|
1149
|
+
// depends on php-cs-fixer's own (nonexistent) upward search.
|
|
1054
1150
|
async resolveCommand(filePath, cwd) {
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1151
|
+
const configPath = resolvePhpCsFixerConfig(filePath);
|
|
1152
|
+
const binary = (await findInVendorBin("php-cs-fixer", cwd)) ??
|
|
1153
|
+
(await which("php-cs-fixer"));
|
|
1154
|
+
// #2413/#2472 review F4: both probes (vendor/bin, then PATH) have
|
|
1155
|
+
// PROVEN the binary is absent — returning `null` here would fall back
|
|
1156
|
+
// to the static `command` above, which is the SAME bare
|
|
1157
|
+
// `php-cs-fixer` this just failed to find, spawning it only to
|
|
1158
|
+
// re-observe the ENOENT already known. Report the proven-missing
|
|
1159
|
+
// state instead so `formatFile` skips the wasted spawn.
|
|
1160
|
+
if (!binary)
|
|
1161
|
+
return FORMATTER_UNAVAILABLE;
|
|
1162
|
+
return configPath
|
|
1163
|
+
? [binary, "fix", "--config", configPath, filePath]
|
|
1164
|
+
: [binary, "fix", filePath];
|
|
1059
1165
|
},
|
|
1060
1166
|
async detect(cwd) {
|
|
1061
1167
|
const vendorBin = await findInVendorBin("php-cs-fixer", cwd);
|
|
1062
1168
|
const globalBin = await which("php-cs-fixer");
|
|
1063
1169
|
if (!vendorBin && !globalBin)
|
|
1064
1170
|
return false;
|
|
1065
|
-
// Only run if project has explicit config
|
|
1171
|
+
// Only run if project has explicit config. This is a presence-only
|
|
1172
|
+
// climb from the project `cwd` (not necessarily the formatted file's
|
|
1173
|
+
// own directory) via this file's own `findUp` — deliberately NOT
|
|
1174
|
+
// merged with `resolvePhpCsFixerConfig` above (#2472 AC4): that
|
|
1175
|
+
// resolver climbs from the FILE's directory and needs the exact
|
|
1176
|
+
// winning path for `--config`, while this only needs a yes/no answer
|
|
1177
|
+
// from whatever `cwd` the caller passed. `rustfmtFormatter.detect`
|
|
1178
|
+
// keeps the same non-merged shape against `resolveCargoPackageEdition`
|
|
1179
|
+
// for the identical reason.
|
|
1066
1180
|
const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"];
|
|
1067
1181
|
const found = await findUp(configs, cwd);
|
|
1068
1182
|
return found.length > 0;
|
|
@@ -1321,9 +1435,15 @@ export const ALL_FORMATTERS = [
|
|
|
1321
1435
|
const FILENAME_FORMATTER_BASENAMES = new Set(ALL_FORMATTERS.flatMap((f) => f.filenames ?? []));
|
|
1322
1436
|
// Cache for detection results - stores array of enabled formatter names per cwd+ext
|
|
1323
1437
|
const detectionCache = new BoundedLruCache(32);
|
|
1438
|
+
// The signature is immutable for a cache generation. This memo is separate
|
|
1439
|
+
// from detectionCache because a cwd can have several extension entries, and a
|
|
1440
|
+
// warm lookup must not repeat the ancestor walk or stat matched configs.
|
|
1441
|
+
const formatterSignatureFlights = new Map();
|
|
1442
|
+
const formatterCacheGeneration = createGenerationSource("formatter-cache");
|
|
1324
1443
|
// These are the formatter configuration files consulted by the policy helpers
|
|
1325
|
-
// above. Their metadata is
|
|
1326
|
-
//
|
|
1444
|
+
// above. Their metadata is captured by the cold detection signature. The
|
|
1445
|
+
// write-result seam invalidates that signature when a config path changes, so
|
|
1446
|
+
// detection re-runs even when PATH and installed tools are unchanged. A
|
|
1327
1447
|
// filename a `has*Config` check reads but this list omits is invisible to the
|
|
1328
1448
|
// cache: the signature never moves when that file is added, so a project that
|
|
1329
1449
|
// opts in AFTER the first `getFormattersForFile` call for its cwd keeps
|
|
@@ -1413,9 +1533,7 @@ const FORMATTER_CONFIG_FILES = [
|
|
|
1413
1533
|
];
|
|
1414
1534
|
async function formatterConfigSignature(cwd) {
|
|
1415
1535
|
const paths = await findUp(FORMATTER_CONFIG_FILES, cwd);
|
|
1416
|
-
const parts = await Promise.all(paths
|
|
1417
|
-
.sort((a, b) => a.localeCompare(b))
|
|
1418
|
-
.map(async (filePath) => {
|
|
1536
|
+
const parts = await Promise.all(paths.sort(compareOrdinal).map(async (filePath) => {
|
|
1419
1537
|
try {
|
|
1420
1538
|
const stat = await fs.stat(filePath);
|
|
1421
1539
|
return `${filePath}:${stat.mtimeMs}:${stat.size}`;
|
|
@@ -1426,6 +1544,18 @@ async function formatterConfigSignature(cwd) {
|
|
|
1426
1544
|
}));
|
|
1427
1545
|
return parts.join("|");
|
|
1428
1546
|
}
|
|
1547
|
+
async function getFormatterConfigSignature(cwd, normalizedCwd) {
|
|
1548
|
+
const existing = formatterSignatureFlights.get(normalizedCwd);
|
|
1549
|
+
if (existing)
|
|
1550
|
+
return existing.promise;
|
|
1551
|
+
const promise = formatterConfigSignature(cwd).finally(() => {
|
|
1552
|
+
const current = formatterSignatureFlights.get(normalizedCwd);
|
|
1553
|
+
if (current?.promise === promise)
|
|
1554
|
+
formatterSignatureFlights.delete(normalizedCwd);
|
|
1555
|
+
});
|
|
1556
|
+
formatterSignatureFlights.set(normalizedCwd, { promise });
|
|
1557
|
+
return promise;
|
|
1558
|
+
}
|
|
1429
1559
|
// --- Public API ---
|
|
1430
1560
|
export async function getFormattersForFile(filePath, cwd) {
|
|
1431
1561
|
const ext = path.extname(filePath).toLowerCase();
|
|
@@ -1439,31 +1569,50 @@ export async function getFormattersForFile(filePath, cwd) {
|
|
|
1439
1569
|
const cacheKey = FILENAME_FORMATTER_BASENAMES.has(base)
|
|
1440
1570
|
? `${normalizedCwd}:${ext}:${base}`
|
|
1441
1571
|
: `${normalizedCwd}:${ext}`;
|
|
1442
|
-
|
|
1443
|
-
//
|
|
1572
|
+
// A warm entry is authoritative until the write-result seam invalidates its
|
|
1573
|
+
// config path. This is the only way to make the warm path free of config
|
|
1574
|
+
// polling while still reacting immediately to pi-authored create/remove/
|
|
1575
|
+
// change events. External editor changes remain a documented session-boundary
|
|
1576
|
+
// limitation, like other write-result-owned freshness seams.
|
|
1444
1577
|
let cached = detectionCache.get(normalizedCwd);
|
|
1445
|
-
if (cached
|
|
1446
|
-
// An EXISTING entry whose signature moved is the user telling us the world
|
|
1447
|
-
// changed. Re-probe PATH too, so "install the tool, touch the config" still
|
|
1448
|
-
// works within one session now that a durable absence latches (#1495).
|
|
1449
|
-
// Scoped to a real change on purpose: keying this off "no entry yet" would
|
|
1450
|
-
// drop every PATH verdict on the first save in each new directory, which in
|
|
1451
|
-
// a monorepo means re-probing forever.
|
|
1452
|
-
detectionCache.delete(normalizedCwd);
|
|
1453
|
-
cached = undefined;
|
|
1454
|
-
resetWhichLatches();
|
|
1455
|
-
}
|
|
1456
|
-
if (!cached) {
|
|
1457
|
-
cached = { signature: configSignature, entries: new Map() };
|
|
1458
|
-
detectionCache.set(normalizedCwd, cached);
|
|
1459
|
-
}
|
|
1460
|
-
if (cached.entries.has(cacheKey)) {
|
|
1578
|
+
if (cached?.entries.has(cacheKey)) {
|
|
1461
1579
|
const enabledNames = cached.entries.get(cacheKey);
|
|
1580
|
+
const selectedFormatterName = enabledNames && enabledNames.length > 0 ? enabledNames[0] : null;
|
|
1581
|
+
logLatency({
|
|
1582
|
+
type: "phase",
|
|
1583
|
+
phase: "formatter_selected",
|
|
1584
|
+
filePath,
|
|
1585
|
+
durationMs: 0,
|
|
1586
|
+
metadata: {
|
|
1587
|
+
formatter: selectedFormatterName,
|
|
1588
|
+
reason: "cache",
|
|
1589
|
+
cached: true,
|
|
1590
|
+
outcome: "hit",
|
|
1591
|
+
cwd,
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1462
1594
|
if (!enabledNames || enabledNames.length === 0)
|
|
1463
1595
|
return [];
|
|
1464
|
-
// Return cached formatters by name (preserves priority order)
|
|
1465
1596
|
return ALL_FORMATTERS.filter((f) => enabledNames.includes(f.name));
|
|
1466
1597
|
}
|
|
1598
|
+
if (!cached) {
|
|
1599
|
+
const generation = formatterCacheGeneration.capture();
|
|
1600
|
+
const configSignature = await getFormatterConfigSignature(cwd, normalizedCwd);
|
|
1601
|
+
// An invalidation can happen while the first signature walk is in flight.
|
|
1602
|
+
// Do not publish a pre-invalidation signature into the new generation.
|
|
1603
|
+
if (!generation.isCurrent()) {
|
|
1604
|
+
return getFormattersForFile(filePath, cwd);
|
|
1605
|
+
}
|
|
1606
|
+
// Another cold caller for this cwd can finish the shared signature flight
|
|
1607
|
+
// first. Re-read the LRU before installing an object so the later caller
|
|
1608
|
+
// merges its extension entry into the existing object instead of replacing
|
|
1609
|
+
// the earlier entry.
|
|
1610
|
+
cached = detectionCache.get(normalizedCwd);
|
|
1611
|
+
if (!cached) {
|
|
1612
|
+
cached = { signature: configSignature, entries: new Map() };
|
|
1613
|
+
detectionCache.set(normalizedCwd, cached);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1467
1616
|
// Detect formatters for this extension (or exact filename, e.g. terragrunt.hcl)
|
|
1468
1617
|
const matching = ALL_FORMATTERS.filter((f) => f.extensions.includes(ext) || f.filenames?.includes(base));
|
|
1469
1618
|
const formatterPolicy = getFormatterPolicyForFile(filePath);
|
|
@@ -1601,6 +1750,7 @@ export async function getFormattersForFile(filePath, cwd) {
|
|
|
1601
1750
|
metadata: {
|
|
1602
1751
|
formatter: selected?.name ?? null,
|
|
1603
1752
|
reason: selectionReason,
|
|
1753
|
+
outcome: "miss",
|
|
1604
1754
|
cwd,
|
|
1605
1755
|
...(provisional && {
|
|
1606
1756
|
cached: false,
|
|
@@ -1616,7 +1766,11 @@ export async function getFormattersForFile(filePath, cwd) {
|
|
|
1616
1766
|
return enabled;
|
|
1617
1767
|
// Store the list of enabled formatter names in cache
|
|
1618
1768
|
const enabledNames = enabled.map((f) => f.name);
|
|
1619
|
-
|
|
1769
|
+
// An invalidation may have removed or replaced this object while detection
|
|
1770
|
+
// awaited a tool probe. Never repopulate the old generation.
|
|
1771
|
+
if (detectionCache.get(normalizedCwd) === cached) {
|
|
1772
|
+
cached.entries.set(cacheKey, enabledNames);
|
|
1773
|
+
}
|
|
1620
1774
|
return enabled;
|
|
1621
1775
|
}
|
|
1622
1776
|
/**
|
|
@@ -1630,9 +1784,21 @@ export async function getFormattersForFile(filePath, cwd) {
|
|
|
1630
1784
|
* therefore re-arms every directory EXCEPT the one the user is working in.
|
|
1631
1785
|
*/
|
|
1632
1786
|
export function clearFormatterCache() {
|
|
1787
|
+
formatterCacheGeneration.bump();
|
|
1788
|
+
formatterSignatureFlights.clear();
|
|
1633
1789
|
detectionCache.clear();
|
|
1634
1790
|
resetWhichLatches();
|
|
1635
1791
|
}
|
|
1792
|
+
const formatterConfigBasenames = new Set(FORMATTER_CONFIG_FILES.map((fileName) => fileName.toLowerCase()));
|
|
1793
|
+
/** Invalidate selection when the write-result seam reports a config path. */
|
|
1794
|
+
export function invalidateFormatterCacheForPath(filePath) {
|
|
1795
|
+
const basename = filePath.includes("\\")
|
|
1796
|
+
? path.win32.basename(filePath)
|
|
1797
|
+
: path.basename(filePath);
|
|
1798
|
+
if (!formatterConfigBasenames.has(basename.toLowerCase()))
|
|
1799
|
+
return;
|
|
1800
|
+
clearFormatterCache();
|
|
1801
|
+
}
|
|
1636
1802
|
/**
|
|
1637
1803
|
* Test-only internals access for the session-state registry probe (#1895):
|
|
1638
1804
|
* the conformance suite must arm all four pieces of state this reset claims
|
|
@@ -1647,16 +1813,13 @@ export function _getFormatterResetStateForTests() {
|
|
|
1647
1813
|
};
|
|
1648
1814
|
}
|
|
1649
1815
|
export function clearFormatterRuntimeState() {
|
|
1650
|
-
|
|
1651
|
-
resetWhichLatches();
|
|
1816
|
+
clearFormatterCache();
|
|
1652
1817
|
// NO `resetLazyInstallAttempts()` here (#1537 review F1). This function runs
|
|
1653
1818
|
// from `resetFormatService()`, which `handleTurnEnd` calls every turn — so
|
|
1654
1819
|
// clearing the lazy-install hold here made "held for the session" mean "held
|
|
1655
1820
|
// for a turn", and a failing install re-ran every turn. The hold's only reset
|
|
1656
1821
|
// is `session_start`'s block in runtime-session.ts.
|
|
1657
1822
|
}
|
|
1658
|
-
// ESC is built via fromCharCode so no raw control byte sits in the source.
|
|
1659
|
-
const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
|
1660
1823
|
const BOX_DRAWING_GLOBAL = /[\u2500-\u257F]/g;
|
|
1661
1824
|
const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
|
|
1662
1825
|
/**
|
|
@@ -1673,7 +1836,7 @@ const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
|
|
|
1673
1836
|
*/
|
|
1674
1837
|
export function firstDiagnosticLine(text) {
|
|
1675
1838
|
for (const raw of (text ?? "").split("\n")) {
|
|
1676
|
-
const line = raw
|
|
1839
|
+
const line = stripAnsi(raw).trimEnd();
|
|
1677
1840
|
const stripped = line.replace(BOX_DRAWING_GLOBAL, "").trim();
|
|
1678
1841
|
if (!stripped)
|
|
1679
1842
|
continue;
|
|
@@ -1699,6 +1862,12 @@ async function resolveFormatterCommand(formatter, absolutePath, cwd) {
|
|
|
1699
1862
|
: null;
|
|
1700
1863
|
if (resolved === SKIP_FORMATTING)
|
|
1701
1864
|
return SKIP_FORMATTING;
|
|
1865
|
+
// The resolver proved the executable absent — DO NOT fall through to the
|
|
1866
|
+
// static bare command, which is the same binary it just probed and missing
|
|
1867
|
+
// (#2413). The caller turns this into a typed `unavailable` outcome, never a
|
|
1868
|
+
// spawn and never a failure.
|
|
1869
|
+
if (resolved === FORMATTER_UNAVAILABLE)
|
|
1870
|
+
return FORMATTER_UNAVAILABLE;
|
|
1702
1871
|
if (resolved !== null)
|
|
1703
1872
|
return resolved;
|
|
1704
1873
|
const fallback = formatter.command.map((c) => c.replace("$FILE", absolutePath));
|
|
@@ -1726,13 +1895,40 @@ export async function formatFile(filePath, formatter) {
|
|
|
1726
1895
|
if (cmd === SKIP_FORMATTING) {
|
|
1727
1896
|
// Style-preserving refusal (#1144): no repo config and no detectable
|
|
1728
1897
|
// indentation to pin — formatting would impose the tool's stock style.
|
|
1729
|
-
return { success: true, changed: false };
|
|
1898
|
+
return { success: true, changed: false, outcome: "skipped" };
|
|
1899
|
+
}
|
|
1900
|
+
if (cmd === FORMATTER_UNAVAILABLE) {
|
|
1901
|
+
// The resolver proved the executable absent (#2413): the oxfmt ENOENT
|
|
1902
|
+
// trap and its siblings. This is unavailable infrastructure, not a code
|
|
1903
|
+
// failure — success stays true so it is never counted as a failed file
|
|
1904
|
+
// or requeued, and the typed outcome keeps it out of the failure bucket.
|
|
1905
|
+
return {
|
|
1906
|
+
success: true,
|
|
1907
|
+
changed: false,
|
|
1908
|
+
outcome: "unavailable",
|
|
1909
|
+
error: `${formatter.name}: formatter executable not found`,
|
|
1910
|
+
};
|
|
1730
1911
|
}
|
|
1731
1912
|
// Run formatter without blocking the event loop.
|
|
1732
1913
|
const result = await safeSpawnAsync(cmd[0], cmd.slice(1), {
|
|
1733
1914
|
timeout: 15000,
|
|
1734
1915
|
cwd,
|
|
1735
1916
|
});
|
|
1917
|
+
// A resolver that could NOT prove absence (it never probed PATH — e.g.
|
|
1918
|
+
// black/sqlfluff, which only look in a venv) legitimately falls back to the
|
|
1919
|
+
// bare static command. When that bare command is missing, the spawn boundary
|
|
1920
|
+
// reports a typed `tool-not-found` failure — never a nonzero exit. Treat it
|
|
1921
|
+
// as unavailable, not a formatting failure, closing the same #2413 class for
|
|
1922
|
+
// the formatters whose `null` correctly means "static untried" (no
|
|
1923
|
+
// oxfmt-specific string matching — this reads the typed spawn-failure kind).
|
|
1924
|
+
if (result.spawnFailure?.kind === "tool-not-found") {
|
|
1925
|
+
return {
|
|
1926
|
+
success: true,
|
|
1927
|
+
changed: false,
|
|
1928
|
+
outcome: "unavailable",
|
|
1929
|
+
error: result.spawnFailure.message,
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1736
1932
|
// Strict by default (#1337): only a formatter with a documented
|
|
1737
1933
|
// benign-nonzero mode (`lenientExitCode`) may exit nonzero and still be
|
|
1738
1934
|
// read as a successful run. Everything else that exits nonzero never
|
|
@@ -1745,6 +1941,7 @@ export async function formatFile(filePath, formatter) {
|
|
|
1745
1941
|
return {
|
|
1746
1942
|
success: false,
|
|
1747
1943
|
changed: false,
|
|
1944
|
+
outcome: "failed",
|
|
1748
1945
|
error: result.error?.message ||
|
|
1749
1946
|
firstDiagnosticLine(result.stderr) ||
|
|
1750
1947
|
// biome, ktlint and `mix format` report on STDOUT; without this
|
|
@@ -1760,12 +1957,14 @@ export async function formatFile(filePath, formatter) {
|
|
|
1760
1957
|
return {
|
|
1761
1958
|
success: true,
|
|
1762
1959
|
changed,
|
|
1960
|
+
outcome: changed ? "formatted" : "unchanged",
|
|
1763
1961
|
};
|
|
1764
1962
|
}
|
|
1765
1963
|
catch (err) {
|
|
1766
1964
|
return {
|
|
1767
1965
|
success: false,
|
|
1768
1966
|
changed: false,
|
|
1967
|
+
outcome: "failed",
|
|
1769
1968
|
error: err instanceof Error ? err.message : String(err),
|
|
1770
1969
|
};
|
|
1771
1970
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared re-check cadence for a cached "nothing here" verdict — the
|
|
3
|
+
* bound both `file-utils.ts`'s nested `.gitignore`/`.pi-lens.json` matcher
|
|
4
|
+
* (#2159/#2071) and `project-lens-config.ts`'s no-config discovery cache
|
|
5
|
+
* (#2483 round 2) use so a negative result is neither re-statted on every
|
|
6
|
+
* call nor cached for the life of the process.
|
|
7
|
+
*
|
|
8
|
+
* A declared leaf module, the same shape as `ledger-bounds.ts` /
|
|
9
|
+
* `spawn-output-cap.ts`: it imports nothing from `clients/`, because
|
|
10
|
+
* `file-utils.ts` already imports `project-lens-config.ts`, and the
|
|
11
|
+
* `clients/` acyclic-imports rule (`no-client-cycles`,
|
|
12
|
+
* `.dependency-cruiser.cjs`) would reject either of those two files
|
|
13
|
+
* importing a constant that lived inside the other. A shared value neither
|
|
14
|
+
* owns is the only way both stay on the same cadence without one importing
|
|
15
|
+
* the other.
|
|
16
|
+
*/
|
|
17
|
+
export const FRESHNESS_CADENCE_MS = 2_000;
|