pi-lens 4.2.0 → 4.3.0

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 (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -3,6 +3,7 @@ import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
3
3
  import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
4
4
  import { detectFileRole } from "./file-role.js";
5
5
  import { logLatency } from "./latency-logger.js";
6
+ import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
6
7
  import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
7
8
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
8
9
  export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
@@ -140,6 +141,12 @@ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
140
141
  },
141
142
  neighborCount: 1,
142
143
  diagnosticCount: diagnostics.length,
144
+ // #3168 F3: the #1444 publish stamp — the run's own observation time, so
145
+ // the carried-render age label states the real age instead of claiming
146
+ // no stamp exists.
147
+ ...(neighbor.publishedAt !== undefined
148
+ ? { observedAt: neighbor.publishedAt }
149
+ : {}),
143
150
  };
144
151
  }
145
152
  /**
@@ -289,3 +296,20 @@ function readNeighborContent(filePath) {
289
296
  return undefined;
290
297
  }
291
298
  }
299
+ /**
300
+ * Fix B (#3167/#3168): the carry label for a cascade run re-rendered at a
301
+ * later turn_end. The carry is bounded to ONE turn (`RuntimeCoordinator.beginTurn`
302
+ * drops anything that would reach 2), so the honest label names the carry
303
+ * count. #3168 F3 corrected this docstring's earlier claim that no stamp
304
+ * exists: the run DOES carry one (`observedAt`, threaded from #1444's
305
+ * `publishedAt` through the resolved-found plumb), so the age half renders
306
+ * from it via `formatCacheAgeLabel`. The deferred-compute re-park arm has no
307
+ * stamp and correctly falls to the neutral wording. Returns `undefined` for
308
+ * non-carried runs: no label noise on fresh observations.
309
+ */
310
+ export function cascadeCarrySuffix(carriedTurns, observedAt) {
311
+ if (carriedTurns === undefined || carriedTurns < 1)
312
+ return undefined;
313
+ const noun = carriedTurns === 1 ? "turn" : "turns";
314
+ return `(carried ${carriedTurns} ${noun} · ${formatCacheAgeLabel(observedAt)})`;
315
+ }
@@ -11,12 +11,16 @@
11
11
  * new-code-duplication finding: two near-identical spawn→collect-stdout
12
12
  * blocks in `clients/resource-sampler.ts` collapsed to one shared helper).
13
13
  *
14
- * Deliberately has NO imports beyond `node:child_process` types, so both
14
+ * Deliberately has NO imports beyond `node:child_process` types and
15
+ * `spawn-output-cap.ts` (itself import-free, #3383), so both
15
16
  * `clients/instance-reaper.ts` and `clients/resource-sampler.ts` (which
16
17
  * `clients/safe-spawn.ts` itself depends on) can import this without risking
17
- * a circular-import chain.
18
+ * a circular-import chain. That is also why the output bound below reports
19
+ * nothing to the degradation ledger: importing the ledger here would put
20
+ * `safe-spawn.ts`'s own dependency above it in the graph.
18
21
  */
19
22
  import { spawn as nodeSpawn } from "node:child_process";
23
+ import { createBoundedOutputSink } from "./spawn-output-cap.js";
20
24
  /**
21
25
  * Detach a best-effort, fire-and-forget child process from the event loop.
22
26
  *
@@ -64,13 +68,18 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
64
68
  try {
65
69
  const child = nodeSpawn(command, args, options);
66
70
  unrefChildAndPipes(child);
67
- let out = "";
68
- child.stdout?.on("data", (chunk) => {
69
- out += chunk.toString();
70
- });
71
+ // #3383: bounded, not `out += chunk`. The producer is a process-table
72
+ // query (`ps`, `wmic`, `powershell`) whose output nothing else limits,
73
+ // and the concatenation runs inside a `data` handler where V8's
74
+ // `RangeError: Invalid string length` would be an uncaught exception
75
+ // rather than this promise's result. A truncated table is the ending
76
+ // this contract already documents for a timed-out child: whatever was
77
+ // kept is parsed, and the caller reads `status`.
78
+ const out = createBoundedOutputSink();
79
+ child.stdout?.on("data", (chunk) => out.append(chunk));
71
80
  child.once("error", (error) => settle({ stdout: "", status: "spawn-error", error }));
72
81
  child.once("close", (code, signal) => settle(code === 0 && signal === null
73
- ? { stdout: out, status: "ok" }
82
+ ? { stdout: out.text, status: "ok" }
74
83
  : {
75
84
  stdout: "",
76
85
  status: "exit-error",
@@ -83,7 +92,7 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
83
92
  // Settle only AFTER the child's fate is known. Killing and
84
93
  // resolving in the same tick bounds the caller and abandons the
85
94
  // child, which is the leak this timeout exists to prevent.
86
- void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out, status: "timeout", timeoutKill }));
95
+ void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out.text, status: "timeout", timeoutKill }));
87
96
  }, timeoutMs);
88
97
  timer.unref();
89
98
  }
@@ -173,6 +173,8 @@ export const CONFIG_DIAGNOSTIC_CODES = {
173
173
  PILENS_CFG_0008: "config resolution failed; whole configuration ignored",
174
174
  /** A tool key is unknown or names a required, non-disableable tool. */
175
175
  PILENS_CFG_0009: "unknown or non-disableable tool config key ignored",
176
+ /** A lower-precedence supported global config file was shadowed by the winner. */
177
+ PILENS_CFG_0010: "lower-precedence global config file shadowed",
176
178
  };
