pi-lens 4.1.5 → 4.2.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 (219) hide show
  1. package/CHANGELOG.md +415 -0
  2. package/README.md +13 -8
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -1
  4. package/dist/clients/actionable-warnings.js +30 -4
  5. package/dist/clients/agent-behavior-client.js +2 -0
  6. package/dist/clients/analysed-root.js +1 -0
  7. package/dist/clients/ast-grep-tool-logger.js +1 -1
  8. package/dist/clients/bash-file-access.js +26 -0
  9. package/dist/clients/biome-client.js +13 -2
  10. package/dist/clients/blocker-freshness.js +403 -35
  11. package/dist/clients/bootstrap.js +6 -1
  12. package/dist/clients/bounded-telemetry.js +2 -0
  13. package/dist/clients/cache-observability.js +122 -1
  14. package/dist/clients/cascade-format.js +200 -4
  15. package/dist/clients/code-quality-warnings.js +10 -2
  16. package/dist/clients/complexity-client.js +5 -0
  17. package/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/dist/clients/config-resolve.js +6 -3
  19. package/dist/clients/config-schema.js +17 -0
  20. package/dist/clients/config-warn.js +2 -2
  21. package/dist/clients/dead-code-client.js +8 -1
  22. package/dist/clients/degradation-ledger.js +16 -1
  23. package/dist/clients/dependency-checker.js +5 -1
  24. package/dist/clients/diagnostic-dispositions.js +12 -1
  25. package/dist/clients/dispatch/ast-grep-catalog.js +65 -0
  26. package/dist/clients/dispatch/auxiliary-lsp.js +28 -0
  27. package/dist/clients/dispatch/dispatcher.js +47 -17
  28. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  29. package/dist/clients/dispatch/facts/import-facts.js +5 -1
  30. package/dist/clients/dispatch/finding-policy.js +222 -0
  31. package/dist/clients/dispatch/indent-detect.js +389 -13
  32. package/dist/clients/dispatch/integration.js +175 -25
  33. package/dist/clients/dispatch/rule-ignores.js +62 -0
  34. package/dist/clients/dispatch/runners/actionlint.js +2 -1
  35. package/dist/clients/dispatch/runners/ast-grep-napi.js +21 -41
  36. package/dist/clients/dispatch/runners/biome-check.js +8 -4
  37. package/dist/clients/dispatch/runners/cpp-check.js +4 -3
  38. package/dist/clients/dispatch/runners/credo.js +2 -1
  39. package/dist/clients/dispatch/runners/cue-vet.js +2 -1
  40. package/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  41. package/dist/clients/dispatch/runners/detekt.js +2 -1
  42. package/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  43. package/dist/clients/dispatch/runners/elixir-check.js +2 -1
  44. package/dist/clients/dispatch/runners/eslint.js +2 -1
  45. package/dist/clients/dispatch/runners/fish-indent.js +2 -1
  46. package/dist/clients/dispatch/runners/gleam-check.js +2 -1
  47. package/dist/clients/dispatch/runners/go-vet.js +2 -1
  48. package/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  49. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  50. package/dist/clients/dispatch/runners/helm-lint.js +3 -1
  51. package/dist/clients/dispatch/runners/helm-render.js +2 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +2 -1
  53. package/dist/clients/dispatch/runners/javac.js +2 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +2 -1
  55. package/dist/clients/dispatch/runners/lsp.js +19 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +3 -2
  57. package/dist/clients/dispatch/runners/mypy.js +2 -1
  58. package/dist/clients/dispatch/runners/oxlint.js +2 -1
  59. package/dist/clients/dispatch/runners/php-lint.js +2 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +2 -1
  61. package/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  62. package/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  63. package/dist/clients/dispatch/runners/pyright.js +2 -1
  64. package/dist/clients/dispatch/runners/rubocop.js +2 -1
  65. package/dist/clients/dispatch/runners/ruff.js +2 -2
  66. package/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  67. package/dist/clients/dispatch/runners/shellcheck.js +2 -1
  68. package/dist/clients/dispatch/runners/shfmt.js +4 -13
  69. package/dist/clients/dispatch/runners/spellcheck.js +2 -2
  70. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  71. package/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  72. package/dist/clients/dispatch/runners/stylelint.js +3 -2
  73. package/dist/clients/dispatch/runners/swiftlint.js +2 -1
  74. package/dist/clients/dispatch/runners/taplo.js +2 -1
  75. package/dist/clients/dispatch/runners/terragrunt.js +2 -1
  76. package/dist/clients/dispatch/runners/tflint.js +2 -1
  77. package/dist/clients/dispatch/runners/tree-sitter.js +2 -21
  78. package/dist/clients/dispatch/runners/trivy-config.js +2 -1
  79. package/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  80. package/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  81. package/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  82. package/dist/clients/dispatch/runners/vale.js +2 -1
  83. package/dist/clients/dispatch/runners/yamllint.js +4 -3
  84. package/dist/clients/dispatch/runners/zig-check.js +2 -1
  85. package/dist/clients/dispatch/types.js +4 -1
  86. package/dist/clients/disposition-publish.js +1 -0
  87. package/dist/clients/effective-config.js +12 -2
  88. package/dist/clients/extension-log.js +2 -0
  89. package/dist/clients/file-kinds.js +26 -1
  90. package/dist/clients/file-role.js +23 -6
  91. package/dist/clients/file-utils.js +156 -10
  92. package/dist/clients/finding-delivery-gate.js +60 -9
  93. package/dist/clients/format-service.js +39 -21
  94. package/dist/clients/formatters.js +113 -200
  95. package/dist/clients/generation-guard.js +6 -2
  96. package/dist/clients/git-guard.js +11 -3
  97. package/dist/clients/gitleaks-client.js +42 -6
  98. package/dist/clients/govulncheck-client.js +48 -7
  99. package/dist/clients/indent-retarget.js +78 -8
  100. package/dist/clients/installer/index.js +524 -161
  101. package/dist/clients/instance-reaper-state.js +7 -0
  102. package/dist/clients/instance-reaper.js +20 -10
  103. package/dist/clients/jscpd-client.js +9 -1
  104. package/dist/clients/knip-client.js +9 -2
  105. package/dist/clients/language-profile.js +35 -3
  106. package/dist/clients/language-registry.js +5 -1
  107. package/dist/clients/latency-logger.js +6 -1
  108. package/dist/clients/lens-config.js +4 -0
  109. package/dist/clients/lens-engine.js +12 -3
  110. package/dist/clients/lens-events.js +2 -1
  111. package/dist/clients/lens-flag-registry.js +79 -0
  112. package/dist/clients/live-bus-emitter.js +2 -1
  113. package/dist/clients/lsp/client.js +25 -21
  114. package/dist/clients/lsp/config.js +36 -7
  115. package/dist/clients/lsp/diagnostic-binding.js +24 -1
  116. package/dist/clients/lsp/document-drift.js +78 -0
  117. package/dist/clients/lsp/index.js +288 -45
  118. package/dist/clients/lsp/launch.js +16 -4
  119. package/dist/clients/lsp/server.js +79 -15
  120. package/dist/clients/lsp/workspace-diagnostics-cache.js +39 -5
  121. package/dist/clients/lsp-mutation.js +5 -3
  122. package/dist/clients/map-with-concurrency.js +12 -8
  123. package/dist/clients/mcp/analyze.js +3 -0
  124. package/dist/clients/mcp/session.js +38 -4
  125. package/dist/clients/ndjson-logger.js +24 -7
  126. package/dist/clients/observed-mutation.js +40 -2
  127. package/dist/clients/opaque-mutation-scan.js +23 -3
  128. package/dist/clients/opengrep-client.js +111 -14
  129. package/dist/clients/package-manager.js +2 -1
  130. package/dist/clients/path-utils.js +123 -3
  131. package/dist/clients/performance-report.js +5 -1
  132. package/dist/clients/php-cs-fixer-config.js +8 -3
  133. package/dist/clients/pipeline.js +77 -7
  134. package/dist/clients/project-diagnostics/cache.js +37 -5
  135. package/dist/clients/project-diagnostics/fresh-fetch.js +173 -18
  136. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +4 -1
  137. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  138. package/dist/clients/project-diagnostics/scanner.js +59 -3
  139. package/dist/clients/project-lens-config.js +48 -2
  140. package/dist/clients/read-guard-logger.js +2 -0
  141. package/dist/clients/read-guard.js +70 -11
  142. package/dist/clients/resource-sampler.js +147 -9
  143. package/dist/clients/review-graph-logger.js +2 -0
  144. package/dist/clients/ruff-client.js +18 -9
  145. package/dist/clients/runtime-agent-end.js +42 -5
  146. package/dist/clients/runtime-context.js +6 -2
  147. package/dist/clients/runtime-coordinator.js +82 -7
  148. package/dist/clients/runtime-session.js +33 -5
  149. package/dist/clients/runtime-tool-call.js +43 -12
  150. package/dist/clients/runtime-tool-result.js +334 -86
  151. package/dist/clients/runtime-turn.js +195 -18
  152. package/dist/clients/safe-spawn.js +258 -5
  153. package/dist/clients/scratch-tree-policy.js +6 -0
  154. package/dist/clients/security-scan-client.js +2 -2
  155. package/dist/clients/session-event-guard.js +119 -6
  156. package/dist/clients/sg-runner.js +25 -12
  157. package/dist/clients/sgconfig.js +79 -2
  158. package/dist/clients/situational-tool-telemetry.js +108 -0
  159. package/dist/clients/test-runner-client.js +258 -78
  160. package/dist/clients/test-runner-delivery.js +43 -0
  161. package/dist/clients/tool-agreement.js +423 -0
  162. package/dist/clients/tool-config.js +249 -0
  163. package/dist/clients/tool-cwd.js +270 -0
  164. package/dist/clients/tool-policy.js +163 -7
  165. package/dist/clients/tool-probe.js +53 -0
  166. package/dist/clients/tool-set-policy.js +42 -0
  167. package/dist/clients/tree-sitter-client.js +120 -0
  168. package/dist/clients/tree-sitter-query-loader.js +127 -189
  169. package/dist/clients/trivy-client.js +9 -1
  170. package/dist/clients/turn-context.js +52 -0
  171. package/dist/clients/widget-state.js +102 -3
  172. package/dist/clients/word-index.js +5 -2
  173. package/dist/clients/write-ordering-guard.js +7 -0
  174. package/dist/index.js +26968 -21741
  175. package/dist/mcp/analyze-cli.js +3 -2
  176. package/dist/mcp/server.js +140 -161
  177. package/dist/tools/activate-tools.js +10 -7
  178. package/dist/tools/ast-grep-outline.js +2 -13
  179. package/dist/tools/ast-grep-replace.js +8 -13
  180. package/dist/tools/ast-grep-search.js +83 -39
  181. package/dist/tools/effective-config.js +4 -3
  182. package/dist/tools/lens-diagnostic-mark.js +8 -11
  183. package/dist/tools/lens-diagnostics.js +516 -133
  184. package/dist/tools/lsp-diagnostics.js +265 -182
  185. package/dist/tools/lsp-navigation.js +20 -44
  186. package/dist/tools/module-report.js +5 -9
  187. package/dist/tools/project-report.js +9 -17
  188. package/dist/tools/render-compact.js +280 -1
  189. package/dist/tools/symbol-search.js +2 -2
  190. package/docs/agent-guide.md +3 -4
  191. package/docs/agent-tools.md +46 -22
  192. package/docs/audit1.md +5 -5
  193. package/docs/configuration.md +9 -0
  194. package/docs/dependencies.md +11 -0
  195. package/docs/dispositions.md +52 -0
  196. package/docs/environment-variables.md +2 -7
  197. package/docs/features.md +7 -7
  198. package/docs/globalconfig.md +14 -2
  199. package/docs/language-coverage.md +1 -0
  200. package/docs/mcp.md +13 -1
  201. package/docs/pi-lens-fixer.md +48 -18
  202. package/docs/pi-lens-investigator.md +38 -18
  203. package/docs/pi-lens-monitor.md +88 -0
  204. package/docs/pi-lens-retro.md +97 -0
  205. package/docs/pi-lens-reviewer.md +53 -31
  206. package/docs/pi-lens-subagent.md +12 -4
  207. package/docs/public-api-stability.md +1 -0
  208. package/docs/real-harness.md +46 -0
  209. package/docs/release-qa-baseline.md +27 -1
  210. package/docs/servercapabilities.md +1 -6
  211. package/docs/settings.md +44 -6
  212. package/docs/tools_improvement2.md +4 -4
  213. package/docs/usage.md +15 -3
  214. package/package.json +10 -1
  215. package/rules/tree-sitter-queries/typescript/sql-injection.yml +23 -7
  216. package/scripts/analyze-pi-lens-logs.mjs +40 -0
  217. package/skills/pi-lens-ast-grep/SKILL.md +23 -6
  218. package/skills/pi-lens-lsp-navigation/SKILL.md +26 -9
  219. package/dist/tools/ast-dump.js +0 -103
