pi-lens 4.2.0 → 4.2.1

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.
@@ -45,8 +45,9 @@ import * as path from "node:path";
45
45
  import { Type } from "../clients/deps/typebox.js";
46
46
  import { markDisposition, normalizeMessage, } from "../clients/diagnostic-dispositions.js";
47
47
  import { insertSuppressComment } from "../clients/dispatch/suppress-writer.js";
48
- import { getFileDiagnostics, } from "../clients/widget-state.js";
48
+ import { normalizeMapKey } from "../clients/path-utils.js";
49
49
  import { resolveLensToolName } from "../clients/tool-config.js";
50
+ import { getFileDiagnostics, } from "../clients/widget-state.js";
50
51
  const DISPOSITIONS = [
51
52
  "false-positive",
52
53
  "suppress",
@@ -70,11 +71,22 @@ function isBlank(text) {
70
71
  * violation) are ambiguous; per #802 this picks the match CLOSEST to the
71
72
  * caller's line rather than guessing — the conservative choice for suppress,
72
73
  * which must never place a comment above the wrong site.
74
+ *
75
+ * #3160: `getFileDiagnostics` keys with `normalizeEphemeralMapKey` — case-
76
+ * preserving on POSIX by design (no filesystem I/O on that hot path) — but
77
+ * the widget's own writers key from `ctx.filePath`, which
78
+ * `createDispatchContext` already canonicalizes to the on-disk casing
79
+ * (#2016/#3098). `absPath` here is the caller's RAW spelling, so on a
80
+ * case-insensitive filesystem a mis-cased mark call must derive the same
81
+ * canonical key the writers used or it silently misses the live diagnostic
82
+ * and falls through to the fuzzy fallback below — do NOT case-fold
83
+ * `normalizeEphemeralMapKey` itself for this: on a case-sensitive filesystem
84
+ * that would merge two genuinely different files.
73
85
  */
74
86
  function widgetCrossCheck(absPath, tool, rule, message, callerLine) {
75
87
  let diagnostics;
76
88
  try {
77
- diagnostics = getFileDiagnostics(absPath);
89
+ diagnostics = getFileDiagnostics(normalizeMapKey(absPath));
78
90
  }
79
91
  catch {
80
92
  return undefined;
@@ -18,13 +18,13 @@ import { DEPENDENCY_DRIFT_MAX_DELIVERIES } from "../clients/blocker-freshness.js
18
18
  import { freshnessFromMtime } from "../clients/freshness.js";
19
19
  import { applyFindingPolicy, loadProjectRulePolicyMap, } from "../clients/dispatch/finding-policy.js";
20
20
  import { gateFindingsByPathFreshness } from "../clients/advisory-provenance.js";
21
- import { markUnreconciledFindings } from "../clients/finding-delivery-gate.js";
21
+ import { formatCacheAgeLabel, markUnreconciledFindings, } from "../clients/finding-delivery-gate.js";
22
22
  import { normalizeRuleId } from "../clients/dispatch/rule-id-normalize.js";
23
23
  import { applyRulePolicy, } from "../clients/dispatch/rule-policy.js";
24
24
  import { compactRenderResult } from "./render-compact.js";
25
25
  import { combineAbortSignals } from "../clients/deadline-utils.js";
26
26
  import { getProjectIgnoreMatcher } from "../clients/file-utils.js";
27
- import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, realpathOrResolve, } from "../clients/path-utils.js";
27
+ import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, normalizeMapKey, realpathOrResolve, } from "../clients/path-utils.js";
28
28
  import { getLSPService } from "../clients/lsp/index.js";
29
29
  import { retireInlineBlockerAndResyncGuard } from "../clients/git-guard.js";
30
30
  import { primaryServerId, resolveLspCwdForFile, } from "../clients/lsp/config.js";
@@ -508,7 +508,7 @@ function formatProjectDeltaDiagnostic(diagnostic, stale) {
508
508
  * rule/message), live -> unchanged. See `clients/finding-delivery-gate.ts`
509
509
  * surface `lens-diagnostics:mode-delta`.
510
510
  */
511
- function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includeFile) {
511
+ function appendProjectDiagnosticsDeltaLines(groups, cwd, report, severity, includeFile) {
512
512
  const scoped = (report?.diagnostics ?? []).filter((diagnostic) => includeFile(diagnostic.filePath) &&
513
513
  matchesSeverity(projectDiagnosticToWidget(diagnostic, diagnostic.filePath), severity));
514
514
  const gated = gateFindingsByPathFreshness({
@@ -532,11 +532,9 @@ function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includ
532
532
  byFile.set(filePath, bucket);
533
533
  }
534
534
  for (const [filePath, fileDiagnostics] of byFile) {
535
- const rel = path.relative(cwd, filePath);
536
- if (!lines.includes(rel))
537
- lines.push(rel);
535
+ const group = getDeltaFileGroup(groups, cwd, filePath);
538
536
  for (const diagnostic of fileDiagnostics) {
539
- lines.push(formatProjectDeltaDiagnostic(diagnostic, staleSet.has(diagnostic)));
537
+ group.projectLines.push(formatProjectDeltaDiagnostic(diagnostic, staleSet.has(diagnostic)));
540
538
  }
541
539
  }
542
540
  return diagnostics.length;
@@ -723,7 +721,15 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
723
721
  }
724
722
  for (const f of gated.stale) {
725
723
  const arr = byFile.get(f.filePath) ?? [];
726
- arr.push({ ...f.warning, stale: true, line: undefined });
724
+ // Fix B (#3167): carry the stamp the row was judged against so the render
725
+ // can emit one age label per file group — the row's own observation stamp,
726
+ // not the report-level one (the #2504 r4 multi-stamp case).
727
+ arr.push({
728
+ ...f.warning,
729
+ stale: true,
730
+ line: undefined,
731
+ staleAsOf: effectiveAt,
732
+ });
727
733
  byFile.set(f.filePath, arr);
728
734
  }
729
735
  return files
@@ -733,10 +739,34 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
733
739
  }))
