pi-lens 3.8.70 → 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 +24 -0
- package/README.md +16 -0
- package/dist/clients/bus-events-logger.js +10 -8
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/diagnostic-dispositions.js +298 -0
- package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/integration.js +19 -4
- package/dist/clients/dispatch/runners/lsp.js +5 -32
- 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/instance-registry.js +74 -4
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/language-profile.js +4 -0
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/client.js +72 -7
- package/dist/clients/lsp/index.js +486 -14
- package/dist/clients/lsp/server.js +17 -1
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/mcp/session.js +8 -0
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +26 -0
- package/dist/clients/pipeline.js +4 -0
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/review-graph/builder.js +78 -52
- package/dist/clients/review-graph/import-resolvers.js +53 -19
- package/dist/clients/runtime-agent-end.js +26 -0
- package/dist/clients/runtime-coordinator.js +9 -1
- package/dist/clients/runtime-session.js +53 -11
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +15 -2
- package/dist/clients/runtime-turn.js +9 -2
- package/dist/clients/session-state-store.js +6 -9
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +5 -0
- package/dist/clients/startup-scan.js +68 -1
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/widget-state.js +22 -0
- package/dist/clients/zizmor-config.js +2 -10
- package/dist/index.js +21089 -18685
- package/dist/tools/lens-diagnostic-mark.js +158 -0
- package/dist/tools/lens-diagnostics.js +41 -7
- package/dist/tools/lsp-diagnostics.js +99 -119
- package/docs/agent-tools.md +11 -4
- package/docs/dispositions.md +85 -0
- package/docs/environment-variables.md +16 -0
- package/docs/fable.md +10 -9
- package/docs/servercapabilities.md +1 -1
- package/docs/tools.md +3 -0
- package/package.json +6 -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/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/docs/agent-tooling-improvement-plan.md +0 -660
- package/docs/inspiration_gograph.md +0 -601
- package/docs/inspiration_neovim.md +0 -1795
- package/docs/inspiration_neovim2.md +0 -402
- package/docs/inspiration_tree_sitter_rules.md +0 -369
- package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
- package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
- package/docs/lsp-bridge-analysis.md +0 -444
- package/docs/newinspiration.md +0 -338
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-elixir.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-lua.wasm +0 -0
- package/grammars/tree-sitter-ocaml.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-toml.wasm +0 -0
- package/grammars/tree-sitter-vue.wasm +0 -0
- package/grammars/tree-sitter-zig.wasm +0 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* `role:"auxiliary"` (clients/lsp/server.ts) + one profile entry here.
|
|
18
18
|
*/
|
|
19
19
|
import { shouldDegradeAuxiliaryLsp } from "../lsp-budget.js";
|
|
20
|
+
import { isSubagentSession } from "../subagent-mode.js";
|
|
20
21
|
import { findLocalOpengrepConfig } from "../opengrep-config.js";
|
|
21
22
|
import { findLocalTyposConfig } from "../typos-config.js";
|
|
22
23
|
import { findLocalZizmorConfig } from "../zizmor-config.js";
|
|
@@ -89,6 +90,11 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
89
90
|
sourceMatch: /ast[-_]?grep/i,
|
|
90
91
|
killSwitchFlag: "no-ast-grep",
|
|
91
92
|
enabledByDefault: true,
|
|
93
|
+
// Matches the in-process ast-grep-napi runner's own `skipTestFiles: true`
|
|
94
|
+
// (#687) — that flag stops applying the moment the ast-grep binary is
|
|
95
|
+
// present, since the napi runner then skips entirely in favor of this
|
|
96
|
+
// LSP surface, which had no test-file gating of its own.
|
|
97
|
+
skipTestFiles: true,
|
|
92
98
|
// The ast-grep LSP runs either the repo's own sgconfig (when present) or
|
|
93
99
|
// pi-lens's shipped baseline sgconfig. In both cases the rule severity is
|
|
94
100
|
// deliberate, so preserve ast-grep's severity semantics: ERROR can block,
|
|
@@ -144,7 +150,12 @@ export const AUXILIARY_LSP_PROFILES = [
|
|
|
144
150
|
* per-file: once over budget, this session never spawns its auxiliary fleet,
|
|
145
151
|
* rather than flip-flopping file to file. */
|
|
146
152
|
export function enabledAuxiliaryLspServerIds(getFlag) {
|
|
147
|
-
|
|
153
|
+
// #449 slice 2 (budget): skip auxiliaries when machine-wide LSP budget is
|
|
154
|
+
// exceeded. #713 (subagent light mode): reuse the same seam — a subagent
|
|
155
|
+
// session also skips auxiliaries; the parent session already runs them on
|
|
156
|
+
// the same cwd. PI_LENS_SUBAGENT_FULL=1 restores full behavior via
|
|
157
|
+
// isSubagentSession() returning false.
|
|
158
|
+
if (shouldDegradeAuxiliaryLsp() || isSubagentSession())
|
|
148
159
|
return [];
|
|
149
160
|
return AUXILIARY_LSP_PROFILES.flatMap((p) => p.enabledByDefault &&
|
|
150
161
|
!(p.killSwitchFlag && getFlag(p.killSwitchFlag) === true)
|
|
@@ -194,7 +205,78 @@ export function isAuxiliaryDiagnosticSuppressed(d, content) {
|
|
|
194
205
|
* equivalent) suppresses a finding identically whether it's seen via the
|
|
195
206
|
* per-edit dispatch runner or a standalone diagnostics query — previously
|
|
196
207
|
* only the former honored it (#586).
|
|
208
|
+
*
|
|
209
|
+
* #692: `opts.fileRole` additionally drops a diagnostic whose auxiliary
|
|
210
|
+
* profile declares `skipTestFiles` (e.g. ast-grep, #687) when the file is a
|
|
211
|
+
* test file — the per-edit dispatch runner (`clients/dispatch/runners/lsp.ts`)
|
|
212
|
+
* has applied this gate since #687/#688 via its own inline check; the
|
|
213
|
+
* `runWorkspaceDiagnostics` workspace sweep (`clients/lsp/index.ts`) called
|
|
214
|
+
* this function WITHOUT ever passing a fileRole, so ast-grep findings on test
|
|
215
|
+
* files that are suppressed per-edit reappeared wholesale in every
|
|
216
|
+
* `mode=full` sweep. Omitting `opts` (or `opts.fileRole`) keeps every existing
|
|
217
|
+
* 2-arg call site's behavior byte-for-byte unchanged.
|
|
218
|
+
*/
|
|
219
|
+
export function applyAuxiliarySuppressions(diagnostics, content, opts) {
|
|
220
|
+
return diagnostics.filter((d) => {
|
|
221
|
+
if (isAuxiliaryDiagnosticSuppressed(d, content))
|
|
222
|
+
return false;
|
|
223
|
+
if (opts?.fileRole === "test") {
|
|
224
|
+
const profile = findAuxiliaryProfileForSource(d.source);
|
|
225
|
+
if (profile?.skipTestFiles)
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* #692: shared aux re-tag implementation — extracted from the per-edit
|
|
233
|
+
* dispatch runner (`clients/dispatch/runners/lsp.ts`) so a scan/sweep path
|
|
234
|
+
* that reconciles LSP diagnostics into widget state (`lens_diagnostics
|
|
235
|
+
* mode=full`, `lsp_diagnostics`) gives its auxiliary-sourced findings
|
|
236
|
+
* (ast-grep, opengrep, zizmor, typos) the SAME tool re-tag, semantic policy,
|
|
237
|
+
* and defect-class classification the per-edit path always has — previously
|
|
238
|
+
* only the per-edit runner ran this loop, so a scan-reconciled aux finding
|
|
239
|
+
* kept `tool: "lsp"` and lost its curated-repo-rules `blockingAllowed`
|
|
240
|
+
* context entirely (#692).
|
|
241
|
+
*
|
|
242
|
+
* `diagnostics` and `rawLspDiags` must be the SAME length and index-aligned —
|
|
243
|
+
* `convertLspDiagnostics` maps its input 1:1, so this holds for every caller
|
|
244
|
+
* that passes it the same array it just converted. Mutates and returns the
|
|
245
|
+
* SURVIVING subset of `diagnostics` (native-inline-suppressed and
|
|
246
|
+
* `skipTestFiles`-dropped entries removed), mirroring the per-edit runner's
|
|
247
|
+
* prior inline behavior exactly.
|
|
197
248
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
249
|
+
export function retagAuxiliaryDiagnostics(diagnostics, rawLspDiags, content, ctx) {
|
|
250
|
+
const blockingAllowedByProfile = new Map();
|
|
251
|
+
const suppressedIndices = new Set();
|
|
252
|
+
for (let i = 0; i < diagnostics.length; i++) {
|
|
253
|
+
const profile = findAuxiliaryProfileForSource(rawLspDiags[i]?.source);
|
|
254
|
+
if (!profile)
|
|
255
|
+
continue;
|
|
256
|
+
if (profile.skipTestFiles && ctx.fileRole === "test") {
|
|
257
|
+
suppressedIndices.add(i);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (isAuxiliaryDiagnosticSuppressed(rawLspDiags[i], content)) {
|
|
261
|
+
suppressedIndices.add(i);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let blockingAllowed = blockingAllowedByProfile.get(profile);
|
|
265
|
+
if (blockingAllowed === undefined) {
|
|
266
|
+
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
|
|
267
|
+
blockingAllowedByProfile.set(profile, blockingAllowed);
|
|
268
|
+
}
|
|
269
|
+
const d = diagnostics[i];
|
|
270
|
+
d.tool = profile.tool;
|
|
271
|
+
d.semantic = profile.semantic(rawLspDiags[i], { blockingAllowed });
|
|
272
|
+
if (d.semantic !== "blocking" && d.severity === "error") {
|
|
273
|
+
d.severity = "warning";
|
|
274
|
+
}
|
|
275
|
+
const defectClass = profile.defectClass?.(rawLspDiags[i]);
|
|
276
|
+
if (defectClass)
|
|
277
|
+
d.defectClass = defectClass;
|
|
278
|
+
}
|
|
279
|
+
return suppressedIndices.size
|
|
280
|
+
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
|
|
281
|
+
: diagnostics;
|
|
200
282
|
}
|
|
@@ -27,6 +27,7 @@ import { loadPiLensProjectConfig } from "../project-lens-config.js";
|
|
|
27
27
|
import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
|
|
28
28
|
import { safeSpawnAsync } from "../safe-spawn.js";
|
|
29
29
|
import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
|
|
30
|
+
import { applyDispositions } from "../diagnostic-dispositions.js";
|
|
30
31
|
import { applyInlineSuppressions } from "./inline-suppressions.js";
|
|
31
32
|
import { getToolPlan } from "./plan.js";
|
|
32
33
|
import { resolveRunnerPath } from "./runner-context.js";
|
|
@@ -559,7 +560,11 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
|
|
|
559
560
|
const overlapSuppressed = suppressLintOverlapsWithLsp(dockerOverlapSuppressed);
|
|
560
561
|
const fileContent = ctx.facts.getFileFact(ctx.filePath, "file.content") ?? "";
|
|
561
562
|
const inlineSuppressed = applyInlineSuppressions(overlapSuppressed, fileContent);
|
|
562
|
-
|
|
563
|
+
// #690: agent/user disposition layer — drop false-positive/suppress marks
|
|
564
|
+
// and anything deferred this session. flagged marks are left in place;
|
|
565
|
+
// lens_diagnostics tags them at render time via the same anchor.
|
|
566
|
+
const dispositionFiltered = applyDispositions(inlineSuppressed, ctx.cwd, ctx.filePath, fileContent);
|
|
567
|
+
let visibleDiagnostics = dispositionFiltered;
|
|
563
568
|
let resolvedCount = 0;
|
|
564
569
|
if (ctx.deltaMode && previousBaseline) {
|
|
565
570
|
const filtered = filterDelta(visibleDiagnostics, previousBaseline, (d) => d.id);
|
|
@@ -821,7 +821,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
821
821
|
coldSnapshotPaths.push(neighborPath);
|
|
822
822
|
continue;
|
|
823
823
|
}
|
|
824
|
-
|
|
824
|
+
// #692: `source: "cascade"` used to be passed here to label `rule`
|
|
825
|
+
// (`cascade:<code>`) — that override is gone (identity must come from
|
|
826
|
+
// the diagnostic's own source; see `scanOrigin`'s doc comment), and
|
|
827
|
+
// cascade neighbor diagnostics are ephemeral display-only output
|
|
828
|
+
// (never reconciled into persisted widget/dedup state), so the label
|
|
829
|
+
// had no remaining purpose and is simply dropped rather than migrated.
|
|
830
|
+
const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
|
|
825
831
|
producedLspData = true;
|
|
826
832
|
const durationMs = Date.now() - neighborStart;
|
|
827
833
|
logCascade({
|
|
@@ -994,7 +1000,11 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
994
1000
|
});
|
|
995
1001
|
if (!rawDiags)
|
|
996
1002
|
return undefined;
|
|
997
|
-
|
|
1003
|
+
// #692: `source: "cascade"` no longer overrides `rule` (see the
|
|
1004
|
+
// doc comment on the sibling call above) — dropped rather than
|
|
1005
|
+
// migrated to `scanOrigin` since cascade output never touches
|
|
1006
|
+
// persisted widget/dedup state.
|
|
1007
|
+
const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
|
|
998
1008
|
const durationMs = Date.now() - neighborStart;
|
|
999
1009
|
// Update cache for this neighbor at the current write sequence
|
|
1000
1010
|
if (writeSeq != null) {
|
|
@@ -1051,10 +1061,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
1051
1061
|
error: String(result.reason),
|
|
1052
1062
|
});
|
|
1053
1063
|
const entry = allDiags.get(normalizeMapKey(neighborPath));
|
|
1064
|
+
// #692: `source: "cascade"` dropped (see the doc comment above the
|
|
1065
|
+
// first cascade call site in this file) — no longer affects `rule`
|
|
1066
|
+
// and cascade output never touches persisted widget/dedup state.
|
|
1054
1067
|
const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
|
|
1055
1068
|
? convertLspDiagnostics(entry.diags
|
|
1056
1069
|
.filter((d) => d.severity === 1)
|
|
1057
|
-
.slice(0, MAX_PER_FILE), neighborPath
|
|
1070
|
+
.slice(0, MAX_PER_FILE), neighborPath)
|
|
1058
1071
|
: [];
|
|
1059
1072
|
neighbors.push({
|
|
1060
1073
|
filePath: neighborPath,
|
|
@@ -1162,7 +1175,9 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
|
|
|
1162
1175
|
continue;
|
|
1163
1176
|
if (now - ts > CASCADE_TTL_MS)
|
|
1164
1177
|
continue;
|
|
1165
|
-
|
|
1178
|
+
// #692: `source: "cascade"` dropped — see the doc comment above the
|
|
1179
|
+
// first cascade `convertLspDiagnostics` call site in this file.
|
|
1180
|
+
const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
|
|
1166
1181
|
if (errors.length === 0)
|
|
1167
1182
|
continue;
|
|
1168
1183
|
neighbors.push({
|
|
@@ -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;
|
|
@@ -225,37 +225,10 @@ const lspRunner = {
|
|
|
225
225
|
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
|
|
226
226
|
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
|
|
227
227
|
// their tool id + semantic policy — language-server diagnostics keep "lsp".
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
const suppressedIndices = new Set();
|
|
233
|
-
for (let i = 0; i < diagnostics.length; i++) {
|
|
234
|
-
const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
|
|
235
|
-
if (!profile)
|
|
236
|
-
continue;
|
|
237
|
-
if (isAuxiliaryDiagnosticSuppressed(validLspDiags[i], content)) {
|
|
238
|
-
suppressedIndices.add(i);
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
let blockingAllowed = blockingAllowedByProfile.get(profile);
|
|
242
|
-
if (blockingAllowed === undefined) {
|
|
243
|
-
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
|
|
244
|
-
blockingAllowedByProfile.set(profile, blockingAllowed);
|
|
245
|
-
}
|
|
246
|
-
const d = diagnostics[i];
|
|
247
|
-
d.tool = profile.tool;
|
|
248
|
-
d.semantic = profile.semantic(validLspDiags[i], { blockingAllowed });
|
|
249
|
-
if (d.semantic !== "blocking" && d.severity === "error") {
|
|
250
|
-
d.severity = "warning";
|
|
251
|
-
}
|
|
252
|
-
const defectClass = profile.defectClass?.(validLspDiags[i]);
|
|
253
|
-
if (defectClass)
|
|
254
|
-
d.defectClass = defectClass;
|
|
255
|
-
}
|
|
256
|
-
const keptDiagnostics = suppressedIndices.size
|
|
257
|
-
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
|
|
258
|
-
: 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 });
|
|
259
232
|
const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
|
|
260
233
|
const resultSemantic = hasErrors
|
|
261
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
|
+
}
|