177
179
  /** The shape every code in the namespace must match. */
178
180
  export const CONFIG_DIAGNOSTIC_CODE_PATTERN = /^PILENS_CFG_\d{4}$/;
@@ -22,12 +22,17 @@
22
22
  * exactly the registry's `kind: "file"` rows. Registry owns WHICH surfaces are
23
23
  * deprecated; this module owns only their precedence.
24
24
  *
25
- * Pure data plus one walk. No file reads, no caching, no state.
25
+ * Pure data, one walk, and — as of the global-config-location PR (refs #2457,
26
+ * #2426) — the ONE existence probe and memoized resolution that decide which
27
+ * file supplies the global tier. The probe is a single `statSync` under the
28
+ * resolution memo; no other file reads, no state beyond that one memo.
26
29
  */
30
+ import * as fs from "node:fs";
27
31
  import * as os from "node:os";
28
32
  import * as path from "node:path";
29
33
  import { DEPRECATED_CONFIG_SURFACES } from "./config-diagnostic-codes.js";
30
- import { isAtOrAboveHomeDir, walkUpDirs } from "./path-utils.js";
34
+ import { errorClassName } from "./error-class.js";
35
+ import { isAtOrAboveHomeDir, isWindowsPath, normalizeFilePath, realpathOrResolve, walkUpDirs, } from "./path-utils.js";
31
36
  /** The one canonical PROJECT config file. Never deprecated. */
32
37
  export const CANONICAL_PROJECT_CONFIG_FILE = ".pi-lens.json";
33
38
  /** Basename of the one canonical GLOBAL config file inside `~/.pi-lens/`. */
@@ -139,9 +144,167 @@ export function configSearchDirs(startDir, homeDir = os.homedir()) {
139
144
  }
140
145
  return dirs;
141
146
  }
147
+ function defaultGlobalConfigExists(file) {
148
+ try {
149
+ return fs.statSync(file).isFile();
150
+ }
151
+ catch (error) {
152
+ // ENOENT is the ordinary ABSENT answer the tier walk consumes. Any other
153
+ // stat failure (ENOTDIR when a file sits where a directory belongs,
154
+ // EACCES, ELOOP) propagates: the discriminated probe in
155
+ // `resolveGlobalConfigLocation` retains the errored tier instead of
156
+ // silently switching config sources (#3251 review H2, defect shape 48),
157
+ // and the load's read of the retained path is reported by the existing
158
+ // `PILENS_CFG_0001` seam.
159
+ if (error.code === "ENOENT")
160
+ return false;
161
+ throw error;
162
+ }
163
+ }
142
164
  /**
143
- * The absolute path of the canonical GLOBAL config — `~/.pi-lens/config.json`,
144
- * or `PI_LENS_CONFIG_PATH` when it overrides that.
165
+ * The agent-dir config path for one `PI_CODING_AGENT_DIR` value, shared by the
166
+ * resolution and the recognized-location set so the two can never spell the
167
+ * same location differently.
168
+ *
169
+ * Shape-aware joining (defect shape 2): a Windows-shaped agent dir is joined
170
+ * with `path.win32` regardless of host — the host-default `path.resolve` on
171
+ * POSIX treats a drive-letter path as RELATIVE and mangles it, which made a
172
+ * case-folded Windows spelling unmatchable (#3251 review M1).
173
+ */
174
+ function agentConfigPathFor(agentDir) {
175
+ return isWindowsPath(agentDir)
176
+ ? path.win32.join(path.win32.resolve(agentDir), "extensions", "pi-lens.json")
177
+ : path.join(path.resolve(agentDir), "extensions", "pi-lens.json");
178
+ }
179
+ /**
180
+ * Resolve which file supplies the global config tier (see
181
+ * `GlobalConfigLocationSource` for the order).
182
+ *
183
+ * Lives here rather than in `lens-config.ts` (#2426 review round 3, S1):
184
+ * this module is the one that already states "WHERE pi-lens config lives",
185
+ * and BOTH discovery seams that must refuse the resolved global path
186
+ * (`project-lens-config.ts`, `workspace-topology.ts`) already import from
187
+ * here — importing the resolver from `lens-config.ts` would close a cycle
188
+ * (`lens-config.ts` itself imports from `project-lens-config.ts`).
189
+ * `lens-config.ts` re-exports the accessors so its own, pre-existing import
190
+ * sites are unaffected.
191
+ */
192
+ export function resolveGlobalConfigLocation(options) {
193
+ // An options object means the caller OWNS the resolution context (a test
194
+ // or explicitly-scoped caller): the ambient host env must not leak into it,
195
+ // or a test that resolves-then-writes "under its own home" lands in the
196
+ // maintainer's real config (dogfooded live: ambient PI_CODING_AGENT_DIR
197
+ // redirected a suite's writeConfig into ~/.config/pi/agent/extensions/
198
+ // pi-lens.json). Owned contexts therefore ignore PI_CODING_AGENT_DIR
199
+ // entirely; only the production resolution (no options) walks the
200
+ // host-relative tier. PI_LENS_HOME does not participate in this resolution
201
+ // at all — the canonical default keeps its released homedir spelling, and
202
+ // the #2457 split-brain (which concerns the machine root, not the config
203
+ // file) stays a separate issue.
204
+ const owned = options !== undefined;
205
+ const homeDir = options?.homeDir ?? os.homedir();
206
+ const legacyDefault = path.join(homeDir, ".pi-lens", CANONICAL_GLOBAL_CONFIG_FILE);
207
+ // Byte-compatible with the released override: any non-empty value wins,
208
+ // untrimmed, exactly as before.
209
+ const override = process.env.PI_LENS_CONFIG_PATH;
210
+ if (override) {
211
+ return { path: path.resolve(override), source: "pi-lens-config-path" };
212
+ }
213
+ // The probe is DISCRIMINATED (#3251 review H2, defect shape 48): "error"
214
+ // (ENOTDIR when a file sits where a directory belongs, EACCES, ELOOP) is
215
+ // NOT absent — the errored tier is RETAINED, so the load's read of that
216
+ // same path fails and the existing `reportConfigReadFailure` seam
217
+ // (`PILENS_CFG_0001`) reports it. Absorbing the error as ABSENT would
218
+ // silently select a lower tier: different user settings applying with no
219
+ // diagnostic, and the read seam would only ever see the finally-read path,
220
+ // never the probe that failed.
221
+ let probeError;
222
+ const probe = (file) => {
223
+ try {
224
+ return (options?.exists ?? defaultGlobalConfigExists)(file)
225
+ ? "present"
226
+ : "absent";
227
+ }
228
+ catch (error) {
229
+ // The caught error is carried to the retained tier's resolution so
230
+ // the loader's retention record can name the error CLASS (never
231
+ // the message, per the #2431/#2451 rule).
232
+ probeError = error;
233
+ return "error";
234
+ }
235
+ };
236
+ const agentDir = owned ? undefined : process.env.PI_CODING_AGENT_DIR?.trim();
237
+ const agentPath = agentDir ? agentConfigPathFor(agentDir) : undefined;
238
+ const legacyProbe = probe(legacyDefault);
239
+ if (legacyProbe === "present") {
240
+ if (agentPath !== undefined && probe(agentPath) === "present") {
241
+ return {
242
+ path: legacyDefault,
243
+ source: "legacy-default-existing",
244
+ shadowedPath: agentPath,
245
+ };
246
+ }
247
+ return { path: legacyDefault, source: "legacy-default-existing" };
248
+ }
249
+ if (legacyProbe === "error") {
250
+ // Fail closed at the location the probe could not evaluate: the file
251
+ // might exist, and switching sources would silently swap the user's
252
+ // settings. The subsequent read reports the failure (`PILENS_CFG_0001`),
253
+ // and the loader emits the probe-failed retention record.
254
+ return {
255
+ path: legacyDefault,
256
+ source: "legacy-default-unprobed",
257
+ existsProbeFailed: {
258
+ path: legacyDefault,
259
+ errorClassName: errorClassName(probeError),
260
+ },
261
+ };
262
+ }
263
+ if (agentPath !== undefined) {
264
+ const agentProbe = probe(agentPath);
265
+ if (agentProbe === "present") {
266
+ return { path: agentPath, source: "pi-coding-agent-dir" };
267
+ }
268
+ if (agentProbe === "error") {
269
+ // Same rule one tier down: the agent-dir identity is retained, and
270
+ // the read of it reports the failure rather than the resolution
271
+ // silently falling to the canonical default.
272
+ return {
273
+ path: agentPath,
274
+ source: "pi-coding-agent-dir-unprobed",
275
+ existsProbeFailed: {
276
+ path: agentPath,
277
+ errorClassName: errorClassName(probeError),
278
+ },
279
+ };
280
+ }
281
+ }
282
+ return { path: legacyDefault, source: "canonical-default" };
283
+ }
284
+ let memoizedGlobalConfigResolution;
285
+ function globalConfigEnvFingerprint() {
286
+ return JSON.stringify([
287
+ process.env.PI_LENS_CONFIG_PATH,
288
+ process.env.PI_CODING_AGENT_DIR,
289
+ ]);
290
+ }
291
+ export function getProductionGlobalConfigResolution() {
292
+ const fingerprint = globalConfigEnvFingerprint();
293
+ if (memoizedGlobalConfigResolution?.fingerprint !== fingerprint) {
294
+ memoizedGlobalConfigResolution = {
295
+ fingerprint,
296
+ resolution: resolveGlobalConfigLocation(),
297
+ };
298
+ }
299
+ return memoizedGlobalConfigResolution.resolution;
300
+ }
301
+ /** Test-only: drop the memoized production resolution so the next call re-probes. */
302
+ export function resetGlobalConfigLocationCache() {
303
+ memoizedGlobalConfigResolution = undefined;
304
+ }
305
+ /**
306
+ * The absolute path of the canonical GLOBAL config (see
307
+ * `GlobalConfigLocationSource` for the full resolution order).
145
308
  *
146
309
  * Lives here rather than in `lens-config.ts` (#2426 review round 3, S1),
147
310
  * which is where it originated: this module is the one that already states
@@ -152,9 +315,74 @@ export function configSearchDirs(startDir, homeDir = os.homedir()) {
152
315
  * `project-lens-config.ts`. `lens-config.ts` re-exports this so its own,
153
316
  * pre-existing import sites are unaffected.
154
317
  */
