pi-lens 3.8.68 → 3.8.69

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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +57 -40
  3. package/dist/clients/agent-nudge.js +262 -0
  4. package/dist/clients/biome-client.js +2 -2
  5. package/dist/clients/bus-publish.js +110 -0
  6. package/dist/clients/diagnostic-logger.js +2 -2
  7. package/dist/clients/diagnostics-publish.js +180 -0
  8. package/dist/clients/dispatch/dispatcher.js +2 -0
  9. package/dist/clients/dispatch/integration.js +153 -2
  10. package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
  11. package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
  12. package/dist/clients/file-utils.js +20 -7
  13. package/dist/clients/installer/index.js +32 -1
  14. package/dist/clients/instance-reaper.js +114 -16
  15. package/dist/clients/jscpd-client.js +2 -2
  16. package/dist/clients/lens-config.js +17 -0
  17. package/dist/clients/lens-engine.js +44 -10
  18. package/dist/clients/lsp/cascade-tier.js +254 -0
  19. package/dist/clients/lsp/client.js +5 -1
  20. package/dist/clients/lsp/index.js +3 -0
  21. package/dist/clients/lsp/server-strategies.js +10 -0
  22. package/dist/clients/lsp/server.js +111 -5
  23. package/dist/clients/mcp/analyze.js +110 -1
  24. package/dist/clients/module-report.js +163 -18
  25. package/dist/clients/path-utils.js +25 -0
  26. package/dist/clients/persist-debounce.js +63 -0
  27. package/dist/clients/pipeline.js +82 -2
  28. package/dist/clients/project-diagnostics/extractors.js +30 -4
  29. package/dist/clients/project-snapshot.js +7 -2
  30. package/dist/clients/quiet-window.js +168 -0
  31. package/dist/clients/recent-touches.js +233 -0
  32. package/dist/clients/runtime-agent-end.js +51 -1
  33. package/dist/clients/runtime-coordinator.js +21 -0
  34. package/dist/clients/runtime-session.js +66 -41
  35. package/dist/clients/runtime-tool-result.js +46 -0
  36. package/dist/clients/sgconfig.js +230 -52
  37. package/dist/clients/source-filter.js +44 -9
  38. package/dist/clients/subagent-mode.js +54 -20
  39. package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
  40. package/dist/clients/tui-fit.js +54 -0
  41. package/dist/clients/turn-summary-render.js +72 -0
  42. package/dist/clients/turn-summary.js +132 -0
  43. package/dist/clients/widget-state.js +27 -30
  44. package/dist/clients/word-index.js +296 -1
  45. package/dist/index.js +15167 -12942
  46. package/dist/mcp/build-staleness.js +123 -0
  47. package/dist/mcp/server.js +377 -43
  48. package/dist/tools/ast-grep-search.js +1 -1
  49. package/dist/tools/lens-diagnostics.js +42 -12
  50. package/dist/tools/lsp-diagnostics.js +117 -4
  51. package/dist/tools/module-report.js +14 -11
  52. package/dist/tools/symbol-search.js +110 -0
  53. package/package.json +1 -1
  54. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  55. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
  56. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
  57. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
  58. package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
  59. package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
  60. package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
  61. package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
  62. package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
  63. package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
  64. package/dist/clients/tree-sitter-fixer.js +0 -127
@@ -14,10 +14,12 @@ import { TOOL_PLANS } from "./plan.js";
14
14
  // Re-export latency tracking types and functions
15
15
  export { clearLatencyReports, formatLatencyReport, getLatencyReports };
16
16
  import * as nodeFs from "node:fs";
17
+ import * as nodePath from "node:path";
17
18
  import { fileURLToPath } from "node:url";
18
19
  import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
19
20
  import { logCascade } from "../cascade-logger.js";
20
21
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
22
+ import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
21
23
  import { getServersForFileWithConfig } from "../lsp/config.js";
22
24
  import { getLSPService } from "../lsp/index.js";
23
25
  import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
