pi-lens 4.2.0 → 4.3.0

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 (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -8,6 +8,7 @@
8
8
  * Requires: npm install -D madge
9
9
  * Docs: https://github.com/pahen/madge
10
10
  */
11
+ import { recordDegradationOnce } from "./degradation-ledger.js";
11
12
  import { createSubsystemLogger } from "./extension-log.js";
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
@@ -36,34 +37,79 @@ export function buildMadgeArgs(target, projectRoot) {
36
37
  if (fs.existsSync(tsConfig)) {
37
38
  args.push("--ts-config", tsConfig);
38
39
  }
39
- // --warning surfaces files madge couldn't resolve into the graph (to stderr;
40
- // stdout JSON is unaffected). Without it those skips are SILENT — and a
41
- // skipped *local* file could hide a real cycle. We log them (see
42
- // parseMadgeSkips) instead of discarding stderr.
43
- args.push("--warning", "--json", target);
40
+ // No `--warning`: madge gates it on `!program.json` (bin/cli.js:195) and this
41
+ // argv always passes `--json`, so the flag is inert; ungated it prints the
42
+ // skipped-file list to STDOUT (lib/output.js), which would corrupt the JSON
43
+ // this reader parses. The skip list is therefore unreachable through any
44
+ // argv this builder emits — `parseMadgeSkips` could only ever return zeros
45
+ // (#3436). The lost visibility is disclosed once per session/root by
46
+ // `recordDegradationOnce` from `parseMadgeCycles`, the one reader both
47
+ // madge lanes pass through, instead of parsed from bytes madge never
48
+ // writes.
49
+ args.push("--json", target);
44
50
  return args;
45
51
  }
46
52
  /**
47
- * Parse madge's `--warning` stderr for skipped (unresolvable) files. External
48
- * package specifiers (bare names / subpaths like `web-tree-sitter`,
49
- * `vitest/config`) are expected — madge doesn't traverse node_modules. We flag
50
- * only **local** skips (relative/absolute paths), which are the ones that could
51
- * silently drop an internal edge and hide a cycle.
53
+ * The ONE reader of madge's `--circular --json` contract (#3428).
52
54
  *
53
- * @returns total skip count and the subset that look local.
55
+ * madge prints an array of cycles, each cycle an array of member paths —
56
+ * `[["src/a.ts", "src/b.ts"]]` — not a dependency graph. Upstream 8.0.0:
57
+ * `bin/cli.js:258-268` hands `Madge.circular()` (an Array, `lib/api.js:103`) to
58
+ * `output.circular`, which for `--json` is `printJSON(circular)`
59
+ * (`lib/output.js:71-73`). The graph OBJECT is what `--json` prints WITHOUT
60
+ * `--circular`, and `buildMadgeArgs` always passes `--circular`.
61
+ *
62
+ * This function exists because there were two readers and they disagreed: the
63
+ * whole-project scan read the array with `Object.entries`, so every cycle it
64
+ * reported was anchored at the array INDEX (`<root>/0`, a path that does not
65
+ * exist) and every member was resolved against the extension host's cwd. Both
66
+ * callers now share this one reader.
67
+ *
68
+ * `baseDir` is the directory madge's relative members resolve against, which is
69
+ * the TARGET it was pointed at (measured, madge 8.0.0: `--json <root>` prints
70
+ * `src/a.ts`; `--json <root>/src/a.ts` prints `a.ts`), so each caller passes
71
+ * the base its own target implies.
72
+ *
73
+ * Malformed JSON throws, which both callers already treat as "did not parse". A
74
+ * parse that is not an array does NOT throw: it yields zero cycles, so an
75
+ * unexpected shape reports no findings instead of turning a completed scan into
76
+ * a failed one (#2154 — a failed madge scan is a COLD lane in `mode=full`).
77
+ *
78
+ * This reader is also the ONE place both madge lanes (the turn-end
79
+ * `runMadgeSpawn` and the session-start `runScanProject`) turn bytes into
80
+ * findings, so the argv's blind spot is recorded here rather than once per
81
+ * caller (#3436). `projectRoot` is the ledger subject, not the resolve
82
+ * `baseDir`: a single-file target resolves members against its own directory
83
+ * but the gap is a property of the project's scan, disclosed once per
84
+ * session/root.
54
85
  */
55
- export function parseMadgeSkips(stderr) {
56
- const lines = (stderr || "").split(/\r?\n/);
57
- const headerIdx = lines.findIndex((l) => /Skipped\s+\d+\s+file/i.test(l));
58
- if (headerIdx === -1)
59
- return { total: 0, local: [] };
60
- const total = Number.parseInt(lines[headerIdx].match(/Skipped\s+(\d+)/i)?.[1] ?? "0", 10) || 0;
61
- const specifiers = lines
62
- .slice(headerIdx + 1)
63
- .map((l) => l.trim())
64
- .filter(Boolean);
65
- const local = specifiers.filter((s) => s.startsWith(".") || s.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(s));
66
- return { total, local };
86
+ function parseMadgeCycles(stdout, baseDir, projectRoot) {
87
+ const parsed = JSON.parse(stdout || "[]");
88
+ const cycles = Array.isArray(parsed) ? parsed : [];
89
+ const circular = [];
90
+ const circularFiles = new Set();
91
+ for (const cycle of cycles) {
92
+ const resolvedPaths = cycle.map((f) => path.resolve(baseDir, f));
93
+ for (const f of resolvedPaths) {
94
+ circularFiles.add(f);
95
+ }
96
+ circular.push({
97
+ file: resolvedPaths[0],
98
+ path: resolvedPaths,
99
+ });
100
+ }
101
+ // madge's `--warning` is inert under `--json` (bin/cli.js:195 gates it on
102
+ // `!program.json`) and, ungated, prints the skip list to stdout where it
103
+ // would corrupt the JSON this reader parses, so no argv `buildMadgeArgs`
104
+ // emits can report which LOCAL files madge failed to resolve. A skipped
105
+ // local file can hide a real cycle, so the gap is disclosed once per
106
+ // session/root instead of being read as a clean graph (#3436).
107
+ recordDegradationOnce({
108
+ kind: "madge-skip-visibility-unavailable",
109
+ subject: projectRoot,
110
+ reason: "madge --warning is inert under --json; skipped-file visibility is unavailable for this scan",
111
+ });
112
+ return { circular, circularFiles };
67
113
  }
68
114
  /**
69
115
  * Defensive cap on concurrent madge spawns for a single `checkFilesBatch`
@@ -520,7 +566,6 @@ export class DependencyChecker {
520
566
  circular: spawnResult.circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
521
567
  checked: true,
522
568
  cacheHit: false,
523
- localSkips: spawnResult.localSkips,
524
569
  };
525
570
  }
526
571
  /**
@@ -542,31 +587,20 @@ export class DependencyChecker {
542
587
  this.log(`Check error: ${result.error.message}`);
543
588
  return { ok: false };
544
589
  }
545
- const output = result.stdout || "[]";
546
- const parsed = JSON.parse(output);
547
- // Madge --circular --json returns array of cycle arrays: [["a.ts", "b.ts"], ...]
548
- const cycles = Array.isArray(parsed) ? parsed : [];
549
- const circular = [];
550
- const circularFiles = new Set();
551
- for (const cycle of cycles) {
552
- const resolvedPaths = cycle.map((f) => path.resolve(projectRoot, f));
553
- for (const f of resolvedPaths) {
554
- circularFiles.add(f);
555
- }
556
- circular.push({
557
- file: resolvedPaths[0],
558
- path: resolvedPaths,
559
- });
560
- }
561
- const skips = parseMadgeSkips(result.stderr || "");
562
- if (skips.local.length > 0) {
563
- this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`);
564
- }
590
+ // madge resolves its printed members against the TARGET, which this
591
+ // lane points at a single FILE, so they come back relative to that
592
+ // file's directory — `a.ts`/`b.ts` for a `src/a.ts` target, and
593
+ // `../lib/b.ts` for a cycle that leaves it (measured with madge 8.0.0,
594
+ // see parseMadgeCycles). Resolving them against `projectRoot` instead
595
+ // anchors `<root>/a.ts`, a path that does not exist, and keys the
596
+ // shared `circularFiles` set with it (#3435). `path.dirname` of the
597
+ // target is the file-independent form of "the directory madge printed
598
+ // relative to"; a top-level target degenerates to `projectRoot`.
599
+ const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", path.dirname(normalized), projectRoot);
565
600
  return {
566
601
  ok: true,
567
602
  circular,
568
603
  circularFiles,
569
- localSkips: skips.local.length,
570
604
  };
571
605
  }
572
606
  catch (err) {
@@ -729,7 +763,6 @@ export class DependencyChecker {
729
763
  circular: spawnResult.circular.filter((d) => d.file === entry.normalized || d.path.includes(entry.normalized)),
730
764
  checked: true,
731
765
  cacheHit: false,
732
- localSkips: spawnResult.localSkips,
733
766
  });
734
767
  }
735
768
  // A batch that learned nothing publishes nothing — advancing the
@@ -800,25 +833,15 @@ export class DependencyChecker {
800
833
  this.log(`Scan error: ${result.error.message}`);
801
834
  return { circular: [], count: 0 };
802
835
  }
803
- const output = result.stdout || "{}";
804
- const data = JSON.parse(output);
805
- const circular = [];
806
- const circularFiles = new Set();
807
- for (const [file, deps] of Object.entries(data)) {
808
- if (Array.isArray(deps) && deps.length > 0) {
809
- const resolvedFile = path.resolve(file);
810
- circularFiles.add(resolvedFile);
811
- circular.push({
812
- file: resolvedFile,
813
- path: [resolvedFile, ...deps.map((d) => path.resolve(d))],
814
- });
815
- }
816
- }
836
+ // The scan's target IS `projectRoot`, so that is the base madge's
837
+ // relative members resolve against (#3428: this site used to read the
838
+ // cycle array as a graph and anchor every finding at `<hostCwd>/0`).
839
+ const { circular, circularFiles } = parseMadgeCycles(result.stdout || "", projectRoot, projectRoot);
817
840
  this.publishState(gen, circular, circularFiles);
818
- // #2154: the one madge site that parsed a graph for this root. Every
819
- // other return here (missing root, no top-level source file, madge
820
- // unavailable, spawn error, parse throw) is the SAME empty shape and
821
- // must not be read as "no cycles in this project".
841
+ // #2154: the one madge site that parsed madge's output for this root.
842
+ // Every other return here (missing root, no top-level source file,
843
+ // madge unavailable, spawn error, parse throw) is the SAME empty shape
844
+ // and must not be read as "no cycles in this project".
822
845
  return { circular, count: circular.length, analyzed: true };
823
846
  }
824
847
  catch (err) {
@@ -118,10 +118,42 @@ export const normalizeMessage = sharedNormalizeMessage;
118
118
  // file, and the module doc/comments above referencing `relativeFile`, keep
119
119
  // working unchanged.
120
120
  const relativeFile = sharedRelativeFile;
121
+ /**
122
+ * The SPAN component of a strict anchor: the `lineContentHash` of the
123
+ * diagnostic's OWN line in `content`. `undefined` when there is no line to
124
+ * hash — no content, no line, or a line outside the content's range.
125
+ *
126
+ * Exported for #3183. The widget store's retention lifetime asks the same
127
+ * question a strict anchor does — "is the marked line still in this file?" —
128
+ * so it reuses this derivation rather than re-spelling it; a second spelling is
129
+ * how the store's answer drifts from the filter's (the retention identity's own
130
+ * drift from `computeWeakAnchor`'s parts cost #3158 a round). Note what is NOT
131
+ * here: the cwd/path component `stableFindingId` adds. Both sides of a
132
+ * retention comparison are the same file, so the span alone discriminates
133
+ * exactly as well while needing no `cwd` — which is why the store can ask it at
134
+ * all (see `retentionIdentity` in `clients/widget-state.ts`).
135
+ */
136
+ export function strictAnchorSpan(content, line) {
137
+ if (content === undefined || line === undefined)
138
+ return undefined;
139
+ const lines = content.split(/\r?\n/);
140
+ if (line < 1 || line > lines.length)
141
+ return undefined;
142
+ return lineContentHash(lines[line - 1] ?? "");
143
+ }
144
+ /**
145
+ * Every span {@link strictAnchorSpan} could produce for `content` — one hash
146
+ * per line (#3183). Membership answers "does the line a mark was made against
147
+ * still exist in this file", wherever it has since moved to.
148
+ */
149
+ export function strictAnchorSpansIn(content) {
150
+ const spans = new Set();
151
+ for (const line of content.split(/\r?\n/))
152
+ spans.add(lineContentHash(line));
153
+ return spans;
154
+ }
121
155
  /** Site-specific anchor — see module doc. Used only for false-positive. */
122
156
  export function computeStrictAnchor(args) {
123
- const lines = args.content?.split(/\r?\n/);
124
- const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
125
157
  return stableFindingId("dd:", {
126
158
  cwd: args.cwd,
127
159
  filePath: args.filePath,
@@ -129,7 +161,11 @@ export function computeStrictAnchor(args) {
129
161
  args.tool ?? "",
130
162
  args.rule ?? "",
131
163
  normalizeMessage(args.message),
132
- lineContentHash(lineText),
164
+ // An absent/out-of-range line hashes the empty string, exactly as the
165
+ // pre-#3183 `lines[args.line - 1] ?? ""` fallback did: stable, but less
166
+ // resistant to a same-file/rule/message collision (see
167
+ // `DispositionAnchorArgs.content`).
168
+ strictAnchorSpan(args.content, args.line) ?? lineContentHash(""),
133
169
  ],
134
170
  });
135
171
  }
@@ -18,7 +18,7 @@
18
18
  * hold dispatch `Diagnostic`s, `applyLspFindingPolicy` for the probe lane,
19
19
  * which holds raw `LSPDiagnostic`s.
20
20
  */
21
- import { applyDispositions, hasAnyDispositionMarks, } from "../diagnostic-dispositions.js";
21
+ import { applyDispositions, applyDispositionsMultiFile, hasAnyDispositionMarks, } from "../diagnostic-dispositions.js";
22
22
  import { loadPiLensProjectConfig } from "../project-lens-config.js";
23
23
  import { retagAuxiliaryDiagnostics } from "./auxiliary-lsp.js";
24
24
  import { applyInlineSuppressions } from "./inline-suppressions.js";
@@ -129,6 +129,57 @@ function probeIdentities(canonical, raw) {
129
129
  export function renderedRuleIdentities(diagnostic) {
130
130
  return expandRenderedIdentities([[diagnostic.tool, diagnostic.rule]]);
131
131
  }
132
+ /**
133
+ * #3246: the identity spellings a mark against a surface that renders NEITHER
134
+ * the tool NOR the rule can carry — the turn-end unresolved-inline-blocker
135
+ * body, whose every line is `formatDiagnostics`' ` L<n>: <message>` (plus an
136
+ * optional fix hint). An agent marking from that text has only the message and
137
+ * the line to give `lens_diagnostic_mark`, whose `tool` AND `rule` parameters
138
+ * are both optional, so the mark's anchor carries neither; a mark made from the
139
+ * widget or `mode=full` carries the full canonical pair for the same finding.
140
+ * Honoring one spelling and not the other is exactly the non-convergence #3088
141
+ * reported, so both go through the SAME expansion `probeIdentities` uses. The
142
+ * bare spelling stays safe for this STOP-tier surface because a `"blocking"`
143
+ * diagnostic can only ever be dropped by the STRICT, content-bound
144
+ * false-positive anchor (`applyDispositions`' F1 rule), which still binds the
145
+ * normalized message and the flagged line's content hash.
146
+ */
147
+ export function inlineBlockerIdentities(diagnostic) {
148
+ return expandRenderedIdentities([
149
+ [diagnostic.tool, diagnostic.rule],
150
+ [undefined, undefined],
151
+ ]);
152
+ }
153
+ /**
154
+ * The whole stack for a turn-end PUSH surface that renders dispatch
155
+ * diagnostics for ONE file — the late-auxiliary drain (#3102) and the
156
+ * late-runner drain (#3248), which are the same shape: a post-freshness-gate
157
+ * `Diagnostic[]`, a `<file>:<line>:<col> [<rule>] <message>` rendering that
158
+ * prints the rule but never the tool, and a suppressed count the caller
159
+ * surfaces once per delivery (#1616).
160
+ *
161
+ * It exists so those two lanes cannot drift apart: this used to be open-coded
162
+ * at the late-auxiliary site, and #3248 would have made a second copy of the
163
+ * identical four arguments — the single-source rule's exact failure shape. The
164
+ * caller still owns its own content read, because the two lanes read on
165
+ * different schedules (the auxiliary drain reads once per FILE across several
166
+ * server pairs; the runner drain reads once per pending runner).
167
+ *
168
+ * `content` is `undefined` when the file could not be read: it becomes `""`,
169
+ * `FindingPolicyOptions.content`'s documented fail-open input, so the STRICT
170
+ * anchor cannot match and nothing is hidden over an I/O error (shape 48).
171
+ */
172
+ export function applyPushedFindingPolicy(findings, options) {
173
+ const { kept } = applyFindingPolicy(findings, {
174
+ cwd: options.cwd,
175
+ filePath: options.filePath,
176
+ content: options.content ?? "",
177
+ // mtime-cached, so several files in one drain cost one stat.
178
+ policyMap: loadProjectRulePolicyMap(options.cwd),
179
+ identities: renderedRuleIdentities,
180
+ });
181
+ return { kept, suppressed: findings.length - kept.length };
182
+ }
132
183
  /** Every rendered `(tool, rule)` spelling plus the `tool`-omitted form of
133
184
  * each, de-duplicated, in order. One derivation for both callers. */
134
185
  function expandRenderedIdentities(rendered) {
@@ -220,3 +271,69 @@ export function applyLspFindingPolicy(diagnostics, options) {
220
271
  };
221
272
  return { kept: survivorsOf(kept), inlineKept: survivorsOf(inlineKept) };
222
273
  }
274
+ /**
275
+ * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
276
+ * session-scan caches and formats them into advisory/blocker text — a
277
+ * reporting lane parallel to (and, before this fix, entirely bypassing)
278
+ * `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
279
+ * false-positive/won't-fix on one of these findings never suppressed it
280
+ * here, so it re-reported on every turn.
281
+ *
282
+ * Filters `findings` through the SAME anchor derivation the dispatch path
283
+ * and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
284
+ * `diagnostic-dispositions.ts`), keyed off each lane's own canonical
285
+ * `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
286
+ * identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
287
+ * already anchors a mark against, not a second, cloned identity that would
288
+ * silently diverge from what the agent actually marked.
289
+ *
290
+ * Returns the surviving findings plus how many were dropped, so a caller can
291
+ * still surface a "suppressed by disposition: N" trace (the #1616
292
+ * suppressed-bucket rule — a security finding must never vanish with no
293
+ * trace, even when the disposition that dropped it is working as intended).
294
+ */
295
+ export function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
296
+ if (findings.length === 0)
297
+ return { kept: findings, suppressed: 0 };
298
+ // #3248: every `(tool, rule)` spelling a mark against these findings can
299
+ // carry, not just the canonical one. NONE of these turn-end surfaces prints
300
+ // a tool — knip renders `<file>:<line> — <type>: <name>`, dead-code
301
+ // `unused <kind> <name>`, the security lanes `Potential secret: <rule>` —
302
+ // so an agent marking from what it was SHOWN has no tool to pass, and
303
+ // `lens_diagnostic_mark`'s `tool` parameter is optional. Honouring only the
304
+ // spelling `lens_diagnostics` happens to show is the #3088 non-convergence,
305
+ // and it is fixed HERE, once, for every caller of this helper rather than
306
+ // per lane. Widening is safe for the blocking members because a
307
+ // `semantic: "blocking"` finding can still only be dropped by the STRICT,
308
+ // content-bound false-positive anchor, which keeps binding the normalized
309
+ // message and the flagged line's content.
310
+ // The expanded candidate is a `DispositionCandidate` with a path, NOT a
311
+ // `ProjectDiagnostic`: the tool-omitted spelling has no `tool`, which that
312
+ // type requires. Only the anchor derivation reads it.
313
+ const candidates = findings.flatMap((finding) => {
314
+ const diagnostic = toDiagnostic(finding);
315
+ return renderedRuleIdentities(diagnostic).map((identity) => ({
316
+ finding,
317
+ candidate: {
318
+ filePath: diagnostic.filePath,
319
+ message: diagnostic.message,
320
+ ...(diagnostic.line !== undefined && { line: diagnostic.line }),
321
+ ...(diagnostic.semantic !== undefined && {
322
+ semantic: diagnostic.semantic,
323
+ }),
324
+ ...(identity.tool !== undefined && { tool: identity.tool }),
325
+ ...(identity.rule !== undefined && { rule: identity.rule }),
326
+ },
327
+ }));
328
+ });
329
+ const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.candidate), cwd, (d) => d.filePath));
330
+ const disposed = new Set();
331
+ for (const candidate of candidates) {
332
+ if (!survivors.has(candidate.candidate))
333
+ disposed.add(candidate.finding);
334
+ }
335
+ const kept = disposed.size === 0
336
+ ? findings
337
+ : findings.filter((finding) => !disposed.has(finding));
338
+ return { kept, suppressed: findings.length - kept.length };
339
+ }
@@ -12,8 +12,14 @@ export function detectIndentation(content) {
12
12
  const spaceCounts = lines
13
13
  .map((line) => line.match(/^ +(?=\S)/)?.[0].length ?? 0)
14
14
  .filter((count) => count > 0);
15
- if (tabs === 0 && spaceCounts.length === 0)
16
- return DEFAULT_INDENTATION;
15
+ if (tabs === 0 && spaceCounts.length === 0) {
16
+ // The formatter gate uses the shared hasDetectableIndentation predicate. If
17
+ // that predicate fired but structural masking removed every candidate line,
18
+ // there is no style to pin and the caller must take the #3038 skip valve
19
+ // instead of imposing the default width. Keep the historical default for
20
+ // content with no raw indentation, whose callers never enter this path.
21
+ return hasDetectableIndentation(content) ? undefined : DEFAULT_INDENTATION;
22
+ }
17
23
  if (tabs > spaceCounts.length)
18
24
  return { style: "tab", width: 1 };
19
25
  if (spaceCounts.length > tabs) {
@@ -11,6 +11,25 @@
11
11
  */
12
12
  import { normalizeRuleId } from "./rule-id-normalize.js";
13
13
  const SUPPRESS_RE = /(?:\/\/|#)\s*pi-lens-ignore:\s*(.+)/;
14
+ const TRAILING_REASON_RE = /(?:\s+--\s+|\s+—\s+|:\s+).+$/u;
15
+ export function splitInlineSuppressionPayload(payload) {
16
+ const reasonMatch = TRAILING_REASON_RE.exec(payload);
17
+ const trailingReason = reasonMatch?.[0];
18
+ if (reasonMatch === null || trailingReason === undefined) {
19
+ return { ruleList: payload, trailingReason: "" };
20
+ }
21
+ return {
22
+ ruleList: payload.slice(0, reasonMatch.index),
23
+ trailingReason,
24
+ };
25
+ }
26
+ export function parseInlineSuppressionRuleIds(payload) {
27
+ const { ruleList } = splitInlineSuppressionPayload(payload);
28
+ return ruleList
29
+ .split(",")
30
+ .map((rule) => rule.trim())
31
+ .filter(Boolean);
32
+ }
14
33
  /**
15
34
  * Normalize a rule id to the form a user writes in a `pi-lens-ignore` comment.
16
35
  * The napi scan and the ast-grep LSP tag the same rule as `ast-grep:<id>` /
@@ -37,10 +56,10 @@ export function applyInlineSuppressions(diagnostics, content) {
37
56
  const m = SUPPRESS_RE.exec(lines[i]);
38
57
  if (!m)
39
58
  continue;
40
- const rules = m[1]
41
- .split(",")
42
- .map((r) => r.trim())
43
- .filter(Boolean);
59
+ const payload = m[1];
60
+ if (payload === undefined)
61
+ continue;
62
+ const rules = parseInlineSuppressionRuleIds(payload);
44
63
  const suppressedLine = i + 1; // same line (1-based)
45
64
  const nextLine = i + 2; // next line (1-based)
46
65
  for (const ruleId of rules) {
@@ -15,22 +15,29 @@
15
15
  import * as path from "node:path";
16
16
  import { minimatch } from "../deps/minimatch.js";
17
17
  import { buildEffectiveAstGrepCatalog } from "./ast-grep-catalog.js";
18
+ import { isWindowsPath, toPosix } from "../path-utils.js";
18
19
  /**
19
20
  * True when `filePath` is carved out of a rule by one of its glob `patterns`.
20
21
  *
21
22
  * The glob is matched against `filePath` relative to `root`, forward-slashed.
22
23
  * Falls back to the absolute (slash-normalized) path when `filePath` isn't
23
- * under `root` (e.g. an out-of-tree temp file), so a glob like `scripts/**`
24
- * simply never matches rather than throwing.
24
+ * under `root` (e.g. an out-of-tree temp file). Leaf-file patterns retain that
25
+ * fallback for sinks such as a double-star logger leaf; directory carve-outs
26
+ * ending in a slash plus double-star remain root-contained so a project's CLI
27
+ * exemption cannot suppress a finding in an unrelated out-of-tree file.
25
28
  */
26
29
  export function isRuleIgnoredForPath(filePath, root, patterns) {
27
30
  if (!patterns || patterns.length === 0)
28
31
  return false;
29
- const relative = path.relative(root, filePath);
30
- const displayPath = (relative.startsWith("..") ? filePath : relative)
31
- .split(path.sep)
32
- .join("/");
33
- return patterns.some((pattern) => minimatch(displayPath, pattern, { dot: true }));
32
+ const pathApi = isWindowsPath(root) || isWindowsPath(filePath) ? path.win32 : path.posix;
33
+ const relative = pathApi.relative(root, filePath);
34
+ const outsideRoot = relative !== "" &&
35
+ (relative === ".." ||
36
+ relative.startsWith(`..${pathApi.sep}`) ||
37
+ pathApi.isAbsolute(relative));
38
+ const displayPath = toPosix(outsideRoot ? filePath : relative);
39
+ return patterns.some((pattern) => (!outsideRoot || !pattern.replaceAll("\\", "/").endsWith("/**")) &&
40
+ minimatch(displayPath, pattern, { dot: true }));
34
41
  }
35
42
  /**
36
43
  * Rule id → its `ignores` globs, for the effective ast-grep catalog at `root`.
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
2
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
4
  import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { PRIORITY } from "../priorities.js";
5
6
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
7
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
6
8
  const actionlint = createAvailabilityChecker("actionlint", ".exe");
7
9
  export function isGitHubWorkflowFile(filePath) {
8
10
  const normalized = filePath.replace(/\\/g, "/");
@@ -31,14 +33,18 @@ function toDiagnostic(issue, filePath) {
31
33
  matchedText: issue.snippet,
32
34
  };
33
35
  }
34
- export function parseActionlintJson(raw, filePath) {
36
+ export function parseActionlintJson(raw, filePath, cwd) {
35
37
  const trimmed = raw.trim();
36
38
  if (!trimmed)
37
39
  return [];
40
+ // #3295: actionlint resolves reusable-workflow and composite-action refs, so
41
+ // its `filepath` is not always the workflow we asked about.
42
+ const absTarget = path.resolve(cwd, filePath);
43
+ const isTarget = (issue) => !issue.filepath || pathsEqual(path.resolve(cwd, issue.filepath), absTarget);
38
44
  try {
39
45
  const parsed = JSON.parse(trimmed);
40
46
  const issues = Array.isArray(parsed) ? parsed : [parsed];
41
- return issues.map((issue) => toDiagnostic(issue, filePath));
47
+ return issues.flatMap((issue) => isTarget(issue) ? [toDiagnostic(issue, filePath)] : []);
42
48
  }
43
49
  catch {
44
50
  // Some actionlint versions or wrappers may emit one JSON object per line.
@@ -48,6 +54,8 @@ export function parseActionlintJson(raw, filePath) {
48
54
  continue;
49
55
  try {
50
56
  const parsed = JSON.parse(line);
57
+ if (!isTarget(parsed))
58
+ continue;
51
59
  diagnostics.push(toDiagnostic(parsed, filePath));
52
60
  }
53
61
  catch {
@@ -82,32 +90,22 @@ const actionlintRunner = {
82
90
  cwd,
83
91
  timeout: 15000,
84
92
  });
85
- const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
86
- let diagnostics = parseActionlintJson(raw, ctx.filePath);
87
- if (diagnostics.length === 0 && result.status !== 0 && raw.trim()) {
88
- diagnostics = [
89
- {
90
- id: `actionlint-${path.basename(ctx.filePath)}-failure`,
91
- message: raw.trim().split(/\r?\n/)[0] || "actionlint failed",
92
- filePath: ctx.filePath,
93
- line: 1,
94
- column: 1,
95
- severity: "error",
96
- semantic: "blocking",
97
- tool: "actionlint",
98
- rule: "actionlint",
99
- defectClass: "correctness",
100
- },
101
- ];
102
- }
103
- if (diagnostics.length === 0) {
104
- return { status: "succeeded", diagnostics: [], semantic: "none" };
105
- }
106
- return {
107
- status: "failed",
108
- diagnostics,
109
- semantic: "blocking",
110
- };
93
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
94
+ const parsed = parseToolRun("actionlint", {
95
+ result,
96
+ output,
97
+ // EXIT TABLE (actionlint 1.7.7 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
98
+ exitCodes: { ran: [1, 2] },
99
+ }, (raw) => parseActionlintJson(raw, ctx.filePath, cwd));
100
+ if (parsed.skipped)
101
+ return parsed.skipped;
102
+ return finishParsedRun({
103
+ tool: "actionlint",
104
+ ctx,
105
+ result,
106
+ diagnostics: parsed.diagnostics,
107
+ classify: () => ({ status: "failed", semantic: "blocking" }),
108
+ });
111
109
  },
112
110
  };
113
111
  export default actionlintRunner;