@herbertgao/pi-extensions 2026.9.15 → 2026.9.17

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 (197) 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/@herbertgao/sol-pi/package.json +1 -1
  5. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
  6. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  7. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  8. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  9. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  10. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  11. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  12. package/node_modules/pi-lens/README.md +45 -2
  13. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  14. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  15. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  16. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  17. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  19. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  20. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  21. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  22. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  23. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  24. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  25. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  26. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  66. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  67. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  68. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  69. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  70. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  71. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  72. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  73. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  74. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  75. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  77. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  78. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  79. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  80. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  81. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  82. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  83. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  84. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  86. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  87. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  88. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  89. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  90. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  91. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  92. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  93. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  94. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  95. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  96. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  97. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  98. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  99. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  100. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  101. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  102. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  103. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  104. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  105. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  106. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  107. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  108. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  109. package/node_modules/pi-lens/dist/index.js +5695 -3733
  110. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  111. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  112. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  113. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  114. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  115. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  116. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  117. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  118. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  119. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  120. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  121. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  122. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  123. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  124. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  125. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  126. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  127. package/node_modules/pi-lens/docs/configuration.md +55 -1
  128. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  129. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  130. package/node_modules/pi-lens/docs/features.md +2 -2
  131. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  132. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  133. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  134. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  135. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  136. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  137. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  138. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  139. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  140. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  141. package/node_modules/pi-lens/docs/settings.md +12 -3
  142. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  143. package/node_modules/pi-lens/package.json +5 -3
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  157. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  158. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  159. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  160. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  161. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  162. package/node_modules/pi-mcp-adapter/README.md +12 -5
  163. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  164. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  166. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  168. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  170. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  171. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  173. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  175. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  176. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  177. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  178. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  179. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  180. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  181. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  182. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  183. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  184. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/package.json +3 -2
  186. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  187. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  188. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  189. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  190. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  191. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  192. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  193. package/node_modules/pi-next-cue/LICENSE +21 -0
  194. package/node_modules/pi-next-cue/README.md +81 -0
  195. package/node_modules/pi-next-cue/index.ts +358 -0
  196. package/node_modules/pi-next-cue/package.json +28 -0
  197. package/package.json +9 -6
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Disposition/rule/inline-suppression policy for the turn-end
3
+ * "Unresolved from this turn" blocker replay (#3246).
4
+ *
5
+ * `runtime-turn.ts` re-serves every live `InlineBlockerRecord` the turn's
6
+ * dispatches left behind. Until this module, it pushed the record's
7
+ * ALREADY-RENDERED `summary` string verbatim, so a `lens_diagnostic_mark`
8
+ * made after the record was written had nothing to act on: the durable
9
+ * disposition store was honored by `lens_diagnostics`, the widget, the cached
10
+ * scanner lanes, late auxiliary and cascade, while this one surface replayed
11
+ * the pre-mark text on every later turn — including turns that edited only an
12
+ * unrelated file. That is #3246's report.
13
+ *
14
+ * The fix is the same shape `blocker-past-eof.ts` already argued for the
15
+ * `lines` field: carry the STRUCTURED diagnostics the summary was rendered
16
+ * from (`dispatchResult.blockers`, via `PipelineResult.inlineBlockerDiagnostics`
17
+ * and `InlineBlockerRecord.diagnostics`) and re-derive at read time, rather
18
+ * than regex-parsing a string built for human display. With the diagnostics in
19
+ * hand this module runs the ONE filter stack every other findings surface runs
20
+ * — `clients/dispatch/finding-policy.ts`'s inline `pi-lens-ignore` → stored
21
+ * dispositions → `.pi-lens.json` rule policy — against the file's CURRENT
22
+ * bytes, and re-renders the survivors with `formatDiagnostics(..., "blocking")`,
23
+ * the SAME renderer `dispatcher.ts` used to build the stored summary. No second
24
+ * anchor implementation and no second renderer: an unmarked record therefore
25
+ * re-renders byte-identically to what dispatch produced.
26
+ *
27
+ * Ordering (#3246 mutation (c)): the policy runs over the WHOLE candidate set
28
+ * and `formatDiagnostics` applies its 10-row display cap to the survivors.
29
+ * Filtering after the cap would leave a marked finding occupying a display slot
30
+ * and hide an unmarked one behind "... and N more" — the cap-before-policy
31
+ * shape `cascade-format.ts` already avoids.
32
+ */
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import { recordDegradationOnce } from "./degradation-ledger.js";
36
+ import { hasAnyDispositionMarks } from "./diagnostic-dispositions.js";
37
+ import { applyFindingPolicy, inlineBlockerIdentities, loadProjectRulePolicyMap, } from "./dispatch/finding-policy.js";
38
+ import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
39
+ import { formatDiagnostics } from "./dispatch/utils/format-utils.js";
40
+ /**
41
+ * The file's CURRENT bytes, or `""` when they cannot be read.
42
+ *
43
+ * `""` is `FindingPolicyOptions.content`'s documented fail-open input: inline
44
+ * suppression becomes a no-op and the STRICT anchor hashes an empty line, so it
45
+ * cannot match a mark made against real content and the finding stays VISIBLE.
46
+ * Never hide a blocking finding over an I/O error (AGENTS.md shape 48).
47
+ */
48
+ function readCurrentContent(filePath) {
49
+ try {
50
+ return fs.readFileSync(filePath, "utf-8");
51
+ }
52
+ catch {
53
+ return "";
54
+ }
55
+ }
56
+ /**
57
+ * Apply the shared finding policy to one live inline-blocker record.
58
+ *
59
+ * Diagnostics are grouped by the file they were raised AGAINST, not by the
60
+ * record's own path: `dispatchResult.blockers` is pooled across every runner
61
+ * dispatched for the edited file, and a chart-wide runner (helm-lint,
62
+ * helm-render) reports blocking diagnostics against sibling files — the same
63
+ * cross-file population `pipeline.ts` filters out of `inlineBlockerLines`.
64
+ * Anchoring those against the record's path would derive the wrong strict
65
+ * anchor and read the wrong file's `pi-lens-ignore` comments.
66
+ */
67
+ export function applyInlineBlockerPolicy(record, cwd) {
68
+ const diagnostics = record.diagnostics;
69
+ if (!diagnostics || diagnostics.length === 0) {
70
+ // Bounded per (kind, subject) by the ledger itself — this condition
71
+ // recurs every turn end for the same record, so a counted row would
72
+ // grow without adding information (AGENTS.md shapes 13/17). Recorded
73
+ // ONLY when the project actually holds marks: with an empty store there
74
+ // is nothing this record failed to honor, and a row every session for
75
+ // every unstructured record would be noise, not a degradation.
76
+ if (hasAnyDispositionMarks(cwd)) {
77
+ recordDegradationOnce({
78
+ kind: "inline-blocker-unstructured",
79
+ subject: `inline-blocker:${toRunnerDisplayPath(cwd, record.filePath)}`,
80
+ reason: "record carries no structured diagnostics; disposition marks cannot be applied to its rendered summary",
81
+ });
82
+ }
83
+ return {
84
+ body: record.summary,
85
+ candidates: 0,
86
+ kept: 0,
87
+ suppressed: 0,
88
+ unstructured: true,
89
+ };
90
+ }
91
+ const byFile = new Map();
92
+ for (const diagnostic of diagnostics) {
93
+ // Resolved against the PROJECT root, never `process.cwd()`: a runner that
94
+ // reports a project-relative path would otherwise be grouped under the
95
+ // agent host's working directory and read the wrong file's bytes. No
96
+ // case/separator fold on the key (catalog shape 1): two spellings of one
97
+ // file would only cost a duplicate read here — `anchorsForDiagnostic`
98
+ // canonicalizes both the cwd and the file path itself, so the anchors,
99
+ // and therefore the verdict, are identical either way.
100
+ const owner = path.resolve(cwd, diagnostic.filePath);
101
+ const group = byFile.get(owner);
102
+ if (group)
103
+ group.push(diagnostic);
104
+ else
105
+ byFile.set(owner, [diagnostic]);
106
+ }
107
+ // `loadPiLensProjectConfig` is mtime-cached, so several groups cost one stat.
108
+ const policyMap = loadProjectRulePolicyMap(cwd);
109
+ const dropped = new Set();
110
+ for (const [filePath, group] of byFile) {
111
+ const { kept } = applyFindingPolicy(group, {
112
+ cwd,
113
+ filePath,
114
+ content: readCurrentContent(filePath),
115
+ policyMap,
116
+ identities: inlineBlockerIdentities,
117
+ });
118
+ const survivors = new Set(kept);
119
+ for (const diagnostic of group) {
120
+ if (!survivors.has(diagnostic))
121
+ dropped.add(diagnostic);
122
+ }
123
+ }
124
+ const kept = dropped.size === 0
125
+ ? [...diagnostics]
126
+ : diagnostics.filter((diagnostic) => !dropped.has(diagnostic));
127
+ return {
128
+ body: kept.length === 0
129
+ ? undefined
130
+ : formatDiagnostics(kept, "blocking").trim(),
131
+ candidates: diagnostics.length,
132
+ kept: kept.length,
133
+ suppressed: dropped.size,
134
+ unstructured: false,
135
+ };
136
+ }
137
+ /**
138
+ * How many file/tool identities {@link summarizeInlineBlockerPolicy}'s one
139
+ * per-turn record names. The COUNTS are always exact; only the identity lists
140
+ * are capped, so a turn that touched a hundred files writes a bounded row
141
+ * rather than a file listing (AGENTS.md shape 17).
142
+ */
143
+ const IDENTITY_CAP = 10;
144
+ /**
145
+ * The ONE bounded record the turn writes for this pass — never one per
146
+ * finding. Derived here rather than accumulated at the call site so the shape
147
+ * and its bound live with the policy they describe.
148
+ */
149
+ export function summarizeInlineBlockerPolicy(entries) {
150
+ const files = [];
151
+ const tools = new Set();
152
+ let stale = 0;
153
+ let candidates = 0;
154
+ let kept = 0;
155
+ let suppressed = 0;
156
+ let unstructured = 0;
157
+ for (const entry of entries) {
158
+ for (const tool of entry.sources ?? [])
159
+ tools.add(tool);
160
+ if (entry.stale)
161
+ stale += 1;
162
+ const outcome = entry.outcome;
163
+ if (outcome === undefined)
164
+ continue;
165
+ candidates += outcome.candidates;
166
+ kept += outcome.kept;
167
+ suppressed += outcome.suppressed;
168
+ if (outcome.unstructured)
169
+ unstructured += 1;
170
+ // The files a mark actually acted on — the identity a reader needs to
171
+ // reconstruct a replay report.
172
+ if (outcome.suppressed > 0 && files.length < IDENTITY_CAP) {
173
+ files.push(entry.displayPath);
174
+ }
175
+ }
176
+ return {
177
+ records: entries.length,
178
+ stale,
179
+ candidates,
180
+ kept,
181
+ dispositionSuppressed: suppressed,
182
+ unstructured,
183
+ files,
184
+ tools: [...tools].slice(0, IDENTITY_CAP),
185
+ };
186
+ }
@@ -13,6 +13,10 @@ import { createRequire } from "node:module";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ // The shared web-tree-sitter ladder imports nothing but node:fs/node:path, so
17
+ // importing it keeps this module's "loads even when the deps that failed are
18
+ // unreachable" property intact.
19
+ import { resolveWebTreeSitterPackageDir } from "../scripts/lib/web-tree-sitter-dir.mjs";
16
20
  const require = createRequire(import.meta.url);
17
21
  const ISSUES_URL = "https://github.com/apmantza/pi-lens/issues";
18
22
  /** Runtime third-party deps whose resolution is the documented failure surface. */
@@ -88,12 +92,21 @@ export function collectInstallDiagnostics() {
88
92
  return ["ast-grep", "ast-grep.exe", "sg", "sg.exe"].some((b) => fs.existsSync(path.join(dir, b)));
89
93
  }, false);
90
94
  const grammars = safe(() => {
91
- let dir = path.dirname(require.resolve("web-tree-sitter"));
92
- while (path.basename(dir) !== "web-tree-sitter" &&
93
- dir !== path.dirname(dir)) {
94
- dir = path.dirname(dir);
95
- }
96
- return fs.existsSync(path.join(dir, "grammars", "tree-sitter-typescript.wasm"));
95
+ // #3409 (sweep sibling of the same shape): this probe used to walk up from
96
+ // the BARE `web-tree-sitter` specifier, so on the host pi ships — a `bun
97
+ // build --compile` binary, where a bare specifier throws MODULE_NOT_FOUND
98
+ // — it reported `grammars: false` for an install whose grammars were
99
+ // present. The one report a user pastes when the long tail of grammars is
100
+ // dead has to measure the same directory the client does, through the same
101
+ // ladder.
102
+ const dir = resolveWebTreeSitterPackageDir({
103
+ resolve: (specifier) => require.resolve(specifier),
104
+ // `root` is this probe's own walk to the package root, computed above.
105
+ packageRoot: () => root,
106
+ cwd: () => process.cwd(),
107
+ });
108
+ return (!!dir &&
109
+ fs.existsSync(path.join(dir, "grammars", "tree-sitter-typescript.wasm")));
97
110
  }, false);
98
111
  if (deps.some((d) => !d.resolved)) {
99
112
  notes.push("One or more runtime dependencies did not resolve. This is the #285/#335 failure mode — usually a package-manager layout (pnpm symlink store / nested install) the runtime's resolver can't traverse, or an outdated runtime.");
@@ -68,8 +68,10 @@ import { createGenerationMap } from "../generation-guard.js";
68
68
  import { resolveToolCwd } from "../tool-cwd.js";
69
69
  import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
70
70
  import { resetSafeSpawnWindowsCommandCache, safeSpawnAsync, } from "../safe-spawn.js";
71
+ import { createBoundedOutputSink, DEFAULT_MAX_OUTPUT_BYTES, } from "../spawn-output-cap.js";
71
72
  import { probeToolAsync } from "../tool-probe.js";
72
73
  import { logSessionStart } from "../sessionstart-logger.js";
74
+ import { resolveGitHubToken } from "../zizmor-config.js";
73
75
  // Global installation directory for pi-lens tools
74
76
  const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
75
77
  const INSTALL_LOCK_PATH = path.join(TOOLS_DIR, ".install.lock");
@@ -247,7 +249,15 @@ const MANAGED_PACKAGE_FORMATTERS = [
247
249
  id: "cmake-format",
248
250
  name: "cmake-format",
249
251
  installStrategy: "pip",
250
- packageName: "cmakelang",
252
+ // The `yaml` EXTRA, never the bare distribution (#3312). cmakelang reads a
253
+ // `.cmake-format.yaml` project config through `import yaml`, and upstream
254
+ // puts PyYAML behind an extra: cmakelang 0.6.13's PyPI metadata declares
255
+ // `pyyaml (>=5.3) ; extra == 'yaml'`. A bare install still answers
256
+ // `cmake-format --version` with status 0 and then dies with
257
+ // `ModuleNotFoundError: No module named 'yaml'` on the first YAML config —
258
+ // the nightly's red cmake row. Every rung of the pip ladder passes this
259
+ // string verbatim to pip/pipx, which both accept the `pkg[extra]` spec.
260
+ packageName: "cmakelang[yaml]",
251
261
  },
252
262
  { id: "oxfmt", name: "oxfmt", installStrategy: "npm", packageName: "oxfmt" },
253
263
  ];
@@ -465,7 +475,7 @@ export const TOOLS = [
465
475
  checkCommand: "jscpd",
466
476
  checkArgs: ["--version"],
467
477
  installStrategy: "npm",
468
- packageName: "jscpd@5.0.12", // v4's packaging bug (reprism dep missing lib/languages/) is gone in v5's ground-up Rust rewrite — verified: real per-platform native binary (jscpd-windows-x64-msvc etc. via optionalDependencies, no missing-dir regression), --min-lines/--min-tokens/--reporters/--output/--ignore all unchanged, JSON schema fields read by clients/jscpd-client.ts's parseReport() (statistics.total.*, duplicates[].firstFile/secondFile.name+start, .lines, .tokens) are identical, and it's ~50x faster on this repo (4.1s -> 76ms detection time) — closes #582
478
+ packageName: "jscpd@5.3.0", // v5.3.0 is the repository's exact devDependency; the v4 packaging defect that required the older v5.0.12 pin is gone, and parseReport() reads the unchanged clone-report fields.
469
479
  binaryName: "jscpd",
470
480
  },
471
481
  // Structural search and dead code detection
@@ -549,6 +559,15 @@ export const TOOLS = [
549
559
  installStrategy: "pip",
550
560
  packageName: "cmake-language-server",
551
561
  binaryName: "cmake-language-server",
562
+ // Upstream 0.1.11 (the latest release) imports `LanguageServer` from
563
+ // `pygls.server`, a symbol pygls 2 removed, while declaring only
564
+ // `pygls>=1.1.1` — so an unconstrained install resolves pygls 2.1.1 and
565
+ // produces a launcher that cannot start. Measured against PyPI for the
566
+ // nightly's interpreter (#3311): `pip download --python-version 3.12
567
+ // cmake-language-server` → `pygls-2.1.1`; with this constraint →
568
+ // `pygls-1.3.1` + `lsprotocol-2023.0.1`, and `cmake-language-server
569
+ // --version` then prints `cmake-language-server 0.1.11` and exits 0.
570
+ pipConstraints: ["pygls<2"],
552
571
  },
553
572
  {
554
573
  id: "yaml-language-server",
@@ -2585,9 +2604,58 @@ async function getToolPathResolved(toolId, onTransient) {
2585
2604
  if (githubPath)
2586
2605
  return githubPath;
2587
2606
  }
2588
- // Check if global
2589
- if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
2590
- return tool.checkCommand;
2607
+ // Check if global. `isCommandAvailable` is a PATH walk plus a stat — it
2608
+ // ignores its `_args` parameter by construction — so it answers "a file with
2609
+ // that name is on PATH", never "that command runs". Every OTHER rung of this
2610
+ // ladder spawn-verifies its candidate with the entry's own `checkArgs` before
2611
+ // resolving to it; this rung did not, and a PATH entry that is a file but
2612
+ // cannot run then SHADOWED the managed install that would have worked
2613
+ // (#3311 lane C):
2614
+ // - rust-analyzer: rustup's `DUP_TOOLS` proxy (rustup 1.29.1 src/lib.rs:32)
2615
+ // sits in ~/.cargo/bin on every rustup box whether or not the
2616
+ // `rust-analyzer` COMPONENT is installed. Where it is not, the proxy
2617
+ // errors out instead of speaking LSP — and the github-release install
2618
+ // pi-lens would have downloaded was never attempted.
2619
+ // - cmake-language-server: `pipx install` exits 0 and drops a launcher on
2620
+ // PATH whose venv resolved pygls 2.x, which removed the symbol the 0.1.11
2621
+ // server imports. This rung returned it ahead of the pip-user rung, whose
2622
+ // verification would have caught it.
2623
+ // A VERDICT — the binary ran and rejected its own check (nonzero exit) —
2624
+ // falls through to the rungs below and, for an installable strategy, to the
2625
+ // managed install. A STALL (timeout/signal, or a spawn-boundary refusal the
2626
+ // binary never saw) and an INCONCLUSIVE probe are not verdicts (#1569/#2722
2627
+ // semantics), so those keep the pre-#3311 behaviour and resolve to PATH —
2628
+ // dropping a working-but-slow tool on a kill would be a worse lie than the
2629
+ // one this fixes. `recordVersion` is deliberately NOT passed: version-pin
2630
+ // drift (#589) is about pi-lens's own managed installs, and feeding a
2631
+ // system-installed version into it would turn every PATH tool at another
2632
+ // version into a forced reinstall.
2633
+ if (await isCommandAvailable(tool.checkCommand)) {
2634
+ // A `verification: "package-entry"` entry (#2722) is verified from the
2635
+ // installed tree BESIDE its shim — `verifyNpmPackageEntry` derives the
2636
+ // package dir from `<…>/node_modules/.bin/<shim>`. A bare PATH name has no
2637
+ // such tree to read, so that evidence is unavailable here and its absence
2638
+ // says nothing about the command: this rung keeps the pre-#3311 behaviour
2639
+ // for those entries rather than inventing a verdict from a failed lookup.
2640
+ if (packageEntryVerification(tool) !== undefined)
2641
+ return tool.checkCommand;
2642
+ let probeStalled = false;
2643
+ const verified = await verifyToolBinary(tool.checkCommand, undefined, () => {
2644
+ probeStalled = true;
2645
+ onTransient();
2646
+ }, getToolVerificationTimeout(tool), tool.checkArgs, undefined, () => {
2647
+ probeStalled = true;
2648
+ });
2649
+ if (verified || probeStalled)
2650
+ return tool.checkCommand;
2651
+ // One record per tool per session: the rejected candidate and the check
2652
+ // that rejected it. verifyToolBinary already logged the kind/exit code.
2653
+ recordDegradationOnce({
2654
+ kind: "installer-path-candidate-unrunnable",
2655
+ subject: toolId,
2656
+ reason: `${tool.checkCommand} on PATH failed its own check (${tool.checkArgs.join(" ")}); ignoring PATH for this tool`,
2657
+ });
2658
+ logSessionStart(`auto-install ${toolId}: PATH candidate ${tool.checkCommand} failed ${tool.checkArgs.join(" ")} — ignoring PATH, trying managed install`);
2591
2659
  }
2592
2660
  if (tool.installStrategy === "npm") {
2593
2661
  const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id, onTransient, tool.checkArgs, getToolVerificationTimeout(tool));
@@ -2775,6 +2843,39 @@ async function findPipUserToolPath(binaryName, onTransient, verificationArgs = [
2775
2843
  }
2776
2844
  return undefined;
2777
2845
  }
2846
+ /**
2847
+ * The verdict of one `<interpreter> -m site --user-base` probe (#3383).
2848
+ *
2849
+ * Both probes accumulate through {@link createBoundedOutputSink} instead of
2850
+ * `stdout += data`: the concatenation ran inside a `data` handler, where V8's
2851
+ * `RangeError: Invalid string length` is an uncaught exception rather than this
2852
+ * promise's value, and nothing bounded an interpreter that decides to write
2853
+ * forever. A TRUNCATED probe resolves EMPTY rather than trimming a prefix,
2854
+ * because the prefix of a path is a different path: `addBinToPath` would put a
2855
+ * plausible-looking wrong directory on PATH. Empty is the value both probes
2856
+ * already resolve when an interpreter is missing, and every caller handles it.
2857
+ *
2858
+ * Exported for `tests/clients/off-seam-output-bounds.test.ts`: both `data`
2859
+ * handlers that feed it sit inside module-private probe loops that only a real
2860
+ * `python3` on PATH can drive, so this is the seam the bound is pinned at.
2861
+ */
2862
+ export function userBaseProbeResult(command, code, stdout) {
2863
+ if (stdout.truncated) {
2864
+ recordDegradationOnce({
2865
+ kind: "spawn-output-cap-truncated",
2866
+ subject: `user-base-probe:${command}`,
2867
+ reason: `\`${command} -m site --user-base\` reached the ${DEFAULT_MAX_OUTPUT_BYTES}-byte default cap after ${stdout.observedBytes} bytes; the probed user base is unusable`,
2868
+ metadata: {
2869
+ capBytes: DEFAULT_MAX_OUTPUT_BYTES,
2870
+ capSource: "default",
2871
+ observedBytes: stdout.observedBytes,
2872
+ killed: false,
2873
+ },
2874
+ });
2875
+ return "";
2876
+ }
2877
+ return code === 0 ? stdout.text.trim() : "";
2878
+ }
2778
2879
  async function getPythonUserBaseCandidates() {
2779
2880
  const candidates = [];
2780
2881
  const seen = new Set();
@@ -2821,9 +2922,9 @@ async function getPythonUserBaseCandidates() {
2821
2922
  resolve("");
2822
2923
  return;
2823
2924
  }
2824
- let stdout = "";
2825
- proc.stdout?.on("data", (data) => (stdout += data));
2826
- proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
2925
+ const stdout = createBoundedOutputSink();
2926
+ proc.stdout?.on("data", (data) => stdout.append(data));
2927
+ proc.on("exit", (code) => resolve(userBaseProbeResult(probe.command, code, stdout)));
2827
2928
  proc.on("error", () => resolve(""));
2828
2929
  });
2829
2930
  add(userBase);
@@ -2839,10 +2940,42 @@ async function getPythonUserBaseCandidates() {
2839
2940
  * call, never the asset download (see installGitHubTool) — the release CDN must
2840
2941
  * not receive the token.
2841
2942
  */
2842
- function githubApiAuthHeaders() {
2843
- const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
2943
+ async function githubApiAuthHeaders() {
2944
+ const token = await resolveGitHubToken();
2844
2945
  return token ? { Authorization: `Bearer ${token}` } : {};
2845
2946
  }
2947
+ class HttpStatusError extends Error {
2948
+ statusCode;
2949
+ constructor(statusCode, url) {
2950
+ super(url ? `HTTP ${statusCode} for ${url}` : `HTTP ${statusCode}`);
2951
+ this.name = "HttpStatusError";
2952
+ this.statusCode = statusCode;
2953
+ }
2954
+ }
2955
+ class GitHubHttpError extends HttpStatusError {
2956
+ anonymousRateLimitExhausted;
2957
+ constructor(statusCode, requestHeaders, responseHeaders) {
2958
+ super(statusCode);
2959
+ this.name = "GitHubHttpError";
2960
+ this.message = `GitHub API HTTP ${statusCode}`;
2961
+ this.anonymousRateLimitExhausted =
2962
+ statusCode === 403 &&
2963
+ !Object.keys(requestHeaders).some((key) => key.toLowerCase() === "authorization") &&
2964
+ readHeader(responseHeaders, "x-ratelimit-remaining") === "0";
2965
+ }
2966
+ }
2967
+ function readHeader(headers, name) {
2968
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
2969
+ return Array.isArray(entry) ? entry[0]?.trim() : entry?.trim();
2970
+ }
2971
+ function isGitHubApiUrl(url) {
2972
+ try {
2973
+ return new URL(url).host.toLowerCase() === "api.github.com";
2974
+ }
2975
+ catch {
2976
+ return false;
2977
+ }
2978
+ }
2846
2979
  function sameHost(a, b) {
2847
2980
  try {
2848
2981
  return new URL(a).host === new URL(b).host;
@@ -2891,7 +3024,9 @@ function httpsGetWithMeta(url, maxRedirects = 5, headers = {}) {
2891
3024
  }
2892
3025
  if (res.statusCode !== 200) {
2893
3026
  res.resume();
2894
- return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
3027
+ return reject(isGitHubApiUrl(url)
3028
+ ? new GitHubHttpError(res.statusCode ?? 0, headers, res.headers)
3029
+ : new HttpStatusError(res.statusCode ?? 0, url));
2895
3030
  }
2896
3031
  const chunks = [];
2897
3032
  res.on("data", (chunk) => chunks.push(chunk));
@@ -2967,11 +3102,21 @@ prefetchedRelease) {
2967
3102
  else {
2968
3103
  logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
2969
3104
  try {
2970
- const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
3105
+ const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, await githubApiAuthHeaders());
2971
3106
  releaseJson = JSON.parse(body.toString("utf8"));
2972
3107
  }
2973
3108
  catch (err) {
2974
- logSessionStart(`github-install ${tool.id}: release fetch failed: ${err.message}`);
3109
+ const reason = err instanceof GitHubHttpError && err.anonymousRateLimitExhausted
3110
+ ? "GitHub API rate limit exhausted; authenticate with `gh auth login` or set GITHUB_TOKEN/GH_TOKEN and retry"
3111
+ : `release fetch failed: ${err.message}`;
3112
+ if (err instanceof GitHubHttpError && err.anonymousRateLimitExhausted) {
3113
+ recordDegradationOnce({
3114
+ kind: "github-api-rate-limit",
3115
+ subject: tool.id,
3116
+ reason,
3117
+ });
3118
+ }
3119
+ logSessionStart(`github-install ${tool.id}: ${reason}`);
2975
3120
  return undefined;
2976
3121
  }
2977
3122
  }
@@ -3524,15 +3669,25 @@ async function refreshGitHubManagedTool(tool, known) {
3524
3669
  let response;
3525
3670
  try {
3526
3671
  response = await httpsGetWithMeta(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, {
3527
- ...githubApiAuthHeaders(),
3672
+ ...(await githubApiAuthHeaders()),
3528
3673
  ...(known.etag ? { "If-None-Match": known.etag } : {}),
3529
3674
  });
3530
3675
  }
3531
3676
  catch (err) {
3677
+ const reason = err instanceof GitHubHttpError && err.anonymousRateLimitExhausted
3678
+ ? "GitHub API rate limit exhausted; authenticate with `gh auth login` or set GITHUB_TOKEN/GH_TOKEN and retry"
3679
+ : `release query failed: ${err.message}`;
3680
+ if (err instanceof GitHubHttpError && err.anonymousRateLimitExhausted) {
3681
+ recordDegradationOnce({
3682
+ kind: "github-api-rate-limit",
3683
+ subject: tool.id,
3684
+ reason,
3685
+ });
3686
+ }
3532
3687
  return {
3533
3688
  ok: false,
3534
3689
  unchanged: true,
3535
- reason: `release query failed: ${err.message}`,
3690
+ reason,
3536
3691
  };
3537
3692
  }
3538
3693
  if (response.statusCode === 304) {
@@ -4349,6 +4504,77 @@ export function pipScriptsDir(base, platform = installerPlatform()) {
4349
4504
  return path.join(base, platform === "win32" ? "Scripts" : "bin");
4350
4505
  }
4351
4506
  const pipPep668LoggedRefusals = createGenerationMap("installer-pep668-log");
4507
+ /**
4508
+ * The constraints environment for `toolId` — `PIP_CONSTRAINT` **and**
4509
+ * `UV_CONSTRAINT`, both naming the same written file — or `{}` when its registry
4510
+ * entry declares no `pipConstraints` (#3311).
4511
+ *
4512
+ * Each resolver's own mechanism, not a pi-lens one, and BOTH are needed because
4513
+ * `installPipTool`'s first rung is pipx, which chooses its own resolver:
4514
+ * - `PIP_CONSTRAINT` is pip's environment form of `-c/--constraint`, and covers
4515
+ * pipx's pip backend, the pi-lens venv rung, `pip --user` and the
4516
+ * private-prefix rung.
4517
+ * - `UV_CONSTRAINT` is uv's ("Equivalent to the `--constraints` command-line
4518
+ * argument", uv 0.12.10 `crates/uv-static/src/env_vars.rs`, added in uv
4519
+ * 0.1.36) and covers pipx's uv backend — which pipx 1.17.6 makes the DEFAULT
4520
+ * "when uv is available … else 'pip'" (`pipx install --help`). That backend
4521
+ * ignores `PIP_CONSTRAINT` entirely, and its `--pip-args` translation covers
4522
+ * an allowlist (`--index-url`, `--extra-index-url`, `--find-links`,
4523
+ * `--trusted-host`, `--no-binary`, `--only-binary`, `--pre`, `--upgrade`,
4524
+ * `--no-cache-dir`) that does not include constraints — so neither the pip env
4525
+ * var nor a pip argv flag can reach it. Measured on pipx 1.17.6 + uv 0.12.10;
4526
+ * the transcripts are in the PR body (#3396 round 2).
4527
+ *
4528
+ * Both variables carry a WHITESPACE-SEPARATED LIST of files, so a path with a
4529
+ * space in it is not a path to either resolver: uv fails the install outright
4530
+ * (`error: File not found: …/space`, measured). The file therefore goes to the
4531
+ * first whitespace-free directory, and without one the install proceeds
4532
+ * unconstrained rather than broken — which the ladder's PATH verification then
4533
+ * judges on its merits.
4534
+ *
4535
+ * The file is (re)written from the registry on every install, so a constraint
4536
+ * that is edited or removed in the registry cannot be served from a stale file.
4537
+ * A write failure is recorded and the install proceeds unconstrained — the same
4538
+ * resolution as before this field existed.
4539
+ */
4540
+ async function pipConstraintEnvFor(toolId) {
4541
+ const constraints = TOOLS.find((t) => t.id === toolId)?.pipConstraints;
4542
+ if (!constraints || constraints.length === 0)
4543
+ return {};
4544
+ const directories = [
4545
+ path.join(getGlobalPiLensDir(), "pip-constraints"),
4546
+ path.join(os.tmpdir(), "pi-lens-pip-constraints"),
4547
+ ];
4548
+ const directory = directories.find((candidate) => !/\s/.test(candidate));
4549
+ if (!directory) {
4550
+ recordDegradationOnce({
4551
+ kind: "pip-constraint-path-unusable",
4552
+ subject: toolId,
4553
+ reason: `no whitespace-free directory for the constraints file (tried ${directories.join(", ")}); installing unconstrained`,
4554
+ });
4555
+ return {};
4556
+ }
4557
+ const file = path.join(directory, `${toolId}.txt`);
4558
+ try {
4559
+ await fs.mkdir(path.dirname(file), { recursive: true });
4560
+ // The shared atomic seam (#1609), not a raw write: a second pi-lens process
4561
+ // may be reading this file as pip's `PIP_CONSTRAINT` while this one
4562
+ // rewrites it, and a torn read would silently under-constrain the install.
4563
+ await writeFileAtomicAsync(file, `${constraints.join("\n")}\n`, {
4564
+ bestEffort: false,
4565
+ });
4566
+ }
4567
+ catch (err) {
4568
+ recordDegradationOnce({
4569
+ kind: "pip-constraint-file-unwritable",
4570
+ subject: toolId,
4571
+ reason: `${file}: ${err instanceof Error ? err.message : String(err)}`,
4572
+ });
4573
+ return {};
4574
+ }
4575
+ logSessionStart(`auto-install pip ${toolId}: constraining resolution with ${constraints.join(", ")} (${file})`);
4576
+ return { PIP_CONSTRAINT: file, UV_CONSTRAINT: file };
4577
+ }
4352
4578
  /**
4353
4579
  * Install a pip package tool
4354
4580
  */
@@ -4363,6 +4589,10 @@ options = {}) {
4363
4589
  try {
4364
4590
  const isWindows = installerPlatform() === "win32";
4365
4591
  const verb = options.upgrade ? ["install", "-U"] : ["install"];
4592
+ // Read from the registry entry rather than added to this function's
4593
+ // signature: every pip rung below, and pipx's OWN internal pip, is bounded
4594
+ // by one env var, so there is nothing per-call to thread through.
4595
+ const pipConstraintEnv = await pipConstraintEnvFor(toolId);
4366
4596
  // Built from `pipCommandCandidates()` — the single source of truth this
4367
4597
  // module and any other caller (the tool-smoke lane's toolchain-presence
4368
4598
  // probe, #2661 review) share, rather than a second, independently
@@ -4397,16 +4627,28 @@ options = {}) {
4397
4627
  });
4398
4628
  return binaryPath;
4399
4629
  };
4400
- const run = (command, args, env) => safeSpawnAsync(command, args, {
4401
- timeout: 120_000,
4402
- ignoreAmbientSignal: true,
4403
- lifetimeCoupled: true,
4404
- cwd: resolveToolCwd("runner", toolId, getGlobalPiLensDir(), {
4405
- cwd: getGlobalPiLensDir(),
4406
- suppressTelemetry: true,
4407
- }).cwd,
4408
- ...(env ? { env } : {}),
4409
- });
4630
+ const run = (command, args, env) => {
4631
+ // `pipConstraintEnv` is empty unless the entry declares
4632
+ // `pipConstraints`, and it is applied HERE — the single spawn seam every
4633
+ // rung of the ladder (pipx, venv, --user, private-prefix) goes through —
4634
+ // so no rung can be reached with the constraint missing. pipx forwards
4635
+ // it: its `run_subprocess` starts from `dict(os.environ)` and blocklists
4636
+ // only PYTHONPATH/__PYVENV_LAUNCHER__ (pipx 1.16.7 src/pipx/util.py
4637
+ // `_fix_subprocess_env`), so PIP_CONSTRAINT reaches the pip it drives.
4638
+ const spawnEnv = Object.keys(pipConstraintEnv).length > 0
4639
+ ? { ...(env ?? process.env), ...pipConstraintEnv }
4640
+ : env;
4641
+ return safeSpawnAsync(command, args, {
4642
+ timeout: 120_000,
4643
+ ignoreAmbientSignal: true,
4644
+ lifetimeCoupled: true,
4645
+ cwd: resolveToolCwd("runner", toolId, getGlobalPiLensDir(), {
4646
+ cwd: getGlobalPiLensDir(),
4647
+ suppressTelemetry: true,
4648
+ }).cwd,
4649
+ ...(spawnEnv ? { env: spawnEnv } : {}),
4650
+ });
4651
+ };
4410
4652
  const addBinToPath = async (binDir) => {
4411
4653
  try {
4412
4654
  await fs.access(binDir);
@@ -4441,10 +4683,17 @@ options = {}) {
4441
4683
  return undefined;
4442
4684
  };
4443
4685
  if (await isCommandAvailable("pipx")) {
4444
- const result = await run("pipx", [
4445
- options.upgrade ? "upgrade" : "install",
4446
- packageName,
4447
- ]);
4686
+ // `--force` on the install verb (#3312): this function runs ONLY when the
4687
+ // tool was not resolvable, yet plain `pipx install <pkg>` over an
4688
+ // existing venv exits 0 while printing "not modifying existing
4689
+ // installation. Pass '--force' …" — so an install that changes nothing
4690
+ // reports success and hands back the same unusable launcher. That is the
4691
+ // #2638/#2661 shape (an install that installs nothing reporting like a
4692
+ // real one) and it also swallows a changed package spec, e.g. a venv
4693
+ // created before `cmakelang` grew its `[yaml]` extra above.
4694
+ const result = await run("pipx", options.upgrade
4695
+ ? ["upgrade", packageName]
4696
+ : ["install", "--force", packageName]);
4448
4697
  const error = (result.error?.message ?? result.stderr).trim();
4449
4698
  if (result.status === 0) {
4450
4699
  const location = await run("pipx", [
@@ -4523,9 +4772,9 @@ options = {}) {
4523
4772
  resolve("");
4524
4773
  return;
4525
4774
  }
4526
- let stdout = "";
4527
- probe.stdout?.on("data", (data) => (stdout += data));
4528
- probe.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
4775
+ const stdout = createBoundedOutputSink();
4776
+ probe.stdout?.on("data", (data) => stdout.append(data));
4777
+ probe.on("exit", (code) => resolve(userBaseProbeResult(candidate.command, code, stdout)));
4529
4778
  probe.on("error", () => resolve(""));
4530
4779
  });
4531
4780
  const binaryPath = base