pi-lens 3.8.69 → 3.8.71
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 +99 -0
- package/README.md +31 -13
- package/dist/clients/bootstrap.js +3 -1
- package/dist/clients/bus-events-logger.js +53 -0
- package/dist/clients/bus-publish.js +45 -2
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/code-quality-warnings.js +27 -0
- package/dist/clients/dead-code-client.js +5 -23
- package/dist/clients/diagnostic-dispositions.js +298 -0
- package/dist/clients/diagnostics-publish.js +38 -2
- package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/facts/function-facts.js +83 -0
- package/dist/clients/dispatch/integration.js +19 -4
- package/dist/clients/dispatch/rules/async-noise.js +1 -1
- package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
- package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
- package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
- package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
- package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
- package/dist/clients/dispatch/rules/high-complexity.js +12 -1
- package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
- package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
- package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
- package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
- package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
- package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
- package/dist/clients/dispatch/runners/lsp.js +23 -34
- package/dist/clients/dispatch/runners/oxlint.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +68 -0
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
- package/dist/clients/disposition-logger.js +39 -0
- package/dist/clients/disposition-publish.js +109 -0
- package/dist/clients/file-utils.js +94 -33
- package/dist/clients/format-events-publish.js +299 -0
- package/dist/clients/git-tracked-ignore.js +208 -0
- package/dist/clients/gitleaks-client.js +40 -5
- package/dist/clients/installer/index.js +123 -6
- package/dist/clients/instance-reaper.js +229 -2
- package/dist/clients/instance-registry.js +141 -2
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/knip-client.js +35 -38
- package/dist/clients/language-profile.js +11 -11
- package/dist/clients/lens-engine.js +12 -0
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/log-cleanup.js +61 -19
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/cascade-tier.js +31 -19
- package/dist/clients/lsp/client.js +124 -14
- package/dist/clients/lsp/config.js +27 -1
- package/dist/clients/lsp/index.js +1028 -68
- package/dist/clients/lsp/server-strategies.js +17 -8
- package/dist/clients/lsp/server.js +80 -28
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/lsp-budget.js +148 -0
- package/dist/clients/mcp/session.js +9 -0
- package/dist/clients/middle-man-analysis.js +260 -0
- package/dist/clients/module-report.js +173 -11
- package/dist/clients/ndjson-logger.js +24 -0
- package/dist/clients/opengrep-client.js +217 -0
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +66 -0
- package/dist/clients/pipeline.js +5 -1
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-diagnostics/extractors.js +21 -1
- package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
- package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/quiet-window.js +57 -2
- package/dist/clients/read-expansion.js +2 -3
- package/dist/clients/read-guard-tool-lines.js +10 -0
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/resource-sampler.js +254 -0
- package/dist/clients/review-graph/builder.js +322 -42
- package/dist/clients/review-graph/import-resolvers.js +53 -19
- package/dist/clients/review-graph/symbol-id.js +57 -0
- package/dist/clients/runtime-agent-end.js +26 -0
- package/dist/clients/runtime-coordinator.js +9 -1
- package/dist/clients/runtime-session.js +93 -17
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +15 -2
- package/dist/clients/runtime-turn.js +62 -13
- package/dist/clients/safe-spawn.js +48 -2
- package/dist/clients/session-state-store.js +6 -9
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +17 -25
- package/dist/clients/source-walker.js +66 -0
- package/dist/clients/startup-scan.js +105 -43
- package/dist/clients/symbol-containment.js +24 -0
- package/dist/clients/test-runner-client.js +544 -29
- package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/widget-state.js +83 -1
- package/dist/clients/write-ordering-guard.js +58 -0
- package/dist/clients/zizmor-config.js +29 -10
- package/dist/index.js +25511 -19414
- package/dist/mcp/server.js +95 -19
- package/dist/tools/activate-tools.js +72 -0
- package/dist/tools/ast-dump.js +2 -8
- package/dist/tools/lens-diagnostic-mark.js +158 -0
- package/dist/tools/lens-diagnostics.js +327 -33
- package/dist/tools/lsp-diagnostics.js +525 -105
- package/dist/tools/module-report.js +18 -5
- package/docs/agent-tools.md +136 -0
- package/docs/ast-grep_rules_catalog.md +564 -0
- package/docs/astplayground.md +178 -0
- package/docs/audit1.md +177 -0
- package/docs/custom-rules.md +231 -0
- package/docs/dependencies.md +54 -0
- package/docs/dispositions.md +85 -0
- package/docs/env_variables.md +10 -0
- package/docs/environment-variables.md +83 -0
- package/docs/fable.md +140 -0
- package/docs/features.md +338 -0
- package/docs/globalconfig.md +79 -0
- package/docs/language-coverage.md +48 -0
- package/docs/lsp-capability-matrix.md +157 -0
- package/docs/lsp-latency-benchmark.md +169 -0
- package/docs/mcp.md +438 -0
- package/docs/module-report-read-symbol.md +258 -0
- package/docs/servercapabilities.md +118 -0
- package/docs/subagent-compat.md +281 -0
- package/docs/tools.md +16 -0
- package/docs/tools_improvement2.md +345 -0
- package/docs/tree-sitter_rules_catalog.md +296 -0
- package/docs/usage.md +129 -0
- package/docs/word-index.md +150 -0
- package/package.json +10 -1
- package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
- package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
- package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
- package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
- package/skills/pi-lens-ast-grep/SKILL.md +3 -1
- package/dist/clients/production-readiness.js +0 -493
|
@@ -16,7 +16,7 @@ import { RUNTIME_CONFIG } from "../../runtime-config.js";
|
|
|
16
16
|
import { PRIORITY } from "../priorities.js";
|
|
17
17
|
import { resolveRunnerPath } from "../runner-context.js";
|
|
18
18
|
import { convertLspDiagnostics } from "../utils/lsp-diagnostics.js";
|
|
19
|
-
import { enabledAuxiliaryLspServerIds,
|
|
19
|
+
import { enabledAuxiliaryLspServerIds, retagAuxiliaryDiagnostics, } from "../auxiliary-lsp.js";
|
|
20
20
|
import { readFileContent } from "./utils.js";
|
|
21
21
|
const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
|
|
22
22
|
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
|
|
@@ -104,6 +104,11 @@ const lspRunner = {
|
|
|
104
104
|
// spawn that didn't complete in the budget, or LSP unavailable for this
|
|
105
105
|
// file) — distinct from `[]`, which means the server replied with zero.
|
|
106
106
|
let lspClientReady = true;
|
|
107
|
+
// True when touchFile ran but couldn't confirm its result within
|
|
108
|
+
// budget (notify write and/or diagnostics wait timed out on at least
|
|
109
|
+
// one spawned server) — an empty `lspDiags` in that case is NOT a
|
|
110
|
+
// confirmed clean result and must not be reported as one (#570).
|
|
111
|
+
let diagnosticsInconclusive = false;
|
|
107
112
|
let failureReason = "";
|
|
108
113
|
const content = readFileContent(ctx.filePath);
|
|
109
114
|
if (!content) {
|
|
@@ -133,6 +138,7 @@ const lspRunner = {
|
|
|
133
138
|
}
|
|
134
139
|
else {
|
|
135
140
|
lspDiags = touched;
|
|
141
|
+
diagnosticsInconclusive = touched.inconclusive === true;
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
catch (err) {
|
|
@@ -173,6 +179,18 @@ const lspRunner = {
|
|
|
173
179
|
// published late still land in the client cache and surface then.
|
|
174
180
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
175
181
|
}
|
|
182
|
+
if (diagnosticsInconclusive) {
|
|
183
|
+
// The touch ran and a client was ready, but the notify write and/or
|
|
184
|
+
// diagnostics wait hit their deadline before the server confirmed
|
|
185
|
+
// completion — `lspDiags` (even if non-empty) is not a trustworthy
|
|
186
|
+
// merged result. Same treatment as `!lspClientReady`: report
|
|
187
|
+
// "skipped" rather than "succeeded" with a possibly-incomplete
|
|
188
|
+
// diagnostics list, so the coverage notice flags the gap instead of
|
|
189
|
+
// the footer reading this as a confirmed clean/partial result (#570).
|
|
190
|
+
// Diagnostics that do arrive late still land in the client cache and
|
|
191
|
+
// surface on the next edit.
|
|
192
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
193
|
+
}
|
|
176
194
|
if (lspDiags.length === 0) {
|
|
177
195
|
return {
|
|
178
196
|
status: "succeeded",
|
|
@@ -207,39 +225,10 @@ const lspRunner = {
|
|
|
207
225
|
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
|
|
208
226
|
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
|
|
209
227
|
// their tool id + semantic policy — language-server diagnostics keep "lsp".
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
const suppressedIndices = new Set();
|
|
215
|
-
for (let i = 0; i < diagnostics.length; i++) {
|
|
216
|
-
const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
|
|
217
|
-
if (!profile)
|
|
218
|
-
continue;
|
|
219
|
-
if (profile.isSuppressed) {
|
|
220
|
-
if (profile.isSuppressed(validLspDiags[i], content)) {
|
|
221
|
-
suppressedIndices.add(i);
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
let blockingAllowed = blockingAllowedByProfile.get(profile);
|
|
226
|
-
if (blockingAllowed === undefined) {
|
|
227
|
-
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
|
|
228
|
-
blockingAllowedByProfile.set(profile, blockingAllowed);
|
|
229
|
-
}
|
|
230
|
-
const d = diagnostics[i];
|
|
231
|
-
d.tool = profile.tool;
|
|
232
|
-
d.semantic = profile.semantic(validLspDiags[i], { blockingAllowed });
|
|
233
|
-
if (d.semantic !== "blocking" && d.severity === "error") {
|
|
234
|
-
d.severity = "warning";
|
|
235
|
-
}
|
|
236
|
-
const defectClass = profile.defectClass?.(validLspDiags[i]);
|
|
237
|
-
if (defectClass)
|
|
238
|
-
d.defectClass = defectClass;
|
|
239
|
-
}
|
|
240
|
-
const keptDiagnostics = suppressedIndices.size
|
|
241
|
-
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
|
|
242
|
-
: diagnostics;
|
|
228
|
+
// #692: shared with the scan/sweep reconcile paths (`retagAuxiliaryDiagnostics`
|
|
229
|
+
// in `../auxiliary-lsp.js`) so a scan-reconciled aux finding gets identical
|
|
230
|
+
// tool/semantic/defectClass tagging instead of keeping tool "lsp".
|
|
231
|
+
const keptDiagnostics = retagAuxiliaryDiagnostics(diagnostics, validLspDiags, content, { cwd: ctx.cwd, fileRole: ctx.fileRole });
|
|
243
232
|
const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
|
|
244
233
|
const resultSemantic = hasErrors
|
|
245
234
|
? "blocking"
|
|
@@ -44,7 +44,7 @@ const oxlintRunner = {
|
|
|
44
44
|
appliesTo: ["jsts"],
|
|
45
45
|
priority: PRIORITY.LINT_SECONDARY,
|
|
46
46
|
enabledByDefault: true,
|
|
47
|
-
skipTestFiles:
|
|
47
|
+
skipTestFiles: false,
|
|
48
48
|
async run(ctx) {
|
|
49
49
|
const cwd = ctx.cwd || process.cwd();
|
|
50
50
|
const policy = getJstsLintPolicyForCwd(cwd);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the `pi-lens-ignore` suppression comment that inline-suppressions.ts
|
|
3
|
+
* already knows how to read (#690's `suppress` disposition). The reader
|
|
4
|
+
* accepts either `//` or `#` on any line regardless of language, but the
|
|
5
|
+
* WRITER must pick the syntactically valid comment character for the target
|
|
6
|
+
* file — an errant `#` line breaks parsing in a `//`-comment language and
|
|
7
|
+
* vice versa.
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
const HASH_COMMENT_EXTENSIONS = new Set([
|
|
11
|
+
".py",
|
|
12
|
+
".rb",
|
|
13
|
+
".sh",
|
|
14
|
+
".bash",
|
|
15
|
+
".zsh",
|
|
16
|
+
".yml",
|
|
17
|
+
".yaml",
|
|
18
|
+
".toml",
|
|
19
|
+
".r",
|
|
20
|
+
".pl",
|
|
21
|
+
".pm",
|
|
22
|
+
".ex",
|
|
23
|
+
".exs",
|
|
24
|
+
".elixir",
|
|
25
|
+
".dockerfile",
|
|
26
|
+
".gitignore",
|
|
27
|
+
".conf",
|
|
28
|
+
".cfg",
|
|
29
|
+
".ini",
|
|
30
|
+
]);
|
|
31
|
+
function commentPrefixFor(filePath) {
|
|
32
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
33
|
+
return HASH_COMMENT_EXTENSIONS.has(ext) ? "#" : "//";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Insert a `pi-lens-ignore: <rule>` comment on the line immediately above
|
|
37
|
+
* `line` (1-based), matching inline-suppressions.ts's "same line or line
|
|
38
|
+
* above" read semantics. If the line above already carries a pi-lens-ignore
|
|
39
|
+
* comment, the rule is appended to its list instead of adding a duplicate
|
|
40
|
+
* comment line. Returns the updated content; throws if `line` is out of range.
|
|
41
|
+
*/
|
|
42
|
+
export function insertSuppressComment(content, filePath, line, rule) {
|
|
43
|
+
const lines = content.split(/\r?\n/);
|
|
44
|
+
if (line < 1 || line > lines.length) {
|
|
45
|
+
throw new Error(`line ${line} is out of range (file has ${lines.length} lines)`);
|
|
46
|
+
}
|
|
47
|
+
const prefix = commentPrefixFor(filePath);
|
|
48
|
+
const aboveIdx = line - 2; // 0-based index of the line immediately above
|
|
49
|
+
const existingAbove = aboveIdx >= 0 ? lines[aboveIdx] : undefined;
|
|
50
|
+
const suppressRe = /((?:\/\/|#)\s*pi-lens-ignore:\s*)(.+)$/;
|
|
51
|
+
const match = existingAbove !== undefined ? suppressRe.exec(existingAbove) : null;
|
|
52
|
+
if (match && existingAbove !== undefined) {
|
|
53
|
+
const rules = match[2]
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((r) => r.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (!rules.includes(rule))
|
|
58
|
+
rules.push(rule);
|
|
59
|
+
lines[aboveIdx] = existingAbove.slice(0, match.index) + match[1] + rules.join(", ");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
// Match the indentation of the flagged line so the inserted comment lines
|
|
63
|
+
// up rather than sitting at column 0.
|
|
64
|
+
const targetLine = lines[line - 1] ?? "";
|
|
65
|
+
const indent = /^\s*/.exec(targetLine)?.[0] ?? "";
|
|
66
|
+
lines.splice(line - 1, 0, `${indent}${prefix} pi-lens-ignore: ${rule}`);
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
@@ -7,7 +7,9 @@ export function convertLspDiagnostics(diags, filePath, options = {}) {
|
|
|
7
7
|
const severity = severityMap[d.severity] ?? "info";
|
|
8
8
|
const semantic = d.severity === 1 ? "blocking" : (d.severity === 2 ? "warning" : "none");
|
|
9
9
|
const code = String(d.code ?? "unknown");
|
|
10
|
-
|
|
10
|
+
// #692: identity ALWAYS derives from the diagnostic's own source — never
|
|
11
|
+
// from a caller-supplied scan label (see `scanOrigin`'s doc comment above).
|
|
12
|
+
const source = d.source ?? tool;
|
|
11
13
|
const hasSuggestion = options.fixSuggestionByIndex?.has(idx) ?? false;
|
|
12
14
|
return {
|
|
13
15
|
id: `${tool}:${code}:${d.range.start.line}`,
|
|
@@ -23,6 +25,7 @@ export function convertLspDiagnostics(diags, filePath, options = {}) {
|
|
|
23
25
|
autoFixAvailable: false,
|
|
24
26
|
fixKind: hasSuggestion ? "suggestion" : undefined,
|
|
25
27
|
fixSuggestion: options.fixSuggestionByIndex?.get(idx),
|
|
28
|
+
scanOrigin: options.scanOrigin,
|
|
26
29
|
};
|
|
27
30
|
});
|
|
28
31
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON telemetry log for diagnostic disposition marks (#690). Same shape as
|
|
3
|
+
* clients/actionable-warnings-logger.ts: machine-global (getGlobalPiLensDir),
|
|
4
|
+
* size-capped with a single `.log.1` backup, isTestMode no-op.
|
|
5
|
+
*
|
|
6
|
+
* Why a log at all: #181 identified false-positive marks flowing to telemetry
|
|
7
|
+
* as THE highest-value rule-tuning signal — which shipped rules misfire, on
|
|
8
|
+
* what tools, how often — and this is that hookup. The disposition store
|
|
9
|
+
* itself is not enough: it keeps only the LATEST entry per anchor (a re-mark
|
|
10
|
+
* overwrites), and `defer` marks never touch the store at all (in-memory by
|
|
11
|
+
* design), so this log is the only durable trace of both re-mark history and
|
|
12
|
+
* defer activity.
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { isTestMode } from "./env-utils.js";
|
|
16
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
17
|
+
import { createNdjsonLogger } from "./ndjson-logger.js";
|
|
18
|
+
const DISPOSITION_LOG_DIR = getGlobalPiLensDir();
|
|
19
|
+
const DISPOSITION_LOG_FILE = path.join(DISPOSITION_LOG_DIR, "dispositions.log");
|
|
20
|
+
const DISPOSITION_LOG_BACKUP_FILE = path.join(DISPOSITION_LOG_DIR, "dispositions.log.1");
|
|
21
|
+
const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_DISPOSITION_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
|
|
22
|
+
const writer = createNdjsonLogger({
|
|
23
|
+
filePath: DISPOSITION_LOG_FILE,
|
|
24
|
+
maxBytes: MAX_LOG_BYTES,
|
|
25
|
+
backupPath: DISPOSITION_LOG_BACKUP_FILE,
|
|
26
|
+
});
|
|
27
|
+
export function logDispositionEvent(entry) {
|
|
28
|
+
if (isTestMode()) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
writer.log({ ts: new Date().toISOString(), ...entry });
|
|
32
|
+
}
|
|
33
|
+
export function getDispositionLogPath() {
|
|
34
|
+
return DISPOSITION_LOG_FILE;
|
|
35
|
+
}
|
|
36
|
+
/** Resolve once all enqueued disposition writes are on disk. */
|
|
37
|
+
export function flushDispositionLog() {
|
|
38
|
+
return writer.flush();
|
|
39
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishes `pilens:diagnostic:disposition` on pi's shared `pi.events` bus
|
|
3
|
+
* (#690).
|
|
4
|
+
*
|
|
5
|
+
* Sibling to `clients/diagnostics-publish.ts` (#502) rather than a new export
|
|
6
|
+
* inside it, per the "owns nothing in common" rule spelled out in
|
|
7
|
+
* format-events-publish.ts's header: the event is diagnostic-DOMAIN, but it
|
|
8
|
+
* shares none of diagnostics-publish's module state or contract —
|
|
9
|
+
* dispositions are point-in-time FACTS ("a mark just happened"), not
|
|
10
|
+
* full-replace state snapshots, so the seq counter, the
|
|
11
|
+
* previously-reported-dirty set, and the whole staleness/replace consumer
|
|
12
|
+
* contract over there simply don't apply here. Only the emit plumbing shape
|
|
13
|
+
* and the `PI_LENS_BUS_PUBLISH` kill switch (`isBusPublishEnabled`, imported
|
|
14
|
+
* from bus-publish.ts) are shared, same as every other producer sibling.
|
|
15
|
+
*
|
|
16
|
+
* Fire-and-forget: publishing must never affect the mark path's success or
|
|
17
|
+
* latency. Any failure is swallowed; `dbg` fires at most once per process on
|
|
18
|
+
* first failure so a wired caller can log it without spamming.
|
|
19
|
+
*
|
|
20
|
+
* Versioning policy: frozen-additive, same discipline as #482/#502. New
|
|
21
|
+
* optional fields may be added under `v: 1`; a breaking change to an existing
|
|
22
|
+
* field's meaning must bump `v`.
|
|
23
|
+
*/
|
|
24
|
+
import { logBusEvent } from "./bus-events-logger.js";
|
|
25
|
+
import { isBusPublishEnabled } from "./bus-publish.js";
|
|
26
|
+
import { normalizeFilePath } from "./path-utils.js";
|
|
27
|
+
export const BUS_DISPOSITION_EVENT = "pilens:diagnostic:disposition";
|
|
28
|
+
export const BUS_DISPOSITION_VERSION = 1;
|
|
29
|
+
let busEmit;
|
|
30
|
+
let hasLoggedFailure = false;
|
|
31
|
+
let hasLoggedUnwired = false;
|
|
32
|
+
let hasLoggedDisabled = false;
|
|
33
|
+
/**
|
|
34
|
+
* Wire the emit function from pi's `pi.events` bus. Called once at extension
|
|
35
|
+
* factory time from index.ts, same call as `wireDiagnosticsBusEmitter` (#502)
|
|
36
|
+
* — both producers share the identical `pi.events.emit` binding. Never called
|
|
37
|
+
* ⇒ `publishDisposition` no-ops, which is exactly the state unit tests and
|
|
38
|
+
* the MCP server path run in (no pi host, no `pi.events`).
|
|
39
|
+
*/
|
|
40
|
+
export function wireDispositionBusEmitter(emitFn) {
|
|
41
|
+
busEmit = emitFn;
|
|
42
|
+
}
|
|
43
|
+
/** Test-only: reset module state between test files. */
|
|
44
|
+
export function _resetDispositionPublishForTests() {
|
|
45
|
+
busEmit = undefined;
|
|
46
|
+
hasLoggedFailure = false;
|
|
47
|
+
hasLoggedUnwired = false;
|
|
48
|
+
hasLoggedDisabled = false;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Publish one `pilens:diagnostic:disposition` event for a mark (one event per
|
|
52
|
+
* markDisposition call). Fire-and-forget: never throws, never awaited.
|
|
53
|
+
*/
|
|
54
|
+
export function publishDisposition(args) {
|
|
55
|
+
if (!isBusPublishEnabled()) {
|
|
56
|
+
if (!hasLoggedDisabled) {
|
|
57
|
+
hasLoggedDisabled = true;
|
|
58
|
+
logBusEvent({
|
|
59
|
+
event: BUS_DISPOSITION_EVENT,
|
|
60
|
+
outcome: "skipped_disabled",
|
|
61
|
+
cwd: normalizeFilePath(args.cwd),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!busEmit) {
|
|
67
|
+
if (!hasLoggedUnwired) {
|
|
68
|
+
hasLoggedUnwired = true;
|
|
69
|
+
logBusEvent({
|
|
70
|
+
event: BUS_DISPOSITION_EVENT,
|
|
71
|
+
outcome: "skipped_unwired",
|
|
72
|
+
cwd: normalizeFilePath(args.cwd),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const payload = {
|
|
79
|
+
v: BUS_DISPOSITION_VERSION,
|
|
80
|
+
source: "pi-lens",
|
|
81
|
+
cwd: normalizeFilePath(args.cwd),
|
|
82
|
+
filePath: normalizeFilePath(args.filePath),
|
|
83
|
+
disposition: args.disposition,
|
|
84
|
+
tool: args.tool,
|
|
85
|
+
rule: args.rule,
|
|
86
|
+
line: args.line,
|
|
87
|
+
anchor: args.anchor,
|
|
88
|
+
reason: args.reason,
|
|
89
|
+
};
|
|
90
|
+
busEmit(BUS_DISPOSITION_EVENT, payload);
|
|
91
|
+
logBusEvent({
|
|
92
|
+
event: BUS_DISPOSITION_EVENT,
|
|
93
|
+
outcome: "emitted",
|
|
94
|
+
cwd: payload.cwd,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
logBusEvent({
|
|
99
|
+
event: BUS_DISPOSITION_EVENT,
|
|
100
|
+
outcome: "emit_failed",
|
|
101
|
+
cwd: normalizeFilePath(args.cwd),
|
|
102
|
+
error: String(err),
|
|
103
|
+
});
|
|
104
|
+
if (!hasLoggedFailure) {
|
|
105
|
+
hasLoggedFailure = true;
|
|
106
|
+
args.dbg?.(`disposition-publish: pilens:diagnostic:disposition emit failed (further failures suppressed): ${err}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -5,8 +5,9 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { minimatch } from "./deps/minimatch.js";
|
|
8
|
+
import { collectTrackedFiles, getTrackedFilesSnapshot } from "./git-tracked-ignore.js";
|
|
8
9
|
import { getGlobalIgnorePatterns, getPiLensGlobalConfigPath, } from "./lens-config.js";
|
|
9
|
-
import { normalizeFilePath } from "./path-utils.js";
|
|
10
|
+
import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
|
|
10
11
|
import { findPiLensProjectConfig, loadPiLensProjectConfig, } from "./project-lens-config.js";
|
|
11
12
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
12
13
|
/**
|
|
@@ -167,7 +168,7 @@ function stripTrailingSpaces(value) {
|
|
|
167
168
|
end -= 1;
|
|
168
169
|
return value.slice(0, end).replace(/\\ /g, " ");
|
|
169
170
|
}
|
|
170
|
-
function parseGitignoreContent(content) {
|
|
171
|
+
function parseGitignoreContent(content, layer) {
|
|
171
172
|
const patterns = [];
|
|
172
173
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
173
174
|
let line = stripTrailingSpaces(rawLine.trimStart());
|
|
@@ -195,6 +196,7 @@ function parseGitignoreContent(content) {
|
|
|
195
196
|
directoryOnly,
|
|
196
197
|
rooted,
|
|
197
198
|
hasSlash: line.includes("/"),
|
|
199
|
+
layer,
|
|
198
200
|
});
|
|
199
201
|
}
|
|
200
202
|
return patterns;
|
|
@@ -225,10 +227,10 @@ function matchesGitignorePattern(pattern, relativePath, isDirectory) {
|
|
|
225
227
|
return candidates.some((value) => minimatch(value, expanded, options));
|
|
226
228
|
});
|
|
227
229
|
}
|
|
228
|
-
export function readGitignorePatterns(rootDir) {
|
|
230
|
+
export function readGitignorePatterns(rootDir, layer = "gitignore") {
|
|
229
231
|
const gitignorePath = path.join(rootDir, ".gitignore");
|
|
230
232
|
try {
|
|
231
|
-
return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8"));
|
|
233
|
+
return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8"), layer);
|
|
232
234
|
}
|
|
233
235
|
catch {
|
|
234
236
|
return [];
|
|
@@ -264,7 +266,7 @@ function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
|
|
|
264
266
|
});
|
|
265
267
|
return nextPatterns;
|
|
266
268
|
};
|
|
267
|
-
// Per-matcher path →
|
|
269
|
+
// Per-matcher path → pattern-verdict memo. The matcher itself is cached by
|
|
268
270
|
// `getProjectIgnoreMatcher` keyed on `.gitignore` mtime, so this Map's
|
|
269
271
|
// lifetime is bounded to a single set of ignore rules — when any
|
|
270
272
|
// `.gitignore` changes, the matcher is rebuilt and the memo is dropped
|
|
@@ -272,41 +274,97 @@ function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
|
|
|
272
274
|
// jscpd, call-graph, source-filter, pipeline) recomputes O(ancestorDirs ×
|
|
273
275
|
// patterns) per file, multiplying into 2-3s of pure CPU on a 2k-file
|
|
274
276
|
// project. With it, the second visitor of the same path is O(1).
|
|
275
|
-
|
|
277
|
+
//
|
|
278
|
+
// #703: this memoizes only the PATTERN verdict (which is deterministic —
|
|
279
|
+
// it never changes for a given matcher instance), NOT the tracked-aware
|
|
280
|
+
// final verdict. The tracked-files set can transition from "not yet
|
|
281
|
+
// primed" to "primed" mid-process (a walker calls `ensureTrackedIndex()`
|
|
282
|
+
// partway through the matcher's lifetime), so baking the tracked check
|
|
283
|
+
// into this memo would let an early, pre-priming call poison every later
|
|
284
|
+
// lookup of the same path for this matcher's entire cache lifetime. The
|
|
285
|
+
// tracked-set lookup itself is a cheap Set#has over syntactically-folded
|
|
286
|
+
// keys (see `isTrackedAndRescued` — no `realpathSync` anywhere in this
|
|
287
|
+
// function), and — critically — it's only ever paid for paths a pattern
|
|
288
|
+
// already flagged as ignored, so it doesn't reintroduce the per-file cost
|
|
289
|
+
// this memo exists to avoid for the common (not-ignored) case.
|
|
290
|
+
const patternMemo = new Map();
|
|
291
|
+
// #703 perf follow-up: `normalizeEphemeralMapKey` (cheap slash-fold +
|
|
292
|
+
// Windows-lowercase, zero fs I/O), NOT `normalizeMapKey` (realpath-backed).
|
|
293
|
+
// This runs on every `isIgnored` call that reaches this branch — walks
|
|
294
|
+
// over this repo alone visit thousands of pattern-ignored compiled
|
|
295
|
+
// `*.js`/`*.d.ts` files, and `dispatch/integration.ts`'s per-edit cascade
|
|
296
|
+
// check hits it too — so a `realpathSync` here would violate the
|
|
297
|
+
// event-loop/slow-FS discipline `isIgnored` is required to keep (#462: 75x
|
|
298
|
+
// slower on 9p/drvfs). Both `resolved` (this matcher's own `path.resolve`,
|
|
299
|
+
// never realpath'd) and the tracked-set's keys (`git-tracked-ignore.ts`,
|
|
300
|
+
// realpath'd ONCE per fetch on the shared root, not per file) are
|
|
301
|
+
// self-consistent within one process/session, which is exactly
|
|
302
|
+
// `normalizeEphemeralMapKey`'s contract — see that function's doc and
|
|
303
|
+
// `git-tracked-ignore.ts`'s module doc for the full reasoning. Accepted
|
|
304
|
+
// edge case: a symlinked or 8.3-short-name project root can make the cheap
|
|
305
|
+
// fold miss even after the fetch side's one realpath — the file then stays
|
|
306
|
+
// pattern-ignored, i.e. degrades to today's (pre-#703) behavior, which is
|
|
307
|
+
// consistent with this whole feature's fail-open contract.
|
|
308
|
+
function isTrackedAndRescued(resolved) {
|
|
309
|
+
const snapshot = getTrackedFilesSnapshot(resolvedRoot);
|
|
310
|
+
if (!snapshot)
|
|
311
|
+
return false; // never primed / git unavailable: fail-open to pattern-only
|
|
312
|
+
return snapshot.has(normalizeEphemeralMapKey(resolved));
|
|
313
|
+
}
|
|
276
314
|
return {
|
|
277
315
|
rootDir: resolvedRoot,
|
|
278
316
|
patterns,
|
|
317
|
+
ensureTrackedIndex() {
|
|
318
|
+
return collectTrackedFiles(resolvedRoot).then(() => undefined);
|
|
319
|
+
},
|
|
279
320
|
isIgnored(filePath, isDirectory = false) {
|
|
280
321
|
const resolved = path.resolve(filePath);
|
|
281
322
|
// Two namespaces (D: for directory queries, F: for file queries)
|
|
282
323
|
// because gitignore semantics differ for trailing-slash patterns.
|
|
283
324
|
const memoKey = (isDirectory ? "D:" : "F:") + resolved;
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
325
|
+
let verdict = patternMemo.get(memoKey);
|
|
326
|
+
if (verdict === undefined) {
|
|
327
|
+
const rootRelative = path.relative(resolvedRoot, resolved);
|
|
328
|
+
if (!rootRelative ||
|
|
329
|
+
rootRelative.startsWith("..") ||
|
|
330
|
+
path.isAbsolute(rootRelative)) {
|
|
331
|
+
verdict = { ignored: false, layer: undefined };
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
let ignored = false;
|
|
335
|
+
let layer;
|
|
336
|
+
const patternDirs = ancestorDirsBetween(resolvedRoot, path.dirname(resolved));
|
|
337
|
+
for (const dir of patternDirs) {
|
|
338
|
+
const dirPatterns = patternsForDir(dir);
|
|
339
|
+
if (dirPatterns.length === 0)
|
|
340
|
+
continue;
|
|
341
|
+
const relative = path.relative(dir, resolved);
|
|
342
|
+
const normalized = normalizeIgnorePath(relative);
|
|
343
|
+
for (const pattern of dirPatterns) {
|
|
344
|
+
if (!matchesGitignorePattern(pattern, normalized, isDirectory))
|
|
345
|
+
continue;
|
|
346
|
+
ignored = !pattern.negated;
|
|
347
|
+
layer = pattern.layer;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
verdict = { ignored, layer };
|
|
306
351
|
}
|
|
352
|
+
patternMemo.set(memoKey, verdict);
|
|
307
353
|
}
|
|
308
|
-
|
|
309
|
-
|
|
354
|
+
if (!verdict.ignored)
|
|
355
|
+
return false;
|
|
356
|
+
// #703 layer semantics: a winning positive match from `global` or
|
|
357
|
+
// `gitignore` emulates git, so it inherits git's "a tracked file is
|
|
358
|
+
// never ignored" rule. A winning match from `pilens` is pi-lens-native
|
|
359
|
+
// intent and stays excluded regardless of tracked status. Directory
|
|
360
|
+
// queries are never tracked-rescued — the tracked set is a file-id
|
|
361
|
+
// set, not a directory set.
|
|
362
|
+
if (!isDirectory &&
|
|
363
|
+
verdict.layer !== "pilens" &&
|
|
364
|
+
isTrackedAndRescued(resolved)) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
310
368
|
},
|
|
311
369
|
};
|
|
312
370
|
}
|
|
@@ -315,10 +373,13 @@ export function createProjectIgnoreMatcher(rootDir, extraPatterns = [], globalPa
|
|
|
315
373
|
// Precedence is gitignore order: LATER patterns override earlier ones. So
|
|
316
374
|
// global (lowest) → project .gitignore → project .pi-lens.json (highest),
|
|
317
375
|
// which lets a project `!negation` re-include a globally-ignored path (#252).
|
|
376
|
+
// Each layer is tagged (#703) so `isIgnored` can tell a git-emulating match
|
|
377
|
+
// (`global`/`gitignore` — subject to "a tracked file is never ignored")
|
|
378
|
+
// apart from pi-lens-native intent (`pilens` — excludes regardless).
|
|
318
379
|
const patterns = [
|
|
319
|
-
...parseGitignoreContent(globalPatterns.join("\n")),
|
|
320
|
-
...readGitignorePatterns(resolvedRoot),
|
|
321
|
-
...parseGitignoreContent(extraPatterns.join("\n")),
|
|
380
|
+
...parseGitignoreContent(globalPatterns.join("\n"), "global"),
|
|
381
|
+
...readGitignorePatterns(resolvedRoot, "gitignore"),
|
|
382
|
+
...parseGitignoreContent(extraPatterns.join("\n"), "pilens"),
|
|
322
383
|
];
|
|
323
384
|
return buildProjectIgnoreMatcher(resolvedRoot, patterns);
|
|
324
385
|
}
|