@@ -28,6 +30,7 @@ import { buildOrUpdateGraph, computeImpactCascade, computeTransitiveImpact, form
28
30
  import { clearModuleGraphCache } from "../review-graph/workspace-modules.js";
29
31
  import { RUNTIME_CONFIG } from "../runtime-config.js";
30
32
  import { findCompiledClassesDir, hasJavaBuildDescriptor, } from "../tool-policy.js";
33
+ import { removeWordIndexDocument, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js";
31
34
  // Register fact providers. All register eagerly here (the dispatch entry) — the
32
35
  // tree-sitter-backed providers included, since the parsing stack loads
33
36
  // `web-tree-sitter` lazily inside client.init(), not at module import, so it
@@ -355,7 +358,11 @@ export function clearReverseDepsIndexCache() {
355
358
  const CASCADE_TRANSITIVE_DEPTH = Math.max(1, Number.parseInt(process.env.PI_LENS_CASCADE_TRANSITIVE_DEPTH ?? "2", 10) || 2);
356
359
  const CASCADE_NEIGHBOUR_BUDGET = Math.max(MAX_FILES, Number.parseInt(process.env.PI_LENS_CASCADE_NEIGHBOUR_BUDGET ?? "40", 10) ||
357
360
  40);
358
- const CASCADE_GRAPH_KINDS = new Set([
361
+ // Exported (not just module-local) so the MCP warm-analyze seam
362
+ // (clients/mcp/analyze.ts, #536) can gate its own buildOrUpdateGraph call on
363
+ // the SAME file-kind eligibility this cascade path uses — one source of truth
364
+ // for "does this language get graph nodes at all", never a second hardcoded copy.
365
+ export const CASCADE_GRAPH_KINDS = new Set([
359
366
  "jsts",
360
367
  "python",
361
368
  "go",
@@ -392,8 +399,67 @@ function isIgnoredCascadeNeighbor(filePath, cwd) {
392
399
  return false;
393
400
  }
394
401
  }
402
+ /**
403
+ * #348 phase 2 per-edit seam: update the warm in-memory word index for one
404
+ * file, mirroring the review graph's per-edit rebuild at the same call site.
405
+ *
406
+ * Rules (each a documented, deliberate simplicity choice, not an oversight):
407
+ * - `wordIndex` null (no index loaded yet) ⇒ no-op. Cold-session handoff is
408
+ * OWNED by phase 1's lifecycle/background build, never invented here — an
409
+ * edit arriving before that build finishes just doesn't update anything;
410
+ * the eventual full build already reflects every file on disk, incl. this
411
+ * edit, so nothing is lost, only delayed.
412
+ * - `wordIndex.forward` undefined (pre-phase-2 index shape, e.g. a snapshot
413
+ * persisted before this feature or one still using the old serialized
414
+ * shape) ⇒ no-op. `updateWordIndexDocument` already refuses to mutate a
415
+ * forward-index-less index (see its doc comment) — this is the same rule
416
+ * surfaced one layer up so the caller isn't left guessing why nothing
417
+ * happened. The NEXT full rebuild (session-start lifecycle) installs a
418
+ * forward-index-bearing index that later edits CAN update incrementally.
419
+ * - `content` undefined (pipeline couldn't read the file — deleted, or a
420
+ * transient race) ⇒ no-op. Deletions aren't plumbed at this seam (this
421
+ * call site only ever sees the edited file's post-write content, never a
422
+ * delete event) — a removed file ages out at the next full rebuild, same
423
+ * scope boundary the review graph accepts for deletes.
424
+ * - File over the shared `WORD_INDEX_MAX_BYTES` cap ⇒ removed/absent from
425
+ * the index (never partially indexed) — same cap phase 1's build path
426
+ * enforces via `collectWordIndexDocs`.
427
+ * - On a successful update, `onUpdated` fires so the caller can schedule a
428
+ * debounced persist (never a synchronous write per edit — same #260
429
+ * discipline as the graph).
430
+ *
431
+ * Race safety against a build-in-progress: this function body is entirely
432
+ * synchronous (no `await` anywhere in it) and is called synchronously at
433
+ * `computeCascadeForFile`'s entry, before its own `await buildOrUpdateGraph`.
434
+ * Node is single-threaded, so two overlapping cascades (#450's unawaited
435
+ * concurrency) can never interleave mid-mutation here — each call runs to
436
+ * completion in one turn. The only cross-build hazard is a full session-start
437
+ * rebuild REPLACING `runtime.wordIndex` with a new object between the caller
438
+ * reading `runtime.wordIndex` (in runtime-tool-result.ts, also synchronous)
439
+ * and this function receiving it — in that case this call simply mutates
440
+ * whichever index object it was handed (old or new), and the other one is
441
+ * abandoned/superseded, never corrupted. No queue, no lock: the simplest rule
442
+ * that is still provably correct.
443
+ */
444
+ function updateWordIndexForCascade(args) {
445
+ const { wordIndex, filePath, content, onUpdated, dbg } = args;
446
+ if (!wordIndex || !wordIndex.forward)
447
+ return;
448
+ if (content === undefined)
449
+ return;
450
+ const byteLength = Buffer.byteLength(content, "utf-8");
451
+ if (byteLength > WORD_INDEX_MAX_BYTES) {
452
+ removeWordIndexDocument(wordIndex, filePath);
453
+ dbg?.(`word-index per-edit: dropped ${filePath} (over size cap)`);
454
+ }
455
+ else {
456
+ updateWordIndexDocument(wordIndex, { path: filePath, content });
457
+ dbg?.(`word-index per-edit: updated ${filePath}`);
458
+ }
459
+ onUpdated?.(wordIndex);
460
+ }
395
461
  export async function computeCascadeForFile(filePath, cwd, options = {}) {
396
- const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState } = options;
462
+ const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
397
463
  ensureCascadeTurnScope(turnSeq);
398
464
  if (hasBlockers) {
399
465
  logCascade({
@@ -425,6 +491,30 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
425
491
  // B10: record this file as a primary edit so later cascade calls in the same
426
492
  // turn won't show it as a neighbor.
427
493
  primaryFilesThisTurn.add(normalizedFileKey);
494
+ // #348 phase 2: warm per-edit word-index maintenance, review-graph style —
495
+ // update at the SAME seam as the graph rebuild below, using content the
496
+ // pipeline already read (no extra I/O). See computeCascadeForFile's
497
+ // `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
498
+ // no-op rules.
499
+ //
500
+ // Deliberately keyed by `path.resolve(filePath)`, NOT `normalizedFile`
501
+ // (which is `normalizeMapKey`'d — realpath-canonicalized + lowercased on
502
+ // Windows). The word index's OWN keys come from `collectWordIndexDocs` →
503
+ // `collectSourceFilesAsync`'s file walk, which yields plain
504
+ // `path.resolve()`-joined paths (native separators, on-disk casing as
505
+ // reported by the walk, no realpath call). Keying this update with the
506
+ // cascade's normalized key would silently create a SECOND, orphaned entry
507
+ // next to the walker's original-cased entry instead of replacing it —
508
+ // exactly the kind of divergence the equivalence-property test is meant to
509
+ // catch, so the key shape here must match the build path's, not the
510
+ // cascade/graph's own (different) normalization scheme.
511
+ updateWordIndexForCascade({
512
+ wordIndex,
513
+ filePath: nodePath.resolve(filePath),
514
+ content: fileContent,
515
+ onUpdated: onWordIndexUpdated,
516
+ dbg,
517
+ });
428
518
  let impact = {
429
519
  filePath: normalizedFile,
430
520
  changedSymbols: [],
@@ -828,6 +918,67 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
828
918
  }
829
919
  // A6: async read to avoid blocking event loop on network-mounted drives
830
920
  const content = await nodeFs.promises.readFile(neighborPath, "utf8");
921
+ // #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
922
+ // silent-on-clean — typescript is the lone core-set instance today)
923
+ // primary can never give this in-lane wait an affirmative clean
924
+ // signal, so the budget is pure cost. Fire the touch (didOpen/
925
+ // didChange still happens — the server starts real work) and record
926
+ // it as outstanding for the agent_settled quiet window to reconcile
927
+ // instead of waiting here. Ambiguous/missing capability data always
928
+ // classifies as "waits" (today's behavior) — see cascade-tier.ts.
929
+ // The whole attempt is try/caught: any surprise (a service shape
930
+ // that doesn't expose getCapabilitySnapshots/getClientForFile, a
931
+ // thrown rejection) falls through to the existing full-wait path
932
+ // below rather than skip the wait on a failure.
933
+ if (isTierAwareCascadeEnabled()) {
934
+ try {
935
+ const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
936
+ const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
937
+ if (tier === "tier3-silent") {
938
+ const spawnedForTouch = await lspService.getClientForFile(neighborPath);
939
+ if (spawnedForTouch) {
940
+ // Sampled BEFORE the touchFile notify: a publish landing
941
+ // in the notify→record gap must read as post-touch at
942
+ // reconcile time, never be misclassified as pre-touch
943
+ // (the reconcile compares this against the client's
944
+ // PER-FILE publish timestamp — see cascade-tier.ts).
945
+ const touchedAt = Date.now();
946
+ await lspService.touchFile(neighborPath, content, {
947
+ diagnostics: "none",
948
+ collectDiagnostics: false,
949
+ silent: true,
950
+ source: "cascade",
951
+ clientScope: "primary",
952
+ });
953
+ recordOutstandingCascadeTouch({
954
+ filePath: neighborPath,
955
+ serverId: spawnedForTouch.client.serverId,
956
+ touchedAt,
957
+ });
958
+ const durationMs = Date.now() - neighborStart;
959
+ logCascade({
960
+ phase: "cascade_tier3_skip",
961
+ filePath,
962
+ neighborFile: neighborPath,
963
+ durationMs,
964
+ lspServerCount: configuredServerCount,
965
+ coldSnapshot: isColdSnapshot,
966
+ metadata: { serverId: spawnedForTouch.client.serverId },
967
+ });
968
+ // Deliberately NOT cached as clean/diagnosed — the wait was
969
+ // skipped, not resolved, so neither neighborTouchCache nor
970
+ // recentlyCleanNeighborCache may treat this as a real answer
971
+ // (#240 doctrine). Return undefined: the degraded-fallback
972
+ // path below still has a chance to surface a passive/stale
973
+ // snapshot, same as any other "no fresh data this touch" case.
974
+ return undefined;
975
+ }
976
+ }
977
+ }
978
+ catch (tierErr) {
979
+ dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
980
+ }
981
+ }
831
982
  // Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
832
983
  // and collect diagnostics from the same touched clients.
833
984
  // Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
@@ -9,12 +9,12 @@
9
9
  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
- import { resolvePackagePath } from "../../package-root.js";
12
+ import { getAstGrepRuleSources, } from "../../sgconfig.js";
13
13
  import { hasEslintConfig } from "../../tool-policy.js";
14
14
  import { enabledAuxiliaryLspServerIds } from "../auxiliary-lsp.js";
15
15
  import { classifyDefect } from "../diagnostic-taxonomy.js";
16
16
  import { PRIORITY } from "../priorities.js";
17
- import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
17
+ import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, loadYamlRulesFresh, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
18
18
  // Lazy load the napi package
19
19
  let sg;
20
20
  let sgLoadAttempted = false;
@@ -122,6 +122,40 @@ export function getLang(filePath, sgModule) {
122
122
  return undefined;
123
123
  }
124
124
  }
125
+ function duplicateRuleIds(rules) {
126
+ const counts = new Map();
127
+ for (const rule of rules) {
128
+ counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1);
129
+ }
130
+ return Array.from(counts)
131
+ .filter(([, count]) => count > 1)
132
+ .map(([id]) => id)
133
+ .sort((a, b) => a.localeCompare(b));
134
+ }
135
+ function appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicateIds, source, filePath, maxTotalDiagnostics) {
136
+ const sourceLabel = `${source.origin} ${source.tier} rules`;
137
+ for (const ruleId of duplicateIds) {
138
+ diagnostics.push({
139
+ id: `ast-grep-napi-config-duplicate-${source.origin}-${source.tier}-${ruleId}`,
140
+ message: `Duplicate ast-grep rule id "${ruleId}" in ${sourceLabel}`,
141
+ filePath,
142
+ line: 1,
143
+ column: 1,
144
+ severity: "error",
145
+ semantic: "blocking",
146
+ tool: "ast-grep-napi",
147
+ rule: ruleId,
148
+ defectClass: "correctness",
149
+ fixable: false,
150
+ autoFixAvailable: false,
151
+ fixSuggestion: `Give every rule in ${sourceLabel} a unique id`,
152
+ });
153
+ seenRuleIds.add(ruleId);
154
+ if (diagnostics.length >= maxTotalDiagnostics)
155
+ return true;
156
+ }
157
+ return false;
158
+ }
125
159
  /**
126
160
  * Run the shipped ast-grep YAML ruleset against a parsed file via napi's native
127
161
  * engine, applying the same suppression policy (linter/tree-sitter overlap,
@@ -138,26 +172,34 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
138
172
  const diagnostics = [];
139
173
  const seenRuleIds = new Set();
140
174
  const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd);
141
- const ruleDirs = [
142
- path.join(process.cwd(), "rules", "ast-grep-rules", "rules"),
143
- path.join(process.cwd(), "rules", "ast-grep-rules"),
144
- resolvePackagePath(import.meta.url, "rules", "ast-grep-rules", "rules"),
145
- resolvePackagePath(import.meta.url, "rules", "ast-grep-rules"),
146
- ];
147
- for (const ruleDir of ruleDirs) {
175
+ // Shared with the raw sgconfig materializer so both surfaces walk the same
176
+ // workspace-rooted sources in the same precedence order.
177
+ const ruleSources = getAstGrepRuleSources(options.projectRoot ?? cwd);
178
+ for (const source of ruleSources) {
148
179
  let rules;
149
180
  try {
150
- rules = loadYamlRules(ruleDir, blockingOnly ? "error" : undefined);
181
+ // Project rules are mutable during a session, so their cache fingerprints
182
+ // relative paths and contents. Bundled catalogs are immutable per install.
183
+ const loader = source.origin === "project" ? loadYamlRulesFresh : loadYamlRules;
184
+ rules = loader(source.dir);
151
185
  }
152
186
  catch {
153
187
  continue;
154
188
  }
189
+ const duplicates = duplicateRuleIds(rules);
190
+ if (appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicates, source, filePath, maxTotalDiagnostics)) {
191
+ return diagnostics;
192
+ }
193
+ const duplicateSet = new Set(duplicates);
155
194
  for (const rule of rules) {
156
- // If the same rule id is loaded from multiple directories
157
- // (workspace + bundled), prefer the first one to avoid duplicates.
195
+ if (duplicateSet.has(rule.id))
196
+ continue;
197
+ // Cross-layer collisions keep the first (higher-precedence) source.
158
198
  if (seenRuleIds.has(rule.id))
159
199
  continue;
160
200
  seenRuleIds.add(rule.id);
201
+ if (blockingOnly && rule.severity !== "error")
202
+ continue;
161
203
  if (suppressLinterOverlap &&
162
204
  LINTER_OVERLAP.has(normalizeRuleId(rule.id)) &&
163
205
  !NON_SUPPRESSIBLE.has(normalizeRuleId(rule.id))) {
@@ -310,7 +352,10 @@ const astGrepNapiRunner = {
310
352
  catch {
311
353
  return { status: "skipped", diagnostics: [], semantic: "none" };
312
354
  }
313
- const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, { blockingOnly: ctx.blockingOnly });
355
+ const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, {
356
+ blockingOnly: ctx.blockingOnly,
357
+ projectRoot: ctx.projectRoot,
358
+ });
314
359
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
315
360
  let semantic = "none";
316
361
  if (hasBlocking) {
@@ -6,11 +6,12 @@
6
6
  * (has/any/all/not/regex).
7
7
  *
8
8
  * Features:
9
- * - Caching with mtime-based invalidation
9
+ * - Mtime caching for bundled rules; content/path caching for project rules
10
10
  * - Severity filtering (error-only for blocking mode)
11
11
  * - Complexity scoring for performance optimization
12
12
  * - Overly broad pattern detection
13
13
  */
14
+ import { createHash } from "node:crypto";
14
15
  import * as fs from "node:fs";
15
16
  import * as path from "node:path";
16
17
  import yaml from "../../deps/js-yaml.js";
@@ -29,41 +30,89 @@ export const MAX_BLOCKING_RULE_COMPLEXITY = 8;
29
30
  // --- Caches ---
30
31
  const rulesCache = new Map();
31
32
  const blockingRulesCache = new Map();
33
+ const contentRulesCache = new Map();
34
+ const contentBlockingRulesCache = new Map();
32
35
  // --- Public API ---
33
36
  export function clearRulesCache() {
34
37
  rulesCache.clear();
35
38
  blockingRulesCache.clear();
39
+ contentRulesCache.clear();
40
+ contentBlockingRulesCache.clear();
36
41
  }
37
42
  export function loadYamlRules(ruleDir, severityFilter) {
38
43
  return getCachedRules(ruleDir, severityFilter);
39
44
  }
40
- export function loadYamlRulesUncached(ruleDir, severityFilter) {
45
+ function findYamlRuleFiles(ruleDir) {
46
+ let entries;
47
+ try {
48
+ entries = fs
49
+ .readdirSync(ruleDir, { withFileTypes: true })
50
+ .sort((a, b) => a.name.localeCompare(b.name));
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ const files = [];
56
+ for (const entry of entries) {
57
+ const full = path.join(ruleDir, entry.name);
58
+ if (entry.isDirectory()) {
59
+ files.push(...findYamlRuleFiles(full));
60
+ }
61
+ else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) {
62
+ files.push(full);
63
+ }
64
+ }
65
+ return files;
66
+ }
67
+ function loadYamlRuleFiles(files, severityFilter) {
41
68
  const rules = [];
42
- if (!fs.existsSync(ruleDir))
43
- return rules;
44
- const files = fs.readdirSync(ruleDir).filter((f) => f.endsWith(".yml"));
45
69
  for (const file of files) {
46
70
  let content;
47
71
  try {
48
- content = fs.readFileSync(path.join(ruleDir, file), "utf-8");
72
+ content = fs.readFileSync(file, "utf-8");
49
73
  }
50
74
  catch {
51
- continue; // unreadable file
75
+ continue;
76
+ }
77
+ const documents = content.split(/^---\s*$/m).filter((doc) => doc.trim());
78
+ for (const document of documents) {
79
+ const rule = parseSimpleYaml(document.trim());
80
+ if (!rule?.id)
81
+ continue;
82
+ if (severityFilter && rule.severity !== severityFilter)
83
+ continue;
84
+ rules.push(rule);
52
85
  }
53
- const documents = content.split(/^---$/m).filter((d) => d.trim());
54
- // Parse each document independently so one malformed rule (e.g. an
55
- // unquoted YAML-special scalar) skips only itself, not the whole file —
56
- // slop-patterns.yml packs many rules into a single file.
57
- for (const doc of documents) {
58
- const rule = parseSimpleYaml(doc.trim());
59
- if (rule?.id) {
60
- if (severityFilter && rule.severity !== severityFilter) {
61
- continue;
62
- }
63
- rules.push(rule);
64
- }
86
+ }
87
+ return rules;
88
+ }
89
+ export function loadYamlRulesUncached(ruleDir, severityFilter) {
90
+ return loadYamlRuleFiles(findYamlRuleFiles(ruleDir), severityFilter);
91
+ }
92
+ /** Content/path-aware cache used for mutable project-owned rule trees. */
93
+ export function loadYamlRulesFresh(ruleDir, severityFilter) {
94
+ const files = findYamlRuleFiles(ruleDir);
95
+ const hash = createHash("sha256");
96
+ for (const file of files) {
97
+ hash.update(path.relative(ruleDir, file));
98
+ hash.update("\0");
99
+ try {
100
+ hash.update(fs.readFileSync(file));
101
+ }
102
+ catch {
103
+ hash.update("missing");
65
104
  }
105
+ hash.update("\0");
66
106
  }
107
+ const signature = hash.digest("hex");
108
+ const cache = severityFilter === "error"
109
+ ? contentBlockingRulesCache
110
+ : contentRulesCache;
111
+ const cached = cache.get(ruleDir);
112
+ if (cached?.signature === signature)
113
+ return cached.rules;
114
+ const rules = loadYamlRuleFiles(files, severityFilter);
115
+ cache.set(ruleDir, { rules, signature });
67
116
  return rules;
68
117
  }
69
118
  export function getCachedRules(ruleDir, severityFilter) {
@@ -30,7 +30,7 @@ export function getProjectDataDir(cwd) {
30
30
  if (!configuredBase && fs.existsSync(legacyProjectDir)) {
31
31
  return legacyProjectDir;
32
32
  }
33
- const base = configuredBase || path.join(os.homedir(), ".pi-lens", "projects");
33
+ const base = configuredBase || path.join(getGlobalPiLensDir(), "projects");
34
34
  const normalized = normalizeFilePath(path.resolve(cwd));
35
35
  const slug = normalized
36
36
  .replace(/^[a-z]:/i, "") // strip Windows drive letter
@@ -41,18 +41,31 @@ export function getProjectDataDir(cwd) {
41
41
  return path.join(base.trim(), slug || "default");
42
42
  }
43
43
  /**
44
- * Machine-global pi-lens directory: `~/.pi-lens/`.
44
+ * Machine-global pi-lens directory: `~/.pi-lens/` by default.
45
45
  *
46
46
  * Used for logs (latency, cascade, read-guard, tree-sitter, actionable-warnings,
47
- * sessionstart), tool binaries (`~/.pi-lens/tools/`, `~/.pi-lens/bin/`), LSP
48
- * server storage, and other state that is intentionally NOT project-scoped
49
- * it spans every project pi-lens has touched.
47
+ * sessionstart), tool binaries (`~/.pi-lens/tools/`, `~/.pi-lens/bin/`), the
48
+ * cross-process instance registry (`instances.json`, #449/#525), the
49
+ * auto-install probe cache, and other state that is intentionally NOT
50
+ * project-scoped — it spans every project pi-lens has touched.
51
+ *
52
+ * Override: set `PI_LENS_HOME=/some/path` to relocate this ENTIRE root (every
53
+ * caller below routes through this one function, so one env var covers all of
54
+ * them — see #525). Tests MUST set this to a per-worker temp dir in
55
+ * `tests/support/vitest-setup.ts` rather than mocking each caller separately;
56
+ * otherwise a test that exercises `registerInstance`/`sweepOrphans` or any
57
+ * logger writes into the developer's REAL `~/.pi-lens` (dogfooded live: a
58
+ * test-fixture instance survived in the real `instances.json` for 17h).
50
59
  *
51
60
  * Distinct from `getProjectDataDir(cwd)`, which respects `PILENS_DATA_DIR`
52
- * and produces per-project subdirectories. Callers writing project caches,
53
- * snapshots, or worklogs should use `getProjectDataDir(cwd)` instead.
61
+ * (project-scoped) and produces per-project subdirectories. Callers writing
62
+ * project caches, snapshots, or worklogs should use `getProjectDataDir(cwd)`
63
+ * instead — `PI_LENS_HOME` is the MACHINE-scoped sibling of that override.
54
64
  */
55
65
  export function getGlobalPiLensDir() {
66
+ const override = process.env.PI_LENS_HOME?.trim();
67
+ if (override)
68
+ return path.resolve(override);
56
69
  return path.join(os.homedir(), ".pi-lens");
57
70
  }
58
71
  /**
@@ -1036,11 +1036,41 @@ export const TOOLS = [
1036
1036
  // bare binary — no binaryInArchive
1037
1037
  },
1038
1038
  },
1039
+ {
1040
+ // Expert ships a bare native binary per platform on GitHub releases. Its
1041
+ // `--stdio` flag is required to start the LSP transport. Windows arm64 uses
1042
+ // the x64 binary through Windows' built-in x64 emulation.
1043
+ id: "expert",
1044
+ name: "Expert",
1045
+ checkCommand: "expert",
1046
+ checkArgs: ["--version"],
1047
+ installStrategy: "github",
1048
+ binaryName: "expert",
1049
+ github: {
1050
+ repo: "expert-lsp/expert",
1051
+ assetMatch: (platform, arch) => {
1052
+ if (arch !== "x64" && arch !== "arm64")
1053
+ return undefined;
1054
+ if (platform === "linux")
1055
+ return arch === "arm64"
1056
+ ? "expert_linux_arm64"
1057
+ : "expert_linux_amd64";
1058
+ if (platform === "darwin")
1059
+ return arch === "arm64"
1060
+ ? "expert_darwin_arm64"
1061
+ : "expert_darwin_amd64";
1062
+ if (platform === "win32")
1063
+ return "expert_windows_amd64.exe";
1064
+ return undefined;
1065
+ },
1066
+ // bare binary — no binaryInArchive
1067
+ },
1068
+ },
1039
1069
  ];
1040
1070
  const ensureInFlight = new Map();
1041
1071
  // Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
1042
1072
  const resolvedPathCache = new Map();
1043
- const PROBE_CACHE_PATH = path.join(os.homedir(), ".pi-lens", "probe-cache.json");
1073
+ const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
1044
1074
  const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
1045
1075
  let _probeCache = null;
1046
1076
  let _probeCacheDirty = false;
@@ -2735,6 +2765,7 @@ export const GITHUB_TOOLS = [
2735
2765
  "clojure-lsp",
2736
2766
  "gleam",
2737
2767
  "marksman",
2768
+ "expert",
2738
2769
  ];
2739
2770
  /**
2740
2771
  * Resolve the GitHub asset filename substring for a tool on a given platform/arch.