@herbertgao/pi-extensions 2026.9.14 → 2026.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +8 -3
  2. package/THIRD_PARTY_NOTICES.md +28 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  5. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  6. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  7. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  8. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  9. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
  10. package/node_modules/pi-jev-auto-mode/README.md +7 -0
  11. package/node_modules/pi-jev-auto-mode/package.json +1 -1
  12. package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
  13. package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
  14. package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
  15. package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
  16. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  17. package/node_modules/pi-lens/README.md +45 -2
  18. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  19. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  20. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  21. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  22. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  23. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  24. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  25. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  26. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  27. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  28. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  29. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  30. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  31. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  66. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  67. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  68. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  69. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  71. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  72. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  73. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  74. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  75. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  76. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  77. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  78. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  79. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  80. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  81. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  82. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  83. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  84. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  85. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  86. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  87. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  88. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  89. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  90. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  91. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  92. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  93. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  94. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  95. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  96. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  97. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  98. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  99. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  100. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  101. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  102. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  103. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  104. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  105. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  106. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  107. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  108. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  109. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  110. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  111. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  112. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  113. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  114. package/node_modules/pi-lens/dist/index.js +5695 -3733
  115. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  116. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  117. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  118. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  119. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  120. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  121. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  122. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  123. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  124. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  125. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  126. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  127. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  128. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  129. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  130. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  131. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  132. package/node_modules/pi-lens/docs/configuration.md +55 -1
  133. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  134. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  135. package/node_modules/pi-lens/docs/features.md +2 -2
  136. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  137. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  138. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  139. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  140. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  141. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  142. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  143. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  144. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  145. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  146. package/node_modules/pi-lens/docs/settings.md +12 -3
  147. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  148. package/node_modules/pi-lens/package.json +5 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  157. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  158. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  159. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  160. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  161. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  162. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  163. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  164. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  165. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  166. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  167. package/node_modules/pi-mcp-adapter/README.md +12 -5
  168. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  169. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  170. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  171. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  173. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  175. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  176. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  177. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  178. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  179. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  180. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  181. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  182. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  183. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  184. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  185. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  186. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  187. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  188. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  189. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  190. package/node_modules/pi-mcp-adapter/package.json +3 -2
  191. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  192. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  193. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  194. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  195. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  196. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  197. package/node_modules/pi-multi-account/CHANGELOG.md +6 -0
  198. package/node_modules/pi-multi-account/README.md +15 -1
  199. package/node_modules/pi-multi-account/index.ts +295 -41
  200. package/node_modules/pi-multi-account/package.json +1 -1
  201. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  202. package/node_modules/pi-next-cue/LICENSE +21 -0
  203. package/node_modules/pi-next-cue/README.md +81 -0
  204. package/node_modules/pi-next-cue/index.ts +358 -0
  205. package/node_modules/pi-next-cue/package.json +28 -0
  206. package/package.json +10 -7
