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
@@ -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;
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
+ import { writeFileAtomic, writeFileAtomicAsync } from "./atomic-write.js";
25
26
  import { getGlobalPiLensDir } from "./file-utils.js";
26
27
  // #735: reuse the #449/#525 reaper's exact conservative liveness check
27
28
  // (`process.kill(pid, 0)`, ESRCH-only-means-dead) rather than inventing a
@@ -34,6 +35,7 @@ import { getGlobalPiLensDir } from "./file-utils.js";
34
35
  // import is actually invoked.
35
36
  import { realIsPidAlive } from "./instance-reaper.js";
36
37
  import { normalizeFilePath } from "./path-utils.js";
38
+ import { getSubagentIdentity, isSubagentSession, } from "./subagent-mode.js";
37
39
  function registryPath() {
38
40
  return path.join(getGlobalPiLensDir(), "instances.json");
39
41
  }
@@ -87,44 +89,32 @@ export async function readInstanceRegistry() {
87
89
  const file = await readRegistryAsync();
88
90
  return file.instances;
89
91
  }
90
- // --- Write (atomic tmp + rename, same pattern as review-graph/builder.ts) ---
92
+ // --- Write (atomic tmp + rename via clients/atomic-write.ts, #762) ---
91
93
  async function writeRegistryAsync(file) {
92
94
  const dir = getGlobalPiLensDir();
93
95
  const target = registryPath();
94
- const tmpPath = `${target}.tmp-${process.pid}`;
95
96
  try {
96
97
  await fs.promises.mkdir(dir, { recursive: true });
97
- await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8");
98
- await fs.promises.rename(tmpPath, target);
99
98
  }
100
99
  catch {
101
- // Best-effort observability substrate — a failed write just means this
100
+ // Best-effort observability substrate — a failed mkdir just means this
102
101
  // update is lost, never a thrown error for the caller.
103
- try {
104
- await fs.promises.rm(tmpPath, { force: true });
105
- }
106
- catch {
107
- // ignore
108
- }
102
+ return;
109
103
  }
104
+ // bestEffort (default): a failed write just means this update is lost,
105
+ // never a thrown error for the caller.
106
+ await writeFileAtomicAsync(target, JSON.stringify(file));
110
107
  }
111
108
  function writeRegistrySync(file) {
112
109
  const dir = getGlobalPiLensDir();
113
110
  const target = registryPath();
114
- const tmpPath = `${target}.tmp-${process.pid}`;
115
111
  try {
116
112
  fs.mkdirSync(dir, { recursive: true });
117
- fs.writeFileSync(tmpPath, JSON.stringify(file), "utf-8");
118
- fs.renameSync(tmpPath, target);
119
113
  }
120
114
  catch {
121
- try {
122
- fs.rmSync(tmpPath, { force: true });
123
- }
124
- catch {
125
- // ignore
126
- }
115
+ return;
127
116
  }
117
+ writeFileAtomic(target, JSON.stringify(file));
128
118
  }
129
119
  // --- Mutations (all read-modify-write whole file) ---
130
120
  /** Create/overwrite this process's entry. */
@@ -137,6 +127,15 @@ export async function registerInstance(projectRoot) {
137
127
  const now = new Date().toISOString();
138
128
  const others = file.instances.filter((entry) => entry.pid !== pid);
139
129
  const existing = file.instances.find((entry) => entry.pid === pid);
130
+ const identity = isSubagentSession() ? getSubagentIdentity() : undefined;
131
+ const subagent = identity
132
+ ? {
133
+ marker: identity.marker,
134
+ agentType: identity.agentName,
135
+ parentPid: identity.parentPid,
136
+ runId: identity.runId,
137
+ }
138
+ : undefined;
140
139
  others.push({
141
140
  pid,
142
141
  startedAt: existing?.startedAt ?? now,
@@ -145,6 +144,7 @@ export async function registerInstance(projectRoot) {
145
144
  lspChildCount: existing?.lspChildren?.length ?? 0,
146
145
  rssBytes: process.memoryUsage().rss,
147
146
  heartbeatAt: now,
147
+ ...(subagent ? { subagent } : {}),
148
148
  });
149
149
  await writeRegistryAsync({ instances: others });
150
150
  }
@@ -11,9 +11,12 @@ import * as fs from "node:fs";
11
11
  import { mkdtempSync } from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
- import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
14
+ import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, } from "./file-utils.js";
15
15
  import { findNodeToolBinary } from "./package-manager.js";
