pi-lens 3.8.71 → 3.8.72

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 (79) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/README.md +8 -0
  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/diagnostic-dispositions.js +64 -2
  7. package/dist/clients/dispatch/integration.js +2 -1
  8. package/dist/clients/dispatch/runners/lsp.js +54 -25
  9. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  10. package/dist/clients/dispatch/runners/pyright.js +1 -0
  11. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  12. package/dist/clients/env-utils.js +38 -0
  13. package/dist/clients/file-utils.js +21 -3
  14. package/dist/clients/instance-registry.js +20 -20
  15. package/dist/clients/jscpd-client.js +52 -39
  16. package/dist/clients/language-profile.js +12 -19
  17. package/dist/clients/lens-config.js +106 -27
  18. package/dist/clients/lens-engine.js +129 -3
  19. package/dist/clients/lsp/cascade-tier.js +1 -38
  20. package/dist/clients/lsp/client.js +3 -1
  21. package/dist/clients/lsp/index.js +621 -95
  22. package/dist/clients/lsp/server-strategies.js +3 -224
  23. package/dist/clients/lsp/server.js +1 -1
  24. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  25. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  26. package/dist/clients/lsp/wait-policy/index.js +10 -0
  27. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  28. package/dist/clients/lsp-budget.js +54 -16
  29. package/dist/clients/mcp/analyze.js +1 -1
  30. package/dist/clients/mcp/host-shim.js +12 -8
  31. package/dist/clients/mcp/ipc.js +118 -0
  32. package/dist/clients/mcp/session.js +2 -2
  33. package/dist/clients/pipeline.js +11 -6
  34. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  35. package/dist/clients/project-diagnostics/scanner.js +37 -6
  36. package/dist/clients/project-lens-config.js +167 -3
  37. package/dist/clients/project-report.js +627 -0
  38. package/dist/clients/project-scale.js +239 -0
  39. package/dist/clients/project-scan-policy.js +11 -1
  40. package/dist/clients/recent-touches.js +6 -11
  41. package/dist/clients/review-graph/builder.js +170 -13
  42. package/dist/clients/review-graph/import-resolvers.js +149 -13
  43. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  44. package/dist/clients/review-graph/workspace-modules.js +21 -9
  45. package/dist/clients/rules-scanner.js +15 -2
  46. package/dist/clients/runtime-agent-end.js +104 -17
  47. package/dist/clients/runtime-config.js +4 -0
  48. package/dist/clients/runtime-coordinator.js +50 -1
  49. package/dist/clients/runtime-session.js +41 -3
  50. package/dist/clients/runtime-tool-result.js +4 -3
  51. package/dist/clients/runtime-turn.js +4 -1
  52. package/dist/clients/safe-spawn.js +264 -40
  53. package/dist/clients/session-lifecycle.js +36 -0
  54. package/dist/clients/session-state-store.js +8 -3
  55. package/dist/clients/source-filter.js +136 -61
  56. package/dist/clients/source-walker.js +89 -0
  57. package/dist/clients/startup-scan.js +189 -111
  58. package/dist/clients/subagent-mode.js +14 -3
  59. package/dist/clients/todo-scanner.js +5 -1
  60. package/dist/clients/tool-policy.js +26 -4
  61. package/dist/clients/warm-attach.js +204 -0
  62. package/dist/clients/word-index.js +36 -5
  63. package/dist/clients/workspace-topology.js +266 -0
  64. package/dist/index.js +13826 -10251
  65. package/dist/mcp/server.js +125 -10
  66. package/dist/tools/ast-grep-search.js +21 -0
  67. package/dist/tools/lens-diagnostic-mark.js +198 -7
  68. package/dist/tools/lens-diagnostics.js +119 -21
  69. package/dist/tools/lsp-diagnostics.js +17 -1
  70. package/dist/tools/lsp-navigation.js +11 -2
  71. package/dist/tools/project-report.js +94 -0
  72. package/dist/tools/symbol-search.js +32 -8
  73. package/docs/environment-variables.md +10 -0
  74. package/docs/features.md +7 -1
  75. package/docs/globalconfig.md +70 -0
  76. package/docs/servercapabilities.md +1 -1
  77. package/docs/usage.md +13 -2
  78. package/package.json +1 -1
  79. package/scripts/download-grammars.js +1 -0
