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.
- package/CHANGELOG.md +106 -1
- package/README.md +8 -0
- package/config/markdownlint/core.json +6 -0
- package/dist/clients/atomic-write.js +83 -0
- package/dist/clients/config-enabled-shape.js +39 -0
- package/dist/clients/diagnostic-dispositions.js +64 -2
- package/dist/clients/dispatch/integration.js +2 -1
- package/dist/clients/dispatch/runners/lsp.js +54 -25
- package/dist/clients/dispatch/runners/markdownlint.js +7 -4
- package/dist/clients/dispatch/runners/pyright.js +1 -0
- package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
- package/dist/clients/env-utils.js +38 -0
- package/dist/clients/file-utils.js +21 -3
- package/dist/clients/instance-registry.js +20 -20
- package/dist/clients/jscpd-client.js +52 -39
- package/dist/clients/language-profile.js +12 -19
- package/dist/clients/lens-config.js +106 -27
- package/dist/clients/lens-engine.js +129 -3
- package/dist/clients/lsp/cascade-tier.js +1 -38
- package/dist/clients/lsp/client.js +3 -1
- package/dist/clients/lsp/index.js +621 -95
- package/dist/clients/lsp/server-strategies.js +3 -224
- package/dist/clients/lsp/server.js +1 -1
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
- package/dist/clients/lsp/wait-policy/classification.js +63 -0
- package/dist/clients/lsp/wait-policy/index.js +10 -0
- package/dist/clients/lsp/wait-policy/strategies.js +244 -0
- package/dist/clients/lsp-budget.js +54 -16
- package/dist/clients/mcp/analyze.js +1 -1
- package/dist/clients/mcp/host-shim.js +12 -8
- package/dist/clients/mcp/ipc.js +118 -0
- package/dist/clients/mcp/session.js +2 -2
- package/dist/clients/pipeline.js +11 -6
- package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
- package/dist/clients/project-diagnostics/scanner.js +37 -6
- package/dist/clients/project-lens-config.js +167 -3
- package/dist/clients/project-report.js +627 -0
- package/dist/clients/project-scale.js +239 -0
- package/dist/clients/project-scan-policy.js +11 -1
- package/dist/clients/recent-touches.js +6 -11
- package/dist/clients/review-graph/builder.js +170 -13
- package/dist/clients/review-graph/import-resolvers.js +149 -13
- package/dist/clients/review-graph/tsconfig-paths.js +242 -0
- package/dist/clients/review-graph/workspace-modules.js +21 -9
- package/dist/clients/rules-scanner.js +15 -2
- package/dist/clients/runtime-agent-end.js +104 -17
- package/dist/clients/runtime-config.js +4 -0
- package/dist/clients/runtime-coordinator.js +50 -1
- package/dist/clients/runtime-session.js +41 -3
- package/dist/clients/runtime-tool-result.js +4 -3
- package/dist/clients/runtime-turn.js +4 -1
- package/dist/clients/safe-spawn.js +264 -40
- package/dist/clients/session-lifecycle.js +36 -0
- package/dist/clients/session-state-store.js +8 -3
- package/dist/clients/source-filter.js +136 -61
- package/dist/clients/source-walker.js +89 -0
- package/dist/clients/startup-scan.js +189 -111
- package/dist/clients/subagent-mode.js +14 -3
- package/dist/clients/todo-scanner.js +5 -1
- package/dist/clients/tool-policy.js +26 -4
- package/dist/clients/warm-attach.js +204 -0
- package/dist/clients/word-index.js +36 -5
- package/dist/clients/workspace-topology.js +266 -0
- package/dist/index.js +13826 -10251
- package/dist/mcp/server.js +125 -10
- package/dist/tools/ast-grep-search.js +21 -0
- package/dist/tools/lens-diagnostic-mark.js +198 -7
- package/dist/tools/lens-diagnostics.js +119 -21
- package/dist/tools/lsp-diagnostics.js +17 -1
- package/dist/tools/lsp-navigation.js +11 -2
- package/dist/tools/project-report.js +94 -0
- package/dist/tools/symbol-search.js +32 -8
- package/docs/environment-variables.md +10 -0
- package/docs/features.md +7 -1
- package/docs/globalconfig.md +70 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/usage.md +13 -2
- package/package.json +1 -1
- 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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
const attached = await tryWarmAttachedDiagnostics(ctx.filePath, content, Math.max(LSP_SPAWN_BUDGET_MS, LSP_DIAGNOSTICS_WAIT_MS));
|
|
130
|
+
usedWarmAttach = attached?.available === true;
|
|
131
|
+
const touched = attached?.available
|
|
132
|
+
? attached.response.diagnostics
|
|
133
|
+
: await lspService.touchFile(ctx.filePath, content, {
|
|
134
|
+
diagnostics: "document",
|
|
135
|
+
collectDiagnostics: true,
|
|
136
|
+
clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
|
|
137
|
+
auxiliaryServerIds,
|
|
138
|
+
maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
|
|
139
|
+
maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
|
|
140
|
+
source: "dispatch-lsp-runner",
|
|
141
|
+
});
|
|
136
142
|
if (touched === undefined) {
|
|
137
143
|
lspClientReady = false;
|
|
138
144
|
}
|
|
139
145
|
else {
|
|
140
146
|
lspDiags = touched;
|
|
141
|
-
diagnosticsInconclusive =
|
|
147
|
+
diagnosticsInconclusive =
|
|
148
|
+
touched
|
|
149
|
+
.inconclusive === true;
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
catch (err) {
|
|
@@ -206,21 +214,42 @@ const lspRunner = {
|
|
|
206
214
|
const blockingDiagIndexes = validLspDiags
|
|
207
215
|
.map((d, idx) => ({ d, idx }))
|
|
208
216
|
.filter(({ d }) => d.severity === 1)
|
|
209
|
-
.slice(0,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
.slice(0, WARM_CODE_ACTION_LOOKUP_LIMIT);
|
|
218
|
+
if (usedWarmAttach) {
|
|
219
|
+
// Diagnostics have already succeeded. Code actions are optional
|
|
220
|
+
// enrichment, so ANY IPC failure degrades to today's skip without
|
|
221
|
+
// promoting the attached session to a local LSP fleet.
|
|
222
|
+
const ranges = blockingDiagIndexes.map(({ d }) => ({
|
|
223
|
+
start: d.range.start,
|
|
224
|
+
end: d.range.end ?? d.range.start,
|
|
225
|
+
}));
|
|
226
|
+
const result = await tryWarmAttachedCodeActions(ctx.filePath, contentHash(content), ranges, LSP_DIAGNOSTICS_WAIT_MS);
|
|
227
|
+
if (result?.available) {
|
|
228
|
+
result.response.actions.forEach((actions, responseIndex) => {
|
|
229
|
+
const diagnosticIndex = blockingDiagIndexes[responseIndex]?.idx;
|
|
230
|
+
const suggestion = buildCodeActionSuggestion(actions);
|
|
231
|
+
if (diagnosticIndex !== undefined && suggestion) {
|
|
232
|
+
fixSuggestionByIndex.set(diagnosticIndex, suggestion);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
222
235
|
}
|
|
223
|
-
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
|
|
239
|
+
try {
|
|
240
|
+
const start = d.range.start;
|
|
241
|
+
const end = d.range.end ?? d.range.start;
|
|
242
|
+
const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
|
|
243
|
+
const suggestion = buildCodeActionSuggestion(actions);
|
|
244
|
+
if (suggestion) {
|
|
245
|
+
fixSuggestionByIndex.set(idx, suggestion);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Best-effort enrichment only; base diagnostics remain authoritative.
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
224
253
|
const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
|
|
225
254
|
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
|
|
226
255
|
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolvePackagePath } from "../../package-root.js";
|
|
1
2
|
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
2
3
|
import { getLinterPolicyForCwd, hasMarkdownlintConfig, } from "../../tool-policy.js";
|
|
3
4
|
import { PRIORITY } from "../priorities.js";
|
|
@@ -93,9 +94,6 @@ const markdownlintRunner = {
|
|
|
93
94
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
94
95
|
}
|
|
95
96
|
const hasConfig = hasMarkdownlintConfig(cwd);
|
|
96
|
-
if (!hasConfig) {
|
|
97
|
-
// Run with sensible defaults even without explicit config
|
|
98
|
-
}
|
|
99
97
|
let cmd = null;
|
|
100
98
|
if (await (markdownlint.isAvailableAsync(cwd))) {
|
|
101
99
|
cmd = markdownlint.getCommand(cwd);
|
|
@@ -105,7 +103,12 @@ const markdownlintRunner = {
|
|
|
105
103
|
}
|
|
106
104
|
if (!cmd)
|
|
107
105
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
108
|
-
const configArgs = hasConfig
|
|
106
|
+
const configArgs = hasConfig
|
|
107
|
+
? []
|
|
108
|
+
: [
|
|
109
|
+
"--config",
|
|
110
|
+
resolvePackagePath(import.meta.url, "config/markdownlint/core.json"),
|
|
111
|
+
];
|
|
109
112
|
const result = await safeSpawnAsync(cmd, [...configArgs, ctx.filePath], {
|
|
110
113
|
timeout: 15000,
|
|
111
114
|
cwd,
|
|
@@ -23,7 +23,7 @@ function runBlastRadiusInBackground(cwd, filePath, languageId, facts) {
|
|
|
23
23
|
void (async () => {
|
|
24
24
|
try {
|
|
25
25
|
const graph = await buildOrUpdateGraph(cwd, [filePath], facts);
|
|
26
|
-
const impact = computeImpactCascade(graph, filePath);
|
|
26
|
+
const impact = computeImpactCascade(graph, filePath, cwd);
|
|
27
27
|
logTreeSitter({
|
|
28
28
|
phase: "blast_radius",
|
|
29
29
|
filePath,
|
|
@@ -43,3 +43,41 @@ export function toPositiveFinite(value) {
|
|
|
43
43
|
const num = typeof value === "number" ? value : Number(value);
|
|
44
44
|
return Number.isFinite(num) && num > 0 ? num : 0;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Factory for the lazy-memoized "positive-finite-number env override, else a
|
|
48
|
+
* default" getter pattern repeated across `startup-scan.ts` (#699, #758) and
|
|
49
|
+
* originally documented as house style in `runtime-config.ts` / `slow-fs.ts` /
|
|
50
|
+
* `subagent-mode.ts` (#763).
|
|
51
|
+
*
|
|
52
|
+
* `get()` reads `process.env[envName]` through {@link toPositiveFinite} the
|
|
53
|
+
* first time it's called, memoizes the result (or `fallback` when the env var
|
|
54
|
+
* is unset/non-finite/non-positive), and returns the memo on every subsequent
|
|
55
|
+
* call. `_resetForTests()` clears the memo so a test can flip the env var and
|
|
56
|
+
* observe a fresh read (matching the existing `_resetForTests` convention).
|
|
57
|
+
*
|
|
58
|
+
* Deliberately does NOT touch `process.env` until `get()` is first called —
|
|
59
|
+
* that's the entire point of the lazy pattern: importing a module must never
|
|
60
|
+
* have a side effect on process env at load time.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const _ttl = lazyEnvNumber("PI_LENS_STARTUP_SCAN_VERDICT_TTL_MS", DEFAULT_TTL_MS);
|
|
65
|
+
* export const getStartupScanVerdictTtlMs = _ttl.get;
|
|
66
|
+
* export const _resetStartupScanVerdictTtlForTests = _ttl._resetForTests;
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function lazyEnvNumber(envName, fallback) {
|
|
70
|
+
let memo;
|
|
71
|
+
return {
|
|
72
|
+
get() {
|
|
73
|
+
if (memo !== undefined)
|
|
74
|
+
return memo;
|
|
75
|
+
const envValue = toPositiveFinite(process.env[envName]);
|
|
76
|
+
memo = envValue > 0 ? envValue : fallback;
|
|
77
|
+
return memo;
|
|
78
|
+
},
|
|
79
|
+
_resetForTests() {
|
|
80
|
+
memo = undefined;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -8,7 +8,7 @@ import { minimatch } from "./deps/minimatch.js";
|
|
|
8
8
|
import { collectTrackedFiles, getTrackedFilesSnapshot } from "./git-tracked-ignore.js";
|
|
9
9
|
import { getGlobalIgnorePatterns, getPiLensGlobalConfigPath, } from "./lens-config.js";
|
|
10
10
|
import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
|
|
11
|
-
import { findPiLensProjectConfig, loadPiLensProjectConfig, } from "./project-lens-config.js";
|
|
11
|
+
import { findPiLensConfigInDir, findPiLensProjectConfig, loadPiLensConfigInDir, loadPiLensProjectConfig, } from "./project-lens-config.js";
|
|
12
12
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
13
13
|
/**
|
|
14
14
|
* Return the directory where pi-lens stores project-specific data
|
|
@@ -252,16 +252,34 @@ function ancestorDirsBetween(rootDir, targetDir) {
|
|
|
252
252
|
}
|
|
253
253
|
function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
|
|
254
254
|
const nestedCache = new Map();
|
|
255
|
+
// #783: layer a NESTED `.pi-lens.json`'s `ignore` field the same way a
|
|
256
|
+
// nested `.gitignore` is already layered — each ancestor directory between
|
|
257
|
+
// `resolvedRoot` and the target is checked for its OWN config file (no
|
|
258
|
+
// upward walk; `findPiLensConfigInDir`/`loadPiLensConfigInDir` look only
|
|
259
|
+
// directly in `dir`), and its `ignore` patterns are anchored to `dir` (same
|
|
260
|
+
// anchoring semantics as a `.gitignore` living in that directory) and
|
|
261
|
+
// tagged `"pilens"` so #703's tracked-file-rescue rule still skips them.
|
|
262
|
+
// `loadPiLensConfigInDir` reuses `project-lens-config.ts`'s own
|
|
263
|
+
// path+mtime cache, so this never re-reads/re-parses JSON that some other
|
|
264
|
+
// caller (or the root-config lookup above) already loaded.
|
|
255
265
|
const patternsForDir = (dir) => {
|
|
256
266
|
if (dir === resolvedRoot)
|
|
257
267
|
return patterns;
|
|
258
268
|
const gitignoreMtime = gitignoreMtimeMs(dir);
|
|
269
|
+
const pilensMtime = findPiLensConfigInDir(dir)?.mtimeMs ?? -1;
|
|
259
270
|
const cached = nestedCache.get(dir);
|
|
260
|
-
if (cached?.gitignoreMtimeMs === gitignoreMtime
|
|
271
|
+
if (cached?.gitignoreMtimeMs === gitignoreMtime &&
|
|
272
|
+
cached?.pilensMtimeMs === pilensMtime) {
|
|
261
273
|
return cached.patterns;
|
|
262
|
-
|
|
274
|
+
}
|
|
275
|
+
const nestedConfig = loadPiLensConfigInDir(dir);
|
|
276
|
+
const nextPatterns = [
|
|
277
|
+
...readGitignorePatterns(dir),
|
|
278
|
+
...parseGitignoreContent(nestedConfig.ignore.join("\n"), "pilens"),
|
|
279
|
+
];
|
|
263
280
|
nestedCache.set(dir, {
|
|
264
281
|
gitignoreMtimeMs: gitignoreMtime,
|
|
282
|
+
pilensMtimeMs: pilensMtime,
|
|
265
283
|
patterns: nextPatterns,
|
|
266
284
|
});
|
|
267
285
|
return nextPatterns;
|
|
@@ -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
|
|
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
|
|
100
|
+
// Best-effort observability substrate — a failed mkdir just means this
|
|
102
101
|
// update is lost, never a thrown error for the caller.
|
|
103
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|