pi-lens 3.8.66 → 3.8.67

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 (33) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +0 -1
  3. package/dist/clients/actionable-warnings-logger.js +11 -36
  4. package/dist/clients/ast-grep-tool-logger.js +11 -36
  5. package/dist/clients/cascade-logger.js +7 -12
  6. package/dist/clients/dead-code-logger.js +11 -22
  7. package/dist/clients/diagnostic-logger.js +7 -31
  8. package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
  9. package/dist/clients/dispatch/dispatcher.js +1 -36
  10. package/dist/clients/dispatch/inline-suppressions.js +61 -0
  11. package/dist/clients/dispatch/integration.js +75 -23
  12. package/dist/clients/dispatch/runners/lsp.js +17 -5
  13. package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
  14. package/dist/clients/latency-logger.js +10 -15
  15. package/dist/clients/lsp/config.js +61 -3
  16. package/dist/clients/lsp/index.js +43 -3
  17. package/dist/clients/ndjson-logger.js +150 -0
  18. package/dist/clients/pipeline.js +25 -41
  19. package/dist/clients/project-diagnostics/scanner.js +8 -2
  20. package/dist/clients/read-guard-logger.js +11 -36
  21. package/dist/clients/review-graph/builder.js +316 -15
  22. package/dist/clients/review-graph/git-identity.js +150 -0
  23. package/dist/clients/review-graph/service.js +3 -3
  24. package/dist/clients/runtime-coordinator.js +83 -0
  25. package/dist/clients/runtime-tool-result.js +10 -2
  26. package/dist/clients/runtime-turn.js +21 -0
  27. package/dist/clients/tree-sitter-logger.js +7 -12
  28. package/dist/clients/tree-sitter-query-loader.js +1 -0
  29. package/dist/tools/lens-diagnostics.js +203 -12
  30. package/package.json +1 -1
  31. package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
  32. package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
  33. package/rules/tree-sitter-queries/python/python-assert-production.yml +4 -0
@@ -189,7 +189,7 @@ const lspRunner = {
189
189
  .map((d, idx) => ({ d, idx }))
190
190
  .filter(({ d }) => d.severity === 1)
191
191
  .slice(0, MAX_CODE_ACTION_LOOKUPS);
192
- for (const { d, idx } of blockingDiagIndexes) {
192
+ await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
193
193
  try {
194
194
  const start = d.range.start;
195
195
  const end = d.range.end ?? d.range.start;
@@ -202,17 +202,26 @@ const lspRunner = {
202
202
  catch {
203
203
  // Best-effort enrichment only; base diagnostics remain authoritative.
204
204
  }
205
- }
205
+ }));
206
206
  const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
207
207
  // convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
208
208
  // auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
209
209
  // their tool id + semantic policy — language-server diagnostics keep "lsp".
210
210
  // blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
211
211
  const blockingAllowedByProfile = new Map();
