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.
Files changed (147) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +31 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +53 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/code-quality-warnings.js +27 -0
  9. package/dist/clients/dead-code-client.js +5 -23
  10. package/dist/clients/diagnostic-dispositions.js +298 -0
  11. package/dist/clients/diagnostics-publish.js +38 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
  13. package/dist/clients/dispatch/dispatcher.js +6 -1
  14. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  15. package/dist/clients/dispatch/integration.js +19 -4
  16. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  17. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  18. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  19. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  20. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  21. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  22. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  23. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  24. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  25. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  26. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  27. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  28. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  29. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  31. package/dist/clients/dispatch/runners/lsp.js +23 -34
  32. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  33. package/dist/clients/dispatch/suppress-writer.js +68 -0
  34. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  35. package/dist/clients/disposition-logger.js +39 -0
  36. package/dist/clients/disposition-publish.js +109 -0
  37. package/dist/clients/file-utils.js +94 -33
  38. package/dist/clients/format-events-publish.js +299 -0
  39. package/dist/clients/git-tracked-ignore.js +208 -0
  40. package/dist/clients/gitleaks-client.js +40 -5
  41. package/dist/clients/installer/index.js +123 -6
  42. package/dist/clients/instance-reaper.js +229 -2
  43. package/dist/clients/instance-registry.js +141 -2
  44. package/dist/clients/json-cache-read.js +65 -0
  45. package/dist/clients/knip-client.js +35 -38
  46. package/dist/clients/language-profile.js +11 -11
  47. package/dist/clients/lens-engine.js +12 -0
  48. package/dist/clients/lens-map.js +1079 -0
  49. package/dist/clients/log-cleanup.js +61 -19
  50. package/dist/clients/lsp/aggregation.js +69 -2
  51. package/dist/clients/lsp/cascade-tier.js +31 -19
  52. package/dist/clients/lsp/client.js +124 -14
  53. package/dist/clients/lsp/config.js +27 -1
  54. package/dist/clients/lsp/index.js +1028 -68
  55. package/dist/clients/lsp/server-strategies.js +17 -8
  56. package/dist/clients/lsp/server.js +80 -28
  57. package/dist/clients/lsp/tsserver-sync.js +145 -0
  58. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  59. package/dist/clients/lsp-budget.js +148 -0
  60. package/dist/clients/mcp/session.js +9 -0
  61. package/dist/clients/middle-man-analysis.js +260 -0
  62. package/dist/clients/module-report.js +173 -11
  63. package/dist/clients/ndjson-logger.js +24 -0
  64. package/dist/clients/opengrep-client.js +217 -0
  65. package/dist/clients/opengrep-config.js +2 -10
  66. package/dist/clients/path-utils.js +66 -0
  67. package/dist/clients/pipeline.js +5 -1
  68. package/dist/clients/project-diagnostics/cache.js +5 -12
  69. package/dist/clients/project-diagnostics/extractors.js +21 -1
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  71. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  72. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  73. package/dist/clients/project-snapshot.js +3 -6
  74. package/dist/clients/quiet-window.js +57 -2
  75. package/dist/clients/read-expansion.js +2 -3
  76. package/dist/clients/read-guard-tool-lines.js +10 -0
  77. package/dist/clients/read-guard.js +1 -1
  78. package/dist/clients/resource-sampler.js +254 -0
  79. package/dist/clients/review-graph/builder.js +322 -42
  80. package/dist/clients/review-graph/import-resolvers.js +53 -19
  81. package/dist/clients/review-graph/symbol-id.js +57 -0
  82. package/dist/clients/runtime-agent-end.js +26 -0
  83. package/dist/clients/runtime-coordinator.js +9 -1
  84. package/dist/clients/runtime-session.js +93 -17
  85. package/dist/clients/runtime-tool-call.js +789 -0
  86. package/dist/clients/runtime-tool-result.js +15 -2
  87. package/dist/clients/runtime-turn.js +62 -13
  88. package/dist/clients/safe-spawn.js +48 -2
  89. package/dist/clients/session-state-store.js +6 -9
  90. package/dist/clients/sgconfig.js +2 -9
  91. package/dist/clients/source-filter.js +17 -25
  92. package/dist/clients/source-walker.js +66 -0
  93. package/dist/clients/startup-scan.js +105 -43
  94. package/dist/clients/symbol-containment.js +24 -0
  95. package/dist/clients/test-runner-client.js +544 -29
  96. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  97. package/dist/clients/typos-config.js +2 -11
  98. package/dist/clients/widget-state.js +83 -1
  99. package/dist/clients/write-ordering-guard.js +58 -0
  100. package/dist/clients/zizmor-config.js +29 -10
  101. package/dist/index.js +25511 -19414
  102. package/dist/mcp/server.js +95 -19
  103. package/dist/tools/activate-tools.js +72 -0
  104. package/dist/tools/ast-dump.js +2 -8
  105. package/dist/tools/lens-diagnostic-mark.js +158 -0
  106. package/dist/tools/lens-diagnostics.js +327 -33
  107. package/dist/tools/lsp-diagnostics.js +525 -105
  108. package/dist/tools/module-report.js +18 -5
  109. package/docs/agent-tools.md +136 -0
  110. package/docs/ast-grep_rules_catalog.md +564 -0
  111. package/docs/astplayground.md +178 -0
  112. package/docs/audit1.md +177 -0
  113. package/docs/custom-rules.md +231 -0
  114. package/docs/dependencies.md +54 -0
  115. package/docs/dispositions.md +85 -0
  116. package/docs/env_variables.md +10 -0
  117. package/docs/environment-variables.md +83 -0
  118. package/docs/fable.md +140 -0
  119. package/docs/features.md +338 -0
  120. package/docs/globalconfig.md +79 -0
  121. package/docs/language-coverage.md +48 -0
  122. package/docs/lsp-capability-matrix.md +157 -0
  123. package/docs/lsp-latency-benchmark.md +169 -0
  124. package/docs/mcp.md +438 -0
  125. package/docs/module-report-read-symbol.md +258 -0
  126. package/docs/servercapabilities.md +118 -0
  127. package/docs/subagent-compat.md +281 -0
  128. package/docs/tools.md +16 -0
  129. package/docs/tools_improvement2.md +345 -0
  130. package/docs/tree-sitter_rules_catalog.md +296 -0
  131. package/docs/usage.md +129 -0
  132. package/docs/word-index.md +150 -0
  133. package/package.json +10 -1
  134. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  135. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  136. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  137. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  138. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  139. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  140. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  141. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  144. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  145. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  146. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  147. 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, findAuxiliaryProfileForSource, } from "../auxiliary-lsp.js";
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
- // blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
211
- const blockingAllowedByProfile = new Map();
212
- // Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
213
- // `# nosemgrep`, #441). Reuses `content` from the sync read above.
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: true,
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
- const source = options.source ?? d.source ?? tool;
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 → boolean memo. The matcher itself is cached by
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
- const isIgnoredMemo = new Map();
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
- const cached = isIgnoredMemo.get(memoKey);
285
- if (cached !== undefined)
286
- return cached;
287
- const rootRelative = path.relative(resolvedRoot, resolved);
288
- if (!rootRelative ||
289
- rootRelative.startsWith("..") ||
290
- path.isAbsolute(rootRelative)) {
291
- isIgnoredMemo.set(memoKey, false);
292
- return false;
293
- }
294
- let ignored = false;
295
- const patternDirs = ancestorDirsBetween(resolvedRoot, path.dirname(resolved));
296
- for (const dir of patternDirs) {
297
- const dirPatterns = patternsForDir(dir);
298
- if (dirPatterns.length === 0)
299
- continue;
300
- const relative = path.relative(dir, resolved);
301
- const normalized = normalizeIgnorePath(relative);
302
- for (const pattern of dirPatterns) {
303
- if (!matchesGitignorePattern(pattern, normalized, isDirectory))
304
- continue;
305
- ignored = !pattern.negated;
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
- isIgnoredMemo.set(memoKey, ignored);
309
- return ignored;
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
  }