@@ -1,18 +1,38 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { parseEnabledShape } from "./config-enabled-shape.js";
5
+ import { findNestedProjectMutationValue, } from "./project-lens-config.js";
4
6
  export function getPiLensGlobalConfigPath(homeDir = os.homedir()) {
5
7
  const override = process.env.PI_LENS_CONFIG_PATH;
6
8
  if (override)
7
9
  return path.resolve(override);
8
10
  return path.join(homeDir, ".pi-lens", "config.json");
9
11
  }
12
+ const warnedInvalidGlobalConfigs = new Set();
13
+ /**
14
+ * Same warn-once-per-(path, reason) contract as project-lens-config.ts's
15
+ * `warnInvalidConfigOnce` — a malformed global config value is logged once
16
+ * and then treated as absent, rather than silently dropped (#792).
17
+ */
18
+ function warnInvalidGlobalConfigOnce(configPath, reason) {
19
+ const key = `${configPath}:${reason}`;
20
+ if (warnedInvalidGlobalConfigs.has(key))
21
+ return;
22
+ warnedInvalidGlobalConfigs.add(key);
23
+ console.error(`[pi-lens] ignoring invalid global config ${configPath}: ${reason}`);
24
+ }
25
+ /** For tests that need to force the warn-once cache to reset between cases. */
26
+ export function resetGlobalConfigWarnCache() {
27
+ warnedInvalidGlobalConfigs.clear();
28
+ }
10
29
  export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath()) {
11
30
  try {
12
31
  const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
13
32
  if (!parsed || typeof parsed !== "object")
14
33
  return undefined;
15
34
  const raw = parsed;
35
+ const warnInvalid = (reason) => warnInvalidGlobalConfigOnce(configPath, reason);
16
36
  const dispatchRaw = raw.dispatch;
17
37
  const dispatch = dispatchRaw && typeof dispatchRaw === "object"
18
38
  ? dispatchRaw
@@ -21,19 +41,17 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
21
41
  const widget = widgetRaw && typeof widgetRaw === "object"
22
42
  ? widgetRaw
23
43
  : undefined;
24
- const formatRaw = raw.format;
25
- const format = formatRaw && typeof formatRaw === "object"
26
- ? formatRaw
44
+ const format = parseEnabledShape(raw.format, "format", warnInvalid);
45
+ const autofix = parseEnabledShape(raw.autofix, "autofix", warnInvalid);
46
+ const formatModeRaw = raw.format;
47
+ const formatModeSource = formatModeRaw && typeof formatModeRaw === "object"
48
+ ? formatModeRaw
27
49
  : undefined;
28
50
  const actionableWarningsRaw = raw.actionableWarnings;
29
51
  const actionableWarnings = actionableWarningsRaw && typeof actionableWarningsRaw === "object"
30
52
  ? actionableWarningsRaw
31
53
  : undefined;
32
- const actionableWarningsAutoFixRaw = actionableWarnings?.autoFix;
33
- const actionableWarningsAutoFix = actionableWarningsAutoFixRaw &&
34
- typeof actionableWarningsAutoFixRaw === "object"
35
- ? actionableWarningsAutoFixRaw
36
- : undefined;
54
+ const actionableWarningsAutoFix = parseEnabledShape(actionableWarnings?.autoFix, "actionableWarnings.autoFix", warnInvalid);
37
55
  const contextInjectionRaw = raw.contextInjection;
38
56
  const contextInjection = contextInjectionRaw && typeof contextInjectionRaw === "object"
39
57
  ? contextInjectionRaw
@@ -42,8 +60,9 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
42
60
  const turnSummary = turnSummaryRaw && typeof turnSummaryRaw === "object"
43
61
  ? turnSummaryRaw
44
62
  : undefined;
45
- const formatMode = format?.mode === "immediate" || format?.mode === "deferred"
46
- ? format.mode
63
+ const formatMode = formatModeSource?.mode === "immediate" ||
64
+ formatModeSource?.mode === "deferred"
65
+ ? formatModeSource.mode
47
66
  : undefined;
48
67
  const ignore = Array.isArray(raw.ignore)
49
68
  ? raw.ignore.filter((p) => typeof p === "string")
@@ -66,10 +85,11 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
66
85
  : undefined,
67
86
  format: format
68
87
  ? {
69
- enabled: typeof format.enabled === "boolean" ? format.enabled : undefined,
88
+ enabled: format.enabled,
70
89
  mode: formatMode,
71
90
  }
72
91
  : undefined,
92
+ autofix: autofix ? { enabled: autofix.enabled } : undefined,
73
93
  actionableWarnings: actionableWarnings
74
94
  ? {
75
95
  enabled: typeof actionableWarnings.enabled === "boolean"
@@ -82,11 +102,7 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
82
102
  ? actionableWarnings.deltaOnly
83
103
  : undefined,
84
104
  autoFix: actionableWarningsAutoFix
85
- ? {
86
- enabled: typeof actionableWarningsAutoFix.enabled === "boolean"
87
- ? actionableWarningsAutoFix.enabled
88
- : undefined,
89
- }
105
+ ? { enabled: actionableWarningsAutoFix.enabled }
90
106
  : undefined,
91
107
  }
92
108
  : undefined,
@@ -119,6 +135,9 @@ export function getGlobalWidgetDefaultVisible(configPath) {
119
135
  export function getGlobalAutoformatEnabled(configPath) {
120
136
  return loadPiLensGlobalConfig(configPath)?.format?.enabled !== false;
121
137
  }
138
+ export function getGlobalAutofixEnabled(configPath) {
139
+ return loadPiLensGlobalConfig(configPath)?.autofix?.enabled !== false;
140
+ }
122
141
  export function getGlobalImmediateFormatDefault(configPath) {
123
142
  return loadPiLensGlobalConfig(configPath)?.format?.mode === "immediate";
124
143
  }
@@ -128,32 +147,92 @@ export function getGlobalContextInjectionEnabled(configPath) {
128
147
  export function getGlobalTurnSummaryEnabled(configPath) {
129
148
  return loadPiLensGlobalConfig(configPath)?.turnSummary?.enabled === true;
130
149
  }
131
- export function resolvePiLensFlag(name, value, config) {
150
+ /**
151
+ * Resolve a flag AND report which config tier decided it — same precedence
152
+ * as {@link resolvePiLensFlag} (which now delegates here), just also
153
+ * returning the `source` so callers can log e.g.
154
+ * "(--no-autofix, source=project)" instead of a bare boolean (#792).
155
+ *
156
+ * Precedence (unchanged, maintainer decision — project wins over global,
157
+ * including re-enabling; only an explicit CLI disabling flag outranks
158
+ * project config): cli → project → global → default.
159
+ */
160
+ export function resolvePiLensFlagWithSource(name, value, config, projectConfig, editedFilePath, projectRoot) {
132
161
  if (value)
133
- return value;
162
+ return { value, source: "cli" };
163
+ const mutationFlag = name === "no-autoformat" ||
164
+ name === "no-autofix" ||
165
+ name === "lens-actionable-warning-autofix"
166
+ ? name
167
+ : undefined;
168
+ const nested = mutationFlag && editedFilePath && projectRoot
169
+ ? findNestedProjectMutationValue(mutationFlag, editedFilePath, projectRoot)
170
+ : undefined;
171
+ const nestedSource = nested
172
+ ? path.resolve(nested.dir) === path.resolve(projectRoot)
173
+ ? "project"
174
+ : `nested-project:${nested.dir}`
175
+ : undefined;
134
176
  if (name === "no-autoformat") {
135
- return config?.format?.enabled === false;
177
+ if (nested)
178
+ return { value: !nested.value, source: nestedSource };
179
+ if (projectConfig?.format?.enabled !== undefined) {
180
+ return { value: !projectConfig.format.enabled, source: "project" };
181
+ }
182
+ if (config?.format?.enabled === false) {
183
+ return { value: true, source: "global" };
184
+ }
185
+ return { value: false, source: "default" };
186
+ }
187
+ if (name === "no-autofix") {
188
+ if (nested)
189
+ return { value: !nested.value, source: nestedSource };
190
+ if (projectConfig?.autofix?.enabled !== undefined) {
191
+ return { value: !projectConfig.autofix.enabled, source: "project" };
192
+ }
193
+ if (config?.autofix?.enabled === false) {
194
+ return { value: true, source: "global" };
195
+ }
196
+ return { value: false, source: "default" };
136
197
  }
137
198
  if (name === "immediate-format") {
138
- return config?.format?.mode === "immediate";
199
+ const immediate = config?.format?.mode === "immediate";
200
+ return { value: immediate, source: immediate ? "global" : "default" };
139
201
  }
140
202
  if (name === "lens-actionable-warnings") {
141
- return config?.actionableWarnings?.enabled === true;
203
+ const enabled = config?.actionableWarnings?.enabled === true;
204
+ return { value: enabled, source: enabled ? "global" : "default" };
142
205
  }
143
206
  if (name === "lens-actionable-warning-actions") {
144
- return config?.actionableWarnings?.includeLspCodeActions === true;
207
+ const enabled = config?.actionableWarnings?.includeLspCodeActions === true;
208
+ return { value: enabled, source: enabled ? "global" : "default" };
145
209
  }
146
210
  if (name === "lens-actionable-warning-autofix") {
147
- return config?.actionableWarnings?.autoFix?.enabled === true;
211
+ if (nested)
212
+ return { value: nested.value, source: nestedSource };
213
+ if (projectConfig?.actionableWarnings?.autoFix?.enabled !== undefined) {
214
+ return {
215
+ value: projectConfig.actionableWarnings.autoFix.enabled,
216
+ source: "project",
217
+ };
218
+ }
219
+ const enabled = config?.actionableWarnings?.autoFix?.enabled === true;
220
+ return { value: enabled, source: enabled ? "global" : "default" };
148
221
  }
149
222
  if (name === "lens-actionable-warning-all") {
150
- return config?.actionableWarnings?.deltaOnly === false;
223
+ const all = config?.actionableWarnings?.deltaOnly === false;
224
+ return { value: all, source: all ? "global" : "default" };
151
225
  }
152
226
  if (name === "no-lens-context") {
153
- return config?.contextInjection?.enabled === false;
227
+ const disabled = config?.contextInjection?.enabled === false;
228
+ return { value: disabled, source: disabled ? "global" : "default" };
154
229
  }
155
230
  if (name === "lens-turn-summary") {
156
- return config?.turnSummary?.enabled === true;
231
+ const enabled = config?.turnSummary?.enabled === true;
232
+ return { value: enabled, source: enabled ? "global" : "default" };
157
233
  }
158
- return value;
234
+ return { value, source: "default" };
235
+ }
236
+ export function resolvePiLensFlag(name, value, config, projectConfig, editedFilePath, projectRoot) {
237
+ return resolvePiLensFlagWithSource(name, value, config, projectConfig, editedFilePath, projectRoot).value;
159
238
  }
@@ -21,6 +21,7 @@ import { getLSPService } from "./lsp/index.js";
21
21
  import { getOrLoadWarmWordIndex } from "./mcp/analyze.js";
22
22
  import { scanProjectDiagnostics } from "./project-diagnostics/scanner.js";
23
23
  import * as path from "node:path";
24
+ import { minimatch } from "./deps/minimatch.js";
24
25
  import { normalizeMapKey } from "./path-utils.js";
25
26
  import { loadProjectSnapshot } from "./project-snapshot.js";
26
27
  import { centralityFromReverseDeps, deserializeWordIndex, searchWordIndex, triggerBackgroundWordIndexBuild, } from "./word-index.js";
@@ -31,6 +32,7 @@ export { ipcPathForCwd, requestWarmAnalyze, } from "./mcp/ipc.js";
31
32
  export { analyzeFileFresh, resolveRebuildScript, runRebuild, summarizeScan, } from "./mcp/review.js";
32
33
  export { runSessionStart, runTurnEnd, } from "./mcp/session.js";
33
34
  export { moduleReport, readEnclosing, readSymbol, renderCompactModuleReport, } from "./module-report.js";
35
+ export { projectReport, renderCompactProjectReport, } from "./project-report.js";
34
36
  // --- Query wrappers (own the remaining internal reach-ins) -------------------
35
37
  /** Recent dispatch latency reports (latency.log schema), newest first. */
36
38
  export function recentLatency(limit = 5, fileFilter) {
@@ -45,6 +47,19 @@ export function recentLatency(limit = 5, fileFilter) {
45
47
  export function projectScan(cwd, maxFiles) {
46
48
  return scanProjectDiagnostics({ cwd, tier: "cheap", maxFiles });
47
49
  }
50
+ /**
51
+ * #784: `scanTruncated` (#760) reaches this seam intact but, until now, no
52
+ * caller rendered it — a capped scan read as a complete clean sweep to the
53
+ * agent/user. One shared, unit-testable line adapters can append to their
54
+ * rendered summary, matching the #777 warm-skip notify's override-hint style.
55
+ * Returns `undefined` when the scan was not truncated (no line to append).
56
+ */
57
+ export function scanTruncationNotice(snapshot) {
58
+ if (!snapshot.scanTruncated)
59
+ return undefined;
60
+ return (`⚠ Scan truncated at ${snapshot.filesScanned} file(s) — results are partial; ` +
61
+ "raise maxProjectFiles in .pi-lens.json to scan fully.");
62
+ }
48
63
  /** Alive LSP client count + per-server status. */
49
64
  export function lspStatus() {
50
65
  const lsp = getLSPService();
@@ -69,6 +84,97 @@ export function ensureLspConfig(cwd) {
69
84
  export function resourceFootprint() {
70
85
  return getResourceFootprintSnapshot();
71
86
  }
87
+ // Same language identifiers ast_grep_search's `lang` param accepts
88
+ // (tools/shared.ts's LANGUAGES) mapped to source file extensions. Duplicated
89
+ // here rather than imported — clients/ never reaches into tools/ (see
90
+ // AGENTS.md's MCP-mirror layering note) — so this is symbol_search's own small
91
+ // copy, scoped to what its `lang` filter needs (extension matching only, no
92
+ // AST/LSP concerns).
93
+ const SYMBOL_SEARCH_LANG_EXTENSIONS = {
94
+ bash: [".sh", ".bash"],
95
+ c: [".c", ".h"],
96
+ cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
97
+ csharp: [".cs"],
98
+ css: [".css", ".scss", ".less"],
99
+ elixir: [".ex", ".exs"],
100
+ go: [".go"],
101
+ haskell: [".hs", ".lhs"],
102
+ html: [".html", ".htm"],
103
+ java: [".java"],
104
+ javascript: [".js", ".jsx", ".mjs", ".cjs"],
105
+ json: [".json", ".jsonc", ".json5"],
106
+ kotlin: [".kt", ".kts"],
107
+ lua: [".lua"],
108
+ nix: [".nix"],
109
+ php: [".php"],
110
+ python: [".py", ".pyi"],
111
+ ruby: [".rb", ".rake", ".gemspec"],
112
+ rust: [".rs"],
113
+ scala: [".scala", ".sc"],
114
+ solidity: [".sol"],
115
+ swift: [".swift"],
116
+ tsx: [".tsx"],
117
+ typescript: [".ts", ".mts", ".cts"],
118
+ yaml: [".yaml", ".yml"],
119
+ };
120
+ function fileMatchesLang(file, lang) {
121
+ const exts = SYMBOL_SEARCH_LANG_EXTENSIONS[lang];
122
+ if (!exts)
123
+ return false;
124
+ return exts.includes(path.extname(file).toLowerCase());
125
+ }
126
+ /** Same glob semantics as ast_grep_search's `paths`: a bare directory/file
127
+ * entry scopes its whole subtree (via a `/**` suffix match), a full glob
128
+ * pattern is matched as-is. Matched relative to `cwd` so absolute index paths
129
+ * and repo-relative globs line up regardless of platform path separators. */
130
+ function fileMatchesPathGlobs(file, cwd, globs) {
131
+ const rel = path.relative(cwd, file).split(path.sep).join("/");
132
+ const options = { dot: true, nocase: process.platform === "win32" };
133
+ return globs.some((raw) => {
134
+ const pattern = raw.split("\\").join("/");
135
+ return (minimatch(rel, pattern, options) ||
136
+ minimatch(rel, `${pattern}/**`, options));
137
+ });
138
+ }
139
+ function buildSymbolSearchFileFilter(cwd, options) {
140
+ const paths = options.paths?.filter((p) => p.trim().length > 0);
141
+ const lang = options.lang?.trim();
142
+ if (!paths?.length && !lang)
143
+ return undefined;
144
+ return (file) => {
145
+ if (paths?.length && !fileMatchesPathGlobs(file, cwd, paths))
146
+ return false;
147
+ if (lang && !fileMatchesLang(file, lang))
148
+ return false;
149
+ return true;
150
+ };
151
+ }
152
+ /**
153
+ * Attaches read-only graph signals to already-ranked hits (#771) — mutates
154
+ * each hit in place. `fanIn` reuses the SAME `centrality` map `symbolSearch`
155
+ * already computed for its ranking boost (zero extra cost); `complexity` is
156
+ * the highest per-symbol `cyclomaticComplexity` the (warm) review graph
157
+ * recorded for that file. Caller only invokes this when `graph` is defined —
158
+ * never builds one itself.
159
+ */
160
+ function annotateSymbolSearchHitsWithGraph(hits, centrality, graph) {
161
+ for (const hit of hits) {
162
+ const normalized = normalizeMapKey(path.resolve(hit.file));
163
+ const fanIn = centrality.get(hit.file) ?? 0;
164
+ let complexity;
165
+ for (const symbolId of graph.symbolNodesByFile.get(normalized) ?? []) {
166
+ const raw = graph.nodes.get(symbolId)?.metadata?.cyclomaticComplexity;
167
+ if (typeof raw === "number" &&
168
+ (complexity === undefined || raw > complexity)) {
169
+ complexity = raw;
170
+ }
171
+ }
172
+ hit.annotations = {
173
+ fanIn,
174
+ ...(complexity !== undefined ? { complexity } : {}),
175
+ };
176
+ }
177
+ }
72
178
  function toSymbolSearchHit(result) {
73
179
  const line = result.lines[0] ?? 1;
74
180
  return {
@@ -98,8 +204,22 @@ function toSymbolSearchHit(result) {
98
204
  * pilens_analyze yet this process, or #348 phase 2's forward-index isn't
99
205
  * available) — this function's public contract (available/hint/results shape)
100
206
  * is unchanged either way.
207
+ *
208
+ * `options.paths`/`options.lang` (#771) scope the word index BEFORE ranking
209
+ * (`searchWordIndex`'s `fileFilter`), so a surviving file's score is identical
210
+ * to an unfiltered run; omitting both reproduces prior output byte-for-byte.
211
+ *
212
+ * Every hit is additionally annotated (#771) with the graph signals already
213
+ * available when the cached review graph happens to be warm —
214
+ * `getCachedReviewGraph` (`clients/review-graph/builder.ts`) is a READ-ONLY
215
+ * accessor: an in-memory miss falls through to a persisted-disk read, and
216
+ * NOTHING here ever triggers a fresh build. A cold cache (no in-memory graph,
217
+ * no persisted snapshot) simply omits `annotations` — this function's latency
218
+ * profile is unchanged either way. The module is dynamic-imported (mirroring
219
+ * module-report.ts's own lazy load of it) so an unused review graph costs
220
+ * nothing on this hot path.
101
221
  */
102
- export function symbolSearch(query, cwd, limit = 20) {
222
+ export async function symbolSearch(query, cwd, limit = 20, options = {}) {
103
223
  const snapshot = loadProjectSnapshot(cwd);
104
224
  const index = getOrLoadWarmWordIndex(cwd) ?? deserializeWordIndex(snapshot?.wordIndex);
105
225
  if (!index) {
@@ -114,11 +234,17 @@ export function symbolSearch(query, cwd, limit = 20) {
114
234
  // Boost well-connected files using the snapshot's reverse-dependency
115
235
  // (importedBy) counts; snapshot keys are normalized, index keys are raw.
116
236
  const centrality = centralityFromReverseDeps(index, snapshot?.reverseDeps, (file) => normalizeMapKey(path.resolve(file)));
117
- const results = searchWordIndex(index, query, { limit, centrality });
237
+ const fileFilter = buildSymbolSearchFileFilter(cwd, options);
238
+ const results = searchWordIndex(index, query, { limit, centrality, fileFilter });
239
+ const hits = results.map(toSymbolSearchHit);
240
+ const { getCachedReviewGraph } = await import("./review-graph/builder.js");
241
+ const graph = getCachedReviewGraph(cwd);
242
+ if (graph)
243
+ annotateSymbolSearchHitsWithGraph(hits, centrality, graph);
118
244
  return {
119
245
  available: true,
120
246
  query,
121
- results: results.map(toSymbolSearchHit),
247
+ results: hits,
122
248
  snapshotGeneratedAt: snapshot?.generatedAt,
123
249
  };
124
250
  }
@@ -57,8 +57,7 @@ import { logCascade } from "../cascade-logger.js";
57
57
  import { logLatency } from "../latency-logger.js";
58
58
  import { normalizeMapKey } from "../path-utils.js";
59
59
  import { registerQuietWindowTask } from "../quiet-window.js";
60
- import { getServersForFileWithConfig } from "./config.js";
61
- import { getStrategy } from "./server-strategies.js";
60
+ export { classifyCascadeWaitTier, classifyServerWaitTier, } from "./wait-policy/classification.js";
62
61
  // --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts /
63
62
  // clients/quiet-window.ts's isQuietWindowEnabled) ---
64
63
  let _enabledCache;
@@ -75,42 +74,6 @@ export function isTierAwareCascadeEnabled() {
75
74
  export function _resetTierAwareCascadeEnabledForTests() {
76
75
  _enabledCache = undefined;
77
76
  }
78
- /**
79
- * Classify whether `filePath`'s PRIMARY language server is a cascade-lane
80
- * Tier-3 (push-only, silent-on-clean) server. Ambiguous or missing capability
81
- * data is always `"waits"` (today's behavior) — this function must never be
82
- * the reason a real answer gets missed.
83
- */
84
- export function classifyCascadeWaitTier(lspService, filePath, snapshots) {
85
- void lspService; // kept in the signature for call-site clarity/typing only
86
- const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
87
- const primary = servers[0];
88
- if (!primary)
89
- return "waits";
90
- const snapshot = snapshots.find((s) => s.serverId === primary.id);
91
- if (!snapshot)
92
- return "waits"; // no live snapshot yet — fail-safe
93
- const mode = snapshot.workspaceDiagnosticsSupport?.mode;
94
- if (mode !== "push-only")
95
- return "waits"; // pull servers are always affirmative
96
- const strategy = getStrategy(primary.id);
97
- if (strategy.silentOnClean !== true)
98
- return "waits"; // 2*/unknown push-only
99
- // #524/#529/#541/#558: `silentOnClean` on a server-id-keyed strategy is
100
- // only proven against the variant it was actually measured against.
101
- // "typescript" today means either classic typescript-language-server
102
- // (confirmed silent-on-clean, 2026-07-12 dual-environment re-measurement)
103
- // or TS7's native `tsc --lsp --stdio` (the SAME re-measurement found it
104
- // publishes 2 version-less diagnostic sets on clean — NOT silent, a
105
- // drift from the earlier #541 measurement). A native-ts7 snapshot must
106
- // NOT inherit the classic verdict: fall through to "waits", the same
107
- // ambiguous/fail-safe path an unmarked or non-push-only server already
108
- // takes. `launchVariant === "classic"` or absent (older snapshots that
109
- // predate the marker) keeps today's tier-3 behavior exactly.
110
- if (snapshot.launchVariant === "native-ts7")
111
- return "waits";
112
- return "tier3-silent";
113
- }
114
77
  // Keyed by normalized file path. A later touch for the same file simply
115
78
  // replaces the earlier entry (only the most recent touch matters — an
116
79
  // older touch's diagnostics, if they ever arrive, are still a strict superset
@@ -22,7 +22,7 @@ import { applyWorkspaceEdit } from "./edits.js";
22
22
  import { recordLspChild, removeLspChild } from "../instance-registry.js";
23
23
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
24
24
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
25
- import { getStrategy } from "./server-strategies.js";
25
+ import { getStrategy } from "./wait-policy/index.js";
26
26
  import { WatchedFilesQueue } from "./watch-queue.js";
27
27
  // Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
28
28
  // hot-path cost. Surfaces each server's publish behavior (version + count) to
@@ -703,6 +703,8 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
703
703
  if (outcome.status === "clean")
704
704
  sawClean = true;
705
705
  }
706
+ if (options.pullOnly)
707
+ return;
706
708
  if (outcome.status === "found" || sawClean)
707
709
  return;
708
710
  }