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.
- package/CHANGELOG.md +25 -0
- package/README.md +0 -1
- package/dist/clients/actionable-warnings-logger.js +11 -36
- package/dist/clients/ast-grep-tool-logger.js +11 -36
- package/dist/clients/cascade-logger.js +7 -12
- package/dist/clients/dead-code-logger.js +11 -22
- package/dist/clients/diagnostic-logger.js +7 -31
- package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
- package/dist/clients/dispatch/dispatcher.js +1 -36
- package/dist/clients/dispatch/inline-suppressions.js +61 -0
- package/dist/clients/dispatch/integration.js +75 -23
- package/dist/clients/dispatch/runners/lsp.js +17 -5
- package/dist/clients/dispatch/runners/tree-sitter.js +7 -2
- package/dist/clients/latency-logger.js +10 -15
- package/dist/clients/lsp/config.js +61 -3
- package/dist/clients/lsp/index.js +43 -3
- package/dist/clients/ndjson-logger.js +150 -0
- package/dist/clients/pipeline.js +25 -41
- package/dist/clients/project-diagnostics/scanner.js +8 -2
- package/dist/clients/read-guard-logger.js +11 -36
- package/dist/clients/review-graph/builder.js +316 -15
- package/dist/clients/review-graph/git-identity.js +150 -0
- package/dist/clients/review-graph/service.js +3 -3
- package/dist/clients/runtime-coordinator.js +83 -0
- package/dist/clients/runtime-tool-result.js +10 -2
- package/dist/clients/runtime-turn.js +21 -0
- package/dist/clients/tree-sitter-logger.js +7 -12
- package/dist/clients/tree-sitter-query-loader.js +1 -0
- package/dist/tools/lens-diagnostics.js +203 -12
- package/package.json +1 -1
- package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
- package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
- 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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
+
}
|
package/dist/clients/pipeline.js
CHANGED
|
@@ -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.
|
|
173
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|