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.
Files changed (83) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +31 -22
  3. package/config/markdownlint/core.json +6 -0
  4. package/dist/clients/atomic-write.js +83 -0
  5. package/dist/clients/config-enabled-shape.js +39 -0
  6. package/dist/clients/deps/js-yaml.js +1 -1
  7. package/dist/clients/diagnostic-dispositions.js +64 -2
  8. package/dist/clients/dispatch/integration.js +2 -1
  9. package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
  10. package/dist/clients/dispatch/runners/lsp.js +54 -25
  11. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  12. package/dist/clients/dispatch/runners/pyright.js +1 -0
  13. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  14. package/dist/clients/env-utils.js +38 -0
  15. package/dist/clients/file-utils.js +21 -3
  16. package/dist/clients/instance-registry.js +32 -21
  17. package/dist/clients/jscpd-client.js +52 -39
  18. package/dist/clients/language-profile.js +12 -19
  19. package/dist/clients/lens-config.js +106 -27
  20. package/dist/clients/lens-engine.js +129 -3
  21. package/dist/clients/lsp/cascade-tier.js +1 -38
  22. package/dist/clients/lsp/client.js +3 -1
  23. package/dist/clients/lsp/index.js +707 -109
  24. package/dist/clients/lsp/server-strategies.js +3 -224
  25. package/dist/clients/lsp/server.js +1 -1
  26. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  27. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  28. package/dist/clients/lsp/wait-policy/index.js +10 -0
  29. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  30. package/dist/clients/lsp-budget.js +54 -16
  31. package/dist/clients/mcp/analyze.js +1 -1
  32. package/dist/clients/mcp/host-shim.js +12 -8
  33. package/dist/clients/mcp/ipc.js +118 -0
  34. package/dist/clients/mcp/session.js +2 -2
  35. package/dist/clients/pipeline.js +11 -6
  36. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  37. package/dist/clients/project-diagnostics/scanner.js +37 -6
  38. package/dist/clients/project-lens-config.js +167 -3
  39. package/dist/clients/project-report.js +627 -0
  40. package/dist/clients/project-scale.js +239 -0
  41. package/dist/clients/project-scan-policy.js +11 -1
  42. package/dist/clients/recent-touches.js +6 -11
  43. package/dist/clients/review-graph/builder.js +170 -13
  44. package/dist/clients/review-graph/import-resolvers.js +149 -13
  45. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  46. package/dist/clients/review-graph/workspace-modules.js +21 -9
  47. package/dist/clients/rules-scanner.js +15 -2
  48. package/dist/clients/runtime-agent-end.js +104 -17
  49. package/dist/clients/runtime-config.js +4 -0
  50. package/dist/clients/runtime-coordinator.js +50 -1
  51. package/dist/clients/runtime-session.js +41 -3
  52. package/dist/clients/runtime-tool-result.js +4 -3
  53. package/dist/clients/runtime-turn.js +4 -1
  54. package/dist/clients/safe-spawn.js +264 -40
  55. package/dist/clients/session-lifecycle.js +36 -0
  56. package/dist/clients/session-state-store.js +8 -3
  57. package/dist/clients/source-filter.js +136 -61
  58. package/dist/clients/source-walker.js +89 -0
  59. package/dist/clients/startup-scan.js +189 -111
  60. package/dist/clients/subagent-mode.js +14 -3
  61. package/dist/clients/todo-scanner.js +5 -1
  62. package/dist/clients/tool-policy.js +26 -4
  63. package/dist/clients/warm-attach.js +204 -0
  64. package/dist/clients/word-index.js +36 -5
  65. package/dist/clients/workspace-topology.js +266 -0
  66. package/dist/index.js +15205 -11330
  67. package/dist/mcp/server.js +125 -10
  68. package/dist/tools/ast-grep-search.js +21 -0
  69. package/dist/tools/lens-diagnostic-mark.js +198 -7
  70. package/dist/tools/lens-diagnostics.js +119 -21
  71. package/dist/tools/lsp-diagnostics.js +143 -10
  72. package/dist/tools/lsp-navigation.js +11 -2
  73. package/dist/tools/project-report.js +94 -0
  74. package/dist/tools/symbol-search.js +32 -8
  75. package/docs/environment-variables.md +10 -0
  76. package/docs/features.md +7 -1
  77. package/docs/globalconfig.md +70 -0
  78. package/docs/servercapabilities.md +1 -1
  79. package/docs/usage.md +13 -2
  80. package/package.json +8 -9
  81. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  82. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  83. 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
- function normalizeMessage(message) {
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
- fs.writeFileSync(p, JSON.stringify(state, null, 2));
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 { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
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`/`.tsx`
127
- * root — and vice versa isn't an issue since JS files never parse
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). Returns undefined for extensions this scoping doesn't
134
- * apply to (css/html), where no filtering is added.
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 && lang !== "typescript" && lang !== "javascript") {
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
- if (astGrepLspEnabled && (await ctx.hasTool("ast-grep"))) {
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 touched = await lspService.touchFile(ctx.filePath, content, {
128
- diagnostics: "document",
129
- collectDiagnostics: true,
130
- clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
131
- auxiliaryServerIds,
132
- maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
133
- maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
134
- source: "dispatch-lsp-runner",
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 = touched.inconclusive === true;
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, MAX_CODE_ACTION_LOOKUPS);
210
- await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
211
- try {
212
- const start = d.range.start;
213
- const end = d.range.end ?? d.range.start;
214
- const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
215
- const suggestion = buildCodeActionSuggestion(actions);
216
- if (suggestion) {
217
- fixSuggestionByIndex.set(idx, suggestion);
218
- }
219
- }
220
- catch {
221
- // Best-effort enrichment only; base diagnostics remain authoritative.
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 ? [] : ["--disable", "MD013"];
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,
@@ -82,6 +82,7 @@ const pyrightRunner = {
82
82
  ? "warning"
83
83
  : "none",
84
84
  };
85
+ // pi-lens-ignore: missing-error-propagation
85
86
  }
86
87
  catch {
87
88
  console.error(`[runner:pyright] JSON parse failed for ${ctx.filePath} — raw output: ${output.slice(0, 200)}`);
@@ -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
- const nextPatterns = readGitignorePatterns(dir);
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;