16
+ import { isAtOrAboveHomeDir } from "./path-utils.js";
17
+ import { getJscpdMaxEntriesDerived } from "./project-scale.js";
16
18
  import { safeSpawnAsync } from "./safe-spawn.js";
19
+ import { shouldRecurseIntoDir, walkTreeStackSync } from "./source-walker.js";
17
20
  const EMPTY_RESULT = {
18
21
  success: false,
19
22
  clones: [],
@@ -45,46 +48,42 @@ export class JscpdClient {
45
48
  * them.
46
49
  */
47
50
  hasSourceFilesRecursive(rootDir) {
48
- const stack = [rootDir];
49
51
  const ignoreMatcher = getProjectIgnoreMatcher(rootDir);
50
- let visited = 0;
51
- const MAX_ENTRIES = 6000;
52
- while (stack.length > 0 && visited < MAX_ENTRIES) {
53
- const dir = stack.pop();
54
- if (!dir)
55
- continue;
56
- let entries;
57
- try {
58
- entries = fs.readdirSync(dir, { withFileTypes: true });
59
- }
60
- catch {
61
- continue;
52
+ const state = { visited: 0 };
53
+ // #776: derived from the `maxProjectFiles` scale knob (project-scale.ts),
54
+ // reproducing this same 6,000 default at the default base.
55
+ const MAX_ENTRIES = getJscpdMaxEntriesDerived(rootDir);
56
+ // #761: the traversal loop, readdir-safety, and directory-recursion
57
+ // decision are the shared `walkTreeStackSync` engine; this client keeps
58
+ // its own per-entry policy (skip ALL symlinks — files too — before the
59
+ // dir/file branch, its own multi-language source regex, and a
60
+ // per-directory entry budget via `shouldStop`, matching the pre-#761
61
+ // `while (stack.length > 0 && visited < MAX_ENTRIES)` condition: the
62
+ // current directory's entry loop always finishes, but no further
63
+ // directory is popped once the budget is spent).
64
+ return walkTreeStackSync(rootDir, (entry, fullPath) => {
65
+ state.visited += 1;
66
+ if (entry.isSymbolicLink())
67
+ return "skip";
68
+ if (entry.isDirectory()) {
69
+ return shouldRecurseIntoDir(entry, fullPath, {
70
+ ignoreMatcher,
71
+ followSymlinks: true,
72
+ })
73
+ ? "recurse"
74
+ : "skip";
62
75
  }
63
- for (const entry of entries) {
64
- visited += 1;
65
- if (entry.isSymbolicLink())
66
- continue;
67
- if (entry.isDirectory()) {
68
- const fullPath = path.join(dir, entry.name);
69
- if (isExcludedDirName(entry.name))
70
- continue;
71
- if (ignoreMatcher.isIgnored(fullPath, true))
72
- continue;
73
- stack.push(fullPath);
74
- continue;
75
- }
76
- if (!entry.isFile())
77
- continue;
78
- if (ignoreMatcher.isIgnored(path.join(dir, entry.name), false))
79
- continue;
80
- if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
81
- if (entry.name.endsWith(".d.ts"))
82
- continue;
83
- return true;
84
- }
76
+ if (!entry.isFile())
77
+ return "skip";
78
+ if (ignoreMatcher.isIgnored(fullPath, false))
79
+ return "skip";
80
+ if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
81
+ if (entry.name.endsWith(".d.ts"))
82
+ return "skip";
83
+ return "stop";
85
84
  }
86
- }
87
- return false;
85
+ return "skip";
86
+ }, { shouldStop: () => state.visited >= MAX_ENTRIES });
88
87
  }
89
88
  /**
90
89
  * Check if jscpd is available, auto-install if not
@@ -144,9 +143,23 @@ export class JscpdClient {
144
143
  * Scan a directory for duplicate code blocks.
145
144
  * Uses a temp output dir to capture JSON report.
146
145
  * @param isTsProject - If true, excludes .js files (they're compiled artifacts in TS projects)
146
+ *
147
+ * Root contract (#747/#250): unlike `KnipClient`/`DeadCodeClient`, this
148
+ * client does NOT resolve a project root via `findNearestMarkerRoot`, so it
149
+ * has historically relied on every caller to guard the scan root itself. The
150
+ * `isAtOrAboveHomeDir` refusal below is belt-and-braces internal protection
151
+ * so a FUTURE caller that forgets that contract can't spawn a whole-$HOME
152
+ * jscpd walk (the observed OOM: a jscpd run from a WSL home reached 44 GB
153
+ * RSS). `options.homeDir` overrides `os.homedir()` for tests.
147
154
  */
148
- async scan(cwd, minLines = 5, minTokens = 50, isTsProject = false) {
155
+ async scan(cwd, minLines = 5, minTokens = 50, isTsProject = false, options = {}) {
149
156
  const targetDir = path.resolve(cwd);
157
+ // #747/#250: never walk from a cwd at/above $HOME — from there jscpd's
158
+ // tokenizer would run across every unrelated repo under home.
159
+ if (isAtOrAboveHomeDir(targetDir, options.homeDir)) {
160
+ this.log(`Refusing scan of unsafe root at/above home: ${targetDir}`);
161
+ return { ...EMPTY_RESULT };
162
+ }
150
163
  // Return early for non-existent or empty directories before probing/installing.
151
164
  if (!fs.existsSync(targetDir)) {
152
165
  return { ...EMPTY_RESULT };
@@ -5,6 +5,7 @@ import { getProjectIgnoreMatcher } from "./file-utils.js";
5
5
  import { LANGUAGE_POLICY, } from "./language-policy.js";
6
6
  import { getSourceFiles } from "./scan-utils.js";
7
7
  import { readDirEntriesSafe, shouldRecurseIntoDir } from "./source-walker.js";
8
+ import { findNearestDirWithAnyBasename } from "./workspace-topology.js";
8
9
  export const SUPPORTED_FILE_KINDS = [
9
10
  "jsts",
10
11
  "python",
@@ -114,24 +115,6 @@ const ROOT_MARKERS_BY_KIND = {
114
115
  nix: ["flake.nix"],
115
116
  toml: ["pyproject.toml", "Cargo.toml", "taplo.toml"],
116
117
  };
117
- function nearestRoot(start, markers) {
118
- let dir = path.resolve(start);
119
- const { root } = path.parse(dir);
120
- while (true) {
121
- for (const marker of markers) {
122
- if (fs.existsSync(path.join(dir, marker))) {
123
- return dir;
124
- }
125
- }
126
- if (dir === root)
127
- break;
128
- const parent = path.dirname(dir);
129
- if (parent === dir)
130
- break;
131
- dir = parent;
132
- }
133
- return undefined;
134
- }
135
118
  // Process-lifetime memo keyed on projectRoot. Only populated when the
136
119
  // caller did not pass an explicit `sourceFiles` array — the explicit-array
137
120
  // case is used by the warmup pipeline to inject pre-collected files and
@@ -225,7 +208,17 @@ export function resolveLanguageRootForFile(filePath, workspaceRoot) {
225
208
  if (!markers || markers.length === 0) {
226
209
  return path.resolve(workspaceRoot);
227
210
  }
228
- const found = nearestRoot(startDir, markers);
211
+ // #807: nearest-marker discovery now shares workspace-topology.ts's single
212
+ // walk/cache/invalidation seam instead of this file's own per-kind
213
+ // `existsSync` climb loop. `findNearestDirWithAnyBasename` additionally
214
+ // rejects a marker at/above `$HOME` (the pre-#807 loop didn't), matching
215
+ // the AGENTS.md walker invariant — harmless here because the
216
+ // workspace-relative check right below already discards any candidate
217
+ // outside `workspaceRoot`, and `workspaceRoot` is never at/above `$HOME`
218
+ // in practice. This keeps resolveLanguageRootForFile's OWN stricter
219
+ // workspace-relative policy check unchanged: the topology service only
220
+ // supplies discovery, not this function's policy.
221
+ const found = findNearestDirWithAnyBasename(startDir, markers);
229
222
  if (!found)
230
223
  return path.resolve(workspaceRoot);
231
224
  const workspace = path.resolve(workspaceRoot);