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
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Publishes `pilens:format:queued`, `pilens:format:start`, and
3
+ * `pilens:autofix:start` on pi's shared `pi.events` bus (#673, #684).
4
+ *
5
+ * Sibling to `clients/bus-publish.ts` (#482 `pilens:files:touched`) and
6
+ * `clients/diagnostics-publish.ts` (#502 `pilens:diagnostics`) rather than a
7
+ * new export crammed into either file: same emit plumbing shape and the same
8
+ * `PI_LENS_BUS_PUBLISH` kill switch (`isBusPublishEnabled`, imported from
9
+ * bus-publish.ts), but this producer owns its own emit-function singleton
10
+ * (wired separately from index.ts, same as diagnostics-publish.ts does) since
11
+ * it has nothing else in common with either sibling's module state.
12
+ *
13
+ * ## Motivation (#673)
14
+ *
15
+ * `pilens:files:touched` only fires AFTER deferred formatting has completed —
16
+ * it tells a same-process listener what pi-lens just mutated, in the past
17
+ * tense. #673 reported a concrete failure mode this leaves uncovered: another
18
+ * in-process extension (a review/snapshot controller) derives an immutable
19
+ * candidate tree from the live worktree mid-turn, unaware that pi-lens has a
20
+ * file queued for deferred formatting that will still land — silently
21
+ * invalidating the snapshot it just took. There was no bus signal for either
22
+ * of the two moments that matter to that use case:
23
+ *
24
+ * 1. A file NEWLY entering the deferred-format pending queue
25
+ * (`pilens:format:queued` — one event per file, only on first queue
26
+ * entry; a file re-touched by a second edit before `agent_end` does NOT
27
+ * re-emit, to avoid event spam for repeated edits to the same file).
28
+ * 2. The deferred-format phase actually starting at `agent_end`
29
+ * (`pilens:format:start` — one event per batch, only when there is at
30
+ * least one queued file to format).
31
+ *
32
+ * This is deliberately visibility-only: it lets a listener know pi-lens MIGHT
33
+ * or IS ABOUT TO mutate specific files via deferred formatting, so it can
34
+ * choose to wait, re-derive, or flag its own snapshot as provisional. It is
35
+ * NOT a synchronous flush/barrier API (a caller cannot block deferred
36
+ * formatting via these events) — that remains a separate, explicitly
37
+ * out-of-scope future feature. Completion is already covered by the existing
38
+ * `pilens:files:touched` (`reason: "format"`) event; these two do not
39
+ * duplicate it.
40
+ *
41
+ * ## `pilens:autofix:start` (#684)
42
+ *
43
+ * Follow-up to #673/#674, applying the identical fix to a sibling race: the
44
+ * `agent_end` actionable-warnings autofix batch (`clients/runtime-agent-end.ts`,
45
+ * gated by `getFlag("lens-actionable-warning-autofix")`) also reads a cached
46
+ * report and applies fixes as a batch well after the edit that produced the
47
+ * warnings — same "runs later, unpredictably" shape as deferred formatting.
48
+ * It only emitted `pilens:files:touched` (`reason: "autofix"`) AFTER the fact.
49
+ * `pilens:autofix:start` fires once per batch, only when the cached
50
+ * actionable-warnings report is fresh (`checkActionableWarningsReportFresh`)
51
+ * AND has at least one autofix-eligible warning to act on — mirroring
52
+ * `pilens:format:start`'s "only when there's genuine work" gate. Note: this is
53
+ * distinct from `pipeline.ts`'s per-edit `runAutofix` (biome/ruff/eslint fixes
54
+ * applied synchronously inside the `tool_result` hook, awaited before the
55
+ * tool result returns) — that path isn't deferred and doesn't have this race,
56
+ * so it doesn't get a `start` event.
57
+ *
58
+ * Versioning policy: frozen-additive per event, same discipline as #482/#502.
59
+ * New optional fields may be added under the same `v: 1` for any of the three
60
+ * events; a breaking change to an existing field's meaning must bump that
61
+ * event's `v` independently (each event versions separately since they're
62
+ * unrelated payloads).
63
+ *
64
+ * Fire-and-forget: publishing must never affect the write path's or
65
+ * `agent_end`'s success or latency. Any failure (bus unavailable, emit
66
+ * throws) is swallowed; an optional `dbg` callback is invoked at most once
67
+ * per event type on first failure so a wired caller can log it without
68
+ * spamming.
69
+ */
70
+ import { logBusEvent } from "./bus-events-logger.js";
71
+ import { isBusPublishEnabled } from "./bus-publish.js";
72
+ import { normalizeFilePath } from "./path-utils.js";
73
+ export const BUS_FORMAT_QUEUED_EVENT = "pilens:format:queued";
74
+ export const BUS_FORMAT_QUEUED_VERSION = 1;
75
+ export const BUS_FORMAT_START_EVENT = "pilens:format:start";
76
+ export const BUS_FORMAT_START_VERSION = 1;
77
+ export const BUS_AUTOFIX_START_EVENT = "pilens:autofix:start";
78
+ export const BUS_AUTOFIX_START_VERSION = 1;
79
+ let busEmit;
80
+ let hasLoggedQueuedFailure = false;
81
+ let hasLoggedQueuedUnwired = false;
82
+ let hasLoggedQueuedDisabled = false;
83
+ let hasLoggedStartFailure = false;
84
+ let hasLoggedStartUnwired = false;
85
+ let hasLoggedStartDisabled = false;
86
+ let hasLoggedAutofixStartFailure = false;
87
+ let hasLoggedAutofixStartUnwired = false;
88
+ let hasLoggedAutofixStartDisabled = false;
89
+ /**
90
+ * Wire the emit function from pi's `pi.events` bus. Called once at extension
91
+ * factory time from index.ts, alongside `wireBusEmitter` (#482) and
92
+ * `wireDiagnosticsBusEmitter` (#502) — all three producers share the
93
+ * identical `pi.events.emit` binding, wired separately per producer.
94
+ */
95
+ export function wireFormatEventsBusEmitter(emitFn) {
96
+ busEmit = emitFn;
97
+ }
98
+ /** Test-only: reset module state between test files. */
99
+ export function _resetFormatEventsPublishForTests() {
100
+ busEmit = undefined;
101
+ hasLoggedQueuedFailure = false;
102
+ hasLoggedQueuedUnwired = false;
103
+ hasLoggedQueuedDisabled = false;
104
+ hasLoggedStartFailure = false;
105
+ hasLoggedStartUnwired = false;
106
+ hasLoggedStartDisabled = false;
107
+ hasLoggedAutofixStartFailure = false;
108
+ hasLoggedAutofixStartUnwired = false;
109
+ hasLoggedAutofixStartDisabled = false;
110
+ }
111
+ /**
112
+ * Publish one `pilens:format:queued` event for a file newly entering the
113
+ * deferred-format pending queue. Callers MUST only invoke this on the
114
+ * NEW-entry branch of `RuntimeCoordinator.deferFormat` (not on a re-touch of
115
+ * an already-queued file) — see `clients/runtime-tool-result.ts`'s call site.
116
+ * Fire-and-forget: never throws, never awaited by the write path.
117
+ */
118
+ export function publishFormatQueued(args) {
119
+ if (!isBusPublishEnabled()) {
120
+ if (!hasLoggedQueuedDisabled) {
121
+ hasLoggedQueuedDisabled = true;
122
+ logBusEvent({
123
+ event: BUS_FORMAT_QUEUED_EVENT,
124
+ outcome: "skipped_disabled",
125
+ cwd: normalizeFilePath(args.cwd),
126
+ });
127
+ }
128
+ return;
129
+ }
130
+ if (!busEmit) {
131
+ if (!hasLoggedQueuedUnwired) {
132
+ hasLoggedQueuedUnwired = true;
133
+ logBusEvent({
134
+ event: BUS_FORMAT_QUEUED_EVENT,
135
+ outcome: "skipped_unwired",
136
+ cwd: normalizeFilePath(args.cwd),
137
+ });
138
+ }
139
+ return;
140
+ }
141
+ try {
142
+ const payload = {
143
+ v: BUS_FORMAT_QUEUED_VERSION,
144
+ source: "pi-lens",
145
+ filePath: normalizeFilePath(args.filePath),
146
+ cwd: normalizeFilePath(args.cwd),
147
+ tool: args.tool,
148
+ };
149
+ busEmit(BUS_FORMAT_QUEUED_EVENT, payload);
150
+ logBusEvent({
151
+ event: BUS_FORMAT_QUEUED_EVENT,
152
+ outcome: "emitted",
153
+ cwd: payload.cwd,
154
+ fileCount: 1,
155
+ });
156
+ }
157
+ catch (err) {
158
+ logBusEvent({
159
+ event: BUS_FORMAT_QUEUED_EVENT,
160
+ outcome: "emit_failed",
161
+ cwd: normalizeFilePath(args.cwd),
162
+ error: String(err),
163
+ });
164
+ if (!hasLoggedQueuedFailure) {
165
+ hasLoggedQueuedFailure = true;
166
+ args.dbg?.(`format-events-publish: pilens:format:queued emit failed (further failures suppressed): ${err}`);
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Publish one `pilens:format:start` event when the `agent_end` deferred-
172
+ * format phase begins. Callers MUST only invoke this when `paths.length > 0`
173
+ * — see `clients/runtime-agent-end.ts`'s call site, placed at the same point
174
+ * as the existing `agent_end_deferred_format_start` latency-log phase so both
175
+ * signals represent the identical moment. Fire-and-forget: never throws,
176
+ * never awaited by `agent_end`.
177
+ */
178
+ export function publishFormatStart(args) {
179
+ if (args.paths.length === 0)
180
+ return;
181
+ if (!isBusPublishEnabled()) {
182
+ if (!hasLoggedStartDisabled) {
183
+ hasLoggedStartDisabled = true;
184
+ logBusEvent({
185
+ event: BUS_FORMAT_START_EVENT,
186
+ outcome: "skipped_disabled",
187
+ cwd: normalizeFilePath(args.cwd),
188
+ });
189
+ }
190
+ return;
191
+ }
192
+ if (!busEmit) {
193
+ if (!hasLoggedStartUnwired) {
194
+ hasLoggedStartUnwired = true;
195
+ logBusEvent({
196
+ event: BUS_FORMAT_START_EVENT,
197
+ outcome: "skipped_unwired",
198
+ cwd: normalizeFilePath(args.cwd),
199
+ });
200
+ }
201
+ return;
202
+ }
203
+ try {
204
+ const paths = args.paths.map((p) => normalizeFilePath(p));
205
+ const payload = {
206
+ v: BUS_FORMAT_START_VERSION,
207
+ source: "pi-lens",
208
+ cwd: normalizeFilePath(args.cwd),
209
+ paths,
210
+ fileCount: paths.length,
211
+ };
212
+ busEmit(BUS_FORMAT_START_EVENT, payload);
213
+ logBusEvent({
214
+ event: BUS_FORMAT_START_EVENT,
215
+ outcome: "emitted",
216
+ cwd: payload.cwd,
217
+ fileCount: payload.fileCount,
218
+ });
219
+ }
220
+ catch (err) {
221
+ logBusEvent({
222
+ event: BUS_FORMAT_START_EVENT,
223
+ outcome: "emit_failed",
224
+ cwd: normalizeFilePath(args.cwd),
225
+ error: String(err),
226
+ });
227
+ if (!hasLoggedStartFailure) {
228
+ hasLoggedStartFailure = true;
229
+ args.dbg?.(`format-events-publish: pilens:format:start emit failed (further failures suppressed): ${err}`);
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Publish one `pilens:autofix:start` event when the `agent_end`
235
+ * actionable-warnings autofix batch begins (#684). Callers MUST only invoke
236
+ * this when `paths.length > 0` — i.e. after the cached report has been
237
+ * confirmed fresh (`checkActionableWarningsReportFresh`) AND has at least one
238
+ * autofix-eligible warning to act on, the same "genuine work" gate
239
+ * `publishFormatStart` applies for deferred formatting — see
240
+ * `clients/runtime-agent-end.ts`'s call site, placed immediately before
241
+ * `applyConservativeActionableWarningFixes` is invoked. Fire-and-forget:
242
+ * never throws, never awaited by `agent_end`.
243
+ */
244
+ export function publishAutofixStart(args) {
245
+ if (args.paths.length === 0)
246
+ return;
247
+ if (!isBusPublishEnabled()) {
248
+ if (!hasLoggedAutofixStartDisabled) {
249
+ hasLoggedAutofixStartDisabled = true;
250
+ logBusEvent({
251
+ event: BUS_AUTOFIX_START_EVENT,
252
+ outcome: "skipped_disabled",
253
+ cwd: normalizeFilePath(args.cwd),
254
+ });
255
+ }
256
+ return;
257
+ }
258
+ if (!busEmit) {
259
+ if (!hasLoggedAutofixStartUnwired) {
260
+ hasLoggedAutofixStartUnwired = true;
261
+ logBusEvent({
262
+ event: BUS_AUTOFIX_START_EVENT,
263
+ outcome: "skipped_unwired",
264
+ cwd: normalizeFilePath(args.cwd),
265
+ });
266
+ }
267
+ return;
268
+ }
269
+ try {
270
+ const paths = args.paths.map((p) => normalizeFilePath(p));
271
+ const payload = {
272
+ v: BUS_AUTOFIX_START_VERSION,
273
+ source: "pi-lens",
274
+ cwd: normalizeFilePath(args.cwd),
275
+ paths,
276
+ fileCount: paths.length,
277
+ eligibleCount: args.eligibleCount,
278
+ };
279
+ busEmit(BUS_AUTOFIX_START_EVENT, payload);
280
+ logBusEvent({
281
+ event: BUS_AUTOFIX_START_EVENT,
282
+ outcome: "emitted",
283
+ cwd: payload.cwd,
284
+ fileCount: payload.fileCount,
285
+ });
286
+ }
287
+ catch (err) {
288
+ logBusEvent({
289
+ event: BUS_AUTOFIX_START_EVENT,
290
+ outcome: "emit_failed",
291
+ cwd: normalizeFilePath(args.cwd),
292
+ error: String(err),
293
+ });
294
+ if (!hasLoggedAutofixStartFailure) {
295
+ hasLoggedAutofixStartFailure = true;
296
+ args.dbg?.(`format-events-publish: pilens:autofix:start emit failed (further failures suppressed): ${err}`);
297
+ }
298
+ }
299
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Shared "untracked AND ignored" file-id computation (#679's `/lens-map`,
3
+ * reused by #694's review-graph ignore-gated node creation) — and, since
4
+ * #703, the sibling "tracked files" set that lets `getProjectIgnoreMatcher`
5
+ * (`file-utils.ts`) honor the OTHER half of the same git semantic.
6
+ *
7
+ * THE critical git semantic this exists to respect: a TRACKED file is never
8
+ * ignored, even when a `.gitignore` pattern matches it (pi-lens's own
9
+ * committed `clients/deps/*.js` vendored sources match the repo's `*.js`
10
+ * ignore pattern and MUST stay graph/map nodes) — which is why this asks git
11
+ * itself (`ls-files --others --ignored --exclude-standard` for the untracked
12
+ * side, `ls-files` alone for the tracked side) instead of running a pattern
13
+ * matcher over `.gitignore` (a matcher-only approach would wrongly drop
14
+ * tracked vendored files, e.g. #703's `clients/test-runner-client.ts`
15
+ * matching `.gitignore`'s `test-*.ts`).
16
+ *
17
+ * Degradation: when git is absent/fails/times out (not a git repo, bare
18
+ * checkout, etc.) both `collectUntrackedIgnoredIds` and `collectTrackedFiles`
19
+ * return `undefined` and every caller SKIPS/degrades — the graph/map shows
20
+ * what's known rather than guessing via a matcher that can't see tracked
21
+ * status, and `getProjectIgnoreMatcher` falls back to pattern-only behavior.
22
+ *
23
+ * Keying asymmetry (deliberate, not an oversight): `collectUntrackedIgnoredIds`
24
+ * keys its ids with `normalizeMapKey` (realpath-backed) because those ids are
25
+ * compared against review-graph/map NODE ids — externally-produced state that
26
+ * genuinely needs canonical on-disk casing/symlink resolution to match up.
27
+ * `collectTrackedFiles`'s ids, by contrast, are only ever compared against a
28
+ * `ProjectIgnoreMatcher`'s own `path.resolve`'d paths, produced by THIS
29
+ * process in the SAME walk — an ephemeral, self-consistent comparison per
30
+ * `normalizeEphemeralMapKey`'s contract (`path-utils.ts`), so it uses the
31
+ * cheap syntactic-only fold instead. Do not "fix" this asymmetry by unifying
32
+ * the two — they solve different problems (#703 review).
33
+ */
34
+ import * as path from "node:path";
35
+ import { isExcludedDirName } from "./file-utils.js";
36
+ import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
37
+ import { safeSpawnAsync } from "./safe-spawn.js";
38
+ /**
39
+ * Parses `git ls-files --others --ignored --exclude-standard` output
40
+ * (repo-relative paths, one per line) into normalized map-key file ids.
41
+ * Exported as a pure function so the parse/normalize step is unit-testable
42
+ * without mocking the spawn.
43
+ */
44
+ export function parseUntrackedIgnoredOutput(stdout, cwd) {
45
+ const ids = new Set();
46
+ for (const line of stdout.split(/\r?\n/)) {
47
+ const rel = line.trim();
48
+ if (!rel)
49
+ continue;
50
+ // Paths inside shared-excluded dirs (node_modules, dist, .git, …) can
51
+ // never be review-graph/map nodes — the graph walk itself routes
52
+ // exclusion through `isExcludedDirName` — so skip them BEFORE paying for
53
+ // `normalizeMapKey` (realpath-backed, per-call filesystem cost): on
54
+ // pi-lens itself this prunes a 66k-line ignored list (node_modules) down
55
+ // to ~1.6k paths that actually need normalizing.
56
+ const dirSegments = rel.split("/").slice(0, -1);
57
+ if (dirSegments.some((segment) => isExcludedDirName(segment)))
58
+ continue;
59
+ ids.add(normalizeMapKey(path.join(cwd, rel)));
60
+ }
61
+ return ids;
62
+ }
63
+ async function fetchUntrackedIgnoredIds(cwd) {
64
+ try {
65
+ const result = await safeSpawnAsync("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd, timeout: 10_000, resourceLabel: "git-tracked-ignore" });
66
+ if (result.error || result.status !== 0)
67
+ return undefined;
68
+ return parseUntrackedIgnoredOutput(result.stdout, cwd);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ // #694: the review-graph build calls this on EVERY incremental/cascade
75
+ // rebuild (a hot, per-edit path), not just `/lens-map`'s one-shot generation —
76
+ // spawning `git ls-files` per edit would be a real per-keystroke cost. Memoize
77
+ // per cwd with a short time bound: cheap enough that a `.gitignore` edit or a
78
+ // newly-untracked file takes effect within a few seconds, but a burst of
79
+ // per-edit rebuilds within that window shares one spawn.
80
+ const CACHE_TTL_MS = 30_000;
81
+ const _cache = new Map();
82
+ /**
83
+ * The untracked-AND-ignored id set for `cwd`, memoized per-process with a
84
+ * {@link CACHE_TTL_MS} time bound so a hot rebuild loop (review-graph
85
+ * incremental/cascade builds) never spawns `git` per file/per edit — see the
86
+ * module doc for the tracked-file semantic and the degrade-to-undefined
87
+ * contract.
88
+ */
89
+ export function collectUntrackedIgnoredIds(cwd) {
90
+ const key = normalizeMapKey(cwd);
91
+ const now = Date.now();
92
+ const cached = _cache.get(key);
93
+ if (cached && now - cached.fetchedAtMs < CACHE_TTL_MS)
94
+ return cached.promise;
95
+ const promise = fetchUntrackedIgnoredIds(cwd);
96
+ _cache.set(key, { promise, fetchedAtMs: now });
97
+ return promise;
98
+ }
99
+ /** Test hook: drop the memoized cache so a test's fake git state is re-read. */
100
+ export function _resetUntrackedIgnoredCacheForTests() {
101
+ _cache.clear();
102
+ }
103
+ /**
104
+ * Parses `git ls-files` output (repo-relative paths, one per line, tracked
105
+ * files only — no `--others`) into normalized ids, keyed for comparison
106
+ * against a `ProjectIgnoreMatcher`'s own (non-realpath'd) `path.resolve`'d
107
+ * paths — NOT against {@link parseUntrackedIgnoredOutput}'s ids, which are
108
+ * realpath-keyed for a different consumer (review-graph/map node ids). The
109
+ * two sets are NOT directly comparable/mergeable despite the shared
110
+ * dir-exclusion-prune shape — see this file's module doc for the asymmetry.
111
+ *
112
+ * `cwd` is realpath'd exactly ONCE here (not per file) to reconcile the one
113
+ * divergence that matters in practice: on Windows, the matcher's
114
+ * `resolvedRoot` comes from `path.resolve` while this fetch's `cwd` may carry
115
+ * different on-disk casing (e.g. `c:\users` vs `C:\Users`) — a single
116
+ * realpath on the shared root, followed by a cheap syntactic fold
117
+ * (`normalizeEphemeralMapKey`) on every per-file join, reconciles that without
118
+ * paying `realpathSync` per tracked file (#703 perf follow-up — a ~2k-file
119
+ * repo was doing ~2k realpath calls in one synchronous burst per fetch).
120
+ * Accepted edge case: a symlinked or 8.3-short-name project root can still
121
+ * make the cheap per-file fold miss even after this one realpath — degrades
122
+ * to today's pattern-only behavior for that root, consistent with the
123
+ * fail-open contract elsewhere in this module.
124
+ */
125
+ export function parseTrackedFilesOutput(stdout, cwd) {
126
+ const ids = new Set();
127
+ const base = normalizeMapKey(cwd);
128
+ for (const line of stdout.split(/\r?\n/)) {
129
+ const rel = line.trim();
130
+ if (!rel)
131
+ continue;
132
+ const dirSegments = rel.split("/").slice(0, -1);
133
+ if (dirSegments.some((segment) => isExcludedDirName(segment)))
134
+ continue;
135
+ ids.add(normalizeEphemeralMapKey(path.join(base, rel)));
136
+ }
137
+ return ids;
138
+ }
139
+ async function fetchTrackedFiles(cwd) {
140
+ try {
141
+ const result = await safeSpawnAsync("git", ["ls-files"], {
142
+ cwd,
143
+ timeout: 10_000,
144
+ resourceLabel: "git-tracked-ignore",
145
+ });
146
+ if (result.error || result.status !== 0)
147
+ return undefined;
148
+ return parseTrackedFilesOutput(result.stdout, cwd);
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ const _trackedCache = new Map();
155
+ // Sync snapshot of the most recently RESOLVED tracked-files fetch per root,
156
+ // keyed the same way as `_trackedCache`. `getProjectIgnoreMatcher`'s
157
+ // `isIgnored` is sync-and-hot (#703 constraint) and can't await a promise, so
158
+ // this is the seam it reads from: `ensureTrackedIndex` (file-utils.ts) awaits
159
+ // `collectTrackedFiles` ONCE per walk, which populates this snapshot as a
160
+ // side effect; `isIgnored` then reads the snapshot synchronously for every
161
+ // file in that same walk. A caller that never primes sees an absent snapshot
162
+ // entry and degrades to pattern-only behavior (fail-open, by design).
163
+ const _trackedSnapshot = new Map();
164
+ /**
165
+ * The tracked-files id set for `cwd` (i.e. `git ls-files`, no `--others`),
166
+ * memoized per-process with the same {@link CACHE_TTL_MS} time bound as
167
+ * {@link collectUntrackedIgnoredIds} — see that function's doc for the hot
168
+ * per-edit-rebuild-loop rationale. Also updates the synchronous snapshot
169
+ * `getTrackedFilesSnapshot` reads, so sync hot-path callers can consult a
170
+ * cheap in-memory Set instead of awaiting this promise per file.
171
+ */
172
+ export function collectTrackedFiles(cwd) {
173
+ // #703 perf follow-up: `normalizeEphemeralMapKey`, not `normalizeMapKey` —
174
+ // this map's only readers/writers are `ensureTrackedIndex`/`isIgnored`
175
+ // (file-utils.ts), both of which derive `cwd` from the SAME matcher's
176
+ // `resolvedRoot` (`path.resolve`, not realpath) within one process, so the
177
+ // cheap syntactic fold is sufficient and avoids a realpath per call — see
178
+ // this file's module doc for why this diverges from `collectUntrackedIgnoredIds`.
179
+ const key = normalizeEphemeralMapKey(cwd);
180
+ const now = Date.now();
181
+ const cached = _trackedCache.get(key);
182
+ if (cached && now - cached.fetchedAtMs < CACHE_TTL_MS)
183
+ return cached.promise;
184
+ const promise = fetchTrackedFiles(cwd).then((result) => {
185
+ _trackedSnapshot.set(key, result);
186
+ return result;
187
+ });
188
+ _trackedCache.set(key, { promise, fetchedAtMs: now });
189
+ return promise;
190
+ }
191
+ /**
192
+ * Synchronous read of the most recent {@link collectTrackedFiles} result for
193
+ * `cwd`. Returns `undefined` when nothing has primed this root yet (never
194
+ * called `collectTrackedFiles`), when git degraded (no repo/spawn failure),
195
+ * OR when a fetch is still in flight — all three collapse to the same
196
+ * fail-open "tracked status unknown, use pattern-only behavior" contract.
197
+ * Called on every `isIgnored` check that reaches the tracked-rescue branch
198
+ * (file-utils.ts's `isTrackedAndRescued`), so this key MUST stay the cheap
199
+ * `normalizeEphemeralMapKey` fold, not a realpath.
200
+ */
201
+ export function getTrackedFilesSnapshot(cwd) {
202
+ return _trackedSnapshot.get(normalizeEphemeralMapKey(cwd));
203
+ }
204
+ /** Test hook: drop the memoized tracked-files cache/snapshot. */
205
+ export function _resetTrackedFilesCacheForTests() {
206
+ _trackedCache.clear();
207
+ _trackedSnapshot.clear();
208
+ }
@@ -17,6 +17,13 @@
17
17
  * - `gitleaks` reference in `package.json` deps, OR
18
18
  * - a git pre-commit hook (.husky/, .git/hooks/) referencing gitleaks
19
19
  *
20
+ * `lens_diagnostics mode=full`'s fresh-fetch path (`clients/project-diagnostics/
21
+ * fresh-fetch.ts`) uses a looser "smart-default" gate instead — any tracked
22
+ * git repo, via `hasGitRepo` — since that's an explicitly-requested
23
+ * comprehensive review where gitleaks's low cost and advisory-only findings
24
+ * make the stricter default needlessly conservative. session_start and
25
+ * per-edit dispatch keep the strict gate above unchanged.
26
+ *
20
27
  * If the gate trips, the runner auto-installs gitleaks from GitHub releases
21
28
  * (installer entry registered in clients/installer/index.ts) and runs
22
29
  * `gitleaks detect --no-git --report-format json` against the analysis root.
@@ -95,6 +102,26 @@ export function hasGitleaksSignal(cwd) {
95
102
  }
96
103
  return false;
97
104
  }
105
+ /**
106
+ * "Smart-default" tier from #130's own considered-but-unshipped options:
107
+ * fire whenever the project is a tracked git repo, not only when an explicit
108
+ * gitleaks signal is present. gitleaks's own scan target is "a git repo's
109
+ * history/tree" — nearly every project qualifies, so this is meaningfully
110
+ * looser than {@link hasGitleaksSignal}. Used ONLY by `mode=full`'s
111
+ * fresh-fetch path (an explicitly-requested comprehensive review, where
112
+ * gitleaks's low cost — ~10MB binary, no external DB pull — and advisory-only
113
+ * findings make the stricter opt-in gate needlessly conservative); session_start
114
+ * and per-edit dispatch keep the strict {@link hasGitleaksSignal} gate
115
+ * unchanged, so day-to-day noise/cost stays exactly as conservative as before.
116
+ */
117
+ export function hasGitRepo(cwd) {
118
+ try {
119
+ return fs.existsSync(path.join(cwd, ".git"));
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
98
125
  // --- Client ---
99
126
  export class GitleaksClient extends SecurityScanClient {
100
127
  constructor(verbose = false) {
@@ -107,6 +134,10 @@ export class GitleaksClient extends SecurityScanClient {
107
134
  static hasGitleaksSignal(cwd) {
108
135
  return hasGitleaksSignal(cwd);
109
136
  }
137
+ /** Smart-default tier (#130) — see {@link hasGitRepo}'s doc comment. */
138
+ static hasGitRepo(cwd) {
139
+ return hasGitRepo(cwd);
140
+ }
110
141
  /**
111
142
  * Auto-install via the GitHub-release path (registered in
112
143
  * `clients/installer/index.ts`) when gitleaks isn't already on PATH.
@@ -118,18 +149,22 @@ export class GitleaksClient extends SecurityScanClient {
118
149
  /**
119
150
  * Scan a directory tree for secrets.
120
151
  *
121
- * Skips early when the directory shows no gitleaks opt-in signal. When
122
- * gitleaks is unavailable, returns an empty result with an explanatory
123
- * summary rather than failing the session_start task.
152
+ * Skips early when the directory shows no gitleaks opt-in signal — unless
153
+ * `requireSignal: false` (the `mode=full` fresh-fetch path uses this to
154
+ * apply the looser #130 "smart-default" gate, {@link hasGitRepo}, instead;
155
+ * session_start and per-edit dispatch never pass this, so their behavior
156
+ * is unchanged). When gitleaks is unavailable, returns an empty result
157
+ * with an explanatory summary rather than failing the session_start task.
124
158
  *
125
159
  * Re-entrancy safe: concurrent calls against the same root share a
126
160
  * single gitleaks process (mirrors `KnipClient` / `JscpdClient` /
127
161
  * `GovulncheckClient`).
128
162
  */
129
- async scan(cwd) {
163
+ async scan(cwd, options) {
130
164
  const targetDir = path.resolve(cwd);
131
165
  const scannedAt = new Date().toISOString();
132
- if (!GitleaksClient.hasGitleaksSignal(targetDir)) {
166
+ const requireSignal = options?.requireSignal ?? true;
167
+ if (requireSignal && !GitleaksClient.hasGitleaksSignal(targetDir)) {
133
168
  return {
134
169
  ...EMPTY_RESULT,
135
170
  success: true,