@@ -176,9 +176,50 @@ function newAttributionState() {
176
176
  injectedCharsSinceLastUsage: 0,
177
177
  newTranscriptCharsSinceLastUsage: 0,
178
178
  attributionCharsCapped: false,
179
+ injectedBytes: emptyInjectedBytes(),
180
+ injectedFindingsRepeated: 0,
181
+ findingIdentities: new Set(),
182
+ findingIdentityCapRecorded: false,
183
+ turnEndAdvisoryBytes: 0,
184
+ toolResultBytes: 0,
185
+ toolResultsTruncated: 0,
179
186
  summary: newCacheUsageSummary(),
180
187
  };
181
188
  }
189
+ const MAX_FINDING_IDENTITIES = 2_000;
190
+ function emptyInjectedBytes() {
191
+ return {
192
+ sessionGuidance: 0,
193
+ turnFindings: 0,
194
+ testFindings: 0,
195
+ agentNudge: 0,
196
+ turnEndAdvisory: 0,
197
+ other: 0,
198
+ };
199
+ }
200
+ function sourceKey(source) {
201
+ return {
202
+ "session-guidance": "sessionGuidance",
203
+ "turn-findings": "turnFindings",
204
+ "test-findings": "testFindings",
205
+ "agent-nudge": "agentNudge",
206
+ }[source];
207
+ }
208
+ /** Best-effort, language-neutral identity extraction from rendered findings. */
209
+ function findingIdentities(text) {
210
+ const out = [];
211
+ for (const line of text.split(/\r?\n/)) {
212
+ const location = line.match(/(?:^|\s)([^\s:]+):(\d+)(?::\d+)?/);
213
+ if (!location)
214
+ continue;
215
+ const rule = line.match(/(?:rule|code)\s*[:=]\s*([\w./-]+)/i)?.[1] ??
216
+ line.match(/\(([\w./-]+)\)\s*$/)?.[1] ??
217
+ line.match(/:\d+(?::\d+)?\s+([\w./-]+)/)?.[1];
218
+ if (rule)
219
+ out.push(`${location[1]}:${location[2]}:${rule}`);
220
+ }
221
+ return out;
222
+ }
182
223
  /**
183
224
  * Fetch (creating if absent) one session's attribution state, refreshing LRU
184
225
  * recency and evicting the oldest entries past the cap — same bounding as
@@ -688,10 +729,37 @@ export function observeCacheContext(args) {
688
729
  countsCapped: sliceSize.capped,
689
730
  };
690
731
  });
732
+ const state = attributionFor(attributionKey(args.sessionId, args.sessionRole));
733
+ for (const slice of injectionSlices) {
734
+ const key = sourceKey(slice.source);
735
+ const bytes = measureInjectedMessages(slice.messages).bytes;
736
+ state.injectedBytes[key] += bytes;
737
+ for (const message of slice.messages) {
738
+ for (const identity of findingIdentities(typeof message.content === "string" ? message.content : "")) {
739
+ if (state.findingIdentities.has(identity)) {
740
+ state.injectedFindingsRepeated += 1;
741
+ continue;
742
+ }
743
+ if (state.findingIdentities.size >= MAX_FINDING_IDENTITIES) {
744
+ if (!state.findingIdentityCapRecorded) {
745
+ state.findingIdentityCapRecorded = true;
746
+ logLatency({
747
+ type: "phase",
748
+ filePath: "<pi-lens>",
749
+ phase: "injected_finding_identity_cap",
750
+ durationMs: 0,
751
+ metadata: { cap: MAX_FINDING_IDENTITIES },
752
+ });
753
+ }
754
+ continue;
755
+ }
756
+ state.findingIdentities.add(identity);
757
+ }
758
+ }
759
+ }
691
760
  const messageCountCapped = existingMessages.length > MAX_REPORTED_MESSAGES ||
692
761
  resultMessages.length > MAX_REPORTED_MESSAGES;
693
762
  recordContextAttribution(attributionKey(args.sessionId, args.sessionRole), existingMessages, sizes);
694
- const state = attributionFor(attributionKey(args.sessionId, args.sessionRole));
695
763
  state.sequenceHashTruncatedSinceLastUsage ||=
696
764
  beforeSequence.truncated ||
697
765
  beforeSequence.contentTruncated ||
@@ -721,6 +789,10 @@ export function observeCacheContext(args) {
721
789
  injectedMessageCountCapped: injectedMessages.length > MAX_REPORTED_MESSAGES,
722
790
  injectedChars: sizes.chars,
723
791
  injectedBytes: sizes.bytes,
792
+ injectedBytesBySource: Object.fromEntries(sourceBreakdown.map((entry) => [
793
+ sourceKey(entry.source),
794
+ entry.bytes,
795
+ ])),
724
796
  injectedEstimatedTokens: estimateTokens(sizes.chars),
725
797
  injectedTokenBasis: "chars-per-token-4-estimate-not-provider-measured",
726
798
  injectedCountsCapped: sizes.capped,
@@ -881,6 +953,13 @@ export function logCacheUsage(message, dbg, context) {
881
953
  const injectedCharsSinceLastTurn = state.injectedCharsSinceLastUsage;
882
954
  const newTranscriptCharsSinceLastTurn = state.newTranscriptCharsSinceLastUsage;
883
955
  const attributionCharsCapped = state.attributionCharsCapped;
956
+ const injectedBytes = {
957
+ ...state.injectedBytes,
958
+ turnEndAdvisory: state.turnEndAdvisoryBytes + state.injectedBytes.turnEndAdvisory,
959
+ };
960
+ const injectedFindingsRepeated = state.injectedFindingsRepeated;
961
+ const toolResultBytes = state.toolResultBytes;
962
+ const toolResultsTruncated = state.toolResultsTruncated;
884
963
  // This record is the turn boundary: reset the per-turn accumulators and
885
964
  // re-arm the prefix-break flag so the next verdict describes the NEXT gap.
886
965
  state.lastUsageAtMs = nowMs;
@@ -898,6 +977,11 @@ export function logCacheUsage(message, dbg, context) {
898
977
  state.injectedCharsSinceLastUsage = 0;
899
978
  state.newTranscriptCharsSinceLastUsage = 0;
900
979
  state.attributionCharsCapped = false;
980
+ state.injectedBytes = emptyInjectedBytes();
981
+ state.injectedFindingsRepeated = 0;
982
+ state.turnEndAdvisoryBytes = 0;
983
+ state.toolResultBytes = 0;
984
+ state.toolResultsTruncated = 0;
901
985
  // Clear the request stamp too: the next turn measures from ITS request, and
902
986
  // a turn whose `context` call pi-lens never saw must fall back rather than
903
987
  // reuse this one.
@@ -939,6 +1023,13 @@ export function logCacheUsage(message, dbg, context) {
939
1023
  injectedCharsSinceLastTurn,
940
1024
  newTranscriptCharsSinceLastTurn,
941
1025
  attributionCharsCapped,
1026
+ injectedBytes,
1027
+ injectedFindingsRepeated,
1028
+ // Delivered tool-result aggregation over the turn (#2800 item 7):
1029
+ // the sum of each delivered footer's bytes= figure and the count
1030
+ // of results whose bound fired.
1031
+ toolResultBytes,
1032
+ toolResultsTruncated,
942
1033
  ...(context
943
1034
  ? {
944
1035
  // MessageEndEvent has no request/context id in the host API. These
@@ -1126,6 +1217,29 @@ export function clearCachePrefixSession(sessionId, sessionRole) {
1126
1217
  // a stale gap baseline and a stale prefix-break flag.
1127
1218
  attributionBySession.delete(key);
1128
1219
  }
1220
+ /** Record the exact quiet-window summary content handed to pi.sendMessage. */
1221
+ export function recordTurnEndAdvisoryBytes(sessionId, bytes, sessionRole = "primary") {
1222
+ const state = attributionFor(attributionKey(sessionId, sessionRole));
1223
+ state.turnEndAdvisoryBytes = Math.min(MAX_INJECTED_BYTES, state.turnEndAdvisoryBytes + Math.max(0, bytes));
1224
+ }
1225
+ /**
1226
+ * Fold one delivered tool result into the per-turn aggregation the
1227
+ * `cache_usage` row reports as `toolResultBytes` / `toolResultsTruncated`
1228
+ * (#2800 item 7). `bytes` is the delivered payload's UTF-8 byte count as
1229
+ * stamped in the result's own footer, and `truncated` is the footer's
1230
+ * truncated flag. Called from each host adapter's delivery seam; the row is
1231
+ * emitted by `logCacheUsage`, which resets both figures at the turn boundary.
1232
+ * The byte sum saturates at the attribution bound so the row stays bounded.
1233
+ */
1234
+ export function recordToolResultDelivery(args) {
1235
+ const state = attributionFor(attributionKey(args.sessionId, args.sessionRole));
1236
+ const bytes = Number.isFinite(args.bytes)
1237
+ ? Math.max(0, Math.floor(args.bytes))
1238
+ : 0;
1239
+ state.toolResultBytes = Math.min(MAX_ATTRIBUTION_BYTES, state.toolResultBytes + bytes);
1240
+ if (args.truncated === true)
1241
+ state.toolResultsTruncated += 1;
1242
+ }
1129
1243
  /**
1130
1244
  * Emit one bounded session summary and retire its attribution state. The
1131
1245
  * summary contains fixed-key counters only; it never serializes transcript or
@@ -1165,6 +1279,13 @@ export function resetCachePrefixObservation() {
1165
1279
  prefixHashBySession.clear();
1166
1280
  attributionBySession.clear();
1167
1281
  }
1282
+ export function resetCacheFindingIdentitiesSession(sessionId, sessionRole) {
1283
+ const state = attributionBySession.get(attributionKey(sessionId, sessionRole));
1284
+ if (!state)
1285
+ return;
1286
+ state.findingIdentities.clear();
1287
+ state.findingIdentityCapRecorded = false;
1288
+ }
1168
1289
  export function _attributionBySessionHasForTests(key) {
1169
1290
  return attributionBySession.has(key);
1170
1291
  }
@@ -1,3 +1,8 @@
1
+ import * as fs from "node:fs";
2
+ import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
3
+ import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
4
+ import { detectFileRole } from "./file-role.js";
5
+ import { logLatency } from "./latency-logger.js";
1
6
  import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
2
7
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
3
8
  export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
@@ -60,20 +65,64 @@ export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
60
65
  * Lives here rather than inline in the quiet-window callback so the delivery
61
66
  * path (reconcile → run → turn_end) is testable end to end; index.ts only wires
62
67
  * it to `runtime.appendCascadeRun`.
68
+ *
69
+ * #3102: the survivors are what the agent READS, so they go through the same
70
+ * `clients/dispatch/finding-policy.ts` stack — inline `pi-lens-ignore` → stored
71
+ * dispositions → the project's `.pi-lens.json` rule policy — that the per-edit
72
+ * dispatcher, `mode=full` and the `source=lsp` probe lane apply. Without it a
73
+ * cold-neighbour ERROR the agent already marked `false-positive` came back on
74
+ * every quiet-window reconcile.
63
75
  */