734
740
  .filter((file) => file.warnings.length > 0);
735
741
  }
742
+ function getDeltaFileGroup(groups, cwd, filePath) {
743
+ const rel = path.relative(cwd, filePath);
744
+ let group = groups.get(rel);
745
+ if (!group) {
746
+ group = {
747
+ rel,
748
+ actionableLines: [],
749
+ qualityLines: [],
750
+ projectLines: [],
751
+ incomplete: false,
752
+ };
753
+ groups.set(rel, group);
754
+ }
755
+ return group;
756
+ }
736
757
  // @delivery-surface: lens-diagnostics:mode-delta
737
758
  function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
738
759
  const actionableEntry = cacheManager.readCache("actionable-warnings", cwd);
739
760
  const qualityEntry = cacheManager.readCache("code-quality-warnings", cwd);
761
+ // #3170: files whose re-verify pass could not complete inside its budget —
762
+ // their rows render as carried, plus this explicit gap label (never a
763
+ // false clean). Computed from the RAW actionable-warnings cache entries
764
+ // because the freshness pipeline below rebuilds the file shape. The
765
+ // re-verify only ever writes the actionable-warnings cache — the quality
766
+ // report's file shape carries no such marker.
767
+ const reverifyIncompletePaths = new Set((actionableEntry?.data?.files ?? [])
768
+ .filter((file) => file.reVerifyIncomplete)
769
+ .map((file) => normalizeMapKey(file.filePath)));
740
770
  const actionable = actionableEntry?.data;
741
771
  const quality = qualityEntry?.data;
742
772
  // Project rule policy (`.pi-lens.json` `rules.<id>.disable` /
@@ -778,32 +808,69 @@ function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
778
808
  warnings: matchingWarnings(file.warnings),
779
809
  }))
780
810
  .filter((file) => file.warnings.length > 0);
781
- const lines = [];
811
+ // #3196: one grouping pass keyed by file — every tier below appends into
812
+ // the SAME per-file group (see `DeltaFileGroup`/`getDeltaFileGroup`), so a
813
+ // file present in more than one report always renders its rows under its
814
+ // own header, wherever that header ends up in the final buffer.
815
+ const groups = new Map();
782
816
  // Fixable warnings from actionable-warnings and quality cache entries retain
