pi-lens 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * #3170 — the bounded persistent-reverify pass.
3
+ *
4
+ * A finding whose own file is unchanged is re-delivered turn after turn from
5
+ * the persisted actionable-warnings report without ever being re-observed
6
+ * against the live server: the in-band publish carries forward DEFERRED-origin
7
+ * file entries while their file has not moved
8
+ * (`mergeActionableWarningsReports`'s scope guard), and the delta freshness
9
+ * gate passes them because the file's mtime has not moved either. When the
10
+ * root cause was fixed in a DIFFERENT file, the carried finding is stale in
11
+ * fact but fresh by every on-disk axis.
12
+ *
13
+ * This module re-observes those files at turn_end, BEFORE the advisory
14
+ * assembles, and the replacement entries fold into the turn's SINGLE in-band
15
+ * publish (#3176 F1: a separate replacement publish spends the carry marker
16
+ * and the main publish then drops the entries at the scope guard — the
17
+ * blocker the review caught).
18
+ *
19
+ * Review-round contracts (#3176):
20
+ * - **F2**: the fresh observation converts through the PRODUCER's own
21
+ * pipeline (`enrichFileFromLsp` — severity-2, code-action enrichment,
22
+ * fixable-only, the per-file caps), fed the touch's collected diagnostics
23
+ * as the cached arm so no second pull happens. No re-implementation.
24
+ * - **F3**: an empty result is clean ONLY when the touch itself answers
25
+ * `confirmation: "confirmed"` — the silent-on-clean rule (#240/#533/#1253).
26
+ * A bare `{diags: []}` (the house double's silent empty, a tier-3 server
27
+ * still analyzing, a `skipReason`) is UNCONFIRMED: the carried warnings are
28
+ * kept verbatim and the render labels the gap. `touched.inconclusive` is
29
+ * `resolveTouchVerdict`'s output computed service-side and is honored
30
+ * directly.
31
+ * - **F4**: every await is bound-wrapped (`bounded()`); `maxClientWaitMs` is
32
+ * only the spawn/ready budget, not a request ceiling.
33
+ * - **F6**: no skip guard — every deferred-origin entry re-verifies each
34
+ * turn (re-arm), per the issue's failure table.
35
+ *
36
+ * Bounds: at most {@link MAX_REVERIFY_FILES} files per turn_end, a wall
37
+ * budget of {@link REVERIFY_BUDGET_MS}, the turn-end abort signal, and no
38
+ * new durable state — candidates come from the persisted report itself,
39
+ * which is what was delivered last turn.
40
+ */
41
+ import * as fs from "node:fs";
42
+ import { enrichFileFromLsp } from "./actionable-warnings.js";
43
+ import { bounded } from "./deadline-utils.js";
44
+ import { logLatency } from "./latency-logger.js";
45
+ import { touchCompletedConfirmationPolicy, } from "./lsp/diagnostic-binding.js";
46
+ import { findAuxiliaryProfileForSource } from "./dispatch/auxiliary-lsp.js";
47
+ /** At most this many files are re-observed per turn_end (the drift backstop's
48
+ * own 4-resyncs-per-pass precedent). */
49
+ export const MAX_REVERIFY_FILES = 4;
50
+ /** Wall-clock budget for the whole pass; a touch that cannot answer inside
51
+ * its remaining slice is recorded unconfirmed, never waited out. */
52
+ export const REVERIFY_BUDGET_MS = 3000;
53
+ /** The touch options the `source=lsp` probe uses, with this pass's source. */
54
+ const TOUCH_SOURCE = "persistent_reverify";
55
+ /**
56
+ * Select the report's re-verify candidates: DEFERRED-origin file entries (the
57
+ * only population the in-band publish carries forward, so the only population
58
+ * that re-serves across turns — F6: with no skip guard, so every carried
59
+ * entry re-verifies each turn) whose file stat is UNCHANGED since the entry's
60
+ * own observation stamp. A file that changed is skipped and counted — the
61
+ * edit path already re-observed it. Missing files are skipped too: the delta
62
+ * freshness gate drops them outright.
63
+ */
64
+ export function selectPersistentReverifyFiles(report, nowMs = Date.now()) {
65
+ const candidates = [];
66
+ let skippedChanged = 0;
67
+ for (const entry of report.files) {
68
+ if (entry.origin !== "deferred")
69
+ continue;
70
+ // F3 (population half): the pass re-observes through a PRIMARY-scope
71
+ // touch, so an auxiliary-lane finding can never be re-confirmed — an
72
+ // entry carrying aux-sourced warnings is skipped (kept verbatim),
73
+ // never scored dropped. The aux lookup is the one shared seam.
74
+ if (entry.warnings.some((w) => findAuxiliaryProfileForSource(w.source ?? "") !== undefined)) {
75
+ continue;
76
+ }
77
+ const stamp = entry.generatedAt ? Date.parse(entry.generatedAt) : NaN;
78
+ if (!Number.isFinite(stamp) || stamp > nowMs)
79
+ continue;
80
+ let mtimeMs;
81
+ try {
82
+ mtimeMs = fs.statSync(entry.filePath).mtimeMs;
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (mtimeMs > stamp) {
88
+ skippedChanged += 1;
89
+ continue;
90
+ }
91
+ if (candidates.length < MAX_REVERIFY_FILES)
92
+ candidates.push(entry);
93
+ }
94
+ return { candidates, skippedChanged };
95
+ }
96
+ /**
97
+ * Run the bounded re-verify pass over the report's candidates and build the
98
+ * replacement entries for the caller's single in-band publish. Never throws:
99
+ * an unexpected per-file failure degrades that file to `unconfirmed` (kept
100
+ * verbatim, labeled), which is the shape-48 direction — the harm reaching the
101
+ * user is a hidden real finding, worse than re-delivering a stale one.
102
+ */
103
+ export async function runPersistentReverify(args) {
104
+ const started = Date.now();
105
+ const deadlineAt = started + (args.budgetMs ?? REVERIFY_BUDGET_MS);
106
+ const nowMs = args.nowMs ?? started;
107
+ const { candidates, skippedChanged } = selectPersistentReverifyFiles(args.report, nowMs);
108
+ const outcomes = [];
109
+ const replacementFiles = [];
110
+ let touched = 0;
111
+ // F2: the producer's own pipeline (enrichFileFromLsp) — constructed once,
112
+ // reused per candidate. deltaOnly: false (a re-observation is not gated on
113
+ // this turn's modified ranges — the file was not edited).
114
+ const buildArgs = {
115
+ cwd: args.cwd,
116
+ sessionId: args.report.sessionId,
117
+ turnIndex: 0,
118
+ files: [],
119
+ modifiedRangesByFile: new Map(),
120
+ dispatchWarnings: [],
121
+ includeLspCodeActions: true,
122
+ deltaOnly: false,
123
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
124
+ lspBudgetMs: REVERIFY_BUDGET_MS,
125
+ dbg: () => { },
126
+ };
127
+ // R3-4: every candidate the budget or the abort cut, so the phase row
128
+ // distinguishes "the pass ran out of time" from "the server answered
129
+ // nothing". Counted from what the loop actually reached, never predicted.
130
+ let processed = 0;
131
+ for (const entry of candidates) {
132
+ if (args.signal?.aborted || Date.now() >= deadlineAt)
133
+ break;
134
+ processed += 1;
135
+ let content;
136
+ try {
137
+ content = fs.readFileSync(entry.filePath, "utf-8");
138
+ }
139
+ catch {
140
+ outcomes.push({
141
+ filePath: entry.filePath,
142
+ displayPath: entry.displayPath,
143
+ outcome: "unconfirmed",
144
+ dropped: 0,
145
+ kept: entry.warnings.length,
146
+ });
147
+ replacementFiles.push({ ...entry, reVerifyIncomplete: true });
148
+ continue;
149
+ }
150
+ // F4: the touch is bound-wrapped — `maxClientWaitMs` bounds the
151
+ // spawn/ready window, `bounded()` is the request ceiling.
152
+ let touchedResult;
153
+ try {
154
+ touchedResult = await bounded(args.lspService.touchFile(entry.filePath, content, {
155
+ diagnostics: "document",
156
+ collectDiagnostics: true,
157
+ source: TOUCH_SOURCE,
158
+ clientScope: "primary",
159
+ }), {
160
+ ms: Math.max(250, deadlineAt - Date.now()),
161
+ signal: args.signal,
162
+ hook: "turn_end",
163
+ label: "persistent_reverify_touch",
164
+ });
165
+ }
166
+ catch {
167
+ // The never-throws contract: a wedged touch degrades to unconfirmed
168
+ // (kept verbatim, labeled), never an escaped error.
169
+ touchedResult = undefined;
170
+ }
171
+ // F3 (#3176): an empty result is clean ONLY when the touch COMPLETED
172
+ // its confirmation policy — the silent-on-clean rule (#240/#533/#1253).
173
+ // A bare `{diags: []}` (the house double's silent empty, a tier-3
174
+ // server still analyzing) or a `skipReason` is UNCONFIRMED: the carried
175
+ // warnings are kept verbatim and the render labels the gap.
176
+ // `touched.inconclusive` is resolveTouchVerdict's output, computed
177
+ // service-side, and is honored directly.
178
+ // R3-3: read the policy through `touchCompletedConfirmationPolicy`, the
179
+ // #1470 seam, NOT the literal `confirmation !== "confirmed"` that
180
+ // `diagnostic-binding.ts:274-288` exists to forbid: `partial` means
181
+ // every server except the named cut-off auxiliaries answered, so the
182
+ // primary's silent-clean gates ran to completion and its observation is
183
+ // as trustworthy as a full confirmation. The literal labels a file the
184
+ // primary fully answered and keeps a finding the touch just re-derived.
185
+ if (touchedResult === undefined ||
186
+ touchedResult.inconclusive === true ||
187
+ touchedResult.skipReason !== undefined ||
188
+ (touchedResult.diagnosticsUnsupportedServerIds ?? []).length > 0 ||
189
+ !touchCompletedConfirmationPolicy(touchedResult)) {
190
+ outcomes.push({
191
+ filePath: entry.filePath,
192
+ displayPath: entry.displayPath,
193
+ outcome: "unconfirmed",
194
+ dropped: 0,
195
+ kept: entry.warnings.length,
196
+ });
197
+ replacementFiles.push({ ...entry, reVerifyIncomplete: true });
198
+ continue;
199
+ }
200
+ touched += 1;
201
+ // F2: the touch's collected diagnostics feed the producer's pipeline
202
+ // as the cached arm — no second pull, the producer's filters and
203
+ // caps are reused verbatim.
204
+ // F4: the outer await is bound-wrapped too — the module carries ZERO
205
+ // syntactic unbounded awaits (#2523 slice-3 worklist), and the hard
206
+ // ceiling composes with enrichFileFromLsp's own internal deadline.
207
+ const freshRecords = (await bounded(enrichFileFromLsp(args.cwd, buildArgs, { filePath: entry.filePath, cached: touchedResult.diags }, {
208
+ lspService: args.lspService,
209
+ pullTimeoutMs: 2000,
210
+ deadlineAt,
211
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
212
+ site: { hook: "turn_end", label: "persistent_reverify" },
213
+ }), {
214
+ ms: Math.max(250, deadlineAt - Date.now()),
215
+ signal: args.signal,
216
+ hook: "turn_end",
217
+ label: "persistent_reverify_enrich",
218
+ })) ?? [];
219
+ let dropped = 0;
220
+ let kept = 0;
221
+ for (const warning of entry.warnings) {
222
+ const stillThere = freshRecords.some((record) => record.rule === warning.rule && record.message === warning.message);
223
+ if (stillThere)
224
+ kept += 1;
225
+ else
226
+ dropped += 1;
227
+ }
228
+ const outcome = freshRecords.length === 0
229
+ ? "clean"
230
+ : dropped === 0
231
+ ? "reconfirmed"
232
+ : "mixed";
233
+ outcomes.push({
234
+ filePath: entry.filePath,
235
+ displayPath: entry.displayPath,
236
+ outcome,
237
+ dropped,
238
+ kept,
239
+ });
240
+ replacementFiles.push({
241
+ ...entry,
242
+ warnings: freshRecords,
243
+ reVerified: true,
244
+ generatedAt: new Date(nowMs).toISOString(),
245
+ });
246
+ }
247
+ logLatency({
248
+ type: "phase",
249
+ toolName: "turn_end",
250
+ filePath: args.cwd,
251
+ phase: "persistent_reverify",
252
+ durationMs: Date.now() - started,
253
+ metadata: {
254
+ candidates: candidates.length,
255
+ touched,
256
+ clean: outcomes.filter((o) => o.outcome === "clean").length,
257
+ reconfirmed: outcomes.filter((o) => o.outcome === "reconfirmed").length,
258
+ mixed: outcomes.filter((o) => o.outcome === "mixed").length,
259
+ unconfirmed: outcomes.filter((o) => o.outcome === "unconfirmed").length,
260
+ skippedChanged,
261
+ skippedBudget: candidates.length - processed,
262
+ },
263
+ });
264
+ return {
265
+ outcomes,
266
+ replacementFiles,
267
+ candidates: candidates.length,
268
+ touched,
269
+ skippedChanged,
270
+ };
271
+ }
@@ -25,6 +25,7 @@ import { loadDispatchIntegration } from "./dispatch/lazy.js";
25
25
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
26
26
  import { classifyProbeFailure, createAvailabilityChecker, createAvailabilityLatch, describeProbeEvidence, logAvailabilityDecision, startHostStallSampler, resolveAvailableOrInstall, resolveCommandArgsWithInstallFallback, resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./dispatch/runners/utils/runner-helpers.js";
27
27
  import { findDetektConfig } from "./dispatch/runners/detekt.js";
28
+ import { formatDiagnostics } from "./dispatch/utils/format-utils.js";
28
29
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
29
30
  import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
30
31
  import { logLatency } from "./latency-logger.js";
@@ -40,12 +41,11 @@ import { probeToolAsync } from "./tool-probe.js";
40
41
  import { bounded } from "./deadline-utils.js";
41
42
  import { HOOK_WALL_BUDGET_MS } from "./hook-budgets.js";
42
43
  import { enabledAuxiliaryLspServerIds } from "./dispatch/auxiliary-lsp.js";
43
- import { recordDegradationOnce } from "./degradation-ledger.js";
44
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
44
45
  import { establishToolAgreement } from "./tool-agreement.js";
45
46
  import { dropFindingsForMissingPaths } from "./advisory-provenance.js";
46
47
  import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasKtlintConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, markdownlintConfigArgs, } from "./tool-policy.js";
47
- const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
48
- const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
48
+ import { exceedsLspSyncLimits } from "./lsp/content-limits.js";
49
49
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
50
50
  const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
51
51
  /**
@@ -60,10 +60,6 @@ function lspSyncBudgetMs() {
60
60
  const raw = Number(process.env.PI_LENS_LSP_SYNC_BUDGET_MS);
61
61
  return Number.isFinite(raw) && raw > 0 ? raw : 3000;
62
62
  }
63
- // Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
64
- // `stack`. Extracted from the walk loop to keep each function's cognitive
65
- // complexity low. Excluded/ignored dirs are not descended; ignored/vanished
66
- // files are skipped.
67
63
  // Files stat'd between event-loop yields. The walk stays on the tool_result
68
64
  // hot path; yielding every N keeps its longest synchronous stretch well under
69
65
  // the <50ms hook-burst budget even at the AUTOFIX_CHANGED_FILE_SCAN_LIMIT cap.
@@ -144,23 +140,6 @@ async function diffProjectSnapshot(root, before) {
144
140
  }
145
141
  return [...changed].sort((a, b) => a.localeCompare(b));
146
142
  }
147
- function exceedsLspSyncLimits(_filePath, content) {
148
- const sizeBytes = Buffer.byteLength(content, "utf-8");
149
- if (sizeBytes > LSP_MAX_FILE_BYTES) {
150
- return {
151
- tooLarge: true,
152
- reason: `${Math.round(sizeBytes / 1024)}KB exceeds ${Math.round(LSP_MAX_FILE_BYTES / 1024)}KB`,
153
- };
154
- }
155
- const lineCount = content.split("\n").length;
156
- if (lineCount > LSP_MAX_FILE_LINES) {
157
- return {
158
- tooLarge: true,
159
- reason: `${lineCount} lines exceeds ${LSP_MAX_FILE_LINES}`,
160
- };
161
- }
162
- return { tooLarge: false, reason: "" };
163
- }
164
143
  function createPhaseTracker(toolName, filePath) {
165
144
  const phases = [];
166
145
  return {
@@ -677,7 +656,13 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
677
656
  return;
678
657
  if (!needsContentRefresh && lspSyncCompleted)
679
658
  return;
680
- const limitCheck = exceedsLspSyncLimits(filePath, fileContent);
659
+ // #3405 r2: the bound is `clients/lsp/content-limits.ts` now, shared with the
660
+ // dispatch runner and the didSave payload. THIS call stays: a file past the
661
+ // bound is not synced to the server AT ALL, which is a stricter policy than
662
+ // the save seam's (drop the redundant text, still send the save) and the one
663
+ // a post-write sync owes — removing it would start writing whole-file
664
+ // didOpen frames for files this pipeline has always refused.
665
+ const limitCheck = exceedsLspSyncLimits(fileContent);
681
666
  if (limitCheck.tooLarge)
682
667
  return;
683
668
  try {
@@ -711,6 +696,11 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
711
696
  source: "lsp_sync",
712
697
  clientScope: "primary",
713
698
  maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
699
+ // #3405: pi-lens just wrote these bytes to disk, so this is the one
700
+ // touch that is a save. A save-triggered server (Expert recompiles
701
+ // the project on didSave and on nothing else) publishes for the edit
702
+ // only because of this flag.
703
+ saved: true,
714
704
  })
715
705
  .then(() => "done")
716
706
  .catch((err) => {
@@ -923,6 +913,15 @@ export async function runFormatPhase(filePath, getFormatService, dbg, signal, bu
923
913
  * → const randomInt = Math.floor(result);
924
914
  */
925
915
  function buildEnrichedBlockerOutput(blockers, fileContent = "") {
916
+ // #3188: no blockers, no banner. The delivery gate above can retract every
917
+ // blocker (all cited files deleted), and the header interpolates the count,
918
+ // so without this the agent reads "🔴 STOP — 0 issue(s) must be fixed:" with
919
+ // nothing under it. Same guard, same reason, as `formatDiagnostics` in
920
+ // dispatch/utils/format-utils.ts — it belongs to the renderer, not to each
921
+ // call site, because the #2028 branch below cannot gate on the count without
922
+ // falling through to the UNGATED raw output.
923
+ if (blockers.length === 0)
924
+ return "";
926
925
  // Empty fileContent (readback failed, e.g. deleted-file race) still
927
926
  // renders the gated blocker list - just without per-line snippets.
928
927
  const fileLines = fileContent ? fileContent.split("\n") : [];
@@ -952,6 +951,14 @@ function buildEnrichedBlockerOutput(blockers, fileContent = "") {
952
951
  export async function runPipeline(ctx, deps) {
953
952
  const { filePath, cwd, toolName, getFlag, getFlagSource, dbg } = ctx;
954
953
  const { getFormatService } = deps;
954
+ const allowAutonomousWriters = ctx.allowAutonomousWriters !== false;
955
+ if (!allowAutonomousWriters) {
956
+ incrementDegradationCount({
957
+ kind: "opaque-mutation-ownership-boundary",
958
+ subject: "pipeline",
959
+ reason: `observed mutation is not evidence of agent authorship (${filePath})`,
960
+ });
961
+ }
955
962
  admitWidgetDiagnosticsWrite(filePath, ctx.telemetry?.writeIndex);
956
963
  const phase = createPhaseTracker(toolName, filePath);
957
964
  const pipelineStart = Date.now();
@@ -975,8 +982,14 @@ export async function runPipeline(ctx, deps) {
975
982
  const piChangedFiles = new Set();
976
983
  const autoformatDisabled = !!getFlag("no-autoformat", filePath);
977
984
  const immediateFormat = !!getFlag("immediate-format");
978
- const formatDeferred = !autoformatDisabled && !immediateFormat && !!fileContent;
979
- if (!autoformatDisabled && immediateFormat && fileContent) {
985
+ const formatDeferred = allowAutonomousWriters &&
986
+ !autoformatDisabled &&
987
+ !immediateFormat &&
988
+ !!fileContent;
989
+ if (allowAutonomousWriters &&
990
+ !autoformatDisabled &&
991
+ immediateFormat &&
992
+ fileContent) {
980
993
  const formatResult = await runFormatPhase(filePath, getFormatService, dbg, ctx.signal);
981
994
  formatChanged = formatResult.formatChanged;
982
995
  formattersUsed = formatResult.formattersUsed;
@@ -1001,6 +1014,9 @@ export async function runPipeline(ctx, deps) {
1001
1014
  else if (formatDeferred) {
1002
1015
  dbg(`autoformat: deferred until agent_end for ${filePath}`);
1003
1016
  }
1017
+ else if (!allowAutonomousWriters) {
1018
+ dbg(`autoformat: skipped for ${filePath} (mutation ownership is unproven)`);
1019
+ }
1004
1020
  else if (autoformatDisabled) {
1005
1021
  const source = getFlagSource?.("no-autoformat", filePath);
1006
1022
  dbg(`autoformat: skipped for ${filePath} (--no-autoformat${source ? `, source=${source}` : ""})`);
@@ -1018,7 +1034,11 @@ export async function runPipeline(ctx, deps) {
1018
1034
  let autofixChangedFiles = [];
1019
1035
  let fixRefresh = false;
1020
1036
  let autofixSkipReason;
1021
- if (ctx.autofixMode === "deferred") {
1037
+ if (!allowAutonomousWriters) {
1038
+ autofixSkipReason = "mutation_ownership_unproven";
1039
+ dbg(`autofix: skipped for ${filePath} (mutation ownership is unproven)`);
1040
+ }
1041
+ else if (ctx.autofixMode === "deferred") {
1022
1042
  autofixSkipReason = "deferred_to_agent_end";
1023
1043
  dbg(`autofix: deferred until agent_end for ${filePath}`);
1024
1044
  }
@@ -1157,34 +1177,22 @@ export async function runPipeline(ctx, deps) {
1157
1177
  });
1158
1178
  }
1159
1179
  }
1160
- const hasBlockers = dispatchResult.hasBlockers;
1161
- const actionableWarnings = dispatchResult.warnings
1162
- .map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
1163
- .filter((warning) => Boolean(warning));
1180
+ // Opaque recovery is a valid analysis input, but its bytes are not an agent
1181
+ // authored surface. Keep diagnostics and advisory output visible while
1182
+ // withholding the edit-directed channels that would tell the agent to mutate
1183
+ // the observed artifact (#3226).
1184
+ const hasBlockers = allowAutonomousWriters && dispatchResult.hasBlockers;
1185
+ const actionableWarnings = allowAutonomousWriters
1186
+ ? dispatchResult.warnings
1187
+ .map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
1188
+ .filter((warning) => Boolean(warning))
1189
+ : [];
1164
1190
  const codeQualityWarnings = dispatchResult.warnings
1165
1191
  .map((diagnostic) => recordFromCodeQualityDiagnostic(diagnostic, cwd))
1166
1192
  .filter((warning) => Boolean(warning));
1167
1193
  if (dispatchResult.diagnostics.length > 0) {
1168
- const logger = getDiagnosticLogger();
1169
1194
  const tracker = getDiagnosticTracker();
1170
1195
  tracker.trackShown(dispatchResult.diagnostics);
1171
- const toKey = (d) => [
1172
- d.tool || "",
1173
- d.id || "",
1174
- d.rule || "",
1175
- d.filePath || "",
1176
- d.line || 0,
1177
- d.column || 0,
1178
- ].join("|");
1179
- const inlineKeys = new Set([...dispatchResult.blockers, ...dispatchResult.fixed].map(toKey));
1180
- for (const d of dispatchResult.diagnostics) {
1181
- logger.logCaught(d, {
1182
- model: ctx.telemetry?.model ?? "unknown",
1183
- sessionId: ctx.telemetry?.sessionId ?? "unknown",
1184
- turnIndex: ctx.telemetry?.turnIndex ?? 0,
1185
- writeIndex: ctx.telemetry?.writeIndex ?? 0,
1186
- }, inlineKeys.has(toKey(d)));
1187
- }
1188
1196
  }
1189
1197
  if (fixedCount > 0)
1190
1198
  getDiagnosticTracker().trackAutoFixed(fixedCount);
@@ -1198,15 +1206,48 @@ export async function runPipeline(ctx, deps) {
1198
1206
  // the deleted file's content), so it is dropped here rather than re-asserted.
1199
1207
  // One bounded stat per unique cited path, only when blockers exist — zero
1200
1208
  // cost on the clean/fast paths.
1201
- const deliverableBlockers = dispatchResult.hasBlockers
1209
+ const deliverableBlockers = hasBlockers
1202
1210
  ? dropFindingsForMissingPaths({
1203
1211
  store: "stop-blocker",
1204
1212
  findings: dispatchResult.blockers,
1205
1213
  cwd,
1206
1214
  citedPath: (b) => b.filePath || undefined,
1207
1215
  })
1208
- : dispatchResult.blockers;
1209
- if (dispatchResult.hasBlockers && fileContent) {
1216
+ : [];
1217
+ // #3190: ONE gate, applied once here, for EVERY surface. The durable record
1218
+ // this function hands `runtime.recordInlineBlockers` (and through it the
1219
+ // turn-end `Unresolved from this turn` re-serve) used to be built from the
1220
+ // UNGATED `dispatchResult.blockerOutput`/`blockers`, so a blocker retracted
1221
+ // from the tool result above was re-delivered at turn end — and the ungated
1222
+ // verdict still latched the commit gate through `updateGitGuardStatus`. It is
1223
+ // exactly `hasBlockers && nothing-survived-the-gate` inverted: the gate
1224
+ // returns `[]` whenever `hasBlockers` is false, so this one expression is the
1225
+ // whole condition.
1226
+ const hasDeliverableBlockers = deliverableBlockers.length > 0;
1227
+ if (dispatchResult.diagnostics.length > 0) {
1228
+ const logger = getDiagnosticLogger();
1229
+ const toKey = (d) => [
1230
+ d.tool || "",
1231
+ d.id || "",
1232
+ d.rule || "",
1233
+ d.filePath || "",
1234
+ d.line || 0,
1235
+ d.column || 0,
1236
+ ].join("|");
1237
+ const inlineKeys = new Set([...deliverableBlockers, ...dispatchResult.fixed].map(toKey));
1238
+ for (const d of dispatchResult.diagnostics) {
1239
+ logger.logCaught(d, {
1240
+ model: ctx.telemetry?.model ?? "unknown",
1241
+ sessionId: ctx.telemetry?.sessionId ?? "unknown",
1242
+ turnIndex: ctx.telemetry?.turnIndex ?? 0,
1243
+ writeIndex: ctx.telemetry?.writeIndex ?? 0,
1244
+ }, inlineKeys.has(toKey(d)));
1245
+ }
1246
+ }
1247
+ const deliveredOutput = !allowAutonomousWriters && dispatchResult.hasBlockers
1248
+ ? dispatchResult.output.slice(dispatchResult.blockerOutput.length)
1249
+ : dispatchResult.output;
1250
+ if (hasBlockers && fileContent) {
1210
1251
  // Enrich blocker output with a code snippet so the agent can see the
1211
1252
  // exact line it wrote that caused each violation — no re-read needed.
1212
1253
  if (deliverableBlockers.length > 0) {
@@ -1218,20 +1259,25 @@ export async function runPipeline(ctx, deps) {
1218
1259
  if (rest)
1219
1260
  output += rest;
1220
1261
  }
1221
- else if (dispatchResult.output) {
1262
+ else if (deliveredOutput) {
1222
1263
  // #2028 review P3: this path fires when readback FAILED - raw output
1223
1264
  // still cites possibly-deleted files. Re-render from the gated set
1224
1265
  // (no snippets without fileContent) and keep the post-blocker slice.
1225
- if (dispatchResult.hasBlockers &&
1266
+ if (hasBlockers &&
1226
1267
  deliverableBlockers.length !== dispatchResult.blockers.length) {
1227
1268
  let gatedOut = buildEnrichedBlockerOutput(deliverableBlockers);
1228
1269
  const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
1229
1270
  if (rest)
1230
1271
  gatedOut += rest;
1231
- output += `\n\n${gatedOut}`;
1272
+ // #3188: every blocker retracted and nothing after the blocker
1273
+ // section leaves nothing to say — appending the separator alone would
1274
+ // still deliver a (whitespace-only) tool-result block, because
1275
+ // `handleToolResult` decides delivery on `output` truthiness.
1276
+ if (gatedOut)
1277
+ output += `\n\n${gatedOut}`;
1232
1278
  }
1233
1279
  else {
1234
- output += `\n\n${dispatchResult.output}`;
1280
+ output += `\n\n${deliveredOutput}`;
1235
1281
  }
1236
1282
  }
1237
1283
  if (fixedCount > 0) {
@@ -1326,7 +1372,7 @@ export async function runPipeline(ctx, deps) {
1326
1372
  turnIndex: ctx.telemetry?.turnIndex ?? 0,
1327
1373
  writeIndex: ctx.telemetry?.writeIndex ?? 0,
1328
1374
  diagnostics: dispatchResult.diagnostics,
1329
- blockers: dispatchResult.blockers,
1375
+ blockers: hasBlockers ? dispatchResult.blockers : [],
1330
1376
  warnings: dispatchResult.warnings,
1331
1377
  fixed: dispatchResult.fixed,
1332
1378
  resolvedCount: dispatchResult.resolvedCount,
@@ -1337,28 +1383,44 @@ export async function runPipeline(ctx, deps) {
1337
1383
  });
1338
1384
  return {
1339
1385
  output,
1340
- hasBlockers,
1386
+ // #3190: the verdict every consumer reads — `updateGitGuardStatus`'s commit
1387
+ // latch above all — is what was DELIVERED, not what the dispatch found. The
1388
+ // two disagreed whenever the deleted-path gate retracted every blocker: the
1389
+ // tool result said `✓ clean` while the commit gate stayed latched, its
1390
+ // summary falling back to that very all-clear line. `emitLensAnalysisComplete`
1391
+ // above deliberately keeps the raw dispatch counts — it records what was
1392
+ // observed, not what was served.
1393
+ hasBlockers: hasDeliverableBlockers,
1341
1394
  cascadePromise,
1342
1395
  isError: false,
1343
1396
  fileModified,
1344
1397
  postWriteStateHash,
1345
1398
  changedFiles,
1346
- inlineBlockerSummary: dispatchResult.hasBlockers
1347
- ? dispatchResult.blockerOutput.trim() || undefined
1399
+ // #3190: re-rendered from the GATED set with `formatDiagnostics(...,
1400
+ // "blocking")` — the very expression `dispatcher.ts:1409` builds
1401
+ // `blockerOutput` with, over a subset of the very array it rendered. When
1402
+ // the gate drops nothing the two are byte-identical (same renderer, same
1403
+ // array, same display cap), so an unretracted record is unchanged; when it
1404
+ // drops something the stored text can no longer disagree with the tool
1405
+ // result, and `inline-blocker-dispositions.ts`' re-render at turn end still
1406
+ // matches this string exactly.
1407
+ inlineBlockerSummary: hasDeliverableBlockers
1408
+ ? formatDiagnostics(deliverableBlockers, "blocking").trim() || undefined
1348
1409
  : undefined,
1349
1410
  // #1561 F1: taken from the very diagnostics `blockerOutput` was rendered
1350
1411
  // from, so the provenance can never disagree with the text it guards. An
1351
1412
  // untagged diagnostic contributes the literal "unknown", which no verdict
1352
1413
  // claims coverage for — it pins the entry rather than silently widening
1353
1414
  // what an LSP check is allowed to clear.
1354
- inlineBlockerSources: dispatchResult.hasBlockers
1415
+ inlineBlockerSources: hasDeliverableBlockers
1355
1416
  ? [
1356
- ...new Set(dispatchResult.blockers.map((d) => d.tool?.trim() || "unknown")),
1417
+ ...new Set(deliverableBlockers.map((d) => d.tool?.trim() || "unknown")),
1357
1418
  ]
1358
1419
  : undefined,
1359
- inlineBlockerLines: dispatchResult.hasBlockers
1360
- ? dispatchResult.blockers
1361
- // #1641 review F2: `dispatchResult.blockers` is NOT guaranteed to be
1420
+ inlineBlockerLines: hasDeliverableBlockers
1421
+ ? deliverableBlockers
1422
+ // #1641 review F2: the blocker array (#3190: the deliverable subset
1423
+ // of `dispatchResult.blockers`) is NOT guaranteed to be
1362
1424
  // scoped to THIS file — a chart-wide runner (helm-lint, helm-render)
1363
1425
  // reports blocking diagnostics against other files in the chart
1364
1426
  // (e.g. `values.yaml`) alongside `ctx.filePath`. The precedent every
@@ -1404,8 +1466,15 @@ export async function runPipeline(ctx, deps) {
1404
1466
  source: "autofix",
1405
1467
  }
1406
1468
  : undefined,
1407
- inlineBlockerFileContent: dispatchResult.hasBlockers
1469
+ inlineBlockerFileContent: hasBlockers
1408
1470
  ? inlineBlockerFileContent
1409
1471
  : undefined,
1472
+ // #3246: the very array `blockerOutput` above was rendered from, so the
1473
+ // turn-end policy filter and its re-render can never disagree with the
1474
+ // text they guard — the same provenance argument `inlineBlockerSources`
1475
+ // makes one field up.
1476
+ inlineBlockerDiagnostics: hasDeliverableBlockers
1477
+ ? deliverableBlockers
1478
+ : undefined,
1410
1479
  };
1411
1480
  }