64
76
  export function buildResolvedFoundCascadeRun(cwd, neighbor) {
65
77
  const { filePath } = neighbor;
66
- const diagnostics = convertLspDiagnostics(neighbor.diagnostics.filter((d) => d.severity === 1), filePath, { tool: "lsp" });
67
- if (diagnostics.length === 0)
78
+ const errors = neighbor.diagnostics.filter((d) => d.severity === 1);
79
+ if (errors.length === 0)
68
80
  return undefined;
81
+ const policyStart = Date.now();
82
+ // This lane's roots are one and the same: index.ts hands `runtime.projectRoot`
83
+ // in as `cwd`, so the retag's config lookup and the disposition/rule-policy
84
+ // store read the same directory. The in-lane cascade's do NOT — see
85
+ // `applyCascadeDisplayPolicy`.
86
+ const { diagnostics, suppressed, auxSuppressed, total } = applyCascadeDisplayPolicy(errors, {
87
+ cwd,
88
+ policyRoot: cwd,
89
+ filePath,
90
+ });
91
+ // One bounded record per RUN, never one per finding (AGENTS.md "bounded
92
+ // observability"). This is a PUSH surface: silence after a mark is the mark
93
+ // working, not a clean verdict, so the count is recorded here rather than
94
+ // re-announced to the agent on every reconcile. `durationMs` covers the
95
+ // content read too — the latency this fold added to the quiet-window
96
+ // callback, measurable in the same per-phase record as every other cost.
97
+ recordCascadeFindingPolicy({
98
+ filePath,
99
+ durationMs: Date.now() - policyStart,
100
+ suppressed,
101
+ total,
102
+ auxSuppressed,
103
+ });
104
+ // No zero-length early return here: `formatCascadeNeighborDiagnostics`
105
+ // renders "" for a neighbour with no diagnostics and the `!formatted` guard
106
+ // below already returns `undefined` for it — a second check was mutation-
107
+ // inert (M7: deleting it left all 7 cascade cases green).
69
108
  const neighbors = [
70
109
  { filePath, reason: "references", diagnostics, lspTouched: true },
71
110
  ];
72
- const formatted = formatCascadeNeighborDiagnostics(cwd, neighbors, {
111
+ const rendered = formatCascadeNeighborDiagnostics(cwd, neighbors, {
73
112
  noun: "cold neighbor",
74
113
  });
75
- if (!formatted)
114
+ if (!rendered)
76
115
  return undefined;
116
+ // #1616 / #3102 AC 4, round 2 F2: a delivery that still has something to say
117
+ // states what it dropped, once per delivery — the same sentence the
118
+ // late-auxiliary advisory renders (`clients/runtime-turn.ts`). Policy drops
119
+ // only: an aux drop is the file's own suppression comment, which the
120
+ // per-edit dispatch path honours silently too, and it stays in the record
121
+ // above. A delivery with NOTHING left says nothing at all — silence on a
122
+ // push surface is not a claim that the neighbour is clean.
123
+ const formatted = suppressed > 0
124
+ ? `${rendered}\nsuppressed by disposition: ${suppressed} finding(s) (marked false-positive or won't-fix).`
125
+ : rendered;
77
126
  return {
78
127
  filePath,
79
128
  result: {
@@ -93,3 +142,150 @@ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
93
142
  diagnosticCount: diagnostics.length,
94
143
  };
95
144
  }
145
+ /**
146
+ * The ONE convert → retag → `applyFindingPolicy` derivation every cascade
147
+ * DISPLAY lane runs before its diagnostics reach the agent (#3102/#3157).
148
+ *
149
+ * Five lanes render `CascadeNeighborResult.diagnostics` through
150
+ * `formatCascadeNeighborDiagnostics`: the quiet-window builder above, and the
151
+ * four in-lane sites in `clients/dispatch/integration.ts` (passive cold
152
+ * snapshot, fresh touch, touch-error fallback, degraded fallback). Each one
153
+ * used to open-code `convertLspDiagnostics(...)` and render the result raw, so
154
+ * a neighbour ERROR the agent had marked `false-positive` was hidden by
155
+ * `mode=delta`/`mode=full`/the per-edit dispatcher/the probe lane and STILL
156
+ * re-reported by the cascade block on every edit that cascaded to it (#3157).
157
+ *
158
+ * TWO ROOTS, deliberately separate (the #1030 rule):
159
+ * - `cwd` is the LANGUAGE root, and only `retagAuxiliaryDiagnostics` sees it
160
+ * — `profile.allowBlocking(cwd)` looks for a local opengrep/zizmor/typos
161
+ * config, exactly as the per-edit runner (`runners/lsp.ts`) passes
162
+ * `ctx.cwd`.
163
+ * - `policyRoot` is the PROJECT root, and the disposition store and the
164
+ * `.pi-lens.json` rule map are read from it — `lens_diagnostic_mark` writes
165
+ * under `runtime.projectRoot` (`index.ts`), so reading from a nested
166
+ * language root in a monorepo opens a different
167
+ * `diagnostic-dispositions.json` and silently no-ops every mark. This is
168
+ * what `dispatcher.ts` already does with `ctx.projectRoot ?? ctx.cwd`.
169
+ *
170
+ * The auxiliary drop set (native `# nosemgrep`-style comments, `skipTestFiles`)
171
+ * is KEPT: every caller's diagnostics come straight off a client cache or a
172
+ * `touchFile` result, so nothing upstream ran `applyAuxiliarySuppressions`
173
+ * (only the `mode=full` workspace sweep does, `clients/lsp/index.ts`) and this
174
+ * is the FIRST application, not a double-apply — which is why the probe lane
175
+ * ignores it and these lanes do not.
176
+ *
177
+ * No `range.start.line` pre-partition here, unlike the late-auxiliary drain,
178
+ * whose own comment gives the alignment reason (`clients/runtime-turn.ts`,
179
+ * above its `anchored` filter): `convertLspDiagnostics` drops line-less
180
+ * entries, which would break the 1:1 index pairing `retagAuxiliaryDiagnostics`
181
+ * needs. That reason holds here too — it is simply already satisfied. Every
182
+ * caller reads the same `client.getAllDiagnostics()` / `touchFile` map, whose
183
+ * `mergeDiagnosticLists` (`clients/lsp/client.ts`) dereferences
184
+ * `diagnostic.range.start.line` unguarded, so a line-less entry throws long
185
+ * before any builder sees it and `converted` is always 1:1 with `errors`.
186
+ *
187
+ * Reads nothing when there is nothing to render, and never logs: the RECORD is
188
+ * the caller's decision, because the in-lane lane walks up to
189
+ * `CASCADE_NEIGHBOUR_BUDGET` neighbours per edit and must aggregate one row per
190
+ * run rather than one per neighbour.
191
+ */
192
+ export function applyCascadeDisplayPolicy(
193
+ /** ERROR-severity raw diagnostics, already capped by the caller's own
194
+ * display cap (`MAX_PER_FILE` in-lane; uncapped for the single-neighbour
195
+ * quiet-window run). */
196
+ errors, options) {
197
+ if (errors.length === 0) {
198
+ return { diagnostics: [], suppressed: 0, auxSuppressed: 0, total: 0 };
199
+ }
200
+ const { cwd, policyRoot, filePath } = options;
201
+ // `undefined` → the fail-open empty string: inline suppression becomes a
202
+ // no-op and a STRICT `false-positive` anchor hashes an empty line, so a
203
+ // finding is never hidden by an I/O error (AGENTS.md shape 48).
204
+ const content = options.content ?? readNeighborContent(filePath);
205
+ const converted = convertLspDiagnostics(errors, filePath);
206
+ // #692/#3046: identity comes from the ONE shared derivation every other
207
+ // surface anchors a mark against — never a hardcoded `tool: "lsp"`.
208
+ const retained = retagAuxiliaryDiagnostics(converted, errors, content ?? "", {
209
+ cwd,
210
+ fileRole: detectFileRole(filePath, content),
211
+ });
212
+ const { kept } = applyFindingPolicy(retained, {
213
+ cwd: policyRoot,
214
+ filePath,
215
+ content: content ?? "",
216
+ policyMap: loadProjectRulePolicyMap(policyRoot),
217
+ identities: renderedRuleIdentities,
218
+ });
219
+ return {
220
+ diagnostics: options.displayCap === undefined
221
+ ? kept
222
+ : kept.slice(0, options.displayCap),
223
+ // Policy drops only, DISJOINT from `auxSuppressed` — the same split the
224
+ // `late_auxiliary_findings` record uses, so one operator reading both
225
+ // records does not have to know that one nests and the other does not.
226
+ suppressed: retained.length - kept.length,
227
+ auxSuppressed: converted.length - retained.length,
228
+ total: converted.length,
229
+ };
230
+ }
231
+ /**
232
+ * The ONE `cascade_finding_policy` row both cascade display lanes write.
233
+ *
234
+ * Gated on EITHER counter (#3102 round 2, F1): gating on the policy count alone
235
+ * made an aux-only drop — an ERROR master rendered, removed by the profile's own
236
+ * `# nosemgrep` / `skipTestFiles` rule — vanish with no row at all, the
237
+ * silent-drop shape this record exists to prevent (AGENTS.md shape 10). The
238
+ * `late_auxiliary_findings` twin reports both counters every drain.
239
+ */
240
+ export function recordCascadeFindingPolicy(entry) {
241
+ const inputTruncated = entry.inputTruncated ?? 0;
242
+ if (entry.suppressed === 0 &&
243
+ entry.auxSuppressed === 0 &&
244
+ inputTruncated === 0)
245
+ return;
246
+ logLatency({
247
+ type: "phase",
248
+ toolName: "cascade",
249
+ filePath: entry.filePath,
250
+ phase: "cascade_finding_policy",
251
+ durationMs: entry.durationMs,
252
+ metadata: {
253
+ suppressed: entry.suppressed,
254
+ total: entry.total,
255
+ auxSuppressed: entry.auxSuppressed,
256
+ ...(inputTruncated > 0 && { inputTruncated }),
257
+ },
258
+ });
259
+ }
260
+ /**
261
+ * The neighbour's current bytes, for the two content-bound halves of the
262
+ * policy stack (inline `pi-lens-ignore` and the STRICT `false-positive`
263
+ * anchor). One synchronous read, paid only once a neighbour actually has ERROR
264
+ * diagnostics to render — the same read `mode=full` pays per flagged file and
265
+ * the probe lane pays per cache replay.
266
+ *
267
+ * SYNC here, while the in-lane cascade's own neighbour read at
268
+ * `clients/dispatch/integration.ts` (the active touch, under its "A6: async
269
+ * read to avoid blocking event loop on network-mounted drives" comment) is
270
+ * ASYNC. The asymmetry is deliberate and measured, not an oversight: that read
271
+ * happens for EVERY actively-touched neighbour in a parallel fan-out, while
272
+ * this one is paid at most once per DELIVERING neighbour — 0.0125 ms per
273
+ * neighbour measured on 40 real 15.6 KiB sources (#3157 AC 3), on loops that
274
+ * already do synchronous fs work (`nodeFs.existsSync` in the degraded
275
+ * fallback, and the `boundToCurrentDisk` verify whenever the producer attached
276
+ * a content hash and its (path, mtime, size) memo misses —
277
+ * `clients/lsp/diagnostic-binding.ts`). An async read here would force the
278
+ * quiet-window builder's synchronous callback to become async for no
279
+ * measurable gain.
280
+ *
281
+ * `undefined` on any failure: the caller degrades to the content-free half
282
+ * rather than hiding a finding it could not identify.
283
+ */
284
+ function readNeighborContent(filePath) {
285
+ try {
286
+ return fs.readFileSync(filePath, "utf-8");
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ }
@@ -4,6 +4,7 @@ import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
4
4
  import { displayProjectDataPath, getProjectDataDir } from "./file-utils.js";
5
5
  import { normalizeMessage, stableFindingId } from "./finding-identity.js";
6
6
  import { normalizeMapKey } from "./path-utils.js";
7
+ import { resolveLensToolName } from "./tool-config.js";
7
8
  /**
8
9
  * Report-budget precedence (#1777). A hint is an opinion; a warning is a
9
10
  * finding. When the report is capped, warnings must be spent first, or an
@@ -239,7 +240,7 @@ export function appendCodeQualityWarningsHistory(cwd, report) {
239
240
  * {@link formatActionableWarningsAdvisory} (#2521): this advisory carried the
240
241
  * identical hardcoded-legacy-path defect, one cache key over.
241
242
  */
242
- export function formatCodeQualityWarningsAdvisory(report, cwd) {
243
+ export function formatCodeQualityWarningsAdvisory(report, cwd, host = "pi") {
243
244
  if (report.summary.warnings === 0)
244
245
  return undefined;
245
246
  const topRules = report.summary.topRules
@@ -261,6 +262,9 @@ export function formatCodeQualityWarningsAdvisory(report, cwd) {
261
262
  .filter(Boolean)
262
263
  .join(", ")
263
264
  : "";
265
+ // #2535 F3: a known tool with no mapping on this host resolves to
266
+ // undefined — omit the instruction rather than naming a dead tool.
267
+ const diagnosticsTool = resolveLensToolName("lens_diagnostics", host);
264
268
  return [
265
269
  `Code-quality warnings introduced/touched this turn: ${report.summary.warnings} across ${report.summary.files} file(s).`,
266
270
  tiers
@@ -269,7 +273,11 @@ export function formatCodeQualityWarningsAdvisory(report, cwd) {
269
273
  topRules ? `Top rules: ${topRules}` : undefined,
270
274
  // #2521: tool route first (`mode=delta` covers the code-quality cache
271
275
  // as well as the actionable one), resolved path second.
272
- "Use lens_diagnostics with mode=delta to inspect these warnings.",
276
+ // #2535 F3: omit when unmapped on this host, never name a dead tool
277
+ // (pinned available on both hosts; defensive only).
278
+ diagnosticsTool
279
+ ? `Use ${diagnosticsTool} with mode=delta to inspect these warnings.`
280
+ : undefined,
273
281
  "No action required unless you are already refactoring these areas.",
274
282
  `Raw report (only if you need the JSON): ${displayProjectDataPath(cwd, "cache", "code-quality-warnings.json")}`,
275
283
  ]
@@ -13,6 +13,7 @@
13
13
  import { createSubsystemLogger } from "./extension-log.js";
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { calcCyclomaticComplexity } from "./dispatch/facts/function-facts.js";
16
17
  import { firstChildOfType, withTreeSitterRoot, isComplexityLanguageId, isComplexitySupportedFile, resolveTreeSitterLanguage, walk, } from "./tree-sitter-shared.js";
17
18
  // JS/TS/JSX share one grammar shape.
18
19
  const JSTS = {
@@ -190,6 +191,9 @@ function isLogicalOp(node, nodes) {
190
191
  }
191
192
  /** Cyclomatic contribution of a subtree: decision points + logical operators. */
192
193
  function subtreeCyclomatic(root, nodes) {
194
+ // Dispatch uses 1-based McCabe; this client exposes 0-based contributions (#2697).
195
+ if (nodes === JSTS)
196
+ return calcCyclomaticComplexity(root) - 1;
193
197
  let cc = 0;
194
198
  walk(root, (n) => {
195
199
  if (nodes.decision.has(n.type))
@@ -438,6 +442,7 @@ export class ComplexityClient {
438
442
  aiCommentPatterns: countAICommentPatterns(content),
439
443
  singleUseFunctions: countSingleUseFunctions(functions),
440
444
  tryCatchCount: countTryCatch(root, nodes),
445
+ functions,
441
446
  };
442
447
  }
443
448
  aggregateFunctionStats(functions) {
@@ -171,6 +171,8 @@ export const CONFIG_DIAGNOSTIC_CODES = {
171
171
  * suppressed" about a configuration that applied nothing at all.
172
172
  */
173
173
  PILENS_CFG_0008: "config resolution failed; whole configuration ignored",
174
+ /** A tool key is unknown or names a required, non-disableable tool. */
175
+ PILENS_CFG_0009: "unknown or non-disableable tool config key ignored",
174
176
  };
175
177
  /** The shape every code in the namespace must match. */
176
178
  export const CONFIG_DIAGNOSTIC_CODE_PATTERN = /^PILENS_CFG_\d{4}$/;
@@ -740,8 +740,8 @@ export function reportPiLensConfigRecords(records) {
740
740
  */
741
741
  export function ignoredRecordCollector(configPath, tier) {
742
742
  const noted = [];
743
- const note = (reason) => {
744
- noted.push({
743
+ const note = (reason, code = "PILENS_CFG_0001") => {
744
+ let notedRecord = {
745
745
  code: "PILENS_CFG_0001",
746
746
  file: configPath,
747
747
  key: "",
@@ -752,7 +752,10 @@ export function ignoredRecordCollector(configPath, tier) {
752
752
  sourceText: reason.sourceText,
753
753
  }),
754
754
  tier,
755
- });
755
+ };
756
+ if (code !== "PILENS_CFG_0001")
757
+ notedRecord.code = code;
758
+ noted.push(notedRecord);
756
759
  };
757
760
  return {
758
761
  note,
@@ -42,6 +42,7 @@ import { LEGACY_ROOT_LSP_KEYS, LSP_NAMESPACE_KEY } from "./config-locations.js";
42
42
  // every config loader's import graph for one type alias.
43
43
  import { DENY_KEY, } from "./config-core/schema.js";
44
44
  import { flagConfigSectionKeys, GLOBAL_NON_FLAG_CONFIG_SECTIONS, LENS_FLAGS, PROJECT_FOREIGN_CONFIG_NAMESPACES, PROJECT_NON_FLAG_CONFIG_SECTIONS, } from "./lens-flag-registry.js";
45
+ import { LENS_TOOL_NAMES } from "./tool-config.js";
45
46
  /** The JSON Schema dialect the published artifact declares. */
46
47
  const CONFIG_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
47
48
  /**
@@ -154,6 +155,22 @@ function buildConfigSchema() {
154
155
  ]) {
155
156
  properties[key] = opaque("stable");
156
157
  }
158
+ properties.tools = {
159
+ type: "object",
160
+ additionalProperties: true,
161
+ properties: Object.fromEntries(LENS_TOOL_NAMES.map((name) => [
162
+ name,
163
+ {
164
+ type: "object",
165
+ additionalProperties: true,
166
+ properties: {
167
+ enabled: { type: "boolean", [STABILITY_TIER_KEY]: "experimental" },
168
+ },
169
+ [STABILITY_TIER_KEY]: "experimental",
170
+ },
171
+ ])),
172
+ [STABILITY_TIER_KEY]: "stable",
173
+ };
157
174
  // Namespaces owned by another tool that ride in the same file (`trivy`,
158
175
  // `helm`). Reserved so they survive validation, `experimental` because their
159
176
  // shape belongs to those runners, not to this schema.
@@ -22,7 +22,7 @@
22
22
  * extraction — the prose is not API (the code is), but silently rewording a
23
23
  * warning inside a refactor is still not a thing this PR does.
24
24
  */
25
- import { DEPRECATED_CONFIG_SURFACES, } from "./config-diagnostic-codes.js";
25
+ import { DEPRECATED_CONFIG_SURFACES, withConfigDiagnosticCode, } from "./config-diagnostic-codes.js";
26
26
  import { recordDegradationOnce } from "./degradation-ledger.js";
27
27
  import { errorClassName } from "./error-class.js";
28
28
  import { logExtension } from "./extension-log.js";
@@ -378,7 +378,7 @@ export function warnIgnoredConfigOnce(options) {
378
378
  logExtension({
379
379
  subsystem,
380
380
  level: "warn",
381
- message,
381
+ message: withConfigDiagnosticCode(message, code),
382
382
  metadata: { configPath: file, reason, code, ...(key ? { key } : {}) },
383
383
  });
384
384
  // HUMAN-audience too: a config the user wrote is being ignored. Routed
@@ -19,6 +19,7 @@ import { findLocalBinsAt, VENV_BIN_DIRS } from "./package-manager.js";
19
19
  import { findNearestMarkerRoot } from "./path-utils.js";
20
20
  import { getScratchTreeFnmatchPatterns } from "./scratch-tree-policy.js";
21
21
  import { safeSpawnAsync } from "./safe-spawn.js";
22
+ import { probeToolAsync } from "./tool-probe.js";
22
23
  import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
23
24
  import { firstOutputLine, spawnFailedWithNoOutput, } from "./dispatch/runners/utils/spawn-outcome.js";
24
25
  import { formatToolFailure } from "./dispatch/runners/utils/tool-failure.js";
@@ -229,7 +230,7 @@ export class PythonDeadCodeClient {
229
230
  let probe;
230
231
  let hostStallMs;
231
232
  try {
232
- probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
233
+ probe = await probeToolAsync(c.cmd, [...c.prefix, "--version"], {
233
234
  timeout: 5000,
234
235
  });
235
236
  }
@@ -415,9 +416,13 @@ export class PythonDeadCodeClient {
415
416
  durationMs,
416
417
  };
417
418
  }
419
+ // vulture's verified exit-code table (above): exit 0 with empty
420
+ // stdout IS the clean-run signal, so this ran over the root
421
+ // (#2154) — unlike knip, whose clean run always prints JSON.
418
422
  return {
419
423
  ...emptyResult(this.language),
420
424
  success: true,
425
+ analyzed: true,
421
426
  summary: "No dead code found",
422
427
  durationMs,
423
428
  };
@@ -430,6 +435,8 @@ export class PythonDeadCodeClient {
430
435
  return {
431
436
  ...emptyResult(this.language),
432
437
  success: true,
438
+ // #2154: parsed vulture output for this root.
439
+ analyzed: true,
433
440
  unusedExports,
434
441
  summary: total === 0
435
442
  ? "No dead code found"
@@ -2,7 +2,7 @@
2
2
  import { logExtension } from "./extension-log.js";
3
3
  import { DEGRADATION_ENTRIES_PER_KIND, LEDGER_FIELD_MAX, truncateForLedger, } from "./ledger-bounds.js";
4
4
  import { logLatency } from "./latency-logger.js";
5
- import { getSinkRotations, getSinkWriteFailures, resetSinkRotations, resetSinkWriteFailures, } from "./ndjson-logger.js";
5
+ import { getSinkRotations, getSinkOptionConflicts, getSinkWriteFailures, resetSinkRotations, resetSinkWriteFailures, } from "./ndjson-logger.js";
6
6
  // #2146: pulled at READ time, never pushed. `process-singletons.ts` is a
7
7
  // dependency leaf on purpose — it cannot import this module without closing a
8
8
  // no-client-cycles cycle through instance-registry/instance-reaper — so the
@@ -233,6 +233,18 @@ export function getDegradationSummary() {
233
233
  })),
234
234
  });
235
235
  }
236
+ const optionConflicts = getSinkOptionConflicts();
237
+ if (optionConflicts.length > 0) {
238
+ summary.push({
239
+ kind: "log-sink-option-conflict",
240
+ count: optionConflicts.length,
241
+ droppedCount: 0,
242
+ latestReasons: optionConflicts.map((sink) => ({
243
+ subject: truncateForLedger(sink.file),
244
+ reason: truncateForLedger("shared writer rotation options differed across module graphs; first writer retained ownership"),
245
+ })),
246
+ });
247
+ }
236
248
  // #2146, same read-time fold: process-singleton resets live in the leaf
237
249
  // module's own bounded log. One entry per family, so this group's count is
238
250
  // the number of families this build could not adopt, never an event tally.
@@ -310,6 +322,9 @@ const INFORMATIONAL_DEGRADATION_KINDS = new Set([
310
322
  // doc comment above) and is frequent/self-healing by design — a `⚠` would
311
323
  // cry wolf on the sampler's ordinary best-effort data loss.
312
324
  "resource-sampler-scanner-escalated",
325
+ // #2874: a successful legacy-directory migration is an upgrade tally, not
326
+ // a call to action. The hash-only subject avoids exposing the project path.
327
+ "data_dir_migrated",
313
328
  ]);
314
329
  export function renderDegradationLines(summary = getDegradationSummary()) {
315
330
  if (!isRenderableSummary(summary))
@@ -815,7 +815,11 @@ export class DependencyChecker {
815
815
  }
816
816
  }
817
817
  this.publishState(gen, circular, circularFiles);
818
- return { circular, count: circular.length };
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".
822
+ return { circular, count: circular.length, analyzed: true };
819
823
  }
820
824
  catch (err) {
821
825
  this.log(`Scan error: ${err.message}`);