212
+ // Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
213
+ // `# nosemgrep`, #441). Reuses `content` from the sync read above.
214
+ const suppressedIndices = new Set();
212
215
  for (let i = 0; i < diagnostics.length; i++) {
213
216
  const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
214
217
  if (!profile)
215
218
  continue;
219
+ if (profile.isSuppressed) {
220
+ if (profile.isSuppressed(validLspDiags[i], content)) {
221
+ suppressedIndices.add(i);
222
+ continue;
223
+ }
224
+ }
216
225
  let blockingAllowed = blockingAllowedByProfile.get(profile);
217
226
  if (blockingAllowed === undefined) {
218
227
  blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
@@ -228,10 +237,13 @@ const lspRunner = {
228
237
  if (defectClass)
229
238
  d.defectClass = defectClass;
230
239
  }
231
- const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
240
+ const keptDiagnostics = suppressedIndices.size
241
+ ? diagnostics.filter((_, i) => !suppressedIndices.has(i))
242
+ : diagnostics;
243
+ const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
232
244
  const resultSemantic = hasErrors
233
245
  ? "blocking"
234
- : diagnostics.length > 0
246
+ : keptDiagnostics.length > 0
235
247
  ? "warning"
236
248
  : "none";
237
249
  return {
@@ -239,7 +251,7 @@ const lspRunner = {
239
251
  // "failed" here means the file has blocking type errors — the check ran
240
252
  // fine. Tag it so the smell analyzer doesn't read it as a runner crash.
241
253
  failureKind: hasErrors ? "blocking_diagnostics" : undefined,
242
- diagnostics,
254
+ diagnostics: keptDiagnostics,
243
255
  semantic: resultSemantic,
244
256
  };
245
257
  },
@@ -7,6 +7,7 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { RuleCache } from "../../cache/rule-cache.js";
10
+ import { isTestFile } from "../../file-utils.js";
10
11
  import { resolvePackagePath } from "../../package-root.js";
11
12
  import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
12
13
  import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
@@ -403,9 +404,13 @@ const treeSitterRunner = {
403
404
  // Run all queries regardless of blockingOnly — warning-tier results are logged
404
405
  // for diagnostic history but filtered from agent output by the dispatcher.
405
406
  // Only skip "review" tier queries on write (too noisy / expensive).
406
- const effectiveQueries = ctx.blockingOnly
407
+ // Per-rule test-file carve-out (#440): rules that are noise in tests (e.g.
408
+ // python-assert-production — `assert` is the idiomatic test assertion) opt
409
+ // out via `skip_test_files` while the runner otherwise runs on test files.
410
+ const fileIsTest = isTestFile(filePath);
411
+ const effectiveQueries = (ctx.blockingOnly
407
412
  ? languageQueries.filter((q) => q.inline_tier !== "review")
408
- : languageQueries;
413
+ : languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
409
414
  logTreeSitter({
410
415
  phase: "queries_loaded",
411
416
  filePath,
@@ -2,27 +2,23 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { isTestMode } from "./env-utils.js";
4
4
  import { getGlobalPiLensDir } from "./file-utils.js";
5
+ import { createNdjsonLogger } from "./ndjson-logger.js";
5
6
  const LATENCY_LOG_DIR = getGlobalPiLensDir();
6
7
  const LATENCY_LOG_FILE = path.join(LATENCY_LOG_DIR, "latency.log");
7
- try {
8
- if (!fs.existsSync(LATENCY_LOG_DIR)) {
9
- fs.mkdirSync(LATENCY_LOG_DIR, { recursive: true });
10
- }
11
- }
12
- catch { }
8
+ const writer = createNdjsonLogger({ filePath: LATENCY_LOG_FILE });
13
9
  export function logLatency(entry) {
14
10
  if (isTestMode()) {
15
11
  return;
16
12
  }
17
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
18
- try {
19
- fs.appendFileSync(LATENCY_LOG_FILE, line);
20
- }
21
- catch { }
13
+ writer.log({ ts: new Date().toISOString(), ...entry });
22
14
  }
23
15
  export function getLatencyLogPath() {
24
16
  return LATENCY_LOG_FILE;
25
17
  }
18
+ /** Resolve once all enqueued latency writes are on disk (tests/shutdown). */
19
+ export function flushLatencyLog() {
20
+ return writer.flush();
21
+ }
26
22
  export function readLatencyLog(limit = 100) {
27
23
  try {
28
24
  const content = fs.readFileSync(LATENCY_LOG_FILE, "utf-8");
@@ -37,8 +33,7 @@ export function readLatencyLog(limit = 100) {
37
33
  }
38
34
  }
39
35
  export function clearLatencyLog() {
40
- try {
41
- fs.writeFileSync(LATENCY_LOG_FILE, "");
42
- }
43
- catch { }
36
+ // Enqueue the truncate in the same serialized queue so a clear cannot race a
37
+ // pending drain. Await flushLatencyLog() if you need the file empty on disk.
38
+ writer.truncate();
44
39
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * LSP Configuration for pi-lens
3
3
  *
4
- * Allows users to define custom LSP servers via configuration.
4
+ * Allows users to define custom LSP servers and override initialization options
5
+ * for built-in servers via configuration.
5
6
  *
6
- * Config file: .pi-lens/lsp.json
7
+ * Config file: .pi-lens/lsp.json (or .pi-lens.json, pi-lsp.json)
7
8
  *
8
- * Example:
9
+ * Example — custom server:
9
10
  * {
10
11
  * "servers": {
11
12
  * "my-server": {
@@ -17,6 +18,33 @@
17
18
  * }
18
19
  * }
19
20
  * }
21
+ *
22
+ * Example — override initializationOptions for a built-in server:
23
+ * {
24
+ * "serverOverrides": {
25
+ * "rust": {
26
+ * "initializationOptions": {
27
+ * "check": { "command": "clippy", "allTargets": true },
28
+ * "cargo": { "features": "all", "targetDir": true }
29
+ * }
30
+ * },
31
+ * "nix": {
32
+ * "initializationOptions": {
33
+ * "nixpkgs": { "expr": "import <nixpkgs> {}" },
34
+ * "options": {
35
+ * "home_manager": { "expr": "(builtins.getFlake (toString ./.)).homeConfigurations.me.options" }
36
+ * }
37
+ * }
38
+ * }
39
+ * }
40
+ * }
41
+ *
42
+ * The `initializationOptions` object is deep-merged onto the server's built-in
43
+ * defaults, so you only need to specify the keys you want to change or add.
44
+ * User-supplied values win on conflicts at every level of nesting.
45
+ *
46
+ * Server IDs match the `id` field of each built-in server definition in
47
+ * clients/lsp/server.ts (e.g. "rust", "nix", "bash", "python", "go", "ts").
20
48
  */
21
49
  import fs from "node:fs/promises";
22
50
  import path from "node:path";
@@ -73,6 +101,7 @@ export function createCustomServer(config, id) {
73
101
  const EMPTY_CONFIG = {
74
102
  customServers: [],
75
103
  disabledServerIds: new Set(),
104
+ serverOverrides: new Map(),
76
105
  };
77
106
  const workspaceConfigs = new Map();
78
107
  /** In-flight config initialization promises to prevent duplicate concurrent loads */
@@ -121,9 +150,26 @@ export async function initLSPConfig(cwd) {
121
150
  }
122
151
  }
123
152
  }
153
+ const serverOverrides = new Map();
154
+ if (config.serverOverrides) {
155
+ for (const [id, entry] of Object.entries(config.serverOverrides)) {
156
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
157
+ const initOpts = entry.initializationOptions;
158
+ if (initOpts !== undefined &&
159
+ typeof initOpts === "object" &&
160
+ initOpts !== null &&
161
+ !Array.isArray(initOpts)) {
162
+ serverOverrides.set(id, {
163
+ initializationOptions: initOpts,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ }
124
169
  workspaceConfigs.set(normalizedCwd, {
125
170
  customServers,
126
171
  disabledServerIds,
172
+ serverOverrides,
127
173
  });
128
174
  })();
129
175
  configInFlight.set(normalizedCwd, promise);
@@ -158,6 +204,18 @@ export function getServersForFileWithConfig(filePath) {
158
204
  return extensions.includes(ext) || extensions.includes(base);
159
205
  });
160
206
  }
207
+ /**
208
+ * Look up an initializationOptions override for a built-in server.
209
+ * Returns undefined when no config was loaded or no override was specified
210
+ * for this server ID.
211
+ *
212
+ * @param serverId Built-in server id (e.g. "rust", "nix", "bash")
213
+ * @param filePath Any file path within the project (used to locate the
214
+ * workspace config that was loaded for this directory tree)
215
+ */
216
+ export function getServerInitOverride(serverId, filePath) {
217
+ return getConfigForFile(filePath).serverOverrides.get(serverId);
218
+ }
161
219
  export function resetLSPConfigStateForTests() {
162
220
  workspaceConfigs.clear();
163
221
  }
@@ -18,12 +18,48 @@ import { logLatency } from "../latency-logger.js";
18
18
  import { withDeadline } from "../deadline-utils.js";
19
19
  import { normalizeMapKey, uriToPath } from "../path-utils.js";
20
20
  import { createLSPClient } from "./client.js";
21
- import { getServersForFileWithConfig } from "./config.js";
21
+ import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
22
22
  import { getLanguageId } from "./language.js";
23
23
  import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
24
24
  import { getStrategy } from "./server-strategies.js";
25
25
  import { raceToCompletion } from "./aggregation.js";
26
26
  import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
27
+ // --- Init override helpers ---
28
+ /**
29
+ * Recursively merges `override` onto `base`. Override wins on leaf conflicts
30
+ * at every nesting level; arrays and non-plain-object values are replaced, not
31
+ * merged (consistent with standard LSP settings merge semantics).
32
+ */
33
+ function deepMergeObjects(base, override) {
34
+ const result = { ...base };
35
+ for (const [key, val] of Object.entries(override)) {
36
+ if (val !== null &&
37
+ typeof val === "object" &&
38
+ !Array.isArray(val) &&
39
+ result[key] !== null &&
40
+ typeof result[key] === "object" &&
41
+ !Array.isArray(result[key])) {
42
+ result[key] = deepMergeObjects(result[key], val);
43
+ }
44
+ else {
45
+ result[key] = val;
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+ /**
51
+ * Merges user-supplied initializationOptions onto a server's built-in defaults.
52
+ * - If neither side is defined → undefined (no options sent).
53
+ * - If only one side is defined → that side is returned directly.
54
+ * - Both defined → deep merge, user wins on conflicts.
55
+ */
56
+ export function mergeInitializationOptions(base, override) {
57
+ if (!override)
58
+ return base;
59
+ if (!base)
60
+ return override;
61
+ return deepMergeObjects(base, override);
62
+ }
27
63
  const BROKEN_BASE_COOLDOWN_MS = 15_000;
28
64
  const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
29
65
  const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
@@ -567,11 +603,13 @@ export class LSPService {
567
603
  }
568
604
  return undefined;
569
605
  }
606
+ const override = getServerInitOverride(server.id, filePath);
607
+ const mergedInit = mergeInitializationOptions(spawned.initialization, override?.initializationOptions);
570
608
  const client = await createLSPClient({
571
609
  serverId: server.id,
572
610
  process: spawned.process,
573
611
  root,
574
- initialization: spawned.initialization,
612
+ initialization: mergedInit,
575
613
  initializeTimeoutMs: server.initializeTimeoutMs,
576
614
  });
577
615
  const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
@@ -1386,7 +1424,9 @@ export class LSPService {
1386
1424
  options.maxFiles > 0
1387
1425
  ? Math.floor(options.maxFiles)
1388
1426
  : getMaxWorkspaceDiagnosticFiles();
1389
- const files = await collectWorkspaceDiagnosticFiles(root, maxFiles, signal);
1427
+ const files = options.files
1428
+ ? options.files.slice(0, maxFiles)
1429
+ : await collectWorkspaceDiagnosticFiles(root, maxFiles, signal);
1390
1430
  // Per-file wall-clock: a language server that hangs during spawn/initialize
1391
1431
  // would otherwise park a worker on `touchFile` FOREVER (the per-edit
1392
1432
  // diagnostic wait is bounded, but client acquisition here is not) — the root
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared write-plumbing for the hand-rolled NDJSON debug loggers in clients/.
3
+ *
4
+ * One buffered async writer replaces eight drifting copies of append+rotate.
5
+ * `log()`/`append()` are synchronous-call, async-write: they enqueue a
6
+ * serialized line and a single in-flight `fs.promises.appendFile` drains the
7
+ * queue — no `appendFileSync` on the per-edit hot path (latency-logger alone
8
+ * fired ~10–20 sync appends per edit, #454/#361/#368).
9
+ *
10
+ * Errors are swallowed best-effort, matching every current logger. A
11
+ * best-effort SYNC flush is registered on `process.on("exit")` (appendFileSync
12
+ * is fine at exit — not the hot path; no child spawning, #234).
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ function resolve(v) {
17
+ return typeof v === "function" ? v() : v;
18
+ }
19
+ // One shared exit handler flushes every logger — avoids an EventEmitter
20
+ // MaxListeners warning once more than ~10 loggers exist (we ship eight, plus
21
+ // diagnostic + test instances). No child spawning at teardown (#234).
22
+ const exitFlushers = new Set();
23
+ let exitHandlerRegistered = false;
24
+ /** Test-only view of the registered exit flushers (see ndjson-logger.test.ts). */
25
+ export function _exitFlushersForTest() {
26
+ return exitFlushers;
27
+ }
28
+ function registerExitFlusher(flushSync) {
29
+ exitFlushers.add(flushSync);
30
+ if (!exitHandlerRegistered) {
31
+ exitHandlerRegistered = true;
32
+ process.on("exit", () => {
33
+ for (const flush of exitFlushers) {
34
+ try {
35
+ flush();
36
+ }
37
+ catch { }
38
+ }
39
+ });
40
+ }
41
+ }
42
+ export function createNdjsonLogger(options) {
43
+ const queue = [];
44
+ let drainPromise = null;
45
+ let ensuredDir = false;
46
+ function ensureDir(file) {
47
+ if (ensuredDir)
48
+ return;
49
+ try {
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ ensuredDir = true;
52
+ }
53
+ catch { }
54
+ }
55
+ function rotateIfNeeded(file) {
56
+ if (options.maxBytes === undefined)
57
+ return;
58
+ try {
59
+ const size = fs.statSync(file).size;
60
+ if (size < options.maxBytes)
61
+ return;
62
+ const backup = options.backupPath
63
+ ? resolve(options.backupPath)
64
+ : `${file}.1`;
65
+ try {
66
+ fs.rmSync(backup, { force: true });
67
+ }
68
+ catch { }
69
+ fs.renameSync(file, backup);
70
+ }
71
+ catch {
72
+ // no file yet, or rename raced — nothing to rotate
73
+ }
74
+ }
75
+ async function drainLoop() {
76
+ // Peek, write, then remove — an item stays in the queue until it is on
77
+ // disk, so a teardown flushSync (which abandons this async loop) never
78
+ // drops an item this loop had already dequeued but not yet written.
79
+ while (queue.length > 0) {
80
+ const item = queue[0];
81
+ const file = resolve(options.filePath);
82
+ ensureDir(file);
83
+ try {
84
+ if (item.kind === "truncate") {
85
+ await fs.promises.writeFile(file, "");
86
+ }
87
+ else {
88
+ rotateIfNeeded(file);
89
+ await fs.promises.appendFile(file, item.line);
90
+ }
91
+ }
92
+ catch {
93
+ // telemetry is best-effort
94
+ }
95
+ queue.shift();
96
+ }
97
+ }
98
+ function drain() {
99
+ // Serialize: a single in-flight drain owns the queue. flush() awaits this
100
+ // same promise, so it never resolves before pending writes land. The loop
101
+ // re-checks queue.length, so items enqueued mid-drain are picked up before
102
+ // the promise settles — no stranded item, no second concurrent drainer.
103
+ if (!drainPromise) {
104
+ drainPromise = drainLoop().finally(() => {
105
+ drainPromise = null;
106
+ });
107
+ }
108
+ return drainPromise;
109
+ }
110
+ function enqueue(item) {
111
+ queue.push(item);
112
+ void drain();
113
+ }
114
+ function flushSync() {
115
+ // Drain the in-memory queue synchronously — safe at process exit.
116
+ while (queue.length > 0) {
117
+ const item = queue.shift();
118
+ const file = resolve(options.filePath);
119
+ ensureDir(file);
120
+ try {
121
+ if (item.kind === "truncate") {
122
+ fs.writeFileSync(file, "");
123
+ }
124
+ else {
125
+ rotateIfNeeded(file);
126
+ fs.appendFileSync(file, item.line);
127
+ }
128
+ }
129
+ catch { }
130
+ }
131
+ }
132
+ // Best-effort teardown flush of anything still buffered, via the single
133
+ // shared exit handler. appendFileSync is fine here — not the hot path.
134
+ registerExitFlusher(flushSync);
135
+ return {
136
+ log(obj) {
137
+ enqueue({ kind: "line", line: `${JSON.stringify(obj)}\n` });
138
+ },
139
+ append(line) {
140
+ enqueue({ kind: "line", line: `${line}\n` });
141
+ },
142
+ truncate() {
143
+ enqueue({ kind: "truncate" });
144
+ },
145
+ async flush() {
146
+ await drain();
147
+ },
148
+ flushSync,
149
+ };
150
+ }
@@ -169,8 +169,12 @@ function createPhaseTracker(toolName, filePath) {
169
169
  export { hasEslintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, };
170
170
  const _eslintCache = new Map();
171
171
  /**
172
- * Run eslint --fix on a file. Returns number of fixable issues resolved,
173
- * or 0 if ESLint is not configured / not available.
172
+ * Run eslint --fix on a file. Runs a single spawn and diffs the file before/after,
173
+ * same idiom as the other autofix helpers below. Exit code 1 (unfixable problems
174
+ * remain) is allowed because fixes may still have been applied; only exit code 2
175
+ * (config/fatal error) is treated as failure.
176
+ * Returns 1 if the file changed, 0 if ESLint is not configured / not available /
177
+ * made no changes.
174
178
  */
175
179
  async function tryEslintFix(filePath, cwd) {
176
180
  const userHasConfig = hasEslintConfig(cwd);
@@ -193,40 +197,7 @@ async function tryEslintFix(filePath, cwd) {
193
197
  if (!cached.available || !cached.bin)
194
198
  return 0;
195
199
  const cmd = cached.bin;
196
- const configArgs = [];
197
- // --fix-dry-run returns JSON with fixable counts without writing to disk.
198
- // Use it to get the real count, then apply with --fix only if needed.
199
- const dry = await safeSpawnAsync(cmd, [
200
- "--fix-dry-run",
201
- "--format",
202
- "json",
203
- "--no-error-on-unmatched-pattern",
204
- ...configArgs,
205
- filePath,
206
- ], { timeout: 30000, cwd });
207
- if (dry.status === 2)
208
- return 0;
209
- let fixableCount = 0;
210
- let anyDryRunOutput = false;
211
- try {
212
- const results = JSON.parse(dry.stdout);
213
- fixableCount = results.reduce((sum, r) => sum + (r.fixableErrorCount ?? 0) + (r.fixableWarningCount ?? 0), 0);
214
- // `--fix-dry-run` reports the POST-fix state: when every problem is
215
- // auto-fixable, `messages`/`fixableErrorCount` are 0 and the fixed source
216
- // lands in the `output` field instead. Keying on `fixableErrorCount` alone
217
- // therefore misses the common "all fixable" case and never applies fixes.
218
- anyDryRunOutput = results.some((r) => typeof r.output === "string");
219
- }
220
- catch {
221
- /* treat as zero fixable on error */
222
- }
223
- if (fixableCount === 0 && !anyDryRunOutput)
224
- return 0;
225
- // Apply the fixes
226
- const fix = await safeSpawnAsync(cmd, ["--fix", "--no-error-on-unmatched-pattern", ...configArgs, filePath], { timeout: 30000, cwd });
227
- if (fix.status === 2)
228
- return 0;
229
- return fixableCount > 0 ? fixableCount : anyDryRunOutput ? 1 : 0;
200
+ return detectFileChangedAfterCommand(filePath, cmd, ["--fix", "--no-error-on-unmatched-pattern", filePath], cwd, [1]);
230
201
  }
231
202
  async function tryStylelintFix(filePath, cwd) {
232
203
  const cmd = await resolveToolCommandWithInstallFallback(cwd, "stylelint");
@@ -926,15 +897,28 @@ export async function runPipeline(ctx, deps) {
926
897
  diagnosticCount: dispatchResult.diagnostics.length,
927
898
  });
928
899
  // --- 6. Cascade diagnostics (LSP only) ---
929
- // Deferred: cascade errors in OTHER files are NOT shown inline surfaced at
930
- // turn_end so mid-refactor intermediate errors don't derail the agent.
931
- const cascadeRun = getFlag("no-lsp")
900
+ // Kicked off UNAWAITED so the graph rebuild + neighbor LSP pulls run
901
+ // concurrently after the edit returns rather than blocking it (#450). The
902
+ // result is never shown inline — settled (bounded) and surfaced at turn_end.
903
+ // The stored promise must never reject: an unhandled rejection is fatal, so a
904
+ // failing compute resolves to an "error" skip-run instead.
905
+ const cascadePromise = getFlag("no-lsp")
932
906
  ? undefined
933
- : await computeCascadeForFile(filePath, cwd, {
907
+ : computeCascadeForFile(filePath, cwd, {
934
908
  hasBlockers,
935
909
  dbg,
936
910
  turnSeq: ctx.telemetry?.turnIndex,
937
911
  writeSeq: ctx.telemetry?.writeIndex,
912
+ seqState: ctx.seqState,
913
+ }).catch((err) => {
914
+ dbg(`cascade compute failed for ${filePath}: ${err}`);
915
+ return {
916
+ filePath,
917
+ result: undefined,
918
+ neighborCount: 0,
919
+ diagnosticCount: 0,
920
+ skipReason: "error",
921
+ };
938
922
  });
939
923
  // --- Final timing + all-clear ---
940
924
  const elapsed = Date.now() - pipelineStart;
@@ -965,7 +949,7 @@ export async function runPipeline(ctx, deps) {
965
949
  return {
966
950
  output,
967
951
  hasBlockers,
968
- cascadeRun,
952
+ cascadePromise,
969
953
  isError: false,
970
954
  fileModified,
971
955
  changedFiles,
@@ -197,7 +197,9 @@ export async function scanProjectDiagnostics(options) {
197
197
  const cwd = path.resolve(options.cwd);
198
198
  const { signal } = options;
199
199
  const maxFiles = Math.max(1, options.maxFiles ?? DEFAULT_MAX_FILES);
200
- const files = await collectSourceFilesAsync(cwd, { maxFiles });
200
+ const files = options.files
201
+ ? options.files.slice(0, maxFiles)
202
+ : await collectSourceFilesAsync(cwd, { maxFiles });
201
203
  // Check cancellation at each phase boundary so a full-mode scan stops
202
204
  // promptly when the agent/user aborts (#341). The per-phase runners are
203
205
  // already file-capped, so phase granularity is enough to bound the work.
@@ -226,7 +228,11 @@ export async function scanProjectDiagnostics(options) {
226
228
  };
227
229
  // A cancelled scan yields a partial snapshot; don't persist it as the
228
230
  // authoritative cross-session cache — only a complete run is cacheable.
229
- if (signal?.aborted)
231
+ // Likewise, an explicit `files` scan (#461) only covers a caller-chosen
232
+ // subset (e.g. git-staged files), not the whole project — persisting it
233
+ // would poison the cross-session cache with a partial view that a later
234
+ // unscoped `refreshRunners=cached` read would wrongly trust as complete.
235
+ if (signal?.aborted || options.files)
230
236
  return snapshot;
231
237
  saveProjectDiagnosticsSnapshot(cwd, snapshot);
232
238
  return snapshot;
@@ -1,25 +1,22 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import { isTestMode } from "./env-utils.js";
4
3
  import { getGlobalPiLensDir } from "./file-utils.js";
4
+ import { createNdjsonLogger } from "./ndjson-logger.js";
5
5
  const READ_GUARD_LOG_DIR = getGlobalPiLensDir();
6
6
  const READ_GUARD_LOG_FILE = path.join(READ_GUARD_LOG_DIR, "read-guard.log");
7
7
  const READ_GUARD_LOG_BACKUP_FILE = path.join(READ_GUARD_LOG_DIR, "read-guard.log.1");
8
8
  const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_READ_GUARD_MAX_BYTES ?? "1048576", 10) ||
9
9
  1048576);
10
+ const writer = createNdjsonLogger({
11
+ filePath: READ_GUARD_LOG_FILE,
12
+ maxBytes: MAX_LOG_BYTES,
13
+ backupPath: READ_GUARD_LOG_BACKUP_FILE,
14
+ });
10
15
  const VERBOSE_READ_GUARD_LOG = process.env.PI_LENS_READ_GUARD_VERBOSE === "1" ||
11
16
  process.env.PI_LENS_READ_GUARD_LOG === "verbose";
12
17
  const LOG_ALLOWED_EDITS = process.env.PI_LENS_READ_GUARD_LOG_ALLOWS === "1";
13
18
  const SNAPSHOT_LOG_SETTING = (process.env.PI_LENS_READ_GUARD_LOG_SNAPSHOTS ?? "1").toLowerCase();
14
19
  const LOG_SNAPSHOT_VALIDATION = !["0", "false", "off"].includes(SNAPSHOT_LOG_SETTING);
15
- try {
16
- if (!fs.existsSync(READ_GUARD_LOG_DIR)) {
17
- fs.mkdirSync(READ_GUARD_LOG_DIR, { recursive: true });
18
- }
19
- }
20
- catch (err) {
21
- void err;
22
- }
23
20
  function shouldLogEvent(event) {
24
21
  if (VERBOSE_READ_GUARD_LOG)
25
22
  return true;
@@ -40,38 +37,16 @@ function shouldLogEvent(event) {
40
37
  event === "edit_partial_apply" ||
41
38
  event === "touched_lines_missing");
42
39
  }
43
- function rotateIfNeeded() {
44
- try {
45
- if (!fs.existsSync(READ_GUARD_LOG_FILE))
46
- return;
47
- const size = fs.statSync(READ_GUARD_LOG_FILE).size;
48
- if (size < MAX_LOG_BYTES)
49
- return;
50
- try {
51
- fs.rmSync(READ_GUARD_LOG_BACKUP_FILE, { force: true });
52
- }
53
- catch (err) {
54
- void err;
55
- }
56
- fs.renameSync(READ_GUARD_LOG_FILE, READ_GUARD_LOG_BACKUP_FILE);
57
- }
58
- catch (err) {
59
- void err;
60
- }
61
- }
62
40
  export function logReadGuardEvent(entry) {
63
41
  if (isTestMode() || !shouldLogEvent(entry.event)) {
64
42
  return;
65
43
  }
66
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
67
- try {
68
- rotateIfNeeded();
69
- fs.appendFileSync(READ_GUARD_LOG_FILE, line);
70
- }
71
- catch (err) {
72
- void err;
73
- }
44
+ writer.log({ ts: new Date().toISOString(), ...entry });
74
45
  }
75
46
  export function getReadGuardLogPath() {
76
47
  return READ_GUARD_LOG_FILE;
77
48
  }
49
+ /** Resolve once all enqueued read-guard writes are on disk (tests/shutdown). */
50
+ export function flushReadGuardLog() {
51
+ return writer.flush();
52
+ }