pi-lens 3.8.71 → 3.8.73
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 +152 -1
- package/README.md +31 -22
- package/config/markdownlint/core.json +6 -0
- package/dist/clients/atomic-write.js +83 -0
- package/dist/clients/config-enabled-shape.js +39 -0
- package/dist/clients/deps/js-yaml.js +1 -1
- package/dist/clients/diagnostic-dispositions.js +64 -2
- package/dist/clients/dispatch/integration.js +2 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
- package/dist/clients/dispatch/runners/lsp.js +54 -25
- package/dist/clients/dispatch/runners/markdownlint.js +7 -4
- package/dist/clients/dispatch/runners/pyright.js +1 -0
- package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
- package/dist/clients/env-utils.js +38 -0
- package/dist/clients/file-utils.js +21 -3
- package/dist/clients/instance-registry.js +32 -21
- package/dist/clients/jscpd-client.js +52 -39
- package/dist/clients/language-profile.js +12 -19
- package/dist/clients/lens-config.js +106 -27
- package/dist/clients/lens-engine.js +129 -3
- package/dist/clients/lsp/cascade-tier.js +1 -38
- package/dist/clients/lsp/client.js +3 -1
- package/dist/clients/lsp/index.js +707 -109
- package/dist/clients/lsp/server-strategies.js +3 -224
- package/dist/clients/lsp/server.js +1 -1
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
- package/dist/clients/lsp/wait-policy/classification.js +63 -0
- package/dist/clients/lsp/wait-policy/index.js +10 -0
- package/dist/clients/lsp/wait-policy/strategies.js +244 -0
- package/dist/clients/lsp-budget.js +54 -16
- package/dist/clients/mcp/analyze.js +1 -1
- package/dist/clients/mcp/host-shim.js +12 -8
- package/dist/clients/mcp/ipc.js +118 -0
- package/dist/clients/mcp/session.js +2 -2
- package/dist/clients/pipeline.js +11 -6
- package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
- package/dist/clients/project-diagnostics/scanner.js +37 -6
- package/dist/clients/project-lens-config.js +167 -3
- package/dist/clients/project-report.js +627 -0
- package/dist/clients/project-scale.js +239 -0
- package/dist/clients/project-scan-policy.js +11 -1
- package/dist/clients/recent-touches.js +6 -11
- package/dist/clients/review-graph/builder.js +170 -13
- package/dist/clients/review-graph/import-resolvers.js +149 -13
- package/dist/clients/review-graph/tsconfig-paths.js +242 -0
- package/dist/clients/review-graph/workspace-modules.js +21 -9
- package/dist/clients/rules-scanner.js +15 -2
- package/dist/clients/runtime-agent-end.js +104 -17
- package/dist/clients/runtime-config.js +4 -0
- package/dist/clients/runtime-coordinator.js +50 -1
- package/dist/clients/runtime-session.js +41 -3
- package/dist/clients/runtime-tool-result.js +4 -3
- package/dist/clients/runtime-turn.js +4 -1
- package/dist/clients/safe-spawn.js +264 -40
- package/dist/clients/session-lifecycle.js +36 -0
- package/dist/clients/session-state-store.js +8 -3
- package/dist/clients/source-filter.js +136 -61
- package/dist/clients/source-walker.js +89 -0
- package/dist/clients/startup-scan.js +189 -111
- package/dist/clients/subagent-mode.js +14 -3
- package/dist/clients/todo-scanner.js +5 -1
- package/dist/clients/tool-policy.js +26 -4
- package/dist/clients/warm-attach.js +204 -0
- package/dist/clients/word-index.js +36 -5
- package/dist/clients/workspace-topology.js +266 -0
- package/dist/index.js +15205 -11330
- package/dist/mcp/server.js +125 -10
- package/dist/tools/ast-grep-search.js +21 -0
- package/dist/tools/lens-diagnostic-mark.js +198 -7
- package/dist/tools/lens-diagnostics.js +119 -21
- package/dist/tools/lsp-diagnostics.js +143 -10
- package/dist/tools/lsp-navigation.js +11 -2
- package/dist/tools/project-report.js +94 -0
- package/dist/tools/symbol-search.js +32 -8
- package/docs/environment-variables.md +10 -0
- package/docs/features.md +7 -1
- package/docs/globalconfig.md +70 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/usage.md +13 -2
- package/package.json +8 -9
- package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
- package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
- package/scripts/download-grammars.js +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared atomic tmp+rename file writer (closes #762).
|
|
3
|
+
*
|
|
4
|
+
* The `${target}.tmp-${process.pid}` + `renameSync` shape was independently
|
|
5
|
+
* hand-rolled in five places (`instance-registry.ts`, `session-state-store.ts`,
|
|
6
|
+
* `recent-touches.ts`, `review-graph/builder.ts`, `diagnostic-dispositions.ts`)
|
|
7
|
+
* as each one picked up the need for a cross-process reader to never observe a
|
|
8
|
+
* partially-written file: `rename()` replaces the destination atomically on
|
|
9
|
+
* both POSIX and Windows (libuv uses `MOVEFILE_REPLACE_EXISTING`), so a
|
|
10
|
+
* concurrent reader always sees either the fully-old or fully-new file, never
|
|
11
|
+
* a torn write. Five independent copies of the same shape invite drift (e.g.
|
|
12
|
+
* forgetting the tmp-file cleanup on the failure path) — this module is the
|
|
13
|
+
* single implementation the rest re-use.
|
|
14
|
+
*
|
|
15
|
+
* Per-write-site error policy varies and is NOT a detail this helper should
|
|
16
|
+
* paper over:
|
|
17
|
+
*
|
|
18
|
+
* - `bestEffort: true` (the default, and the majority of today's sites): a
|
|
19
|
+
* write or rename failure just means this update is lost — swallow it,
|
|
20
|
+
* after a best-effort tmp-file cleanup. Appropriate for observability
|
|
21
|
+
* substrate (instance registry, recent-touches, review-graph cache,
|
|
22
|
+
* session widget state) where a dropped write is, at worst, a stale/
|
|
23
|
+
* missing sample.
|
|
24
|
+
* - `bestEffort: false`: rethrow the failure (after the same best-effort tmp
|
|
25
|
+
* cleanup). This is `clients/diagnostic-dispositions.ts`'s policy (#757):
|
|
26
|
+
* unlike the writers above, a disposition mark is functionally
|
|
27
|
+
* load-bearing — a silently lost mark is a correctness bug, not a dropped
|
|
28
|
+
* observability sample — so callers must see the failure rather than have
|
|
29
|
+
* it swallowed. See the #757 CHANGELOG entry for the full reasoning.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
/**
|
|
33
|
+
* Synchronous atomic write: `fs.writeFileSync` to `${targetPath}.tmp-${pid}`,
|
|
34
|
+
* then `fs.renameSync` over `targetPath`. On any failure (from either step),
|
|
35
|
+
* attempts a best-effort `fs.rmSync(tmp, { force: true })` cleanup, then
|
|
36
|
+
* either swallows (default) or rethrows per `options.bestEffort`.
|
|
37
|
+
*
|
|
38
|
+
* Does not create the parent directory — callers that need one must
|
|
39
|
+
* `mkdirSync` before calling this (matches every existing call site, which
|
|
40
|
+
* already does its own mkdir as a separate step).
|
|
41
|
+
*/
|
|
42
|
+
export function writeFileAtomic(targetPath, data, options) {
|
|
43
|
+
const bestEffort = options?.bestEffort ?? true;
|
|
44
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}`;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(tmpPath, data, "utf-8");
|
|
47
|
+
fs.renameSync(tmpPath, targetPath);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
try {
|
|
51
|
+
fs.rmSync(tmpPath, { force: true });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore — best-effort cleanup of our own tmp file
|
|
55
|
+
}
|
|
56
|
+
if (!bestEffort)
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Async counterpart of {@link writeFileAtomic}, built on `fs.promises` instead
|
|
62
|
+
* of the sync `fs` API — for call sites that must not block the event loop
|
|
63
|
+
* (e.g. writes on a hot per-turn/per-touch path). Same tmp-naming, cleanup,
|
|
64
|
+
* and `bestEffort` semantics.
|
|
65
|
+
*/
|
|
66
|
+
export async function writeFileAtomicAsync(targetPath, data, options) {
|
|
67
|
+
const bestEffort = options?.bestEffort ?? true;
|
|
68
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}`;
|
|
69
|
+
try {
|
|
70
|
+
await fs.promises.writeFile(tmpPath, data, "utf-8");
|
|
71
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.promises.rm(tmpPath, { force: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// ignore — best-effort cleanup of our own tmp file
|
|
79
|
+
}
|
|
80
|
+
if (!bestEffort)
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parser for the `{ enabled?: boolean }` shape used by BOTH the global
|
|
3
|
+
* config (`clients/lens-config.ts`, `~/.pi-lens/config.json`) and the project
|
|
4
|
+
* config (`clients/project-lens-config.ts`, `.pi-lens.json`) for their
|
|
5
|
+
* mutation-control keys (`format`, `autofix`, `actionableWarnings.autoFix`).
|
|
6
|
+
*
|
|
7
|
+
* Extracted (#792) so both loaders parse and warn on the identical shape
|
|
8
|
+
* identically instead of two independent hand-rolled implementations that had
|
|
9
|
+
* diverged in strictness — project warned on invalid `enabled` values via its
|
|
10
|
+
* own `warnInvalidConfigOnce`, global silently dropped them. This module has
|
|
11
|
+
* no dependency on either config module (avoids an import cycle) — callers
|
|
12
|
+
* supply their own `warnInvalid` callback so each config keeps its own
|
|
13
|
+
* warn-once plumbing (keyed by its own config path).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Parse a raw `{ enabled?: boolean }` value.
|
|
17
|
+
*
|
|
18
|
+
* - `undefined` input → `undefined` (key absent from the config).
|
|
19
|
+
* - Non-object (or array) input → `warnInvalid` is called, returns `undefined`.
|
|
20
|
+
* - Object with no `enabled` key → `{}` (present but nothing to say).
|
|
21
|
+
* - `enabled` present but not a boolean → `warnInvalid` is called, returns `{}`.
|
|
22
|
+
* - `enabled` a boolean → `{ enabled }`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseEnabledShape(value, fieldPath, warnInvalid) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
warnInvalid(`${fieldPath} must be an object`);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const raw = value;
|
|
32
|
+
if (!("enabled" in raw))
|
|
33
|
+
return {};
|
|
34
|
+
if (typeof raw.enabled !== "boolean") {
|
|
35
|
+
warnInvalid(`${fieldPath}.enabled must be a boolean`);
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
return { enabled: raw.enabled };
|
|
39
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Exposes both the default export (callers that do `import yaml from …`) and the
|
|
4
4
|
* named `dump`/`load` helpers, sourced from the one import.
|
|
5
5
|
*/
|
|
6
|
-
import yaml from "js-yaml";
|
|
6
|
+
import * as yaml from "js-yaml";
|
|
7
7
|
export default yaml;
|
|
8
8
|
export const dump = yaml.dump;
|
|
9
9
|
export const load = yaml.load;
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
import { createHash } from "node:crypto";
|
|
57
57
|
import * as fs from "node:fs";
|
|
58
58
|
import * as path from "node:path";
|
|
59
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
59
60
|
import { logDispositionEvent } from "./disposition-logger.js";
|
|
60
61
|
import { publishDisposition } from "./disposition-publish.js";
|
|
61
62
|
import { getProjectDataDir } from "./file-utils.js";
|
|
@@ -66,7 +67,10 @@ import { lineContentHash } from "./read-guard.js";
|
|
|
66
67
|
// needed. Stores WEAK anchors (see module doc) so a deferred finding stays
|
|
67
68
|
// hidden all session even if the flagged line itself is edited.
|
|
68
69
|
const deferredThisSession = new Set();
|
|
69
|
-
|
|
70
|
+
/** Exported (#802) so lens-diagnostic-mark's cross-check against live widget
|
|
71
|
+
* diagnostics matches a message the same way anchor derivation does — a
|
|
72
|
+
* second, slightly different normalizer would make a real match invisible. */
|
|
73
|
+
export function normalizeMessage(message) {
|
|
70
74
|
return message.replace(/\s+/g, " ").trim().toLowerCase();
|
|
71
75
|
}
|
|
72
76
|
function relativeFile(filePath, cwd) {
|
|
@@ -144,6 +148,15 @@ function readState(cwd) {
|
|
|
144
148
|
parsed && typeof parsed === "object" ? parsed : {};
|
|
145
149
|
}
|
|
146
150
|
catch {
|
|
151
|
+
// Now that writeState is tmp+rename atomic, a torn read (another process
|
|
152
|
+
// mid-write) can no longer land here — this only fires on genuine
|
|
153
|
+
// corruption/wrong-shape content. Caching `{}` against this stat is still
|
|
154
|
+
// correct, not a permanent trap: any future rewrite of the file (a fix,
|
|
155
|
+
// or this process's own next writeState) changes mtime/size, which
|
|
156
|
+
// invalidates the cache below on the next readState call. Only a file
|
|
157
|
+
// that never changes again would serve empty state forever — and
|
|
158
|
+
// reparsing the same invalid bytes every hot-path call would yield the
|
|
159
|
+
// same `{}` anyway, so the cache costs nothing in that case.
|
|
147
160
|
state = {};
|
|
148
161
|
}
|
|
149
162
|
stateCache = {
|
|
@@ -155,10 +168,20 @@ function readState(cwd) {
|
|
|
155
168
|
};
|
|
156
169
|
return state;
|
|
157
170
|
}
|
|
171
|
+
// Atomic tmp+rename via clients/atomic-write.ts (#762; shared with
|
|
172
|
+
// instance-registry.ts / recent-touches.ts / review-graph/builder.ts): a
|
|
173
|
+
// cross-process reader must never observe a partially-written file —
|
|
174
|
+
// rename() replaces the destination atomically on both POSIX and Windows
|
|
175
|
+
// (libuv uses MOVEFILE_REPLACE_EXISTING), so a concurrent readState sees
|
|
176
|
+
// either the old JSON or the new JSON, never a torn write that fails to
|
|
177
|
+
// parse. Unlike those best-effort writers, `bestEffort: false` here means a
|
|
178
|
+
// failure still propagates (matches the pre-atomic writeFileSync's behavior,
|
|
179
|
+
// which never swallowed errors either) — a disposition mark silently vanishing
|
|
180
|
+
// is a correctness bug for this store, not just a lost observability sample.
|
|
158
181
|
function writeState(cwd, state) {
|
|
159
182
|
const p = statePath(cwd);
|
|
160
183
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
161
|
-
|
|
184
|
+
writeFileAtomic(p, JSON.stringify(state, null, 2), { bestEffort: false });
|
|
162
185
|
// Refresh the cache from the write we just did instead of invalidating it —
|
|
163
186
|
// avoids an immediate re-stat+re-parse of the file we already have in hand,
|
|
164
187
|
// and guards against coarse filesystem mtime granularity making a
|
|
@@ -296,3 +319,42 @@ export function applyDispositions(diagnostics, cwd, filePath, content) {
|
|
|
296
319
|
return true;
|
|
297
320
|
});
|
|
298
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* WEAK-anchor-only disposition filter for the "instant" (cache-only)
|
|
324
|
+
* lens_diagnostics modes (delta/all). Drops diagnostics disposed `suppress`
|
|
325
|
+
* or deferred this session — both WEAK-anchored (`file|tool|rule|message`, no
|
|
326
|
+
* line-content hash; see module doc), so this needs ZERO file I/O: it computes
|
|
327
|
+
* only the weak anchor and never touches the diagnostic's line content.
|
|
328
|
+
*
|
|
329
|
+
* `false-positive` is deliberately NOT filtered here: it is STRICT-anchored,
|
|
330
|
+
* which requires the flagged line's content to re-derive its hash, and reading
|
|
331
|
+
* every findings file just for that would defeat the instant contract of these
|
|
332
|
+
* cache-only modes. A false-positive mark still filters at the next per-edit
|
|
333
|
+
* dispatch (`dispatcher.ts`) and in `mode=full`'s merge — both of which already
|
|
334
|
+
* have file content in hand and call `applyDispositions` (the full,
|
|
335
|
+
* content-based filter). suppress/defer, being intent-level and weak-anchored,
|
|
336
|
+
* are the marks that must apply the instant a query re-serves cached findings,
|
|
337
|
+
* and they do so here without any read.
|
|
338
|
+
*/
|
|
339
|
+
export function applyWeakDispositions(diagnostics, cwd, filePath) {
|
|
340
|
+
if (!diagnostics.length)
|
|
341
|
+
return diagnostics;
|
|
342
|
+
const dispositions = readState(cwd).dispositions;
|
|
343
|
+
if (!dispositions && deferredThisSession.size === 0)
|
|
344
|
+
return diagnostics;
|
|
345
|
+
return diagnostics.filter((d) => {
|
|
346
|
+
const weak = computeWeakAnchor({
|
|
347
|
+
cwd,
|
|
348
|
+
filePath,
|
|
349
|
+
tool: d.tool,
|
|
350
|
+
rule: d.rule,
|
|
351
|
+
message: d.message,
|
|
352
|
+
line: d.line,
|
|
353
|
+
});
|
|
354
|
+
if (deferredThisSession.has(weak))
|
|
355
|
+
return false;
|
|
356
|
+
if (dispositions?.[weak]?.disposition === "suppress")
|
|
357
|
+
return false;
|
|
358
|
+
return true;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
@@ -19,7 +19,8 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
|
|
20
20
|
import { logCascade } from "../cascade-logger.js";
|
|
21
21
|
import { getDiagnosticTracker } from "../diagnostic-tracker.js";
|
|
22
|
-
import {
|
|
22
|
+
import { isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
|
|
23
|
+
import { classifyCascadeWaitTier } from "../lsp/wait-policy/index.js";
|
|
23
24
|
import { getServersForFileWithConfig } from "../lsp/config.js";
|
|
24
25
|
import { getLSPService } from "../lsp/index.js";
|
|
25
26
|
import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
|
|
@@ -10,9 +10,12 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { loadAstGrepNapi, } from "../../deps/ast-grep-napi.js";
|
|
12
12
|
import { getAstGrepRuleSources, } from "../../sgconfig.js";
|
|
13
|
+
import { logLatency } from "../../latency-logger.js";
|
|
13
14
|
import { hasEslintConfig } from "../../tool-policy.js";
|
|
14
15
|
import { enabledAuxiliaryLspServerIds } from "../auxiliary-lsp.js";
|
|
15
16
|
import { classifyDefect } from "../diagnostic-taxonomy.js";
|
|
17
|
+
import { isAuxiliaryLspAlive } from "../../lsp/index.js";
|
|
18
|
+
import { resolveAstGrepNativeExe } from "../../lsp/wait-policy/index.js";
|
|
16
19
|
import { PRIORITY } from "../priorities.js";
|
|
17
20
|
import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, loadYamlRulesFresh, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
|
|
18
21
|
// Lazy load the napi package
|
|
@@ -123,22 +126,26 @@ export function canHandle(filePath) {
|
|
|
123
126
|
/**
|
|
124
127
|
* The TypeScript grammar is a syntactic superset of JavaScript, so a
|
|
125
128
|
* `JavaScript`-tagged rule using generic node kinds (`variable_declarator`,
|
|
126
|
-
* `assignment_expression`, …) still matches against a parsed `.ts
|
|
127
|
-
*
|
|
129
|
+
* `assignment_expression`, …) still matches against a parsed `.ts` root
|
|
130
|
+
* — and vice versa isn't an issue since JS files never parse
|
|
128
131
|
* TS-only syntax, but a `TypeScript`-tagged rule with a plain-JS-compatible
|
|
129
132
|
* body would equally double-fire alongside a `JavaScript` twin on a `.ts`
|
|
130
133
|
* file. Without this, `language:` reads as a real filter but isn't one for
|
|
131
134
|
* ts↔js pairs, so twin rules sharing a base name (e.g. `hardcoded-url` /
|
|
132
135
|
* `hardcoded-url-js`) both match the same construct in the SAME runner
|
|
133
|
-
* invocation (#657).
|
|
134
|
-
*
|
|
136
|
+
* invocation (#657). TSX is deliberately its own grammar here: the primary
|
|
137
|
+
* ast-grep CLI/LSP also treats `tsx` as distinct from `typescript`, so a
|
|
138
|
+
* language-tagged rule only runs on the exact grammar it names. Returns
|
|
139
|
+
* undefined for extensions this scoping doesn't apply to (css/html), where
|
|
140
|
+
* no filtering is added.
|
|
135
141
|
*/
|
|
136
142
|
export function ruleLanguageForFile(filePath) {
|
|
137
143
|
const ext = path.extname(filePath).toLowerCase();
|
|
138
144
|
switch (ext) {
|
|
139
145
|
case ".ts":
|
|
140
|
-
case ".tsx":
|
|
141
146
|
return "typescript";
|
|
147
|
+
case ".tsx":
|
|
148
|
+
return "tsx";
|
|
142
149
|
case ".js":
|
|
143
150
|
case ".jsx":
|
|
144
151
|
return "javascript";
|
|
@@ -213,10 +220,32 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
|
|
|
213
220
|
const maxTotalDiagnostics = options.maxTotalDiagnostics ?? MAX_TOTAL_DIAGNOSTICS;
|
|
214
221
|
const blockingOnly = options.blockingOnly === true;
|
|
215
222
|
const log = options.log;
|
|
223
|
+
const unsupportedLanguageLog = options.unsupportedLanguageLog ?? new Set();
|
|
216
224
|
const diagnostics = [];
|
|
217
225
|
const seenRuleIds = new Set();
|
|
218
226
|
const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd);
|
|
219
227
|
const fileLang = ruleLanguageForFile(filePath);
|
|
228
|
+
// Unsupported-language skips are expected in bulk (every non-jsts rule in the
|
|
229
|
+
// catalog, e.g. ~30 Python rules) — aggregate them into ONE latency-log entry
|
|
230
|
+
// per evaluation instead of per-rule terminal lines (#282 follow-up).
|
|
231
|
+
const newlyUnsupported = new Map();
|
|
232
|
+
const flushUnsupportedRuleSkips = () => {
|
|
233
|
+
if (newlyUnsupported.size === 0)
|
|
234
|
+
return;
|
|
235
|
+
logLatency({
|
|
236
|
+
type: "phase",
|
|
237
|
+
phase: "astgrep_napi_unsupported_rules_skipped",
|
|
238
|
+
filePath,
|
|
239
|
+
durationMs: 0,
|
|
240
|
+
metadata: {
|
|
241
|
+
skippedByLanguage: Object.fromEntries(Array.from(newlyUnsupported.entries(), ([language, ruleIds]) => [
|
|
242
|
+
language,
|
|
243
|
+
{ count: ruleIds.length, ruleIds },
|
|
244
|
+
])),
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
newlyUnsupported.clear();
|
|
248
|
+
};
|
|
220
249
|
// Shared with the raw sgconfig materializer so both surfaces walk the same
|
|
221
250
|
// workspace-rooted sources in the same precedence order.
|
|
222
251
|
const ruleSources = getAstGrepRuleSources(options.projectRoot ?? cwd);
|
|
@@ -233,6 +262,7 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
|
|
|
233
262
|
}
|
|
234
263
|
const duplicates = duplicateRuleIds(rules);
|
|
235
264
|
if (appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicates, source, filePath, maxTotalDiagnostics)) {
|
|
265
|
+
flushUnsupportedRuleSkips();
|
|
236
266
|
return diagnostics;
|
|
237
267
|
}
|
|
238
268
|
const duplicateSet = new Set(duplicates);
|
|
@@ -258,7 +288,16 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
|
|
|
258
288
|
continue;
|
|
259
289
|
}
|
|
260
290
|
const lang = rule.language?.toLowerCase();
|
|
261
|
-
if (lang &&
|
|
291
|
+
if (lang &&
|
|
292
|
+
lang !== "typescript" &&
|
|
293
|
+
lang !== "tsx" &&
|
|
294
|
+
lang !== "javascript") {
|
|
295
|
+
if (!unsupportedLanguageLog.has(rule.id)) {
|
|
296
|
+
unsupportedLanguageLog.add(rule.id);
|
|
297
|
+
const ids = newlyUnsupported.get(lang) ?? [];
|
|
298
|
+
ids.push(rule.id);
|
|
299
|
+
newlyUnsupported.set(lang, ids);
|
|
300
|
+
}
|
|
262
301
|
continue;
|
|
263
302
|
}
|
|
264
303
|
// Scope TypeScript/JavaScript-tagged rules to the file's actual
|
|
@@ -338,6 +377,7 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
|
|
|
338
377
|
}
|
|
339
378
|
}
|
|
340
379
|
}
|
|
380
|
+
flushUnsupportedRuleSkips();
|
|
341
381
|
return diagnostics;
|
|
342
382
|
}
|
|
343
383
|
// --- Runner Definition ---
|
|
@@ -357,7 +397,20 @@ const astGrepNapiRunner = {
|
|
|
357
397
|
// report against the LSP's `tool: ast-grep` diagnostics. Resume ONLY as the
|
|
358
398
|
// fallback when the binary is absent / can't spawn (Gate B).
|
|
359
399
|
const astGrepLspEnabled = enabledAuxiliaryLspServerIds((f) => ctx.pi?.getFlag?.(f)).includes("ast-grep");
|
|
360
|
-
|
|
400
|
+
// Gate B asks whether the LSP will handle this file, not whether a bare
|
|
401
|
+
// `ast-grep` command happens to be on PATH. The launcher first tries the
|
|
402
|
+
// platform-native package binary, then PATH; mirror that resolution here.
|
|
403
|
+
// A live client covers the already-warm case, while a resolvable binary
|
|
404
|
+
// covers the cold case before the LSP has spawned for this root.
|
|
405
|
+
const astGrepLspAlive = astGrepLspEnabled
|
|
406
|
+
? await isAuxiliaryLspAlive("ast-grep", ctx.filePath)
|
|
407
|
+
: false;
|
|
408
|
+
let astGrepBinaryResolvable = false;
|
|
409
|
+
if (astGrepLspEnabled && !astGrepLspAlive) {
|
|
410
|
+
astGrepBinaryResolvable =
|
|
411
|
+
Boolean(resolveAstGrepNativeExe()) || (await ctx.hasTool("ast-grep"));
|
|
412
|
+
}
|
|
413
|
+
if (astGrepLspEnabled && (astGrepLspAlive || astGrepBinaryResolvable)) {
|
|
361
414
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
362
415
|
}
|
|
363
416
|
const sgModule = await loadSg();
|
|
@@ -18,6 +18,8 @@ import { resolveRunnerPath } from "../runner-context.js";
|
|
|
18
18
|
import { convertLspDiagnostics } from "../utils/lsp-diagnostics.js";
|
|
19
19
|
import { enabledAuxiliaryLspServerIds, retagAuxiliaryDiagnostics, } from "../auxiliary-lsp.js";
|
|
20
20
|
import { readFileContent } from "./utils.js";
|
|
21
|
+
import { tryWarmAttachedCodeActions, tryWarmAttachedDiagnostics, } from "../../warm-attach.js";
|
|
22
|
+
import { contentHash, WARM_CODE_ACTION_LOOKUP_LIMIT, } from "../../mcp/ipc.js";
|
|
21
23
|
const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
|
|
22
24
|
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
|
|
23
25
|
const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
|
|
@@ -27,7 +29,6 @@ const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
|
|
|
27
29
|
// after the cap still land in the client's cache and surface on the
|
|
28
30
|
// next edit. Overridable via PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS.
|
|
29
31
|
const LSP_DIAGNOSTICS_WAIT_MS = 2500;
|
|
30
|
-
const MAX_CODE_ACTION_LOOKUPS = 6;
|
|
31
32
|
const MAX_CODE_ACTION_TITLES = 3;
|
|
32
33
|
function normalizeActionTitle(title) {
|
|
33
34
|
return title.replace(/\s+/g, " ").trim();
|
|
@@ -109,6 +110,7 @@ const lspRunner = {
|
|
|
109
110
|
// one spawned server) — an empty `lspDiags` in that case is NOT a
|
|
110
111
|
// confirmed clean result and must not be reported as one (#570).
|
|
111
112
|
let diagnosticsInconclusive = false;
|
|
113
|
+
let usedWarmAttach = false;
|
|
112
114
|
let failureReason = "";
|
|
113
115
|
const content = readFileContent(ctx.filePath);
|
|
114
116
|
if (!content) {
|
|
@@ -124,21 +126,27 @@ const lspRunner = {
|
|
|
124
126
|
// path so their warm diagnostics merge into this same result.
|
|
125
127
|
const auxiliaryServerIds = enabledAuxiliaryLspServerIds((f) => ctx.pi.getFlag(f));
|
|
126
128
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
const attached = await tryWarmAttachedDiagnostics(ctx.filePath, content, Math.max(LSP_SPAWN_BUDGET_MS, LSP_DIAGNOSTICS_WAIT_MS));
|
|
130
|
+
usedWarmAttach = attached?.available === true;
|
|
131
|
+
const touched = attached?.available
|
|
132
|
+
? attached.response.diagnostics
|
|
133
|
+
: await lspService.touchFile(ctx.filePath, content, {
|
|
134
|
+
diagnostics: "document",
|
|
135
|
+
collectDiagnostics: true,
|
|
136
|
+
clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
|
|
137
|
+
auxiliaryServerIds,
|
|
138
|
+
maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
|
|
139
|
+
maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
|
|
140
|
+
source: "dispatch-lsp-runner",
|
|
141
|
+
});
|
|
136
142
|
if (touched === undefined) {
|
|
137
143
|
lspClientReady = false;
|
|
138
144
|
}
|
|
139
145
|
else {
|
|
140
146
|
lspDiags = touched;
|
|
141
|
-
diagnosticsInconclusive =
|
|
147
|
+
diagnosticsInconclusive =
|
|
148
|
+
touched
|
|
149
|
+
.inconclusive === true;
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
catch (err) {
|
|
@@ -206,21 +214,42 @@ const lspRunner = {
|
|
|
206
214
|
const blockingDiagIndexes = validLspDiags
|
|
207
215
|
.map((d, idx) => ({ d, idx }))
|
|
208
216
|
.filter(({ d }) => d.severity === 1)
|
|
209
|
-
.slice(0,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
.slice(0, WARM_CODE_ACTION_LOOKUP_LIMIT);
|
|
218
|
+
if (usedWarmAttach) {
|
|
219
|
+
// Diagnostics have already succeeded. Code actions are optional
|
|
220
|
+
// enrichment, so ANY IPC failure degrades to today's skip without
|
|
221
|
+
// promoting the attached session to a local LSP fleet.
|
|
222
|
+
const ranges = blockingDiagIndexes.map(({ d }) => ({
|
|
223
|
+
start: d.range.start,
|
|
224
|
+
end: d.range.end ?? d.range.start,
|
|
225
|
+
}));
|
|
226
|
+
const result = await tryWarmAttachedCodeActions(ctx.filePath, contentHash(content), ranges, LSP_DIAGNOSTICS_WAIT_MS);
|
|
227
|
+
if (result?.available) {
|
|
228
|
+
result.response.actions.forEach((actions, responseIndex) => {
|
|
229
|
+
const diagnosticIndex = blockingDiagIndexes[responseIndex]?.idx;
|
|
230
|
+
const suggestion = buildCodeActionSuggestion(actions);
|
|
231
|
+
if (diagnosticIndex !== undefined && suggestion) {
|
|
232
|
+
fixSuggestionByIndex.set(diagnosticIndex, suggestion);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
222
235
|
}
|
|
223
|
-
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
|
|
239
|
+
try {
|
|
240
|
+
const start = d.range.start;
|
|
241
|
+
const end = d.range.end ?? d.range.start;
|
|
242
|
+
const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
|
|
243
|
+
const suggestion = buildCodeActionSuggestion(actions);
|
|
244
|
+
if (suggestion) {
|
|
245
|
+
fixSuggestionByIndex.set(idx, suggestion);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Best-effort enrichment only; base diagnostics remain authoritative.
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
224
253
|
const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
|
|
225
254
|
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
|
|
226
255
|
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolvePackagePath } from "../../package-root.js";
|
|
1
2
|
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
2
3
|
import { getLinterPolicyForCwd, hasMarkdownlintConfig, } from "../../tool-policy.js";
|
|
3
4
|
import { PRIORITY } from "../priorities.js";
|
|
@@ -93,9 +94,6 @@ const markdownlintRunner = {
|
|
|
93
94
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
94
95
|
}
|
|
95
96
|
const hasConfig = hasMarkdownlintConfig(cwd);
|
|
96
|
-
if (!hasConfig) {
|
|
97
|
-
// Run with sensible defaults even without explicit config
|
|
98
|
-
}
|
|
99
97
|
let cmd = null;
|
|
100
98
|
if (await (markdownlint.isAvailableAsync(cwd))) {
|
|
101
99
|
cmd = markdownlint.getCommand(cwd);
|
|
@@ -105,7 +103,12 @@ const markdownlintRunner = {
|
|
|
105
103
|
}
|
|
106
104
|
if (!cmd)
|
|
107
105
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
108
|
-
const configArgs = hasConfig
|
|
106
|
+
const configArgs = hasConfig
|
|
107
|
+
? []
|
|
108
|
+
: [
|
|
109
|
+
"--config",
|
|
110
|
+
resolvePackagePath(import.meta.url, "config/markdownlint/core.json"),
|
|
111
|
+
];
|
|
109
112
|
const result = await safeSpawnAsync(cmd, [...configArgs, ctx.filePath], {
|
|
110
113
|
timeout: 15000,
|
|
111
114
|
cwd,
|
|
@@ -23,7 +23,7 @@ function runBlastRadiusInBackground(cwd, filePath, languageId, facts) {
|
|
|
23
23
|
void (async () => {
|
|
24
24
|
try {
|
|
25
25
|
const graph = await buildOrUpdateGraph(cwd, [filePath], facts);
|
|
26
|
-
const impact = computeImpactCascade(graph, filePath);
|
|
26
|
+
const impact = computeImpactCascade(graph, filePath, cwd);
|
|
27
27
|
logTreeSitter({
|
|
28
28
|
phase: "blast_radius",
|
|
29
29
|
filePath,
|
|
@@ -43,3 +43,41 @@ export function toPositiveFinite(value) {
|
|
|
43
43
|
const num = typeof value === "number" ? value : Number(value);
|
|
44
44
|
return Number.isFinite(num) && num > 0 ? num : 0;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Factory for the lazy-memoized "positive-finite-number env override, else a
|
|
48
|
+
* default" getter pattern repeated across `startup-scan.ts` (#699, #758) and
|
|
49
|
+
* originally documented as house style in `runtime-config.ts` / `slow-fs.ts` /
|
|
50
|
+
* `subagent-mode.ts` (#763).
|
|
51
|
+
*
|
|
52
|
+
* `get()` reads `process.env[envName]` through {@link toPositiveFinite} the
|
|
53
|
+
* first time it's called, memoizes the result (or `fallback` when the env var
|
|
54
|
+
* is unset/non-finite/non-positive), and returns the memo on every subsequent
|
|
55
|
+
* call. `_resetForTests()` clears the memo so a test can flip the env var and
|
|
56
|
+
* observe a fresh read (matching the existing `_resetForTests` convention).
|
|
57
|
+
*
|
|
58
|
+
* Deliberately does NOT touch `process.env` until `get()` is first called —
|
|
59
|
+
* that's the entire point of the lazy pattern: importing a module must never
|
|
60
|
+
* have a side effect on process env at load time.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const _ttl = lazyEnvNumber("PI_LENS_STARTUP_SCAN_VERDICT_TTL_MS", DEFAULT_TTL_MS);
|
|
65
|
+
* export const getStartupScanVerdictTtlMs = _ttl.get;
|
|
66
|
+
* export const _resetStartupScanVerdictTtlForTests = _ttl._resetForTests;
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function lazyEnvNumber(envName, fallback) {
|
|
70
|
+
let memo;
|
|
71
|
+
return {
|
|
72
|
+
get() {
|
|
73
|
+
if (memo !== undefined)
|
|
74
|
+
return memo;
|
|
75
|
+
const envValue = toPositiveFinite(process.env[envName]);
|
|
76
|
+
memo = envValue > 0 ? envValue : fallback;
|
|
77
|
+
return memo;
|
|
78
|
+
},
|
|
79
|
+
_resetForTests() {
|
|
80
|
+
memo = undefined;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -8,7 +8,7 @@ import { minimatch } from "./deps/minimatch.js";
|
|
|
8
8
|
import { collectTrackedFiles, getTrackedFilesSnapshot } from "./git-tracked-ignore.js";
|
|
9
9
|
import { getGlobalIgnorePatterns, getPiLensGlobalConfigPath, } from "./lens-config.js";
|
|
10
10
|
import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
|
|
11
|
-
import { findPiLensProjectConfig, loadPiLensProjectConfig, } from "./project-lens-config.js";
|
|
11
|
+
import { findPiLensConfigInDir, findPiLensProjectConfig, loadPiLensConfigInDir, loadPiLensProjectConfig, } from "./project-lens-config.js";
|
|
12
12
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
13
13
|
/**
|
|
14
14
|
* Return the directory where pi-lens stores project-specific data
|
|
@@ -252,16 +252,34 @@ function ancestorDirsBetween(rootDir, targetDir) {
|
|
|
252
252
|
}
|
|
253
253
|
function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
|
|
254
254
|
const nestedCache = new Map();
|
|
255
|
+
// #783: layer a NESTED `.pi-lens.json`'s `ignore` field the same way a
|
|
256
|
+
// nested `.gitignore` is already layered — each ancestor directory between
|
|
257
|
+
// `resolvedRoot` and the target is checked for its OWN config file (no
|
|
258
|
+
// upward walk; `findPiLensConfigInDir`/`loadPiLensConfigInDir` look only
|
|
259
|
+
// directly in `dir`), and its `ignore` patterns are anchored to `dir` (same
|
|
260
|
+
// anchoring semantics as a `.gitignore` living in that directory) and
|
|
261
|
+
// tagged `"pilens"` so #703's tracked-file-rescue rule still skips them.
|
|
262
|
+
// `loadPiLensConfigInDir` reuses `project-lens-config.ts`'s own
|
|
263
|
+
// path+mtime cache, so this never re-reads/re-parses JSON that some other
|
|
264
|
+
// caller (or the root-config lookup above) already loaded.
|
|
255
265
|
const patternsForDir = (dir) => {
|
|
256
266
|
if (dir === resolvedRoot)
|
|
257
267
|
return patterns;
|
|
258
268
|
const gitignoreMtime = gitignoreMtimeMs(dir);
|
|
269
|
+
const pilensMtime = findPiLensConfigInDir(dir)?.mtimeMs ?? -1;
|
|
259
270
|
const cached = nestedCache.get(dir);
|
|
260
|
-
if (cached?.gitignoreMtimeMs === gitignoreMtime
|
|
271
|
+
if (cached?.gitignoreMtimeMs === gitignoreMtime &&
|
|
272
|
+
cached?.pilensMtimeMs === pilensMtime) {
|
|
261
273
|
return cached.patterns;
|
|
262
|
-
|
|
274
|
+
}
|
|
275
|
+
const nestedConfig = loadPiLensConfigInDir(dir);
|
|
276
|
+
const nextPatterns = [
|
|
277
|
+
...readGitignorePatterns(dir),
|
|
278
|
+
...parseGitignoreContent(nestedConfig.ignore.join("\n"), "pilens"),
|
|
279
|
+
];
|
|
263
280
|
nestedCache.set(dir, {
|
|
264
281
|
gitignoreMtimeMs: gitignoreMtime,
|
|
282
|
+
pilensMtimeMs: pilensMtime,
|
|
265
283
|
patterns: nextPatterns,
|
|
266
284
|
});
|
|
267
285
|
return nextPatterns;
|