783
817
  // their own severity tier. Apply the same threshold semantics as the LSP path.
818
+ // F5/F10 (#3168): at most ONE label group (age and/or #3170's re-verify
819
+ // gap) per file. The actionable tier is folded first, so its stale row (if
820
+ // any) wins the group's age label; the quality tier only supplies one when
821
+ // the actionable tier had none for that file (#3168 F10(c)'s quality-only
822
+ // shape). The `(re-verify incomplete)` flag is a fact about the file (from
823
+ // the raw actionable cache, independent of which tier's rows happen to
824
+ // render it), so it is recorded on the group the first time either tier
825
+ // touches that file and never predicted.
784
826
  if (filteredActionableFiles.length > 0) {
785
827
  for (const file of filteredActionableFiles) {
786
- const rel = path.relative(cwd, file.filePath);
787
- lines.push(`${rel}`);
828
+ const group = getDeltaFileGroup(groups, cwd, file.filePath);
788
829
  for (const w of file.warnings) {
789
830
  const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
790
- lines.push(` ⚠ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
831
+ group.actionableLines.push(` ⚠ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
832
+ }
833
+ const key = normalizeMapKey(file.filePath);
834
+ if (reverifyIncompletePaths.has(key))
835
+ group.incomplete = true;
836
+ if (!group.staleRow) {
837
+ const staleWarning = file.warnings.find((w) => w.stale);
838
+ if (staleWarning)
839
+ group.staleRow = { staleAsOf: staleWarning.staleAsOf };
791
840
  }
792
841
  }
793
842
  }
794
843
  // Quality issues
795
844
  if (filteredQualityFiles.length > 0) {
796
845
  for (const file of filteredQualityFiles) {
797
- const rel = path.relative(cwd, file.filePath);
798
- if (!lines.includes(rel))
799
- lines.push(rel);
846
+ const group = getDeltaFileGroup(groups, cwd, file.filePath);
800
847
  for (const w of file.warnings) {
801
848
  const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
802
- lines.push(` ℹ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
849
+ group.qualityLines.push(` ℹ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
850
+ }
851
+ const key = normalizeMapKey(file.filePath);
852
+ if (reverifyIncompletePaths.has(key))
853
+ group.incomplete = true;
854
+ if (!group.staleRow) {
855
+ const staleWarning = file.warnings.find((w) => w.stale);
856
+ if (staleWarning)
857
+ group.staleRow = { staleAsOf: staleWarning.staleAsOf };
803
858
  }
804
859
  }
805
860
  }
806
- const projectDeltaCount = appendProjectDiagnosticsDeltaLines(lines, cwd, projectDelta, severity, includeFile);
861
+ const projectDeltaCount = appendProjectDiagnosticsDeltaLines(groups, cwd, projectDelta, severity, includeFile);
862
+ const lines = [];
863
+ for (const group of groups.values()) {
864
+ lines.push(group.rel);
865
+ lines.push(...group.actionableLines);
866
+ lines.push(...group.qualityLines);
867
+ lines.push(...group.projectLines);
868
+ if (group.staleRow) {
869
+ lines.push(` (${formatCacheAgeLabel(group.staleRow.staleAsOf)})`);
870
+ }
871
+ if (group.incomplete)
872
+ lines.push(" (re-verify incomplete)");
873
+ }
807
874
  const selectedActionableFiles = filteredActionableFiles;
808
875
  const selectedQualityFiles = filteredQualityFiles;
809
876
  const aw = selectedActionableFiles.reduce((count, file) => count + file.warnings.length, 0);
@@ -23,7 +23,8 @@ import { classifyCascadeWaitTier } from "../clients/lsp/wait-policy/index.js";
23
23
  import { attemptTsserverSyncDiagnostics } from "../clients/lsp/tsserver-sync.js";
24
24
  import { convertLspDiagnostics } from "../clients/dispatch/utils/lsp-diagnostics.js";
25
25
  import { demoteInferredProjectDiagnostics } from "../clients/lsp/inferred-project.js";
26
- import { isBlocking, reconcileScanDiagnostics, } from "../clients/widget-state.js";
26
+ import { normalizeMapKey } from "../clients/path-utils.js";
27
+ import { countRetainedSuppressedRows, isBlocking, reconcileScanDiagnostics, } from "../clients/widget-state.js";
27
28
  import { makeProgressReporter } from "./scan-progress.js";
28
29
  import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../clients/warm-attach.js";
29
30
  import { extensionsForLanguage, SCAN_LANGUAGE_PRIORITY, } from "../clients/language-registry.js";
@@ -239,7 +240,18 @@ onConfirmedNoBlockers, getService = getLSPService) {
239
240
  // Preserve input order (including duplicate entries); normalize each
240
241
  // path before grouping so Windows separators and dot segments cannot
241
242
  // change cache/group identity. Explicit lists never enter the walker.
242
- const absPaths = rawPaths.map((entry) => path.normalize(path.isAbsolute(entry) ? entry : path.resolve(cwd, entry)));
243
+ // #3160/#3182: `path.resolve(cwd, entry)` folds dot segments (and
244
+ // separators) structurally — it ignores `cwd` whenever `entry` is
245
+ // already absolute, so the old `path.isAbsolute` ternary is
246
+ // redundant. `normalizeMapKey` then adopts on-disk CASING where the
247
+ // path exists — an agent-typed, possibly mis-cased `paths` entry
248
+ // must key `reconcileScanDiagnostics`'s widget-state write the same
249
+ // way a canonical writer (clients/pipeline.ts's ctx.filePath) or the
250
+ // #3160-fixed lens_diagnostic_mark reader would. `path.resolve`
251
+ // stays required for the relative→absolute step: `normalizeMapKey`
252
+ // folds dot segments itself since #3184, but never resolves against
253
+ // `cwd` (a cwd fold there broke every monorepo, #2490).
254
+ const absPaths = rawPaths.map((entry) => normalizeMapKey(path.resolve(cwd, entry)));
243
255
  return runBatchFileDiagnostics(absPaths, severity, lspService, {
244
256
  concurrency,
245
257
  waitMs,
@@ -264,9 +276,12 @@ onConfirmedNoBlockers, getService = getLSPService) {
264
276
  details: {},
265
277
  };
266
278
  }
267
- const absPath = path.isAbsolute(rawPath)
268
- ? rawPath
269
- : path.resolve(cwd, rawPath);
279
+ // #3184: the single-`path` sibling of the `paths` batch above, and
280
+ // keyed the same way — `runFileDiagnostics` hands this straight to
281
+ // `reconcileScanDiagnostics`, whose `normalizeEphemeralMapKey` key
282
+ // derivation folds neither dot segments nor casing. Same expression as
283
+ // :444 so both modes of this tool write under ONE key per file.
284
+ const absPath = normalizeMapKey(path.resolve(cwd, rawPath));
270
285
  let stat;
271
286
  try {
272
287
  stat = fs.statSync(absPath);
@@ -616,6 +631,17 @@ severity) {
616
631
  // otherwise have SEEN, never one per finding (AGENTS.md "bounded
617
632
  // observability"). `total` is the in-scope population for the same
618
633
  // reason the count is, so the record and the rendered line agree.
634
+ //
635
+ // #3158 round 2 F4: `retainedSuppressed` is the widget store's
636
+ // SUCCESS-path number — how many suppressed rows the store is carrying
637
+ // INTO this scan, written by the scans before it. Until this, retention
638
+ // was observable only when its per-file cap truncated
639
+ // (`widget-suppressed-retention-capped`), so a healthy footer chip had no
640
+ // record behind it at all. Folded into the record this lane already emits
641
+ // rather than added as a second one: same cardinality (one per filtered
642
+ // file per scan, never one per row), no new sink, and the two numbers are
643
+ // read together — `suppressed` is what THIS scan dropped,
644
+ // `retainedSuppressed` is what the footer chip is still counting.
619
645
  logLatency({
620
646
  type: "phase",
621
647
  toolName: "lsp_diagnostics",
@@ -625,6 +651,7 @@ severity) {
625
651
  metadata: {
626
652
  suppressed,
627
653
  total: inScopeBefore,
654
+ retainedSuppressed: countRetainedSuppressedRows(file),
628
655
  },
629
656
  });
630
657
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {