@herbertgao/pi-extensions 2026.9.15 → 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 (195) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  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-lens/CHANGELOG.md +498 -0
  10. package/node_modules/pi-lens/README.md +45 -2
  11. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  12. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  13. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  14. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  15. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  16. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  17. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  18. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  19. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  20. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  21. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  22. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  23. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  24. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  25. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  26. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  64. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  65. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  66. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  67. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  68. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  69. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  70. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  71. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  72. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  73. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  74. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  75. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  76. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  77. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  78. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  79. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  81. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  82. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  83. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  84. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  85. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  86. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  87. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  88. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  89. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  90. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  91. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  92. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  93. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  94. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  95. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  96. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  97. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  98. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  99. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  100. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  101. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  102. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  103. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  104. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  105. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  106. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  107. package/node_modules/pi-lens/dist/index.js +5695 -3733
  108. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  109. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  110. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  111. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  112. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  113. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  123. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  124. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  125. package/node_modules/pi-lens/docs/configuration.md +55 -1
  126. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  127. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  128. package/node_modules/pi-lens/docs/features.md +2 -2
  129. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  130. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  131. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  132. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  133. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  134. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  135. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  136. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  137. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  138. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  139. package/node_modules/pi-lens/docs/settings.md +12 -3
  140. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  141. package/node_modules/pi-lens/package.json +5 -3
  142. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  157. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  158. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  159. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  160. package/node_modules/pi-mcp-adapter/README.md +12 -5
  161. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  162. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  163. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  164. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  166. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  168. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  170. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  171. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  173. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  175. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  176. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  177. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  178. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  179. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  180. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  181. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  182. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  183. package/node_modules/pi-mcp-adapter/package.json +3 -2
  184. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  186. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  187. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  188. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  189. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  190. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  191. package/node_modules/pi-next-cue/LICENSE +21 -0
  192. package/node_modules/pi-next-cue/README.md +81 -0
  193. package/node_modules/pi-next-cue/index.ts +358 -0
  194. package/node_modules/pi-next-cue/package.json +28 -0
  195. package/package.json +8 -5
@@ -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)
@@ -8,6 +8,7 @@
8
8
  * Requires: npm install -D madge
9
9
  * Docs: https://github.com/pahen/madge
10
10
  */
11
+ import { recordDegradationOnce } from "./degradation-ledger.js";
11
12
  import { createSubsystemLogger } from "./extension-log.js";
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
@@ -36,34 +37,79 @@ export function buildMadgeArgs(target, projectRoot) {
36
37
  if (fs.existsSync(tsConfig)) {
37
38
  args.push("--ts-config", tsConfig);
38
39
  }
39
- // --warning surfaces files madge couldn't resolve into the graph (to stderr;
40
- // stdout JSON is unaffected). Without it those skips are SILENT — and a
41
- // skipped *local* file could hide a real cycle. We log them (see
42
- // parseMadgeSkips) instead of discarding stderr.
43
- args.push("--warning", "--json", target);
40
+ // No `--warning`: madge gates it on `!program.json` (bin/cli.js:195) and this
41
+ // argv always passes `--json`, so the flag is inert; ungated it prints the
42
+ // skipped-file list to STDOUT (lib/output.js), which would corrupt the JSON
43
+ // this reader parses. The skip list is therefore unreachable through any
44
+ // argv this builder emits — `parseMadgeSkips` could only ever return zeros
45
+ // (#3436). The lost visibility is disclosed once per session/root by
46
+ // `recordDegradationOnce` from `parseMadgeCycles`, the one reader both
47
+ // madge lanes pass through, instead of parsed from bytes madge never
48
+ // writes.
49
+ args.push("--json", target);
44
50
  return args;
45
51
  }
46
52
  /**
47
- * Parse madge's `--warning` stderr for skipped (unresolvable) files. External
48
- * package specifiers (bare names / subpaths like `web-tree-sitter`,
49
- * `vitest/config`) are expected — madge doesn't traverse node_modules. We flag
50
- * only **local** skips (relative/absolute paths), which are the ones that could
51
- * silently drop an internal edge and hide a cycle.
53
+ * The ONE reader of madge's `--circular --json` contract (#3428).
52
54
  *
53
- * @returns total skip count and the subset that look local.
55
+ * madge prints an array of cycles, each cycle an array of member paths —
56
+ * `[["src/a.ts", "src/b.ts"]]` — not a dependency graph. Upstream 8.0.0:
57
+ * `bin/cli.js:258-268` hands `Madge.circular()` (an Array, `lib/api.js:103`) to
58
+ * `output.circular`, which for `--json` is `printJSON(circular)`
59
+ * (`lib/output.js:71-73`). The graph OBJECT is what `--json` prints WITHOUT
60
+ * `--circular`, and `buildMadgeArgs` always passes `--circular`.
61
+ *
62
+ * This function exists because there were two readers and they disagreed: the
63
+ * whole-project scan read the array with `Object.entries`, so every cycle it
64
+ * reported was anchored at the array INDEX (`<root>/0`, a path that does not
65
+ * exist) and every member was resolved against the extension host's cwd. Both
66
+ * callers now share this one reader.
67
+ *
68
+ * `baseDir` is the directory madge's relative members resolve against, which is
69
+ * the TARGET it was pointed at (measured, madge 8.0.0: `--json <root>` prints
70
+ * `src/a.ts`; `--json <root>/src/a.ts` prints `a.ts`), so each caller passes
71
+ * the base its own target implies.
72
+ *
73
+ * Malformed JSON throws, which both callers already treat as "did not parse". A
74
+ * parse that is not an array does NOT throw: it yields zero cycles, so an
75
+ * unexpected shape reports no findings instead of turning a completed scan into
76
+ * a failed one (#2154 — a failed madge scan is a COLD lane in `mode=full`).
77
+ *
78
+ * This reader is also the ONE place both madge lanes (the turn-end
79
+ * `runMadgeSpawn` and the session-start `runScanProject`) turn bytes into
80
+ * findings, so the argv's blind spot is recorded here rather than once per
81
+ * caller (#3436). `projectRoot` is the ledger subject, not the resolve
82
+ * `baseDir`: a single-file target resolves members against its own directory
83
+ * but the gap is a property of the project's scan, disclosed once per
84
+ * session/root.
54
85
  */
55
- export function parseMadgeSkips(stderr) {
56
- const lines = (stderr || "").split(/\r?\n/);
57
- const headerIdx = lines.findIndex((l) => /Skipped\s+\d+\s+file/i.test(l));
58
- if (headerIdx === -1)
59
- return { total: 0, local: [] };
60
- const total = Number.parseInt(lines[headerIdx].match(/Skipped\s+(\d+)/i)?.[1] ?? "0", 10) || 0;
61
- const specifiers = lines
62
- .slice(headerIdx + 1)
63
- .map((l) => l.trim())
64
- .filter(Boolean);
65
- const local = specifiers.filter((s) => s.startsWith(".") || s.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(s));
66
- return { total, local };
86
+ function parseMadgeCycles(stdout, baseDir, projectRoot) {
87
+ const parsed = JSON.parse(stdout || "[]");
88
+ const cycles = Array.isArray(parsed) ? parsed : [];
89
+ const circular = [];
90
+ const circularFiles = new Set();
91
+ for (const cycle of cycles) {
92
+ const resolvedPaths = cycle.map((f) => path.resolve(baseDir, f));
93
+ for (const f of resolvedPaths) {
94
+ circularFiles.add(f);
95
+ }
96
+ circular.push({
97
+ file: resolvedPaths[0],
98
+ path: resolvedPaths,
99
+ });
100
+ }
101
+ // madge's `--warning` is inert under `--json` (bin/cli.js:195 gates it on
102
+ // `!program.json`) and, ungated, prints the skip list to stdout where it
103
+ // would corrupt the JSON this reader parses, so no argv `buildMadgeArgs`
104
+ // emits can report which LOCAL files madge failed to resolve. A skipped
105
+ // local file can hide a real cycle, so the gap is disclosed once per
106
+ // session/root instead of being read as a clean graph (#3436).
107
+ recordDegradationOnce({
108
+ kind: "madge-skip-visibility-unavailable",
109
+ subject: projectRoot,
110
+ reason: "madge --warning is inert under --json; skipped-file visibility is unavailable for this scan",
111
+ });
112
+ return { circular, circularFiles };
67
113
  }
68
114
  /**
69
115
  * Defensive cap on concurrent madge spawns for a single `checkFilesBatch`
@@ -520,7 +566,6 @@ export class DependencyChecker {
520
566
  circular: spawnResult.circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
521
567
  checked: true,
522
568
  cacheHit: false,
523
- localSkips: spawnResult.localSkips,
524
569
  };
525
570
  }
526
571
  /**
@@ -542,31 +587,20 @@ export class DependencyChecker {
542
587
  this.log(`Check error: ${result.error.message}`);
543
588
  return { ok: false };
544
589
  }
545
- const output = result.stdout || "[]";
546
- const parsed = JSON.parse(output);
547
- // Madge --circular --json returns array of cycle arrays: [["a.ts", "b.ts"], ...]
548
- const cycles = Array.isArray(parsed) ? parsed : [];
549
- const circular = [];
550
- const circularFiles = new Set();
551
- for (const cycle of cycles) {
552
- const resolvedPaths = cycle.map((f) => path.resolve(projectRoot, f));
553
- for (const f of resolvedPaths) {
554
- circularFiles.add(f);
555
- }
556
- circular.push({
557
- file: resolvedPaths[0],
558
- path: resolvedPaths,
559
- });
560
- }
561
- const skips = parseMadgeSkips(result.stderr || "");
562
- if (skips.local.length > 0) {
563
- this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`);
564
- }
590
+ // madge resolves its printed members against the TARGET, which this
591
+ // lane points at a single FILE, so they come back relative to that
592
+ // file's directory — `a.ts`/`b.ts` for a `src/a.ts` target, and
593
+ // `../lib/b.ts` for a cycle that leaves it (measured with madge 8.0.0,
594
+ // see parseMadgeCycles). Resolving them against `projectRoot` instead
595
+ // anchors `<root>/a.ts`, a path that does not exist, and keys the
596
+ // shared `circularFiles` set with it (#3435). `path.dirname` of the
597
+ // target is the file-independent form of "the directory madge printed
598
+ // relative to"; a top-level target degenerates to `projectRoot`.
599
+ const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", path.dirname(normalized), projectRoot);
565
600
  return {
566
601
  ok: true,
567
602
  circular,
568
603
  circularFiles,
569
- localSkips: skips.local.length,
570
604
  };
571
605
  }
572
606
  catch (err) {
@@ -729,7 +763,6 @@ export class DependencyChecker {
729
763
  circular: spawnResult.circular.filter((d) => d.file === entry.normalized || d.path.includes(entry.normalized)),
730
764
  checked: true,
731
765
  cacheHit: false,
732
- localSkips: spawnResult.localSkips,
733
766
  });
734
767
  }
735
768
  // A batch that learned nothing publishes nothing — advancing the
@@ -800,25 +833,15 @@ export class DependencyChecker {
800
833
  this.log(`Scan error: ${result.error.message}`);
801
834
  return { circular: [], count: 0 };
802
835
  }
803
- const output = result.stdout || "{}";
804
- const data = JSON.parse(output);
805
- const circular = [];
806
- const circularFiles = new Set();
807
- for (const [file, deps] of Object.entries(data)) {
808
- if (Array.isArray(deps) && deps.length > 0) {
809
- const resolvedFile = path.resolve(file);
810
- circularFiles.add(resolvedFile);
811
- circular.push({
812
- file: resolvedFile,
813
- path: [resolvedFile, ...deps.map((d) => path.resolve(d))],
814
- });
815
- }
816
- }
836
+ // The scan's target IS `projectRoot`, so that is the base madge's
837
+ // relative members resolve against (#3428: this site used to read the
838
+ // cycle array as a graph and anchor every finding at `<hostCwd>/0`).
839
+ const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", projectRoot, projectRoot);
817
840
  this.publishState(gen, circular, circularFiles);
818
- // #2154: the one madge site that parsed a graph for this root. Every
819
- // other return here (missing root, no top-level source file, madge
820
- // unavailable, spawn error, parse throw) is the SAME empty shape and
821
- // must not be read as "no cycles in this project".
841
+ // #2154: the one madge site that parsed madge's output for this root.
842
+ // Every other return here (missing root, no top-level source file,
843
+ // madge unavailable, spawn error, parse throw) is the SAME empty shape
844
+ // and must not be read as "no cycles in this project".
822
845
  return { circular, count: circular.length, analyzed: true };
823
846
  }
824
847
  catch (err) {
@@ -118,10 +118,42 @@ export const normalizeMessage = sharedNormalizeMessage;
118
118
  // file, and the module doc/comments above referencing `relativeFile`, keep
119
119
  // working unchanged.
120
120
  const relativeFile = sharedRelativeFile;
121
+ /**
122
+ * The SPAN component of a strict anchor: the `lineContentHash` of the
123
+ * diagnostic's OWN line in `content`. `undefined` when there is no line to
124
+ * hash — no content, no line, or a line outside the content's range.
125
+ *
126
+ * Exported for #3183. The widget store's retention lifetime asks the same
127
+ * question a strict anchor does — "is the marked line still in this file?" —
128
+ * so it reuses this derivation rather than re-spelling it; a second spelling is
129
+ * how the store's answer drifts from the filter's (the retention identity's own
130
+ * drift from `computeWeakAnchor`'s parts cost #3158 a round). Note what is NOT
131
+ * here: the cwd/path component `stableFindingId` adds. Both sides of a
132
+ * retention comparison are the same file, so the span alone discriminates
133
+ * exactly as well while needing no `cwd` — which is why the store can ask it at
134
+ * all (see `retentionIdentity` in `clients/widget-state.ts`).
135
+ */
136
+ export function strictAnchorSpan(content, line) {
137
+ if (content === undefined || line === undefined)
138
+ return undefined;
139
+ const lines = content.split(/\r?\n/);
140
+ if (line < 1 || line > lines.length)
141
+ return undefined;
142
+ return lineContentHash(lines[line - 1] ?? "");
143
+ }
144
+ /**
145
+ * Every span {@link strictAnchorSpan} could produce for `content` — one hash
146
+ * per line (#3183). Membership answers "does the line a mark was made against
147
+ * still exist in this file", wherever it has since moved to.
148
+ */
149
+ export function strictAnchorSpansIn(content) {
150
+ const spans = new Set();
151
+ for (const line of content.split(/\r?\n/))
152
+ spans.add(lineContentHash(line));
153
+ return spans;
154
+ }
121
155
  /** Site-specific anchor — see module doc. Used only for false-positive. */
122
156
  export function computeStrictAnchor(args) {
123
- const lines = args.content?.split(/\r?\n/);
124
- const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
125
157
  return stableFindingId("dd:", {
126
158
  cwd: args.cwd,
127
159
  filePath: args.filePath,
@@ -129,7 +161,11 @@ export function computeStrictAnchor(args) {
129
161
  args.tool ?? "",
130
162
  args.rule ?? "",
131
163
  normalizeMessage(args.message),
132
- lineContentHash(lineText),
164
+ // An absent/out-of-range line hashes the empty string, exactly as the
165
+ // pre-#3183 `lines[args.line - 1] ?? ""` fallback did: stable, but less
166
+ // resistant to a same-file/rule/message collision (see
167
+ // `DispositionAnchorArgs.content`).
168
+ strictAnchorSpan(args.content, args.line) ?? lineContentHash(""),
133
169
  ],
134
170
  });
135
171
  }