@@ -74,6 +74,14 @@ export class JscpdClient {
74
74
  constructor(verbose = false) {
75
75
  this.log = verbose ? createSubsystemLogger("jscpd") : () => { };
76
76
  }
77
+ /**
78
+ * Wait for scans already started by this client to settle. Test owners use
79
+ * this as the lifecycle barrier before removing fixture roots; the scan
80
+ * promise includes the jscpd child and its report-directory cleanup.
81
+ */
82
+ async shutdown() {
83
+ await Promise.allSettled(this.inFlight.values());
84
+ }
77
85
  /**
78
86
  * Fast recursive source file presence check.
79
87
  * Avoids running jscpd when repo has no relevant source files.
@@ -85,6 +85,12 @@ let recentPhases = [];
85
85
  * zero-duration session-end summary of records already logged individually,
86
86
  * not new work of its own.
87
87
  *
88
+ * #3310: `lsp_empty_first_publish_held` is the same shape as the #1459
89
+ * scanner-coverage rows — a decision record written from inside a live
90
+ * `lsp_touch_file` wait whose `durationMs` is ANOTHER write's age (how long
91
+ * after `didOpen` the server's pre-index empty publish arrived), not its own
92
+ * work. The touch it fires inside owns any real stall.
93
+ *
88
94
  * #2044: `test_runner_failed_target_state` is a zero-duration decision after a
89
95
  * bounded filesystem probe. The surrounding turn-end test-selection phase owns
90
96
  * any real work, so this row must not replace it in stall attribution.
@@ -104,6 +110,7 @@ const LAST_PHASE_EXCLUDED = new Set([
104
110
  "availability_decision",
105
111
  "finding_dead_path_drop",
106
112
  "finding_stale_line_demote",
113
+ "finding_path_stat_budget_exhausted",
107
114
  "lsp_scanner_coverage_gap",
108
115
  "lsp_notify_resync_deferred",
109
116
  "lsp_notify_write_late_landed",
@@ -112,6 +119,7 @@ const LAST_PHASE_EXCLUDED = new Set([
112
119
  "path_attribution_verified_rollup",
113
120
  "concurrent_session_bind_rollup",
114
121
  "auxiliary_readiness",
122
+ "lsp_empty_first_publish_held",
115
123
  ]);
116
124
  /**
117
125
  * The last non-`loop_block` phase logged, or undefined if none yet. Carries its
@@ -1,12 +1,13 @@
1
1
  import { resetIgnoredConfigWarnCache, warnIgnoredConfigOnce, } from "./config-warn.js";
2
+ import { recordDegradationOnce } from "./degradation-ledger.js";
2
3
  import { errorClassName } from "./error-class.js";
3
4
  import * as path from "node:path";
4
5
  import { assignFlagConfigSection, flagConfigSectionKeys, flagValueFromConfig, getLensFlagSpec, GLOBAL_NON_FLAG_CONFIG_SECTIONS, LENS_FLAGS, readFlagConfigValue, } from "./lens-flag-registry.js";
5
- import { CANONICAL_GLOBAL_CONFIG_FILE, getPiLensGlobalConfigPath, GLOBAL_CONFIG_LOCATIONS, LEGACY_ROOT_LSP_KEYS, } from "./config-locations.js";
6
+ import { CANONICAL_GLOBAL_CONFIG_FILE, canonicalPathIdentity, getPiLensGlobalConfigPath, getProductionGlobalConfigResolution, GLOBAL_CONFIG_LOCATIONS, LEGACY_ROOT_LSP_KEYS, } from "./config-locations.js";
6
7
  // Re-exported so this module's own, pre-existing import sites (`./lens-config.js`
7
8
  // / `../lens-config.js`) keep working. The function itself now lives in
8
9
  // `config-locations.ts` (#2426 review round 3, S1) — see the doc comment there.
9
- export { getPiLensGlobalConfigPath } from "./config-locations.js";
10
+ export { getPiLensGlobalConfigPath, getProductionGlobalConfigResolution, isResolvedGlobalConfigPath, resolveGlobalConfigLocation, resetGlobalConfigLocationCache, } from "./config-locations.js";
10
11
  import { ignoredRecordCollector, readConfigDocument, reportConfigReadFailure, reportPiLensConfigRecords, resolveOnePiLensConfigDocument, } from "./config-resolve.js";
11
12
  import { findNestedProjectMutationValue, } from "./project-lens-config.js";
12
13
  import { readToolConfig } from "./tool-config.js";
@@ -46,7 +47,64 @@ function asConfigObject(value) {
46
47
  ? value
47
48
  : undefined;
48
49
  }
49
- export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath()) {
50
+ /**
51
+ * Report a probe-error RETENTION decision once (global-config-location PR,
52
+ * refs #2457; review H2 remedy B).
53
+ *
54
+ * Fired by `loadPiLensGlobalConfig` when it derived the path itself and the
55
+ * resolution retained a location whose existence probe THREW. The record
56
+ * names the retained path (subject) and the failed probe's error class;
57
+ * once per session via `recordDegradationOnce`. A successful
58
+ * non-canonical selection fires nothing (opt-in by file creation, visible
59
+ * via `pilens_effective_config`).
60
+ */
61
+ function reportGlobalConfigProbeRetention() {
62
+ const resolution = getProductionGlobalConfigResolution();
63
+ if (resolution.source !== "legacy-default-unprobed" &&
64
+ resolution.source !== "pi-coding-agent-dir-unprobed") {
65
+ return;
66
+ }
67
+ recordDegradationOnce({
68
+ kind: "config-location-probe-failed",
69
+ subject: resolution.path,
70
+ reason: `existence probe for ${resolution.existsProbeFailed?.path} failed (${resolution.existsProbeFailed?.errorClassName}); the location is retained as the config source and its read failures report through PILENS_CFG_0001`,
71
+ metadata: { subsystem: "lens-config", configPath: resolution.path },
72
+ });
73
+ }
74
+ /** Report a lower-precedence global config that exists beside the winner. */
75
+ function reportGlobalConfigShadowing() {
76
+ const resolution = getProductionGlobalConfigResolution();
77
+ if (resolution.shadowedPath === undefined)
78
+ return;
79
+ const winningPath = canonicalPathIdentity(resolution.path);
80
+ const shadowedPath = canonicalPathIdentity(resolution.shadowedPath);
81
+ recordDegradationOnce({
82
+ kind: "config-location-shadowed",
83
+ subject: winningPath,
84
+ reason: `shadowed global config ${shadowedPath}; winning path is the record subject`,
85
+ metadata: {
86
+ subsystem: "lens-config",
87
+ configPath: winningPath,
88
+ shadowedPath,
89
+ },
90
+ code: "PILENS_CFG_0010",
91
+ });
92
+ }
93
+ export function loadPiLensGlobalConfig(configPath) {
94
+ // An unresolvable existence probe on the derived path is the one decision
95
+ // that must carry its own bounded record (#3251 review H2 remedy B): the
96
+ // resolution RETAINED the location it could not evaluate, the read below
97
+ // reports its own `config-ignored` row when it fails, and this record
98
+ // names the retention decision itself - the failed probe's path and error
99
+ // class - so "different settings silently applying" is impossible.
100
+ // Explicit-path callers stay silent (#2427 quiet contract); the default
101
+ // parameter is evaluated FIRST so the body can tell derived from explicit.
102
+ const derivedPath = configPath === undefined;
103
+ if (derivedPath) {
104
+ reportGlobalConfigProbeRetention();
105
+ reportGlobalConfigShadowing();
106
+ }
107
+ const resolvedPath = configPath ?? getPiLensGlobalConfigPath();
50
108
  const location = globalCanonicalLocation();
51
109
  // #2445: this used to be `JSON.parse(fs.readFileSync(...))` inside a bare
52
110
  // `catch { return undefined; }`, so a malformed `~/.pi-lens/config.json`
@@ -58,12 +116,12 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
58
116
  // `reportConfigReadFailure` derives the subsystem from the DOCUMENT so the
59
117
  // LSP loader's report of this file lands under `lens-config` as well and the
60
118
  // warn-once latch collapses the two into one honest notice.
61
- const outcome = readConfigDocument(configPath);
119
+ const outcome = readConfigDocument(resolvedPath);
62
120
  if (outcome.status === "missing")
63
121
  return undefined;
64
122
  if (outcome.status === "error") {
65
123
  reportConfigReadFailure({
66
- file: configPath,
124
+ file: resolvedPath,
67
125
  location,
68
126
  tier: "global",
69
127
  error: outcome.error,
@@ -92,7 +150,7 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
92
150
  // The flush is in a `finally` so a throw in the projection still delivers
93
151
  // what was composed before it, on top of the whole-config record the catch
94
152
  // adds.
95
- const { note, records: notedRecords } = ignoredRecordCollector(configPath, "global");
153
+ const { note, records: notedRecords } = ignoredRecordCollector(resolvedPath, "global");
96
154
  try {
97
155
  const parsed = outcome.value;
98
156
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -110,7 +168,7 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
110
168
  // function still decides what a bad value is called.
111
169
  const document = {
112
170
  tier: "global",
113
- file: configPath,
171
+ file: resolvedPath,
114
172
  location,
115
173
  value: parsed,
116
174
  };
@@ -269,7 +327,7 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
269
327
  // calling the same leaf `config-warn.ts` itself delegates to keeps every
270
328
  // caller of this guarantee, `config-core/` included, on ONE
271
329
  // implementation rather than two that happen to agree today.
272
- warnInvalidGlobalConfigOnce(configPath, `global config could not be interpreted (${errorClassName(error)}); configuration ignored`, "PILENS_CFG_0008");
330
+ warnInvalidGlobalConfigOnce(resolvedPath, `global config could not be interpreted (${errorClassName(error)}); configuration ignored`, "PILENS_CFG_0008");
273
331
  return undefined;
274
332
  }
275
333
  finally {
@@ -32,7 +32,8 @@ import { hashDiagnosticContent, } from "./diagnostic-binding.js";
32
32
  import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16 } from "./edits.js";
33
33
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
34
34
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
35
- import { negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
35
+ import { negotiateSaveOptions, negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
36
+ import { exceedsLspSyncLimits } from "./content-limits.js";
36
37
  import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
37
38
  import { getStrategy } from "./wait-policy/index.js";
38
39
  import { WatchedFilesQueue } from "./watch-queue.js";
@@ -1162,13 +1163,13 @@ export function applyDynamicCapabilities(state) {
1162
1163
  }
1163
1164
  export function resolveConfigurationSection(initialization, section) {
1164
1165
  if (!initialization)
1165
- return section ? null : {};
1166
+ return {};
1166
1167
  if (!section)
1167
1168
  return initialization;
1168
1169
  let cur = initialization;
1169
1170
  for (const part of section.split(".")) {
1170
1171
  if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
1171
- return null;
1172
+ return {};
1172
1173
  }
1173
1174
  cur = cur[part];
1174
1175
  }
@@ -1326,6 +1327,60 @@ export function setupIncomingHandlers(state, initialization, onDiagnosticsPublis
1326
1327
  const currentVersion = state.documentVersions.get(normalizedPath);
1327
1328
  return currentVersion !== undefined && docVersion < currentVersion;
1328
1329
  };
1330
+ // #3310: HOLD the empty first publish of an asynchronously-indexing
1331
+ // server. Measured (docs/lsp-capability-matrix.md's `first-publish`
1332
+ // column): intelephense answers `didOpen` with `[]` while its
1333
+ // whole-workspace index builds and publishes the real set once
1334
+ // indexing ends, so letting that first publish through cached an empty
1335
+ // set, bumped the publication stamp and emitted — which resolved the
1336
+ // push wait AND satisfied the "answered" evidence check in
1337
+ // `clients/lsp/index.ts`, rendering a file with an error as "confirmed
1338
+ // clean". A timeout is not a false clean; an empty pre-index publish
1339
+ // must not become one either.
1340
+ //
1341
+ // Held means: not cached, no version bump, no emit — so it can neither
1342
+ // resolve a wait nor count as evidence. Nothing is scheduled and
1343
+ // nothing is awaited: the server's OWN next publish releases the hold
1344
+ // (it is no longer a first publish), and the existing per-server budget
1345
+ // stays the only bound, so there is no new timer, listener or
1346
+ // cancellation path. A genuinely clean file still gets an affirmative
1347
+ // clean, because the measured server re-publishes `[]` at the end of
1348
+ // indexing.
1349
+ //
1350
+ // Three conditions, each load-bearing:
1351
+ // - the MEASURED class (never every push server): a Tier 2/2* server
1352
+ // that publishes `[]` once for a clean file keeps resolving the wait
1353
+ // on it, with no added latency (#3310 AC2);
1354
+ // - one-shot per client session: the cold index builds once, so a warm
1355
+ // touch — or a class server that never re-publishes — pays nothing;
1356
+ // - FIRST publication for this document: no cached push AND no pending
1357
+ // debounce timer. An empty publish that CLEARS an earlier non-empty
1358
+ // one (a fix landing) is never held, in either arrival order.
1359
+ if (strategy.emptyFirstPublish === "indexing" &&
1360
+ !state.emptyFirstPublishHoldSpent &&
1361
+ newDiags.length === 0 &&
1362
+ !state.pushDiagnostics.has(normalizedPath) &&
1363
+ !state.pendingDiagnostics.has(normalizedPath)) {
1364
+ state.emptyFirstPublishHoldSpent = true;
1365
+ // Bounded by construction: one record per client session, because
1366
+ // the hold itself is one-shot.
1367
+ logLatency({
1368
+ type: "phase",
1369
+ phase: "lsp_empty_first_publish_held",
1370
+ filePath: normalizedPath,
1371
+ durationMs: Math.max(0, publishReceivedAt -
1372
+ (state.documentOpenedAt.get(normalizedPath) ?? publishReceivedAt)),
1373
+ metadata: {
1374
+ serverId: state.serverId,
1375
+ emptyFirstPublish: strategy.emptyFirstPublish,
1376
+ // `pubVersion`, the `[lsp-pub]` trace's own field name for the
1377
+ // publish's LSP document version — not a lifecycle identity, and
1378
+ // not the retired glossary spelling (AGENTS.md "generation").
1379
+ pubVersion: docVersion ?? "push-unversioned",
1380
+ },
1381
+ });
1382
+ return;
1383
+ }
1329
1384
  // Seed on first push for servers whose first push is known complete.
1330
1385
  // Bypasses the debounce timer entirely — resolves waiting promises immediately.
1331
1386
  if (strategy.seedFirstPush &&
@@ -1477,7 +1532,9 @@ export function setupIncomingHandlers(state, initialization, onDiagnosticsPublis
1477
1532
  // dot-path into the server's config, e.g. "scan.jobs") — not a fixed
1478
1533
  // single-element array duplicating the whole blob for every item. An item
1479
1534
  // with no `section` gets the whole blob (that's what "no section" means
1480
- // per spec); an unresolvable section gets `null`, never the whole blob.
1535
+ // per spec); an unresolvable section gets `{}` (never `null`: strict consumers such as
1536
+ // vscode-css-language-server and @prisma/language-server throw or exit on
1537
+ // null), never the whole blob.
1481
1538
  state.connection.onRequest("workspace/configuration", async (params) => {
1482
1539
  const items = params?.items ?? [];
1483
1540
  return items.map((item) => resolveConfigurationSection(initialization, item?.section));
@@ -2455,7 +2512,64 @@ export function handleNotifyExternalChange(state, filePath, type) {
2455
2512
  const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2456
2513
  state.watchQueue.enqueue(uri, type);
2457
2514
  }
2458
- async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0) {
2515
+ /**
2516
+ * #3405: tell the server the document it just received is the file's saved
2517
+ * on-disk state.
2518
+ *
2519
+ * pi-lens has no unsaved-buffer concept — every notification it sends carries
2520
+ * bytes read from disk — so a save-triggered server is the one class of server
2521
+ * this client could never reach: `CLIENT_CAPABILITIES` has advertised
2522
+ * `synchronization.didSave` since #278 and no caller emitted one, so Expert
2523
+ * (whose ONLY whole-project recompile trigger is didSave —
2524
+ * `expert-lsp/expert@6bbad8c` `apps/expert/lib/expert/state.ex:243-257`)
2525
+ * published nothing for an edit made through pi-lens.
2526
+ *
2527
+ * Sent ONLY when the caller declared this touch a save (`saved`) AND the server
2528
+ * declared `textDocumentSync.save`; `includeText` decides whether the text
2529
+ * rides along. Both gates fail closed: an undeclared notification is the #278
2530
+ * class of hazard, and a save claimed for content whose didOpen/didChange never
2531
+ * left the process would tell the server to diagnose bytes it does not have —
2532
+ * so every call site below sends this only after its own content notification
2533
+ * returned `true` from `safeSendNotification`, and never for a document this
2534
+ * client has not opened.
2535
+ */
2536
+ async function sendDidSave(state, uri, content) {
2537
+ const save = state.saveOptions;
2538
+ if (!save)
2539
+ return;
2540
+ if (!isClientAlive(state))
2541
+ return;
2542
+ // #3405 r2 (M3406-1): `text` is a SECOND full copy of bytes the server was
2543
+ // just handed, so it is the one payload here that can be file-sized. The
2544
+ // explicit `lsp_diagnostics` writer reads whole files with no bound of its
2545
+ // own, so without this the same 32MB string would be framed twice. Bound it
2546
+ // at the shared seam both writers pass through rather than at either caller.
2547
+ //
2548
+ // Omitting `text` is what the contract tolerates, and it is not a lost save:
2549
+ // `DidSaveTextDocumentParams.text` is OPTIONAL ("Optional the content when
2550
+ // saved") in the spec, FsAutoComplete — the one `includeText: true` server in
2551
+ // the registry — types it `string option` and, given `None`, keeps the source
2552
+ // it already holds and still runs its re-check
2553
+ // (ionide/FsAutoComplete@85886b1, AdaptiveServerState.fs lines 2649-2673),
2554
+ // and pi-lens only ever sends this after a landed didOpen/didChange for the
2555
+ // same document, so the server has these bytes either way. Skipping the save
2556
+ // outright would instead drop the diagnose trigger this PR exists to restore.
2557
+ const limit = save.includeText
2558
+ ? exceedsLspSyncLimits(content)
2559
+ : { tooLarge: false, reason: "" };
2560
+ if (limit.tooLarge) {
2561
+ recordDegradationOnce({
2562
+ kind: "lsp-did-save-text-omitted",
2563
+ subject: state.serverId,
2564
+ reason: limit.reason,
2565
+ });
2566
+ }
2567
+ await safeSendNotification(state.connection, "textDocument/didSave", {
2568
+ textDocument: { uri },
2569
+ ...(save.includeText && !limit.tooLarge ? { text: content } : {}),
2570
+ });
2571
+ }
2572
+ async function handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, coalescedCount = 0, saved = false) {
2459
2573
  if (!isClientAlive(state))
2460
2574
  return;
2461
2575
  const normalizedPath = normalizeMapKey(filePath);
@@ -2506,6 +2620,8 @@ async function handleNotifyOpenOnce(state, filePath, content, languageId, preser
2506
2620
  recordSentContent(state, normalizedPath, version, content, coalescedCount);
2507
2621
  state.openDocuments.add(normalizedPath);
2508
2622
  state.openDocumentUris?.set(normalizedPath, uri);
2623
+ if (saved && reopenSent)
2624
+ await sendDidSave(state, uri, content);
2509
2625
  return;
2510
2626
  }
2511
2627
  const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
@@ -2514,6 +2630,8 @@ async function handleNotifyOpenOnce(state, filePath, content, languageId, preser
2514
2630
  });
2515
2631
  if (changeSent)
2516
2632
  recordSentContent(state, normalizedPath, version, content, coalescedCount);
2633
+ if (saved && changeSent)
2634
+ await sendDidSave(state, uri, content);
2517
2635
  return;
2518
2636
  }
2519
2637
  state.pendingOpens.add(normalizedPath);
@@ -2553,6 +2671,8 @@ async function handleNotifyOpenOnce(state, filePath, content, languageId, preser
2553
2671
  state.openDocuments.add(normalizedPath);
2554
2672
  state.closedDocuments?.delete(normalizedPath);
2555
2673
  state.openDocumentUris?.set(normalizedPath, uri);
2674
+ if (saved && openSent)
2675
+ await sendDidSave(state, uri, content);
2556
2676
  // Telemetry is deliberately detached after didOpen succeeds.
2557
2677
  // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
2558
2678
  // the probe must never open the serverEditsAllowed/activeMutationContext
@@ -2580,7 +2700,7 @@ async function handleNotifyOpenOnce(state, filePath, content, languageId, preser
2580
2700
  * for notifications, so dropping a started write could violate didOpen before
2581
2701
  * didChange ordering or leave a partial message in the pipe.
2582
2702
  */
2583
- function enqueueDocumentNotify(state, normalizedPath, run) {
2703
+ function enqueueDocumentNotify(state, normalizedPath, run, saved = false) {
2584
2704
  let queue = state.notifyChangeQueues.get(normalizedPath);
2585
2705
  if (!queue) {
2586
2706
  queue = { running: false };
@@ -2597,6 +2717,7 @@ function enqueueDocumentNotify(state, normalizedPath, run) {
2597
2717
  run,
2598
2718
  waiters,
2599
2719
  coalescedCount: (previous?.coalescedCount ?? 0) + (previous ? 1 : 0),
2720
+ saved: saved || previous?.saved === true,
2600
2721
  };
2601
2722
  if (queue.running)
2602
2723
  return;
@@ -2611,7 +2732,7 @@ function enqueueDocumentNotify(state, normalizedPath, run) {
2611
2732
  break;
2612
2733
  queue.pending = undefined;
2613
2734
  try {
2614
- await next.run(next.coalescedCount);
2735
+ await next.run(next.coalescedCount, next.saved);
2615
2736
  for (const waiter of next.waiters)
2616
2737
  waiter.resolve();
2617
2738
  }
@@ -2640,11 +2761,11 @@ function cancelDocumentNotifyQueues(state) {
2640
2761
  }
2641
2762
  state.notifyChangeQueues.clear();
2642
2763
  }
2643
- export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
2764
+ export function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false, saved = false) {
2644
2765
  if (!isClientAlive(state))
2645
2766
  return Promise.resolve();
2646
2767
  const normalizedPath = normalizeMapKey(filePath);
2647
- return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount));
2768
+ return enqueueDocumentNotify(state, normalizedPath, (coalescedCount, queuedSaved) => handleNotifyOpenOnce(state, filePath, content, languageId, preserveDiagnostics, silent, coalescedCount, queuedSaved), saved);
2648
2769
  }
2649
2770
  async function handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount = 0) {
2650
2771
  if (!isClientAlive(state))
@@ -2691,6 +2812,10 @@ export function handleNotifyChange(state, filePath, content) {
2691
2812
  if (!isClientAlive(state))
2692
2813
  return Promise.resolve();
2693
2814
  const normalizedPath = normalizeMapKey(filePath);
2815
+ // #3405: no `saved` argument — `LSPService.updateFile` is this path's only
2816
+ // entry point and no caller declares a save through it, so a change never
2817
+ // originates one. A save intent inherited from a superseded open entry is
2818
+ // dropped here by construction rather than sent after a bare didChange.
2694
2819
  return enqueueDocumentNotify(state, normalizedPath, (coalescedCount) => handleNotifyChangeOnce(state, filePath, content, normalizedPath, coalescedCount));
2695
2820
  }
2696
2821
  /** Close a document through the same lifecycle path exposed by the client. */
@@ -3643,6 +3768,8 @@ export async function createLSPClient(options) {
3643
3768
  pullRequestSequences: new Map(),
3644
3769
  workspacePullResultCache: new Map(),
3645
3770
  openDocuments: new Set(),
3771
+ // #3310: one-shot, per client session.
3772
+ emptyFirstPublishHoldSpent: false,
3646
3773
  closedDocuments: new Set(),
3647
3774
  openDocumentUris: new Map(),
3648
3775
  pendingOpens: new Set(),
@@ -3798,6 +3925,7 @@ export async function createLSPClient(options) {
3798
3925
  }
3799
3926
  state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3800
3927
  state.syncKind = negotiateSyncKind(initResult?.capabilities);
3928
+ state.saveOptions = negotiateSaveOptions(initResult?.capabilities);
3801
3929
  state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
3802
3930
  {}).sort((a, b) => a.localeCompare(b));
3803
3931
  for (const cmd of detectExecuteCommands(initResult)) {
@@ -3835,8 +3963,8 @@ export async function createLSPClient(options) {
3835
3963
  /** #2358: the OS pid of the live server process (see interface doc). */
3836
3964
  getProcessPid: () => lspProcess.pid,
3837
3965
  notify: {
3838
- async open(filePath, content, languageId, preserveDiagnostics, silent) {
3839
- return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
3966
+ async open(filePath, content, languageId, preserveDiagnostics, silent, saved) {
3967
+ return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent, saved);
3840
3968
  },
3841
3969
  async change(filePath, content) {
3842
3970
  return handleNotifyChange(state, filePath, content);
@@ -261,13 +261,15 @@ export async function loadLSPConfig(cwd, homeDir = os.homedir(), options = {}) {
261
261
  const resolution = resolvePiLensConfig({
262
262
  cwd,
263
263
  globalDir: getGlobalPiLensDir(),
264
- // `homeDir` is threaded, not dropped: it is the `$HOME` this call resolves
265
- // against, and the canonical global config is `$HOME/.pi-lens/config.json`
266
- // whenever `PI_LENS_CONFIG_PATH` does not override it. Production behavior
267
- // is unchanged (the default IS `os.homedir()`); what it buys is that the
268
- // seam means the same `$HOME` on both sides of the resolution, so a test
269
- // can exercise a relocated `PI_LENS_HOME` without reaching the real one.
270
- globalConfigPath: getPiLensGlobalConfigPath(homeDir),
264
+ // The global tier reads the PRODUCTION resolution (global-config-location
265
+ // PR, refs #2457): which file supplies it is an env fact (PI_LENS_CONFIG_PATH,
266
+ // then a legacy default, then PI_CODING_AGENT_DIR, then the
267
+ // PI_LENS_HOME-relocated canonical default), not a property of the
268
+ // `$HOME` this call's project walk is ceiling-bounded by. `homeDir`
269
+ // stays threaded below for the walk and `globalDir`; a test redirecting
270
+ // the global tier does it through the env, the same way production is
271
+ // controlled.
272
+ globalConfigPath: getPiLensGlobalConfigPath(),
271
273
  homeDir,
272
274
  // The subsystem comes from the failing DOCUMENT, not from this loader
273
275
  // (#2445). This resolution opens `~/.pi-lens/config.json` and
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one byte/line bound on document content pi-lens hands a language server.
3
+ *
4
+ * #3405 r2 (M3406-1): this predicate used to exist twice — as a private
5
+ * `exceedsLspSyncLimits` in `clients/pipeline.ts` and again inline in
6
+ * `clients/dispatch/runners/lsp.ts` — so a THIRD writer could be added that
7
+ * simply had neither. That is what happened: `tools/lsp-diagnostics.ts` reads a
8
+ * whole file with `fs.readFileSync` and touches with it, and once the touch
9
+ * could also carry `textDocument/didSave.text` (`includeText`), the same
10
+ * unbounded string was serialized into a second JSON-RPC frame. Both callers now
11
+ * ask this module, and so does the didSave payload seam, so the bound is one
12
+ * constant pair in one place rather than a convention each new writer must
13
+ * remember.
14
+ *
15
+ * The two established callers act on it by skipping their whole LSP step — a
16
+ * file this large is not synced at all. That behaviour is deliberately NOT
17
+ * folded in here: what a caller does at the bound is the caller's policy, and
18
+ * `sendDidSave`'s is different (it drops the redundant `text` and still sends
19
+ * the save, because the server already received these bytes in the didOpen or
20
+ * didChange this save follows).
21
+ */
22
+ import { RUNTIME_CONFIG } from "../runtime-config.js";
23
+ const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
24
+ const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
25
+ export function exceedsLspSyncLimits(content) {
26
+ const sizeBytes = Buffer.byteLength(content, "utf-8");
27
+ if (sizeBytes > LSP_MAX_FILE_BYTES) {
28
+ return {
29
+ tooLarge: true,
30
+ reason: `${sizeBytes} bytes > ${LSP_MAX_FILE_BYTES} limit`,
31
+ };
32
+ }
33
+ const lineCount = content.split("\n").length;
34
+ if (lineCount > LSP_MAX_FILE_LINES) {
35
+ return {
36
+ tooLarge: true,
37
+ reason: `${lineCount} lines > ${LSP_MAX_FILE_LINES} limit`,
38
+ };
39
+ }
40
+ return { tooLarge: false, reason: "" };
41
+ }
@@ -3792,7 +3792,7 @@ export class LSPService {
3792
3792
  // (or any synchronous throw) still reads as a rejected write rather
3793
3793
  // than rejecting the whole per-file `Promise.all`.
3794
3794
  const writePromise = entry.client.notify
3795
- .open(filePath, content, languageId, undefined, silent)
3795
+ .open(filePath, content, languageId, undefined, silent, options.saved === true)
3796
3796
  .then(() => true);
3797
3797
  // #1714: the document is now in this auxiliary's input queue,
3798
3798
  // whether or not the write settles inside our budget. Counted here
@@ -68,9 +68,30 @@ function splitPathEntries(value, delimiter) {
68
68
  .map((entry) => entry.trim())
69
69
  .filter((entry) => entry.length > 0);
70
70
  }
71
+ /**
72
+ * Dedupe key for one PATH entry, under `platform`'s rules.
73
+ *
74
+ * #1193 P3: the body used the HOST-DEFAULT `path.normalize` inside a branch
75
+ * already committed to `platform` (AGENTS.md defect shape 2), so on the ubuntu
76
+ * lane the win32 arm lowercased without folding separators or dot segments and
77
+ * `C:\Tools` / `C:/Tools` / `C:\x\..\Tools` were three keys for one directory.
78
+ * Each arm now parses with its own `path` flavor. That is the whole fix: a
79
+ * `toPosix` call on the win32 arm would be INERT — `win32.normalize` already
80
+ * answers backslashes for either input separator (measured: adding it left
81
+ * every case green) — and the POSIX arm must NOT fold separators at all,
82
+ * because a backslash is a legal POSIX filename character and `/opt/a\b` and
83
+ * `/opt/a/b` are two directories there.
84
+ *
85
+ * Ephemeral by construction: the key exists only inside one
86
+ * `combinePathValuesForPlatform` call's `seen` set — the ORIGINAL entry string
87
+ * is what survives into the joined PATH — so it never reaches a persisted or
88
+ * cross-process record and `normalizeEphemeralMapKey`'s `process.platform` read
89
+ * would defeat the explicit-`platform` seam these arms exist for.
90
+ */
71
91
  function normalizePathEntry(entry, platform) {
72
- const normalized = path.normalize(entry);
73
- return platform === "win32" ? normalized.toLowerCase() : normalized;
92
+ if (platform !== "win32")
93
+ return path.posix.normalize(entry);
94
+ return path.win32.normalize(entry).toLowerCase();
74
95
  }
75
96
  export function combinePathValuesForPlatform(values, platform = process.platform) {
76
97
  const unique = [];