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
|
@@ -9,19 +9,22 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
|
-
import { access, readFile } from "node:fs/promises";
|
|
12
|
+
import { access, lstat, readFile } from "node:fs/promises";
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
15
|
import { emitBounded } from "../bounded-telemetry.js";
|
|
16
16
|
import { withTimeout } from "../deadline-utils.js";
|
|
17
|
-
import {
|
|
17
|
+
import { minimatch } from "../deps/minimatch.js";
|
|
18
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
|
|
18
19
|
// vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
|
|
19
20
|
// `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
|
|
20
21
|
import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
|
|
21
22
|
import { logExtension } from "../extension-log.js";
|
|
22
23
|
import { recordLspChild, removeLspChild } from "../instance-registry.js";
|
|
24
|
+
import { workspaceDiagnosticsCacheSessionStart } from "./workspace-diagnostics-session.js";
|
|
23
25
|
import { logLatency } from "../latency-logger.js";
|
|
24
26
|
import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
|
|
27
|
+
import { getProcessSingleton } from "../process-singletons.js";
|
|
25
28
|
import { getAmbientAbortSignal } from "../safe-spawn.js";
|
|
26
29
|
import { raceToCompletion } from "./aggregation.js";
|
|
27
30
|
import { hashDiagnosticContent, } from "./diagnostic-binding.js";
|
|
@@ -67,7 +70,7 @@ function extractSpawnMarker(args) {
|
|
|
67
70
|
return undefined;
|
|
68
71
|
}
|
|
69
72
|
// --- Constants ---
|
|
70
|
-
const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
|
|
73
|
+
export const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
|
|
71
74
|
/**
|
|
72
75
|
* The client capabilities advertised in every `initialize`. The textDocument set
|
|
73
76
|
* is intentionally COMPLETE and spec-compliant: servers built on
|
|
@@ -96,6 +99,11 @@ export const CLIENT_CAPABILITIES = {
|
|
|
96
99
|
workspaceFolders: true,
|
|
97
100
|
configuration: true,
|
|
98
101
|
didChangeWatchedFiles: { dynamicRegistration: true },
|
|
102
|
+
fileOperations: {
|
|
103
|
+
dynamicRegistration: false,
|
|
104
|
+
willRename: true,
|
|
105
|
+
didRename: true,
|
|
106
|
+
},
|
|
99
107
|
},
|
|
100
108
|
textDocument: {
|
|
101
109
|
synchronization: {
|
|
@@ -115,7 +123,27 @@ export const CLIENT_CAPABILITIES = {
|
|
|
115
123
|
implementation: { dynamicRegistration: false },
|
|
116
124
|
references: { dynamicRegistration: false },
|
|
117
125
|
documentSymbol: { dynamicRegistration: false },
|
|
118
|
-
codeAction: {
|
|
126
|
+
codeAction: {
|
|
127
|
+
dynamicRegistration: false,
|
|
128
|
+
dataSupport: true,
|
|
129
|
+
resolveSupport: { properties: ["edit", "command"] },
|
|
130
|
+
// #1971 review: without literal support, LSP 3.17 constrains
|
|
131
|
+
// textDocument/codeAction responses to Command[] — resolve of an edit
|
|
132
|
+
// would then be protocol-invalid. We parse CodeAction objects.
|
|
133
|
+
codeActionLiteralSupport: {
|
|
134
|
+
codeActionKind: {
|
|
135
|
+
valueSet: [
|
|
136
|
+
"quickfix",
|
|
137
|
+
"refactor",
|
|
138
|
+
"refactor.extract",
|
|
139
|
+
"refactor.inline",
|
|
140
|
+
"refactor.rewrite",
|
|
141
|
+
"source",
|
|
142
|
+
"source.organizeImports",
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
119
147
|
rename: { dynamicRegistration: false },
|
|
120
148
|
publishDiagnostics: {
|
|
121
149
|
relatedInformation: true,
|
|
@@ -253,6 +281,78 @@ function installCrashGuard() {
|
|
|
253
281
|
throw reason instanceof Error ? reason : new Error(String(reason));
|
|
254
282
|
});
|
|
255
283
|
}
|
|
284
|
+
/** #2065: bound both the path count and UTF-16 heap represented by retained
|
|
285
|
+
* Incremental-sync text. The map keeps the newest sent paths when evicting. */
|
|
286
|
+
export const MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES = 128;
|
|
287
|
+
export const MAX_INCREMENTAL_TEXT_RETAINED_BYTES = 64 * 1024 * 1024;
|
|
288
|
+
/**
|
|
289
|
+
* #2065 fix round 1: a PROJECTION registry, not a second source of truth for
|
|
290
|
+
* "which clients are live" — its only job is letting `memory-sampler.ts`
|
|
291
|
+
* total retained-text bytes across clients without importing `LSPService`.
|
|
292
|
+
* Considered projecting straight off `LSPService.state.clients` (the
|
|
293
|
+
* manager's own client map, `clients/lsp/index.ts`) instead of keeping this
|
|
294
|
+
* Set at all, and rejected it for two reasons:
|
|
295
|
+
* 1. Import direction: `clients/lsp/index.ts` imports THIS file, not the
|
|
296
|
+
* reverse, so reading `LSPService.state.clients` from here (or from
|
|
297
|
+
* `memory-sampler.ts`, which every other snapshot in that module reaches
|
|
298
|
+
* by importing the owning subsystem directly, e.g.
|
|
299
|
+
* `getReviewGraphWorkspaceCacheSnapshot`, `getDispatchCascadeCacheStats`)
|
|
300
|
+
* means importing the higher-level manager from the lower-level client
|
|
301
|
+
* module — a layering inversion, not a cycle fix.
|
|
302
|
+
* 2. `LSPService.state.clients` is ITSELF only lazily reconciled against
|
|
303
|
+
* real client death — a crashed client's slot is noticed and evicted on
|
|
304
|
+
* the next `getClientForFile` attach, not at crash time (see
|
|
305
|
+
* `markExitedIfUnset`'s docstring above). Projecting off it would not
|
|
306
|
+
* give memory-sampler any more real-time accuracy than this Set; it
|
|
307
|
+
* would just move the same "who deregisters, and when" question onto a
|
|
308
|
+
* larger, higher-risk surface (the manager's routing map) for no gain.
|
|
309
|
+
*
|
|
310
|
+
* What makes this safe to keep as a second collection: it has exactly ONE
|
|
311
|
+
* lifecycle seam. `spawnClient` is the only writer that adds (below), and
|
|
312
|
+
* `disposeClientConnection` is the only writer that removes — and that
|
|
313
|
+
* function is itself the single convergence point every permanent-death path
|
|
314
|
+
* (`connection.onError`, `connection.onClose`, the process `exit` handler,
|
|
315
|
+
* and `clientShutdownOnce`'s `finally`) already funnels through, guarded by
|
|
316
|
+
* the same `connectionDisposed` idempotency flag that gates the connection
|
|
317
|
+
* teardown itself. Membership can therefore never drift from "has this
|
|
318
|
+
* client's connection been torn down" — there is nowhere else in the file
|
|
319
|
+
* that can flip a client from alive to permanently dead without going
|
|
320
|
+
* through `disposeClientConnection`.
|
|
321
|
+
*
|
|
322
|
+
* PROCESS-SCOPED, not module-scoped (#2130 criterion 2). This Set is what
|
|
323
|
+
* `memory_sample.subsystems.lsp.clients` counts, and a module-scope Set counted
|
|
324
|
+
* only the clients this MODULE EVALUATION spawned. pi evaluates the pi-lens
|
|
325
|
+
* graph up to nine times per process (#2146, measured `host_boot` = 9) and each
|
|
326
|
+
* evaluation builds its own `LSPService` and fleet (#2157 item 1), so a
|
|
327
|
+
* secondary root's servers — spawned by a later evaluation — were invisible to
|
|
328
|
+
* the sampler running in the first. That is the reported symptom exactly:
|
|
329
|
+
* `clients: 1` in `memory_sample` beside `lspChildCount: 2` in `instances.json`,
|
|
330
|
+
* with two tsservers alive. Sharing the Set through `getProcessSingleton` makes
|
|
331
|
+
* both the count and `roots` span every root the process serves.
|
|
332
|
+
*
|
|
333
|
+
* Both seams share it, which is the whole requirement: sharing only the add
|
|
334
|
+
* site would build a Set that never shrinks, a worse leak than the undercount.
|
|
335
|
+
*/
|
|
336
|
+
const ACTIVE_CLIENTS_FAMILY = "lsp-client.active-clients";
|
|
337
|
+
/** Bump when the cell's shape changes. */
|
|
338
|
+
const ACTIVE_CLIENTS_VERSION = 1;
|
|
339
|
+
function activeLspClientSet() {
|
|
340
|
+
return getProcessSingleton(ACTIVE_CLIENTS_FAMILY, ACTIVE_CLIENTS_VERSION, () => new Set());
|
|
341
|
+
}
|
|
342
|
+
export function getLspDocumentTextRetentionSnapshot() {
|
|
343
|
+
let entries = 0;
|
|
344
|
+
let bytes = 0;
|
|
345
|
+
const roots = new Set();
|
|
346
|
+
const active = activeLspClientSet();
|
|
347
|
+
for (const state of active) {
|
|
348
|
+
entries += state.incrementalTextRetainedEntries ?? 0;
|
|
349
|
+
bytes += state.incrementalTextRetainedBytes ?? 0;
|
|
350
|
+
if (typeof state.root === "string" && state.root.length > 0) {
|
|
351
|
+
roots.add(state.root);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { clients: active.size, entries, bytes, roots: roots.size };
|
|
355
|
+
}
|
|
256
356
|
function isClientAlive(state) {
|
|
257
357
|
return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
|
|
258
358
|
}
|
|
@@ -260,6 +360,13 @@ function disposeClientConnection(state) {
|
|
|
260
360
|
if (state.connectionDisposed)
|
|
261
361
|
return;
|
|
262
362
|
state.connectionDisposed = true;
|
|
363
|
+
// #2065 fix round 1: this is the ONE place every permanent-death path
|
|
364
|
+
// converges (onError, onClose, the process 'exit' handler, and
|
|
365
|
+
// clientShutdownOnce's finally) — see setupConnectionLifecycle below and
|
|
366
|
+
// clientShutdownOnce. Deregistering here, guarded by the idempotency flag
|
|
367
|
+
// above, means a crash that never re-attaches still frees its retained
|
|
368
|
+
// text instead of pinning it for the rest of the process lifetime.
|
|
369
|
+
activeLspClientSet().delete(state);
|
|
263
370
|
try {
|
|
264
371
|
state.connection.dispose();
|
|
265
372
|
}
|
|
@@ -623,7 +730,7 @@ function retirePullSource(state, identifier) {
|
|
|
623
730
|
for (const source of [state.pullResultIds, state.pullRequestSequences]) {
|
|
624
731
|
if (!source)
|
|
625
732
|
continue;
|
|
626
|
-
for (const key of
|
|
733
|
+
for (const key of source.keys()) {
|
|
627
734
|
if (key.endsWith(suffix))
|
|
628
735
|
source.delete(key);
|
|
629
736
|
}
|
|
@@ -788,6 +895,50 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
|
|
|
788
895
|
},
|
|
789
896
|
});
|
|
790
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* #2066: the ONE full-document walk a send needs, replacing the two that used
|
|
900
|
+
* to run independently — a `charCodeAt` loop counting `\n` for the
|
|
901
|
+
* `lsp_document_send` line count, and a `.split(/\r\n|\r|\n/)` in
|
|
902
|
+
* `buildContentChanges` that materialized one substring per line to read the
|
|
903
|
+
* last one. The #1095 sha256 is a third walk and stays; it is native, and it
|
|
904
|
+
* is load-bearing.
|
|
905
|
+
*
|
|
906
|
+
* The two counts here are deliberately NOT the same number and must not be
|
|
907
|
+
* collapsed into one. `lfNewlineCount` counts `\n` only, because its consumer
|
|
908
|
+
* pairs against `diagnostic_past_eof`, whose gate counts LF BYTES
|
|
909
|
+
* (`clients/diagnostic-line-freshness.ts`); merging the conventions would put
|
|
910
|
+
* the two records one apart on exactly the mixed-ending documents the pairing
|
|
911
|
+
* exists to debug. `lastLine` uses the full LSP terminator set (`\r\n`, lone
|
|
912
|
+
* `\r`, `\n` — #1669 review F6), because a CRLF or classic-Mac document whose
|
|
913
|
+
* terminators went uncounted computes a range ending mid-document.
|
|
914
|
+
*/
|
|
915
|
+
function scanSentContent(content) {
|
|
916
|
+
let lfNewlineCount = 0;
|
|
917
|
+
let index = 0;
|
|
918
|
+
let start = 0;
|
|
919
|
+
// `charCodeAt`, not `codePointAt` (typescript:S7758, accepted): both are
|
|
920
|
+
// compared against 10/13, which no surrogate unit or astral code point can
|
|
921
|
+
// equal, so they are interchangeable here — but `codePointAt` measured 8%
|
|
922
|
+
// slower on a 400 KiB LF document (0.716 vs 0.663 ms/scan), because every
|
|
923
|
+
// ordinary character pays a surrogate-pair check this loop never uses.
|
|
924
|
+
for (let i = 0; i < content.length; i++) {
|
|
925
|
+
const code = content.charCodeAt(i);
|
|
926
|
+
if (code !== 10 && code !== 13)
|
|
927
|
+
continue;
|
|
928
|
+
if (code === 10) {
|
|
929
|
+
lfNewlineCount++;
|
|
930
|
+
}
|
|
931
|
+
else if (content.charCodeAt(i + 1) === 10) {
|
|
932
|
+
// `\r\n` is ONE line terminator, and still carries the `\n` the
|
|
933
|
+
// LF-only count wants.
|
|
934
|
+
lfNewlineCount++;
|
|
935
|
+
i++;
|
|
936
|
+
}
|
|
937
|
+
index++;
|
|
938
|
+
start = i + 1;
|
|
939
|
+
}
|
|
940
|
+
return { lfNewlineCount, lastLine: { index, start } };
|
|
941
|
+
}
|
|
791
942
|
/**
|
|
792
943
|
* #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
|
|
793
944
|
* tag it with the document version it was sent as, so a later
|
|
@@ -796,17 +947,85 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
|
|
|
796
947
|
* read on the notification path (I1). Bounded by the same file-size gates the
|
|
797
948
|
* caller already applies to the content it hands us.
|
|
798
949
|
*/
|
|
799
|
-
function recordSentContent(state, normalizedPath, version, content) {
|
|
950
|
+
function recordSentContent(state, normalizedPath, version, content, coalescedCount = 0) {
|
|
951
|
+
const scan = scanSentContent(content);
|
|
952
|
+
const previous = state.documentContentHashes.get(normalizedPath);
|
|
953
|
+
if (previous && previous.version > version) {
|
|
954
|
+
incrementDegradationCount({
|
|
955
|
+
kind: "lsp-document-send-order",
|
|
956
|
+
subject: `${state.serverId}:${normalizedPath}`,
|
|
957
|
+
reason: `recorded version ${version} after newer version ${previous.version}`,
|
|
958
|
+
});
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (previous?.text !== undefined) {
|
|
962
|
+
state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
|
|
963
|
+
state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? previous.text.length * 2) -
|
|
964
|
+
previous.text.length * 2);
|
|
965
|
+
// #2065 fix round 1 F5: the path is about to be rewritten below (and
|
|
966
|
+
// possibly re-marked text-bearing) — drop the stale membership first so
|
|
967
|
+
// the reinsert further down lands at the END of the recency order
|
|
968
|
+
// instead of leaving a duplicate-free but stale position.
|
|
969
|
+
state.incrementalTextBearingPaths?.delete(normalizedPath);
|
|
970
|
+
}
|
|
971
|
+
// #2065 fix round 1 F5: eviction recency now lives in
|
|
972
|
+
// `incrementalTextBearingPaths` (above/below), not this map's own
|
|
973
|
+
// insertion order — no remaining reader iterates `documentContentHashes`
|
|
974
|
+
// for order (every other site below is a keyed `.get`/`.delete`). The
|
|
975
|
+
// reinsert is kept anyway so a log or future debug dump that DOES walk
|
|
976
|
+
// this map in insertion order still reads "most recently sent last",
|
|
977
|
+
// matching the aux set it mirrors, rather than a stale mixed order.
|
|
978
|
+
state.documentContentHashes.delete(normalizedPath);
|
|
800
979
|
state.documentContentHashes.set(normalizedPath, {
|
|
801
980
|
version,
|
|
802
981
|
hash: hashDiagnosticContent(content),
|
|
803
982
|
// #1669: retain the full text only for Incremental — the sole reader
|
|
804
983
|
// (`buildContentChanges`) needs it to compute the NEXT change against
|
|
805
984
|
// what the server last saw; Full/None never read this field.
|
|
985
|
+
// #2066: `lastLine` is that reader's answer, already computed by the
|
|
986
|
+
// scan above — it rides along with the text it describes.
|
|
806
987
|
...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
|
|
807
988
|
text: content,
|
|
989
|
+
lastLine: scan.lastLine,
|
|
808
990
|
}),
|
|
809
991
|
});
|
|
992
|
+
if (state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
|
|
993
|
+
state.incrementalTextRetainedEntries =
|
|
994
|
+
(state.incrementalTextRetainedEntries ?? 0) + 1;
|
|
995
|
+
state.incrementalTextRetainedBytes =
|
|
996
|
+
(state.incrementalTextRetainedBytes ?? 0) + content.length * 2;
|
|
997
|
+
// #2065 fix round 1 F5: track membership in the same insertion (=
|
|
998
|
+
// recency) order as `documentContentHashes` itself, so eviction below
|
|
999
|
+
// never needs to spread and scan the full content-binding map —
|
|
1000
|
+
// including paths whose text was already stripped — just to find the
|
|
1001
|
+
// oldest text-bearing one.
|
|
1002
|
+
if (!state.incrementalTextBearingPaths) {
|
|
1003
|
+
state.incrementalTextBearingPaths = new Set();
|
|
1004
|
+
}
|
|
1005
|
+
state.incrementalTextBearingPaths.add(normalizedPath);
|
|
1006
|
+
while ((state.incrementalTextRetainedEntries ?? 0) >
|
|
1007
|
+
MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES ||
|
|
1008
|
+
(state.incrementalTextRetainedBytes ?? 0) >
|
|
1009
|
+
MAX_INCREMENTAL_TEXT_RETAINED_BYTES) {
|
|
1010
|
+
const oldestPath = state.incrementalTextBearingPaths
|
|
1011
|
+
?.values()
|
|
1012
|
+
.next().value;
|
|
1013
|
+
if (oldestPath === undefined)
|
|
1014
|
+
break;
|
|
1015
|
+
const binding = state.documentContentHashes.get(oldestPath);
|
|
1016
|
+
state.incrementalTextBearingPaths.delete(oldestPath);
|
|
1017
|
+
if (!binding || binding.text === undefined)
|
|
1018
|
+
continue;
|
|
1019
|
+
// #2066: `lastLine` is dropped here with the text it describes. The
|
|
1020
|
+
// #1095 version/hash binding is what must survive the strip.
|
|
1021
|
+
state.documentContentHashes.set(oldestPath, {
|
|
1022
|
+
version: binding.version,
|
|
1023
|
+
hash: binding.hash,
|
|
1024
|
+
});
|
|
1025
|
+
state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
|
|
1026
|
+
state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? 0) - binding.text.length * 2);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
810
1029
|
// #1641 criterion 3: the in-memory document's version + content length AT
|
|
811
1030
|
// SEND TIME, so a later "diagnostic cited a line past current disk EOF"
|
|
812
1031
|
// record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
|
|
@@ -818,14 +1037,9 @@ function recordSentContent(state, normalizedPath, version, content) {
|
|
|
818
1037
|
// convention as the gate itself (newline count + 1 — a trailing `\n` adds
|
|
819
1038
|
// one more, empty, addressable line; an empty document is still 1 line),
|
|
820
1039
|
// or the two records disagree by one at exactly the boundary this counter
|
|
821
|
-
// exists to help debug.
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
let newlineCount = 0;
|
|
825
|
-
for (let i = 0; i < content.length; i++) {
|
|
826
|
-
if (content.charCodeAt(i) === 10)
|
|
827
|
-
newlineCount++;
|
|
828
|
-
}
|
|
1040
|
+
// exists to help debug. #2066: the count comes from `scanSentContent`
|
|
1041
|
+
// above, which folded this loop into the walk `buildContentChanges` was
|
|
1042
|
+
// already paying for; the LF-only convention is unchanged.
|
|
829
1043
|
logLatency({
|
|
830
1044
|
type: "phase",
|
|
831
1045
|
phase: "lsp_document_send",
|
|
@@ -834,7 +1048,8 @@ function recordSentContent(state, normalizedPath, version, content) {
|
|
|
834
1048
|
metadata: {
|
|
835
1049
|
version,
|
|
836
1050
|
contentLength: content.length,
|
|
837
|
-
contentLineCount:
|
|
1051
|
+
contentLineCount: scan.lfNewlineCount + 1,
|
|
1052
|
+
...(coalescedCount > 0 && { coalescedCount }),
|
|
838
1053
|
},
|
|
839
1054
|
});
|
|
840
1055
|
}
|
|
@@ -864,23 +1079,24 @@ function buildContentChanges(state, normalizedPath, content) {
|
|
|
864
1079
|
if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
|
|
865
1080
|
return [{ text: content }];
|
|
866
1081
|
}
|
|
867
|
-
const
|
|
868
|
-
if (
|
|
1082
|
+
const previous = state.documentContentHashes.get(normalizedPath);
|
|
1083
|
+
if (previous?.text === undefined) {
|
|
869
1084
|
return [{ text: content }];
|
|
870
1085
|
}
|
|
871
|
-
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const
|
|
1086
|
+
const previousText = previous.text;
|
|
1087
|
+
// #2066: `recordSentContent` scanned this exact text when it sent it, so
|
|
1088
|
+
// read where it left the last line instead of walking (and splitting) the
|
|
1089
|
+
// document a second time. An entry carrying text without that position was
|
|
1090
|
+
// written by something else — resolve it through the SAME scanner, never a
|
|
1091
|
+
// second convention that can drift from it.
|
|
1092
|
+
const lastLine = previous.lastLine ?? scanSentContent(previousText).lastLine;
|
|
1093
|
+
const lastLineText = previousText.slice(lastLine.start);
|
|
878
1094
|
return [
|
|
879
1095
|
{
|
|
880
1096
|
range: {
|
|
881
1097
|
start: { line: 0, character: 0 },
|
|
882
1098
|
end: {
|
|
883
|
-
line: lastLine,
|
|
1099
|
+
line: lastLine.index,
|
|
884
1100
|
character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
|
|
885
1101
|
},
|
|
886
1102
|
},
|
|
@@ -1208,14 +1424,33 @@ export function setupIncomingHandlers(state, initialization) {
|
|
|
1208
1424
|
if (state.serverEditsAllowed <= 0 || !params?.edit) {
|
|
1209
1425
|
return { applied: false, failureReason: "edit not solicited" };
|
|
1210
1426
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1427
|
+
// #2450 fix round 3 (F3) removed a `depth === 1` re-check here as
|
|
1428
|
+
// dead code. It is not dead — it moved to the WRITE side, where it
|
|
1429
|
+
// is the live invariant: `state.activeMutationContext` is assigned
|
|
1430
|
+
// only by `syncMutationContextSlot` (below in this file), which
|
|
1431
|
+
// populates it exactly when `activeMutationDepth === 1` and
|
|
1432
|
+
// empties it on every other frame transition. The depth test
|
|
1433
|
+
// therefore still runs, on each entry and each unwind; repeating
|
|
1434
|
+
// it here would be a second copy of the same predicate, not an
|
|
1435
|
+
// independent gate. What this handler reads is consequently either
|
|
1436
|
+
// the context of the single frame currently in flight or
|
|
1437
|
+
// `undefined` — never a settled frame's context, and never a
|
|
1438
|
+
// context belonging to some other window that is still open
|
|
1439
|
+
// (#2479 review rounds 2/3).
|
|
1440
|
+
const context = state.activeMutationContext;
|
|
1441
|
+
// `workspace/applyEdit` is only ever honored inside the
|
|
1442
|
+
// `serverEditsAllowed` window this handler just checked, which is
|
|
1443
|
+
// opened exclusively by an executeCommand call — so a fallback here is
|
|
1444
|
+
// always executeCommand-solicited, never a bare edit (#2450). This
|
|
1445
|
+
// fallback context carries no runtime/cacheManager (there is no live
|
|
1446
|
+
// reference to those singletons at this call site); lsp-mutation.ts's
|
|
1447
|
+
// bookkeepLspMutation falls back to the mutation bridge for exactly
|
|
1448
|
+
// that reason rather than silently dropping the write's bookkeeping.
|
|
1214
1449
|
const telemetryContext = context ?? {
|
|
1215
1450
|
cwd: state.root,
|
|
1216
1451
|
correlationId: newLspMutationCorrelationId(),
|
|
1217
1452
|
tool: "lsp-workspace-applyEdit",
|
|
1218
|
-
source: "lsp-
|
|
1453
|
+
source: "lsp-execute-command",
|
|
1219
1454
|
};
|
|
1220
1455
|
try {
|
|
1221
1456
|
await applyWorkspaceEdit(params.edit, state.root, {
|
|
@@ -2215,7 +2450,7 @@ export function handleNotifyExternalChange(state, filePath, type) {
|
|
|
2215
2450
|
const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
|
|
2216
2451
|
state.watchQueue.enqueue(uri, type);
|
|
2217
2452
|
}
|
|
2218
|
-
|
|
2453
|
+
async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0) {
|
|
2219
2454
|
if (!isClientAlive(state))
|
|
2220
2455
|
return;
|
|
2221
2456
|
const normalizedPath = normalizeMapKey(filePath);
|
|
@@ -2263,7 +2498,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
2263
2498
|
// #1669 review F7: only mirror the send locally once it actually left
|
|
2264
2499
|
// the process — see safeSendNotification's doc comment.
|
|
2265
2500
|
if (reopenSent)
|
|
2266
|
-
recordSentContent(state, normalizedPath, version, content);
|
|
2501
|
+
recordSentContent(state, normalizedPath, version, content, coalescedCount);
|
|
2267
2502
|
state.openDocuments.add(normalizedPath);
|
|
2268
2503
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
2269
2504
|
return;
|
|
@@ -2273,7 +2508,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
2273
2508
|
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
2274
2509
|
});
|
|
2275
2510
|
if (changeSent)
|
|
2276
|
-
recordSentContent(state, normalizedPath, version, content);
|
|
2511
|
+
recordSentContent(state, normalizedPath, version, content, coalescedCount);
|
|
2277
2512
|
return;
|
|
2278
2513
|
}
|
|
2279
2514
|
state.pendingOpens.add(normalizedPath);
|
|
@@ -2308,7 +2543,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
2308
2543
|
return;
|
|
2309
2544
|
const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
|
|
2310
2545
|
if (openSent)
|
|
2311
|
-
recordSentContent(state, normalizedPath, 0, content);
|
|
2546
|
+
recordSentContent(state, normalizedPath, 0, content, coalescedCount);
|
|
2312
2547
|
state.pendingOpens.delete(normalizedPath);
|
|
2313
2548
|
state.openDocuments.add(normalizedPath);
|
|
2314
2549
|
state.closedDocuments?.delete(normalizedPath);
|
|
@@ -2332,10 +2567,83 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
|
|
|
2332
2567
|
},
|
|
2333
2568
|
});
|
|
2334
2569
|
}
|
|
2335
|
-
|
|
2570
|
+
/**
|
|
2571
|
+
* Replace only notifications that have not started a transport write. A
|
|
2572
|
+
* microtask starts the first entry so a synchronous burst of callers installs
|
|
2573
|
+
* one latest entry before any bytes are handed to vscode-jsonrpc. Once an
|
|
2574
|
+
* entry starts, it runs to completion; LSP has no cancellation/acknowledgement
|
|
2575
|
+
* for notifications, so dropping a started write could violate didOpen before
|
|
2576
|
+
* didChange ordering or leave a partial message in the pipe.
|
|
2577
|
+
*/
|
|
2578
|
+
function enqueueDocumentNotify(state, normalizedPath, run) {
|
|
2579
|
+
let queue = state.notifyChangeQueues.get(normalizedPath);
|
|
2580
|
+
if (!queue) {
|
|
2581
|
+
queue = { running: false };
|
|
2582
|
+
state.notifyChangeQueues.set(normalizedPath, queue);
|
|
2583
|
+
}
|
|
2584
|
+
return new Promise((resolve, reject) => {
|
|
2585
|
+
const previous = queue?.pending;
|
|
2586
|
+
// Keep superseded callers attached to the replacement's completion. The
|
|
2587
|
+
// notification is dropped, but callers such as the auxiliary backlog
|
|
2588
|
+
// ledger must not observe completion before the newest content is sent.
|
|
2589
|
+
const waiters = previous?.waiters ?? [];
|
|
2590
|
+
waiters.push({ resolve, reject });
|
|
2591
|
+
queue.pending = {
|
|
2592
|
+
run,
|
|
2593
|
+
waiters,
|
|
2594
|
+
coalescedCount: (previous?.coalescedCount ?? 0) + (previous ? 1 : 0),
|
|
2595
|
+
};
|
|
2596
|
+
if (queue.running)
|
|
2597
|
+
return;
|
|
2598
|
+
queue.running = true;
|
|
2599
|
+
// Let same-turn callers replace the unwritten entry before the runner
|
|
2600
|
+
// invokes the transport. Different path queues still start independently.
|
|
2601
|
+
void Promise.resolve().then(async () => {
|
|
2602
|
+
try {
|
|
2603
|
+
for (;;) {
|
|
2604
|
+
const next = queue.pending;
|
|
2605
|
+
if (!next)
|
|
2606
|
+
break;
|
|
2607
|
+
queue.pending = undefined;
|
|
2608
|
+
try {
|
|
2609
|
+
await next.run(next.coalescedCount);
|
|
2610
|
+
for (const waiter of next.waiters)
|
|
2611
|
+
waiter.resolve();
|
|
2612
|
+
}
|
|
2613
|
+
catch (error) {
|
|
2614
|
+
for (const waiter of next.waiters)
|
|
2615
|
+
waiter.reject(error);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
finally {
|
|
2620
|
+
queue.running = false;
|
|
2621
|
+
if (!queue.pending &&
|
|
2622
|
+
state.notifyChangeQueues.get(normalizedPath) === queue) {
|
|
2623
|
+
state.notifyChangeQueues.delete(normalizedPath);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
/** Drop unwritten document notifications when a client is torn down. */
|
|
2630
|
+
function cancelDocumentNotifyQueues(state) {
|
|
2631
|
+
for (const queue of state.notifyChangeQueues.values()) {
|
|
2632
|
+
for (const waiter of queue.pending?.waiters ?? [])
|
|
2633
|
+
waiter.resolve();
|
|
2634
|
+
queue.pending = undefined;
|
|
2635
|
+
}
|
|
2636
|
+
state.notifyChangeQueues.clear();
|
|
2637
|
+
}
|
|
2638
|
+
export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
|
|
2336
2639
|
if (!isClientAlive(state))
|
|
2337
|
-
return;
|
|
2640
|
+
return Promise.resolve();
|
|
2338
2641
|
const normalizedPath = normalizeMapKey(filePath);
|
|
2642
|
+
return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount));
|
|
2643
|
+
}
|
|
2644
|
+
async function handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount = 0) {
|
|
2645
|
+
if (!isClientAlive(state))
|
|
2646
|
+
return;
|
|
2339
2647
|
const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
|
|
2340
2648
|
if (!state.openDocuments.has(normalizedPath)) {
|
|
2341
2649
|
// Safety fallback: keep protocol ordering valid even if caller sends
|
|
@@ -2352,7 +2660,7 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
2352
2660
|
state.documentOpenedAt.set(normalizedPath, Date.now());
|
|
2353
2661
|
state.diagnosticPublicationCounts.set(normalizedPath, 0);
|
|
2354
2662
|
if (fallbackOpenSent)
|
|
2355
|
-
recordSentContent(state, normalizedPath, 0, content);
|
|
2663
|
+
recordSentContent(state, normalizedPath, 0, content, coalescedCount);
|
|
2356
2664
|
state.openDocuments.add(normalizedPath);
|
|
2357
2665
|
state.openDocumentUris?.set(normalizedPath, uri);
|
|
2358
2666
|
return;
|
|
@@ -2367,7 +2675,18 @@ export async function handleNotifyChange(state, filePath, content) {
|
|
|
2367
2675
|
contentChanges: buildContentChanges(state, normalizedPath, content),
|
|
2368
2676
|
});
|
|
2369
2677
|
if (changeSent)
|
|
2370
|
-
recordSentContent(state, normalizedPath, version, content);
|
|
2678
|
+
recordSentContent(state, normalizedPath, version, content, coalescedCount);
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* #2113: serialize the read/build/send/record transaction per document. The
|
|
2682
|
+
* content-change payload must be built against the content recorded by the
|
|
2683
|
+
* preceding send for this path; unrelated paths retain parallel sends.
|
|
2684
|
+
*/
|
|
2685
|
+
export function handleNotifyChange(state, filePath, content) {
|
|
2686
|
+
if (!isClientAlive(state))
|
|
2687
|
+
return Promise.resolve();
|
|
2688
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
2689
|
+
return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount));
|
|
2371
2690
|
}
|
|
2372
2691
|
/** Close a document through the same lifecycle path exposed by the client. */
|
|
2373
2692
|
export async function closeDocument(state, filePath) {
|
|
@@ -2393,7 +2712,44 @@ export async function closeDocument(state, filePath) {
|
|
|
2393
2712
|
// harmless and cheap) — mirror openDocuments' own per-close cleanup so it
|
|
2394
2713
|
// doesn't grow unbounded across a long session's worth of open/close churn.
|
|
2395
2714
|
state.projectIdentityProbedFiles?.delete(normalizedPath);
|
|
2715
|
+
const retained = state.documentContentHashes.get(normalizedPath);
|
|
2716
|
+
if (retained?.text !== undefined) {
|
|
2717
|
+
state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
|
|
2718
|
+
state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? retained.text.length * 2) -
|
|
2719
|
+
retained.text.length * 2);
|
|
2720
|
+
// #2065 fix round 2 N1: `recordSentContent` and the eviction loop both
|
|
2721
|
+
// delete from `incrementalTextBearingPaths` when they strip a path's
|
|
2722
|
+
// text, but neither runs on close — this was the missing third writer.
|
|
2723
|
+
// Without it, a closed path's membership in the aux set survives
|
|
2724
|
+
// forever: below the 128-entry/64 MiB cap the eviction loop that would
|
|
2725
|
+
// otherwise clean it up never runs at all, so the set grows by one
|
|
2726
|
+
// stale entry per open/close cycle with nothing ever draining it — the
|
|
2727
|
+
// exact unbounded-per-path-store class #2065 exists to close, just
|
|
2728
|
+
// moved into the aux index instead of the byte-retention count itself.
|
|
2729
|
+
state.incrementalTextBearingPaths?.delete(normalizedPath);
|
|
2730
|
+
}
|
|
2731
|
+
// Keep the hash/version binding only while the document is open; the full
|
|
2732
|
+
// text is needed for the next edit, never after didClose (#2065).
|
|
2733
|
+
state.documentContentHashes.delete(normalizedPath);
|
|
2396
2734
|
clearDiagnosticsForPath(state, normalizedPath);
|
|
2735
|
+
// #2065 fix round 2 (reverts fix round 1 F4): `pullGenerations` is the
|
|
2736
|
+
// eighth per-path map in this family, but it is deliberately NOT cleared
|
|
2737
|
+
// here — the same reasoning `clearDiagnosticsForPath` already documents
|
|
2738
|
+
// above for `pullRequestSequences` applies to this counter too, and fix
|
|
2739
|
+
// round 1's F4 missed it. `pullGenerationFor` defaults a missing entry to
|
|
2740
|
+
// `0`, so deleting the entry on close resets the counter. A pull already
|
|
2741
|
+
// in flight before the close captured its generation BEFORE this close's
|
|
2742
|
+
// `clearDiagnosticsForPath` bump above ran. If the document is closed and
|
|
2743
|
+
// reopened before that in-flight pull's write lands, deleting the entry
|
|
2744
|
+
// here would let the counter read back as `0` again — the exact value the
|
|
2745
|
+
// stale write's stale captured generation equals — so the write-time
|
|
2746
|
+
// `!== generation` check at its call site would wrongly accept a stale
|
|
2747
|
+
// answer computed against the pre-close content. Leaving the counter
|
|
2748
|
+
// monotonic and un-reset keeps every pre-close capture permanently
|
|
2749
|
+
// distinguishable from whatever the counter reads afterward, no matter how
|
|
2750
|
+
// many closes or reopens happen in between. The cost is one integer per
|
|
2751
|
+
// path that outlives the document's open lifetime — negligible next to
|
|
2752
|
+
// correctness here.
|
|
2397
2753
|
}
|
|
2398
2754
|
/**
|
|
2399
2755
|
* #1620 residual review F1: `fast` and `processExiting` only ever escalate —
|
|
@@ -2467,6 +2823,9 @@ async function clientShutdownOnce(state, options) {
|
|
|
2467
2823
|
state.pendingOpens.clear();
|
|
2468
2824
|
state.openDocuments.clear();
|
|
2469
2825
|
state.openDocumentUris?.clear();
|
|
2826
|
+
// #2357: superseded notifications that have not started writing are moot
|
|
2827
|
+
// once this client is dead; resolve their callers and release the queue.
|
|
2828
|
+
cancelDocumentNotifyQueues(state);
|
|
2470
2829
|
// #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
|
|
2471
2830
|
// probe memo is moot along with everything else document-scoped.
|
|
2472
2831
|
state.projectIdentityProbedFiles?.clear();
|
|
@@ -2532,6 +2891,9 @@ async function clientShutdownOnce(state, options) {
|
|
|
2532
2891
|
}
|
|
2533
2892
|
}
|
|
2534
2893
|
finally {
|
|
2894
|
+
// #2065 fix round 1: deregistration now lives in disposeClientConnection
|
|
2895
|
+
// itself (the convergence point for every crash/dispose path, not just
|
|
2896
|
+
// this graceful one) — see its comment.
|
|
2535
2897
|
disposeClientConnection(state);
|
|
2536
2898
|
const pid = state.lspProcess.pid;
|
|
2537
2899
|
logLatency({
|
|
@@ -2908,6 +3270,35 @@ async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
|
|
|
2908
3270
|
}
|
|
2909
3271
|
return isClientAlive(state);
|
|
2910
3272
|
}
|
|
3273
|
+
// #2479: `activeMutationContext` is DERIVED state, not a stack. The frame that
|
|
3274
|
+
// takes `activeMutationDepth` 0 -> 1 owns the context for its whole lifetime
|
|
3275
|
+
// (`mutationContextOwner`); the slot merely exposes that owner while it is the
|
|
3276
|
+
// ONLY frame in flight. Deeper or overlapping frames therefore empty the slot
|
|
3277
|
+
// for as long as they are open — concurrent and nested commands deliberately
|
|
3278
|
+
// do not cross-correlate — and it refills by itself when they unwind, with no
|
|
3279
|
+
// frame having to remember anything.
|
|
3280
|
+
//
|
|
3281
|
+
// Recomputing beats saving and restoring because these frames do not nest:
|
|
3282
|
+
// `LSPClient.executeCommand` and `LSPService.executeCommand` fan out with no
|
|
3283
|
+
// mutex, parallel `lsp_navigation` calls with `apply: true` overlap, and #449
|
|
3284
|
+
// light mode shares one client across agents. With A (the owner), B and C
|
|
3285
|
+
// overlapping and the MIDDLE one settling first, a per-frame restore hands A's
|
|
3286
|
+
// context back while C's window is the open one — and `LspMutationContext`
|
|
3287
|
+
// carries a cwd plus a directly-threaded runtime/cacheManager, with
|
|
3288
|
+
// `readTurnState` / `appendProjectChange` cwd-scoped, so that routes a live
|
|
3289
|
+
// edit's bookkeeping into another project's change log (strictly worse than
|
|
3290
|
+
// the honest fallback the pre-#2479 code gave). An owner-liveness flag does
|
|
3291
|
+
// not fix it either: it answers "is an owner live", not "does the slot belong
|
|
3292
|
+
// to the window this edit is in" — and C's own unwind then puts back the
|
|
3293
|
+
// `undefined` it saved while the owner is still running, resurrecting #2479
|
|
3294
|
+
// (review round 3). Deriving answers the second question by construction, and
|
|
3295
|
+
// "no command in flight == no context" — the invariant the
|
|
3296
|
+
// `serverEditsAllowed` gate on `workspace/applyEdit` is paired with — falls
|
|
3297
|
+
// out of it with no depth-0 special case.
|
|
3298
|
+
function syncMutationContextSlot(state) {
|
|
3299
|
+
state.activeMutationContext =
|
|
3300
|
+
state.activeMutationDepth === 1 ? state.mutationContextOwner : undefined;
|
|
3301
|
+
}
|
|
2911
3302
|
// Run an advertised server command via workspace/executeCommand, with the
|
|
2912
3303
|
// generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
|
|
2913
3304
|
// hardening invariants: allowlist-by-advertisement (only commands the server
|
|
@@ -2926,10 +3317,20 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
|
|
|
2926
3317
|
}
|
|
2927
3318
|
state.serverEditsAllowed += 1;
|
|
2928
3319
|
state.activeMutationDepth = (state.activeMutationDepth ?? 0) + 1;
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
3320
|
+
// #2479: the frame that takes the depth to 1 owns the context; the slot is
|
|
3321
|
+
// derived from that owner (see `syncMutationContextSlot` above). The
|
|
3322
|
+
// pre-#2479 shape only ever CLEARED the slot, and only once the depth
|
|
3323
|
+
// returned to 0, so a nested `executeCommand` unwinding from depth 2 back
|
|
3324
|
+
// to 1 left the OUTER call without its own context for the rest of its
|
|
3325
|
+
// window: every server-initiated `applyEdit` it still solicited read
|
|
3326
|
+
// `undefined`, fell to the mutation bridge, and carried the generic
|
|
3327
|
+
// `agent-tool:lsp-workspace-applyEdit` receipt instead of the outer
|
|
3328
|
+
// operation's own (`lsp-rename` / `lsp-execute-command`).
|
|
3329
|
+
const isMutationContextOwner = state.activeMutationDepth === 1;
|
|
3330
|
+
if (isMutationContextOwner) {
|
|
3331
|
+
state.mutationContextOwner = mutationContext;
|
|
3332
|
+
}
|
|
3333
|
+
syncMutationContextSlot(state);
|
|
2933
3334
|
try {
|
|
2934
3335
|
let result;
|
|
2935
3336
|
try {
|
|
@@ -2956,8 +3357,15 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
|
|
|
2956
3357
|
finally {
|
|
2957
3358
|
state.serverEditsAllowed -= 1;
|
|
2958
3359
|
state.activeMutationDepth = Math.max(0, (state.activeMutationDepth ?? 0) - 1);
|
|
2959
|
-
|
|
2960
|
-
|
|
3360
|
+
// The owner drops its context; every frame then resyncs. Both the
|
|
3361
|
+
// hand-back to a still-live owner once the deeper frames unwind and
|
|
3362
|
+
// the empty slot while more than one frame is open are consequences of
|
|
3363
|
+
// the derivation, not separate rules each frame has to get right
|
|
3364
|
+
// (#2479 review rounds 2/3).
|
|
3365
|
+
if (isMutationContextOwner) {
|
|
3366
|
+
state.mutationContextOwner = undefined;
|
|
3367
|
+
}
|
|
3368
|
+
syncMutationContextSlot(state);
|
|
2961
3369
|
}
|
|
2962
3370
|
}
|
|
2963
3371
|
// #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
|
|
@@ -3060,6 +3468,14 @@ async function resolveCodeActionBestEffort(state, action) {
|
|
|
3060
3468
|
edit: await normalizeClientWorkspaceEdit(state, action.edit),
|
|
3061
3469
|
};
|
|
3062
3470
|
}
|
|
3471
|
+
if (!state.operationSupport.codeActionResolve) {
|
|
3472
|
+
recordDegradationOnce({
|
|
3473
|
+
kind: "lsp-capability-skip",
|
|
3474
|
+
subject: `${state.serverId}:codeAction/resolve`,
|
|
3475
|
+
reason: "server did not advertise codeActionProvider.resolveProvider",
|
|
3476
|
+
});
|
|
3477
|
+
return action;
|
|
3478
|
+
}
|
|
3063
3479
|
let resolved;
|
|
3064
3480
|
try {
|
|
3065
3481
|
resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
|
|
@@ -3082,7 +3498,7 @@ async function resolveCodeActionBestEffort(state, action) {
|
|
|
3082
3498
|
// --- Client Factory ---
|
|
3083
3499
|
export async function createLSPClient(options) {
|
|
3084
3500
|
installCrashGuard();
|
|
3085
|
-
const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
|
|
3501
|
+
const { serverId, process: lspProcess, root, sessionCwd, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
|
|
3086
3502
|
// #449/#472: register this LSP child in the cross-process instance registry
|
|
3087
3503
|
// as soon as we have a live pid — BEFORE `initialize` completes, not after.
|
|
3088
3504
|
// Registering early means a child that dies/hangs during initialize (the
|
|
@@ -3095,6 +3511,11 @@ export async function createLSPClient(options) {
|
|
|
3095
3511
|
serverId,
|
|
3096
3512
|
command: lspProcess.command,
|
|
3097
3513
|
marker: extractSpawnMarker(lspProcess.args),
|
|
3514
|
+
sessionIdentity: {
|
|
3515
|
+
projectRoot: sessionCwd ?? process.cwd(),
|
|
3516
|
+
rootSource: sessionCwd ? "service-cwd" : "lsp-fallback",
|
|
3517
|
+
startedAt: new Date(workspaceDiagnosticsCacheSessionStart()).toISOString(),
|
|
3518
|
+
},
|
|
3098
3519
|
}).catch((err) => {
|
|
3099
3520
|
// best-effort observability — never fail LSP startup over this
|
|
3100
3521
|
logLatency({
|
|
@@ -3204,8 +3625,12 @@ export async function createLSPClient(options) {
|
|
|
3204
3625
|
diagnosticsVersion: 0,
|
|
3205
3626
|
diagnosticsVersionsByPath: new Map(),
|
|
3206
3627
|
documentVersions: new Map(),
|
|
3628
|
+
notifyChangeQueues: new Map(),
|
|
3207
3629
|
diagnosticDocVersions: new Map(),
|
|
3208
3630
|
documentContentHashes: new Map(),
|
|
3631
|
+
incrementalTextRetainedEntries: 0,
|
|
3632
|
+
incrementalTextBearingPaths: new Set(),
|
|
3633
|
+
incrementalTextRetainedBytes: 0,
|
|
3209
3634
|
diagnosticBindings: new Map(),
|
|
3210
3635
|
pullResultIds: new Map(),
|
|
3211
3636
|
documentPullDiagnosticsBySource: new Map(),
|
|
@@ -3238,6 +3663,7 @@ export async function createLSPClient(options) {
|
|
|
3238
3663
|
// SAFETY: state construction completes before the flush closure can run.
|
|
3239
3664
|
watchQueue: undefined,
|
|
3240
3665
|
};
|
|
3666
|
+
activeLspClientSet().add(state);
|
|
3241
3667
|
// #271: batch per-file workspace/didChangeWatchedFiles into one notification
|
|
3242
3668
|
// per debounce window, so an N-file turn re-indexes the server once, not N×.
|
|
3243
3669
|
state.watchQueue = new WatchedFilesQueue((changes) => {
|
|
@@ -3340,6 +3766,31 @@ export async function createLSPClient(options) {
|
|
|
3340
3766
|
state.workspaceDiagnosticsSupport =
|
|
3341
3767
|
detectWorkspaceDiagnosticsSupport(initResult);
|
|
3342
3768
|
state.operationSupport = detectOperationSupport(initResult);
|
|
3769
|
+
const capabilities = (initResult
|
|
3770
|
+
?.capabilities ?? {});
|
|
3771
|
+
const workspace = isCapabilityRecord(capabilities.workspace)
|
|
3772
|
+
? capabilities.workspace
|
|
3773
|
+
: undefined;
|
|
3774
|
+
const fileOperations = isCapabilityRecord(workspace)
|
|
3775
|
+
? workspace.fileOperations
|
|
3776
|
+
: undefined;
|
|
3777
|
+
const malformedFileOperationRegistrations = new Set();
|
|
3778
|
+
if (isCapabilityRecord(fileOperations)) {
|
|
3779
|
+
for (const operation of ["willRename", "didRename"]) {
|
|
3780
|
+
if (fileOperations[operation] !== undefined &&
|
|
3781
|
+
parseFileOperationFilters(fileOperations[operation]) === undefined) {
|
|
3782
|
+
malformedFileOperationRegistrations.add(operation);
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
state.fileOperationMalformedRegistrations =
|
|
3787
|
+
malformedFileOperationRegistrations;
|
|
3788
|
+
if (isCapabilityRecord(fileOperations)) {
|
|
3789
|
+
state.fileOperationFilters = {
|
|
3790
|
+
willRename: parseFileOperationFilters(fileOperations.willRename),
|
|
3791
|
+
didRename: parseFileOperationFilters(fileOperations.didRename),
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3343
3794
|
state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
|
|
3344
3795
|
state.syncKind = negotiateSyncKind(initResult?.capabilities);
|
|
3345
3796
|
state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
|
|
@@ -3376,6 +3827,8 @@ export async function createLSPClient(options) {
|
|
|
3376
3827
|
checkAlive: () => checkProcessAlive(),
|
|
3377
3828
|
/** #1277: cheap request round-trip proving the server still responds. */
|
|
3378
3829
|
pingLiveness: (timeoutMs) => clientPingLiveness(state, timeoutMs),
|
|
3830
|
+
/** #2358: the OS pid of the live server process (see interface doc). */
|
|
3831
|
+
getProcessPid: () => lspProcess.pid,
|
|
3379
3832
|
notify: {
|
|
3380
3833
|
async open(filePath, content, languageId, preserveDiagnostics, silent) {
|
|
3381
3834
|
return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
|
|
@@ -3444,6 +3897,9 @@ export async function createLSPClient(options) {
|
|
|
3444
3897
|
getOperationSupport() {
|
|
3445
3898
|
return state.operationSupport;
|
|
3446
3899
|
},
|
|
3900
|
+
getMalformedFileOperationRegistrations() {
|
|
3901
|
+
return state.fileOperationMalformedRegistrations ?? new Set();
|
|
3902
|
+
},
|
|
3447
3903
|
getAdvertisedCommands() {
|
|
3448
3904
|
return [...state.advertisedCommands];
|
|
3449
3905
|
},
|
|
@@ -3521,6 +3977,9 @@ export async function createLSPClient(options) {
|
|
|
3521
3977
|
isDocumentOpen(filePath) {
|
|
3522
3978
|
return state.openDocuments.has(normalizeMapKey(filePath));
|
|
3523
3979
|
},
|
|
3980
|
+
openDocumentPaths() {
|
|
3981
|
+
return [...state.openDocuments];
|
|
3982
|
+
},
|
|
3524
3983
|
isBusy() {
|
|
3525
3984
|
return (activeRequestsByConnection.get(connection) ?? 0) > 0;
|
|
3526
3985
|
},
|
|
@@ -3570,11 +4029,26 @@ export async function createLSPClient(options) {
|
|
|
3570
4029
|
},
|
|
3571
4030
|
closeDocument: (filePath) => closeDocument(state, filePath),
|
|
3572
4031
|
async willRenameFiles(oldFilePath, newFilePath) {
|
|
4032
|
+
if (!isClientAlive(state))
|
|
4033
|
+
return null;
|
|
4034
|
+
// #1971 review: the server registered WHICH paths it cares about.
|
|
4035
|
+
// Sending outside those filters asks a question the server never
|
|
4036
|
+
// signed up to answer.
|
|
4037
|
+
const oldUri = pathToFileURL(oldFilePath).href;
|
|
4038
|
+
const newUri = pathToFileURL(newFilePath).href;
|
|
4039
|
+
if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.willRename, oldUri, newUri, oldFilePath, newFilePath))) {
|
|
4040
|
+
recordDegradationOnce({
|
|
4041
|
+
kind: "lsp-capability-skip",
|
|
4042
|
+
subject: `${state.serverId}:workspace/willRenameFiles:filter`,
|
|
4043
|
+
reason: "filter-mismatch",
|
|
4044
|
+
});
|
|
4045
|
+
return null;
|
|
4046
|
+
}
|
|
3573
4047
|
const result = await navRequest(state, "workspace/willRenameFiles", {
|
|
3574
4048
|
files: [
|
|
3575
4049
|
{
|
|
3576
|
-
oldUri
|
|
3577
|
-
newUri
|
|
4050
|
+
oldUri,
|
|
4051
|
+
newUri,
|
|
3578
4052
|
},
|
|
3579
4053
|
],
|
|
3580
4054
|
});
|
|
@@ -3583,11 +4057,28 @@ export async function createLSPClient(options) {
|
|
|
3583
4057
|
async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
|
|
3584
4058
|
if (!isClientAlive(state))
|
|
3585
4059
|
return;
|
|
4060
|
+
// `fileOperationFilters.didRename` is parsed by the same predicate that
|
|
4061
|
+
// derives operationSupport.didRenameFiles (see
|
|
4062
|
+
// isFileOperationRegistrationOptions). Thus filters undefined iff the
|
|
4063
|
+
// capability is absent; the service-level gate remains the enforcement
|
|
4064
|
+
// point for aggregated dispatch.
|
|
4065
|
+
// #1971 review: same registration-filter discipline as willRename —
|
|
4066
|
+
// the server only signed up to hear about matching paths.
|
|
4067
|
+
const wireOldUri = oldUri ?? pathToFileURL(oldFilePath).href;
|
|
4068
|
+
const wireNewUri = newUri ?? pathToFileURL(newFilePath).href;
|
|
4069
|
+
if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.didRename, wireOldUri, wireNewUri, oldFilePath, newFilePath))) {
|
|
4070
|
+
recordDegradationOnce({
|
|
4071
|
+
kind: "lsp-capability-skip",
|
|
4072
|
+
subject: `${state.serverId}:workspace/didRenameFiles:filter`,
|
|
4073
|
+
reason: "filter-mismatch",
|
|
4074
|
+
});
|
|
4075
|
+
return;
|
|
4076
|
+
}
|
|
3586
4077
|
await safeSendNotification(state.connection, "workspace/didRenameFiles", {
|
|
3587
4078
|
files: [
|
|
3588
4079
|
{
|
|
3589
|
-
oldUri:
|
|
3590
|
-
newUri:
|
|
4080
|
+
oldUri: wireOldUri,
|
|
4081
|
+
newUri: wireNewUri,
|
|
3591
4082
|
},
|
|
3592
4083
|
],
|
|
3593
4084
|
});
|
|
@@ -3818,8 +4309,13 @@ function detectOperationSupport(initResult) {
|
|
|
3818
4309
|
return false;
|
|
3819
4310
|
if (typeof value === "boolean")
|
|
3820
4311
|
return value;
|
|
3821
|
-
return
|
|
4312
|
+
return isCapabilityRecord(value);
|
|
3822
4313
|
};
|
|
4314
|
+
const codeActionProvider = capabilities?.codeActionProvider;
|
|
4315
|
+
const workspace = capabilities?.workspace;
|
|
4316
|
+
const fileOperations = isCapabilityRecord(workspace)
|
|
4317
|
+
? workspace.fileOperations
|
|
4318
|
+
: undefined;
|
|
3823
4319
|
return {
|
|
3824
4320
|
definition: hasProvider("definitionProvider"),
|
|
3825
4321
|
typeDefinition: hasProvider("typeDefinitionProvider"),
|
|
@@ -3830,8 +4326,135 @@ function detectOperationSupport(initResult) {
|
|
|
3830
4326
|
documentSymbol: hasProvider("documentSymbolProvider"),
|
|
3831
4327
|
workspaceSymbol: hasProvider("workspaceSymbolProvider"),
|
|
3832
4328
|
codeAction: hasProvider("codeActionProvider"),
|
|
4329
|
+
codeActionResolve: isCapabilityRecord(codeActionProvider) &&
|
|
4330
|
+
codeActionProvider.resolveProvider === true,
|
|
3833
4331
|
rename: hasProvider("renameProvider"),
|
|
4332
|
+
willRenameFiles: isCapabilityRecord(fileOperations) &&
|
|
4333
|
+
isFileOperationRegistrationOptions(fileOperations.willRename),
|
|
4334
|
+
didRenameFiles: isCapabilityRecord(fileOperations) &&
|
|
4335
|
+
isFileOperationRegistrationOptions(fileOperations.didRename),
|
|
3834
4336
|
implementation: hasProvider("implementationProvider"),
|
|
3835
4337
|
callHierarchy: hasProvider("callHierarchyProvider"),
|
|
3836
4338
|
};
|
|
3837
4339
|
}
|
|
4340
|
+
function isCapabilityRecord(value) {
|
|
4341
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4342
|
+
}
|
|
4343
|
+
function isFileOperationRegistrationOptions(value) {
|
|
4344
|
+
return parseFileOperationFilters(value) !== undefined;
|
|
4345
|
+
}
|
|
4346
|
+
/**
|
|
4347
|
+
* Parse the filters of a `FileOperationRegistrationOptions` object. Returns
|
|
4348
|
+
* `undefined` when the options are absent or malformed — callers treat that as
|
|
4349
|
+
* "no interest declared" and fail closed, never sending the operation.
|
|
4350
|
+
*/
|
|
4351
|
+
function parseFileOperationFilters(value) {
|
|
4352
|
+
if (!isCapabilityRecord(value) || !Array.isArray(value.filters)) {
|
|
4353
|
+
return undefined;
|
|
4354
|
+
}
|
|
4355
|
+
const filters = [];
|
|
4356
|
+
for (const entry of value.filters) {
|
|
4357
|
+
if (!isCapabilityRecord(entry))
|
|
4358
|
+
return undefined;
|
|
4359
|
+
const pattern = entry.pattern;
|
|
4360
|
+
if (!isCapabilityRecord(pattern) ||
|
|
4361
|
+
typeof pattern.glob !== "string" ||
|
|
4362
|
+
pattern.glob.trim() === "") {
|
|
4363
|
+
return undefined;
|
|
4364
|
+
}
|
|
4365
|
+
if (entry.scheme !== undefined &&
|
|
4366
|
+
(typeof entry.scheme !== "string" ||
|
|
4367
|
+
entry.scheme.trim() === "" ||
|
|
4368
|
+
!/^[A-Za-z][A-Za-z0-9+.-]*$/.test(entry.scheme))) {
|
|
4369
|
+
return undefined;
|
|
4370
|
+
}
|
|
4371
|
+
const matches = pattern.matches;
|
|
4372
|
+
if (matches !== undefined && matches !== "file" && matches !== "folder") {
|
|
4373
|
+
return undefined;
|
|
4374
|
+
}
|
|
4375
|
+
if (pattern.options !== undefined && !isCapabilityRecord(pattern.options)) {
|
|
4376
|
+
return undefined;
|
|
4377
|
+
}
|
|
4378
|
+
const options = isCapabilityRecord(pattern.options)
|
|
4379
|
+
? pattern.options
|
|
4380
|
+
: undefined;
|
|
4381
|
+
if (options?.ignoreCase !== undefined &&
|
|
4382
|
+
typeof options.ignoreCase !== "boolean") {
|
|
4383
|
+
return undefined;
|
|
4384
|
+
}
|
|
4385
|
+
filters.push({
|
|
4386
|
+
scheme: entry.scheme?.toLowerCase(),
|
|
4387
|
+
glob: pattern.glob,
|
|
4388
|
+
matches: matches === "file" || matches === "folder" ? matches : undefined,
|
|
4389
|
+
ignoreCase: options?.ignoreCase === true,
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
return filters;
|
|
4393
|
+
}
|
|
4394
|
+
/**
|
|
4395
|
+
* Whether ANY parsed filter expresses interest in either path of a rename.
|
|
4396
|
+
* Conservative by design: unsupported URI schemes, an unresolved explicit
|
|
4397
|
+
* file/folder kind, malformed URIs, and zero filters all match nothing.
|
|
4398
|
+
*/
|
|
4399
|
+
async function fileOperationFiltersMatch(filters, oldUri, newUri, oldFilePath, newFilePath) {
|
|
4400
|
+
if (!filters || filters.length === 0)
|
|
4401
|
+
return false;
|
|
4402
|
+
const candidates = [oldUri, newUri].flatMap(fileOperationUriCandidates);
|
|
4403
|
+
const entityKind = filters.some((filter) => filter.matches !== undefined)
|
|
4404
|
+
? await probeRenameEntityKind(oldFilePath, newFilePath)
|
|
4405
|
+
: undefined;
|
|
4406
|
+
for (const filter of filters) {
|
|
4407
|
+
if (filter.scheme !== undefined && filter.scheme !== "file")
|
|
4408
|
+
continue;
|
|
4409
|
+
if (filter.matches !== undefined && filter.matches !== entityKind)
|
|
4410
|
+
continue;
|
|
4411
|
+
for (const candidate of candidates) {
|
|
4412
|
+
try {
|
|
4413
|
+
if (minimatch(candidate, filter.glob, {
|
|
4414
|
+
nocase: filter.ignoreCase,
|
|
4415
|
+
dot: true,
|
|
4416
|
+
})) {
|
|
4417
|
+
return true;
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
catch {
|
|
4421
|
+
// Malformed glob implementations must never widen server fan-out.
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
return false;
|
|
4426
|
+
}
|
|
4427
|
+
function fileOperationUriCandidates(uri) {
|
|
4428
|
+
let parsed;
|
|
4429
|
+
try {
|
|
4430
|
+
parsed = new URL(uri);
|
|
4431
|
+
}
|
|
4432
|
+
catch {
|
|
4433
|
+
return [];
|
|
4434
|
+
}
|
|
4435
|
+
// pi-lens performs local filesystem renames only. Even when a caller supplies
|
|
4436
|
+
// preserved URI spelling, another scheme cannot describe that operation.
|
|
4437
|
+
if (parsed.protocol.toLowerCase() !== "file:")
|
|
4438
|
+
return [];
|
|
4439
|
+
let uriPath;
|
|
4440
|
+
try {
|
|
4441
|
+
uriPath = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
|
|
4442
|
+
}
|
|
4443
|
+
catch {
|
|
4444
|
+
return [];
|
|
4445
|
+
}
|
|
4446
|
+
return [uriPath];
|
|
4447
|
+
}
|
|
4448
|
+
async function probeRenameEntityKind(oldFilePath, newFilePath) {
|
|
4449
|
+
for (const filePath of [oldFilePath, newFilePath]) {
|
|
4450
|
+
try {
|
|
4451
|
+
const info = await lstat(filePath);
|
|
4452
|
+
return info.isDirectory() ? "folder" : "file";
|
|
4453
|
+
}
|
|
4454
|
+
catch {
|
|
4455
|
+
// Before the rename the old path normally exists; after it, the new path
|
|
4456
|
+
// does. Probe both instead of deriving behavior from the host OS.
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
return undefined;
|
|
4460
|
+
}
|