155
- export function getPiLensGlobalConfigPath(homeDir = os.homedir()) {
156
- const override = process.env.PI_LENS_CONFIG_PATH;
157
- if (override)
158
- return path.resolve(override);
159
- return path.join(homeDir, ".pi-lens", CANONICAL_GLOBAL_CONFIG_FILE);
318
+ export function getPiLensGlobalConfigPath(homeDir) {
319
+ return homeDir === undefined
320
+ ? getProductionGlobalConfigResolution().path
321
+ : resolveGlobalConfigLocation({ homeDir }).path;
322
+ }
323
+ /**
324
+ * Whether `candidate` matches ANY location the global config tier recognizes —
325
+ * the resolved file, the legacy default, and the agent-dir target.
326
+ *
327
+ * The one predicate every project-config discovery seam consults before it
328
+ * adopts a candidate (#783's nested layering, the upward walk, and the
329
+ * workspace marker index): a file the global tier recognizes must not be
330
+ * discovered a second time as a project config — the discovery loader would
331
+ * full-validate it, ignore every global-only key in it, and advise the user
332
+ * to set those keys in the very file it is ignoring. The check covers the
333
+ * POTENTIAL set, not just whichever file currently won the read: a second
334
+ * recognized file that exists but lost the tier walk (the grandfathered
335
+ * legacy file beside an adopted agent-dir file, say) is equally not a project
336
+ * config.
337
+ *
338
+ * Comparison is alias-anchored on BOTH sides (#3251 review M1 round 2):
339
+ * each path is canonicalized through `realpathOrResolve` (resolving existing
340
+ * symlink aliases; a nonexistent path falls back to its resolved spelling —
341
+ * the documented fallback) and then `normalizeFilePath` (separator-agnostic,
342
+ * case-insensitive on Windows, casing-adoption). A symlinked-home alias of a
343
+ * recognized location is therefore the same identity as its real spelling,
344
+ * and a case-folded or separator-differing Windows spelling cannot dodge the
345
+ * refusal either.
346
+ */
347
+ export function isResolvedGlobalConfigPath(candidate) {
348
+ return recognizedGlobalConfigPaths().has(canonicalPathIdentity(candidate));
349
+ }
350
+ /**
351
+ * Canonical path identity for one candidate: realpath-anchored so an
352
+ * existing symlink alias resolves to the same identity as its real spelling
353
+ * (#3251 review M1 round 2), with `realpathOrResolve`'s documented
354
+ * nonexistent-path fallback (the resolved spelling, dot-folded and
355
+ * case-normalized by `normalizeFilePath` — which pays its own `realpath`
356
+ * only when the path exists). The discovery seams consult this predicate
357
+ * per config candidate, and the env-derived recognized set is canonicalized
358
+ * with the SAME helper once per env fingerprint, so both sides of the
359
+ * comparison share one identity function.
360
+ */
361
+ export function canonicalPathIdentity(candidate) {
362
+ // Shape-aware (defect shape 2): a Windows-shaped path is resolved with
363
+ // `path.win32` — the host resolver on POSIX treats a drive-letter path as
364
+ // RELATIVE and mangles it before any comparison could run. On a real
365
+ // Windows host this arm is the native one, and `normalizeFilePath`'s
366
+ // Windows branch does the alias resolution itself (realpathSync.native
367
+ // plus canonical-casing adoption).
368
+ if (isWindowsPath(candidate)) {
369
+ return normalizeFilePath(path.win32.resolve(candidate));
370
+ }
371
+ return normalizeFilePath(realpathOrResolve(candidate));
372
+ }
373
+ let recognizedCache;
374
+ function recognizedGlobalConfigPaths() {
375
+ const fingerprint = globalConfigEnvFingerprint();
376
+ if (recognizedCache?.fingerprint !== fingerprint) {
377
+ const paths = new Set();
378
+ const override = process.env.PI_LENS_CONFIG_PATH;
379
+ if (override)
380
+ paths.add(canonicalPathIdentity(path.resolve(override)));
381
+ paths.add(canonicalPathIdentity(path.join(os.homedir(), ".pi-lens", CANONICAL_GLOBAL_CONFIG_FILE)));
382
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
383
+ if (agentDir)
384
+ paths.add(canonicalPathIdentity(agentConfigPathFor(agentDir)));
385
+ recognizedCache = { fingerprint, paths };
386
+ }
387
+ return recognizedCache.paths;
160
388
  }
@@ -60,7 +60,7 @@ import * as fs from "node:fs";
60
60
  import * as os from "node:os";
61
61
  import * as path from "node:path";
62
62
  import { DEPRECATED_CONFIG_SURFACES, } from "./config-diagnostic-codes.js";
63
- import { CANONICAL_GLOBAL_CONFIG_FILE, CANONICAL_PROJECT_CONFIG_FILE, GLOBAL_CONFIG_LOCATIONS, LEGACY_ROOT_LSP_KEYS, LSP_NAMESPACE_KEY, PROJECT_CONFIG_LOCATIONS, configSearchDirs, } from "./config-locations.js";
63
+ import { CANONICAL_GLOBAL_CONFIG_FILE, CANONICAL_PROJECT_CONFIG_FILE, GLOBAL_CONFIG_LOCATIONS, isResolvedGlobalConfigPath, LEGACY_ROOT_LSP_KEYS, LSP_NAMESPACE_KEY, PROJECT_CONFIG_LOCATIONS, configSearchDirs, } from "./config-locations.js";
64
64
  // The core's halves are imported directly rather than through
65
65
  // `config-core/index.js` (#2426). The barrel re-exports the whole vocabulary
66
66
  // including `process-spec.js`, which reaches `project-trust.js` and the
@@ -126,6 +126,12 @@ function collectDocuments(dir, locations, tier, onReadError) {
126
126
  const documents = [];
127
127
  for (const location of locations) {
128
128
  const file = path.join(dir, location.relativePath);
129
+ // The global tier reads its file by absolute path; a candidate that IS
130
+ // that file is refused rather than adopted as a project/nested-project
131
+ // document, which would double-read and double-validate it
132
+ // (global-config-location PR, refs #2457).
133
+ if (tier !== "global" && isResolvedGlobalConfigPath(file))
134
+ continue;
129
135
  const outcome = readConfigDocument(file);
130
136
  if (outcome.status === "missing")
131
137
  continue;
@@ -37,10 +37,21 @@ let ledgerGeneration = 0;
37
37
  export function getDegradationLedgerGeneration() {
38
38
  return ledgerGeneration;
39
39
  }
40
+ /**
41
+ * Test-only population probe for bounded-container regressions. Keep this
42
+ * read-only and deliberately expose sizes, not the retained identities.
43
+ */
44
+ export function _getDegradationLedgerStateForTests() {
45
+ return {
46
+ onceKeys: onceKeys.size,
47
+ tallies: tallies.size,
48
+ retainedEntries: [...groups.values()].reduce((total, group) => total + group.entries.length, 0),
49
+ };
50
+ }
40
51
  export function recordDegradation(record) {
41
52
  try {
42
53
  const kind = boundedKind(record.kind);
43
- const subject = truncateForLedger(record.subject);
54
+ const subject = subjectForLedger(record.subject);
44
55
  const reason = truncateForLedger(record.reason);
45
56
  let group = groups.get(kind);
46
57
  if (!group) {
@@ -67,7 +78,7 @@ export function recordDegradation(record) {
67
78
  export function recordDegradationOnce(record) {
68
79
  try {
69
80
  const kind = boundedKind(record.kind);
70
- const subject = truncateForLedger(record.subject);
81
+ const subject = subjectForLedger(record.subject);
71
82
  const key = `${kind}\0${subject}`;
72
83
  if (onceKeys.has(key))
73
84
  return;
@@ -96,7 +107,7 @@ export function recordDegradationOnce(record) {
96
107
  export function incrementDegradationCount(record) {
97
108
  try {
98
109
  const kind = boundedKind(record.kind);
99
- const subject = truncateForLedger(record.subject);
110
+ const subject = subjectForLedger(record.subject);
100
111
  const reason = truncateForLedger(record.reason);
101
112
  const key = `${kind}\0${subject}`;
102
113
  const count = (tallies.get(key) ?? 0) + 1;
@@ -172,6 +183,25 @@ function boundLedgerMetadata(metadata) {
172
183
  function isPowerOfTwo(value) {
173
184
  return value > 0 && (value & (value - 1)) === 0;
174
185
  }
186
+ /**
187
+ * A subject is an IDENTITY: it is the row's discriminator, the `filePath` of
188
+ * its durable latency row, and the text `renderDegradationLines` prints after
189
+ * the count. A blank one names nothing — `⚠ kind: 1 — : reason` — so blank and
190
+ * missing are the same case and both read `unknown` (#3389 verify round 2,
191
+ * F3395-02: a socket error carrying `code: ""` wrote an empty subject).
192
+ *
193
+ * This is deliberately NOT `normalizeForLedger`'s job, though every ledger
194
+ * field passes through it: that normalizer keeps falsy primitives on purpose
195
+ * (`tests/clients/ledger-bounds.test.ts` "keeps primitives, including falsy
196
+ * ones"), because a metadata VALUE of `""` or `0` is data, not absence. Only
197
+ * the identity fields fold blank into missing, and `0`/`false` still name
198
+ * something here too — the check reads the NORMALIZED text, never the caller's
199
+ * truthiness.
200
+ */
201
+ function subjectForLedger(value) {
202
+ const subject = truncateForLedger(value);
203
+ return subject.trim() === "" ? "unknown" : subject;
204
+ }
175
205
  function boundedKind(value) {
176
206
  const kind = truncateForLedger(value);
177
207
  if (groups.has(kind) || kind === OVERFLOW_KIND)