@herbertgao/pi-extensions 2026.9.15 → 2026.9.16

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 (195) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  5. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  6. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  7. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  8. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  9. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  10. package/node_modules/pi-lens/README.md +45 -2
  11. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  12. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  13. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  14. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  15. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  16. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  17. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  18. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  19. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  20. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  21. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  22. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  23. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  24. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  25. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  26. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  64. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  65. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  66. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  67. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  68. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  69. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  70. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  71. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  72. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  73. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  74. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  75. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  76. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  77. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  78. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  79. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  81. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  82. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  83. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  84. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  85. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  86. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  87. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  88. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  89. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  90. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  91. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  92. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  93. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  94. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  95. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  96. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  97. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  98. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  99. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  100. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  101. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  102. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  103. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  104. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  105. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  106. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  107. package/node_modules/pi-lens/dist/index.js +5695 -3733
  108. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  109. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  110. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  111. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  112. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  113. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  123. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  124. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  125. package/node_modules/pi-lens/docs/configuration.md +55 -1
  126. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  127. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  128. package/node_modules/pi-lens/docs/features.md +2 -2
  129. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  130. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  131. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  132. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  133. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  134. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  135. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  136. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  137. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  138. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  139. package/node_modules/pi-lens/docs/settings.md +12 -3
  140. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  141. package/node_modules/pi-lens/package.json +5 -3
  142. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  157. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  158. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  159. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  160. package/node_modules/pi-mcp-adapter/README.md +12 -5
  161. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  162. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  163. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  164. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  166. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  168. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  170. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  171. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  173. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  175. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  176. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  177. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  178. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  179. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  180. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  181. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  182. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  183. package/node_modules/pi-mcp-adapter/package.json +3 -2
  184. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  186. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  187. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  188. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  189. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  190. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  191. package/node_modules/pi-next-cue/LICENSE +21 -0
  192. package/node_modules/pi-next-cue/README.md +81 -0
  193. package/node_modules/pi-next-cue/index.ts +358 -0
  194. package/node_modules/pi-next-cue/package.json +28 -0
  195. package/package.json +8 -5
@@ -1823,7 +1823,10 @@ export function clearFormatterRuntimeState() {
1823
1823
  // is `session_start`'s block in runtime-session.ts.
1824
1824
  }
1825
1825
  const BOX_DRAWING_GLOBAL = /[\u2500-\u257F]/g;
1826
- const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
1826
+ /** Blank, or a rule made of box-drawing characters and spacing alone. */
1827
+ const DECORATION_RULE = /^[\u2500-\u257F\s]*$/;
1828
+ /** The renderer's section heading: one word, then box-drawing to end of line. */
1829
+ const DECORATION_HEADING = /^\S+[ \t]+[\u2500-\u257F][\u2500-\u257F\s]*$/;
1827
1830
  /**
1828
1831
  * First line of `text` that actually carries a diagnostic.
1829
1832
  *
@@ -1839,18 +1842,53 @@ const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
1839
1842
  export function firstDiagnosticLine(text) {
1840
1843
  for (const raw of (text ?? "").split("\n")) {
1841
1844
  const line = stripAnsi(raw).trimEnd();
1842
- const stripped = line.replace(BOX_DRAWING_GLOBAL, "").trim();
1843
- if (!stripped)
1845
+ if (isDecorativeLine(line))
1844
1846
  continue;
1845
- // "format ━━━━━━━━" is a section banner, not a diagnostic. Require a rule
1846
- // AND a short remainder so a real one-line error containing a box
1847
- // character is not discarded.
1848
- if (HAS_BOX_DRAWING.test(line) && stripped.length <= 24)
1849
- continue;
1850
- return stripped.slice(0, 300);
1847
+ return line.replace(BOX_DRAWING_GLOBAL, "").trim().slice(0, 300);
1851
1848
  }
1852
1849
  return undefined;
1853
1850
  }
1851
+ /**
1852
+ * The two decoration SHAPES a renderer emits, and nothing else: a rule made of
1853
+ * box-drawing characters alone (blank lines included), and a `<word> ━━━━`
1854
+ * section heading whose remainder after the word is box-drawing only.
1855
+ *
1856
+ * The single normalization both readers above and below share, so the bounded
1857
+ * tail cannot surface what the first-line reader already rejects (#3312 review
1858
+ * R3-4: the tail put biome's `format ━━━━` banner back in front of the strict
1859
+ * #1337 seam). Matching SHAPES rather than "contains a box character and is
1860
+ * short" is #3312 review R4-1: the length heuristic deleted
1861
+ * `━ traceback source excerpt`, a diagnostic whose own text opens with a box
1862
+ * character. A line carrying any non-decorative text is never decoration.
1863
+ */
1864
+ function isDecorativeLine(line) {
1865
+ return DECORATION_RULE.test(line) || DECORATION_HEADING.test(line);
1866
+ }
1867
+ const FORMATTER_ERROR_TAIL_LINES = 20;
1868
+ /**
1869
+ * Preserve a bounded tail of a formatter's diagnostic output.
1870
+ *
1871
+ * A traceback's first line is not actionable on its own (#3312). Keep the
1872
+ * final lines, which include the exception and its message, while bounding
1873
+ * the model-facing error when a formatter emits an unexpectedly large log.
1874
+ *
1875
+ * Decorative lines are dropped FIRST, by the same predicate `firstDiagnosticLine`
1876
+ * uses: a tail that kept them would hand the strict #1337 seam the banner this
1877
+ * module already decided is not a diagnostic, and every banner line it kept
1878
+ * would evict a real traceback line from the bound.
1879
+ */
1880
+ export function diagnosticTail(text, maxLines = FORMATTER_ERROR_TAIL_LINES) {
1881
+ const lines = [];
1882
+ for (const raw of (text ?? "").split("\n")) {
1883
+ const line = stripAnsi(raw).trimEnd();
1884
+ if (isDecorativeLine(line))
1885
+ continue;
1886
+ lines.push(line.slice(0, 300));
1887
+ }
1888
+ if (lines.length === 0)
1889
+ return undefined;
1890
+ return lines.slice(-maxLines).join("\n");
1891
+ }
1854
1892
  /**
1855
1893
  * Resolve a formatter command without allowing the static command fallback to
1856
1894
  * bypass a resolver's style-preservation refusal (#1345). `null` means the
@@ -1963,11 +2001,11 @@ export async function formatFile(filePath, formatter) {
1963
2001
  changed: false,
1964
2002
  outcome: "failed",
1965
2003
  error: result.error?.message ||
1966
- firstDiagnosticLine(result.stderr) ||
2004
+ diagnosticTail(result.stderr) ||
1967
2005
  // biome, ktlint and `mix format` report on STDOUT; without this
1968
2006
  // their diagnostic is discarded and the user is told only that
1969
2007
  // the tool "exited with status 1".
1970
- firstDiagnosticLine(result.stdout) ||
2008
+ diagnosticTail(result.stdout) ||
1971
2009
  `${formatter.name} exited with status ${result.status}`,
1972
2010
  };
1973
2011
  }
@@ -32,31 +32,132 @@ function capAffectedFiles(files, cwd) {
32
32
  truncated: unique.length > MAX_ADVISORY_AFFECTED_FILES,
33
33
  };
34
34
  }
35
- /** Recovery is safe only when blocker content and its file provenance agree. */
36
- function hasCompleteBlockingProvenance(blockerContent, blockingFiles, cwd) {
35
+ /**
36
+ * The turn-end composer's per-file attribution line
37
+ * (`clients/runtime-turn.ts:1085`). The optional suppression notice carries its
38
+ * own `": "`, so this shape is tried BEFORE the per-edit one below — otherwise
39
+ * everything up to `disposition` reads as the file name (#3282 F5).
40
+ */
41
+ const TURN_END_BLOCKER_SECTION = /^Unresolved from this turn — ([^\s].*?)(?: \(suppressed by disposition: \d+ finding\(s\)\))?:$/;
42
+ /**
43
+ * The file a line of `blockerContent` ATTRIBUTES a new blocker section to, or
44
+ * `undefined` when the line is rendered body that attributes nothing.
45
+ *
46
+ * Exactly the two shapes the two writers emit, and nothing else:
47
+ *
48
+ * - `<path>: <text>` at column 0 — `syncGitGuardRecord`'s own join below;
49
+ * - `Unresolved from this turn — <path>[ (suppressed by disposition: N
50
+ * finding(s))]:` — the turn-end composer's blocker section header.
51
+ *
52
+ * Both writers render the blocker BODY with `formatDiagnostics(…, "blocking")`
53
+ * (`clients/dispatch/utils/format-utils.ts:29`), which indents every line it
54
+ * emits — the ` L<n>: <message>` diagnostics, their ` 💡 Fix:` lines, the
55
+ * ` ... and N more` tail, and the two-space continuations of a multi-line
56
+ * message. The leading-whitespace test is therefore what separates body from
57
+ * attribution, and it is why #3282 happened: a line-by-line parse read
58
+ * ` L1: alpha is unsafe` as a file named `L1`.
59
+ */
60
+ function blockerSectionFile(line) {
61
+ const turnEndSection = TURN_END_BLOCKER_SECTION.exec(line);
62
+ if (turnEndSection)
63
+ return turnEndSection[1];
64
+ if (/^\s/.test(line))
65
+ return undefined;
66
+ const separator = line.indexOf(": ");
67
+ if (separator <= 0)
68
+ return undefined;
69
+ return line.slice(0, separator);
70
+ }
71
+ /**
72
+ * The path key of every blocker SECTION `blockerContent` attributes, or
73
+ * `undefined` when the text cannot be attributed at all.
74
+ *
75
+ * `blockerContent` is a sequence of per-file blocker SECTIONS: an attribution
76
+ * line, then the rendered body it owns, up to the next attribution line or the
77
+ * composer's `"\n\n"` part separator (`clients/runtime-turn.ts:4291`). The one
78
+ * consumer is `clearedLastKnownBlocker` below, which drops `blockerContent`
79
+ * when the file that just dispatched clean was the last entry in
80
+ * `blockingFiles` — sound only if `blockingFiles` accounts for the WHOLE of
81
+ * that text.
82
+ *
83
+ * Named recurrences, both live:
84
+ *
85
+ * - #3282: the writers render MULTI-LINE blocker text and a line-by-line parse
86
+ * judged every such record untrusted, so the first blocker of a session
87
+ * latched `blocking_provenance_untrusted` and refused every later commit —
88
+ * including after the agent fixed it.
89
+ * - #1561: a section for a file `blockingFiles` omits must keep the gate
90
+ * closed, and so must a section no file owns at all. `blockerParts` is
91
+ * multi-lane — the trivy CRITICAL report (`clients/runtime-turn.ts:2141`),
92
+ * knip (`:2147`), the secrets lane (`:2099`) and cascade (`:1340`) each push
93
+ * sections belonging to no file — and clearing one of those on a clean
94
+ * dispatch of the file beside it would drop a live CVE or leaked secret out
95
+ * of the commit gate. That is the fail-closed direction, and the reason an
96
+ * unattributable line answers `unknown` rather than being swallowed.
97
+ *
98
+ * The rule is one-directional on purpose — every SECTION is owned, and
99
+ * `blockingFiles` may name more. Demanding a bijection was #3282's second
100
+ * cause: the composer writes `blockingFiles: affectedFiles`
101
+ * (`clients/runtime-turn.ts:4292`), which carries every file the turn touched
102
+ * plus every cascade neighbour with diagnostics, while only the files with a
103
+ * surviving blocker get a section — so any turn that edited a second file was
104
+ * judged untrusted for the rest of the session. The extra direction also
105
+ * guarded nothing: the one consumer clears only when `blockingFiles` has
106
+ * shrunk to the single file that just dispatched clean, and every section is
107
+ * then that file's by this rule. For the same reason a duplicate section and a
108
+ * duplicate `blockingFiles` entry need no clause of their own: neither can make
109
+ * the clear drop a blocker some other file owns. An all-blank `blockerContent`
110
+ * is NOT in that company — round 1 claimed it was, and the section-count guard
111
+ * below is why (HIGH-3287-1).
112
+ */
113
+ function blockingProvenance(blockerContent, blockingFiles, cwd) {
37
114
  if (typeof blockerContent !== "string" || blockerContent.length === 0)
38
- return false;
39
- if (!Array.isArray(blockingFiles) || blockingFiles.length === 0)
40
- return false;
115
+ return undefined;
116
+ // `Array.isArray` only: a non-empty `blockingFiles` is not a separate
117
+ // requirement (MEDIUM-3287-2, mutation M11 — it stayed green across the whole
118
+ // seam suite). With at least one section required below, an empty
119
+ // `blockingFiles` cannot satisfy the ownership test either way, so the clause
120
+ // decided nothing; the entry type check under it still does, and has its own
121
+ // red (M8).
122
+ if (!Array.isArray(blockingFiles))
123
+ return undefined;
41
124
  if (blockingFiles.some((file) => typeof file !== "string" || file.trim().length === 0)) {
42
- return false;
125
+ return undefined;
43
126
  }
44
127
  const provenanceKeys = blockingFiles.map((file) => guardPathKey(file, cwd));
45
- if (new Set(provenanceKeys).size !== provenanceKeys.length)
46
- return false;
47
- const blockerKeys = blockerContent.split("\n").map((line) => {
48
- const separator = line.indexOf(": ");
49
- if (separator <= 0)
50
- return undefined;
51
- const file = line.slice(0, separator).trim();
52
- return file.length > 0 ? guardPathKey(file, cwd) : undefined;
53
- });
54
- if (blockerKeys.some((key) => key === undefined))
55
- return false;
56
- const uniqueBlockerKeys = new Set(blockerKeys);
57
- return (uniqueBlockerKeys.size === blockerKeys.length &&
58
- uniqueBlockerKeys.size === provenanceKeys.length &&
59
- [...uniqueBlockerKeys].every((key) => provenanceKeys.includes(key)));
128
+ const sectionKeys = [];
129
+ let inSection = false;
130
+ for (const line of blockerContent.split("\n")) {
131
+ // A ZERO-LENGTH line only: the composer's part separator. A
132
+ // whitespace-only line is how `formatDiagnostics` renders a blank line
133
+ // inside a multi-line diagnostic message, and it is body (#3282 F13).
134
+ if (line.length === 0) {
135
+ inSection = false;
136
+ continue;
137
+ }
138
+ const file = blockerSectionFile(line);
139
+ if (file === undefined) {
140
+ // Body of the open section, or — with no section open — text that no
141
+ // entry of `blockingFiles` can be held responsible for.
142
+ if (!inSection)
143
+ return undefined;
144
+ continue;
145
+ }
146
+ sectionKeys.push(guardPathKey(file, cwd));
147
+ inSection = true;
148
+ }
149
+ // No line opened a section, so there is nothing for `blockingFiles` to be
150
+ // checked against and nothing a per-file dispatch may claim authority over.
151
+ // All-blank-but-non-empty text lands here (HIGH-3287-1, round 1's fail-open):
152
+ // `[].every(…)` is vacuously true, so treating zero sections as "nothing left
153
+ // to attribute" let a clean dispatch of an unrelated file DELETE a record
154
+ // whose `blockingFiles` named a different file, and the gate then answered
155
+ // `no_record`. Unattributed is not clean.
156
+ if (sectionKeys.length === 0)
157
+ return undefined;
158
+ return sectionKeys.every((key) => provenanceKeys.includes(key))
159
+ ? sectionKeys
160
+ : undefined;
60
161
  }
61
162
  function getShellCommand(input) {
62
163
  if (!input || typeof input !== "object")
@@ -669,8 +770,53 @@ export function retireInlineBlockerAndResyncGuard(args) {
669
770
  }
670
771
  return true;
671
772
  }
773
+ /**
774
+ * Bring the commit-gate LATCH back in line with the turn-end disposition
775
+ * policy (#3248), at the point where the post-policy survivor set per file is
776
+ * final.
777
+ *
778
+ * The defect this exists for: the agent marks every blocker on a file
779
+ * `false-positive`, the turn-end composer renders no `Unresolved from this
780
+ * turn` section and no `🔴 STOP` — and `lens-guard` still blocks the commit,
781
+ * because `evaluateGitGuard` reads the LATCH first and the latch counted the
782
+ * still-present map entry. The blocker record stays in the map on purpose: the
783
+ * policy is content-bound and re-derived every turn end, so retiring the entry
784
+ * would be silencing rather than filtering (AGENTS.md shape 10). It carries
785
+ * the verdict instead, and the two gate derivations honor it — this latch, and
786
+ * `syncGitGuardRecord`'s persisted `blockerContent` below.
787
+ *
788
+ * The persisted record needs no third writer here. The composer that calls
789
+ * this rewrites it later in the SAME turn end from the same survivor set —
790
+ * `writeGitGuardRecord` with `hasBlockers` derived from the surviving blocker
791
+ * sections (`clients/runtime-turn.ts:4287`, and the dedupe path at `:4231`),
792
+ * or `clearCache("turn-end-findings")` when nothing survives
793
+ * (`clients/runtime-turn.ts:4379`) — and that clear is itself gated on the
794
+ * latch this call just recomputed. Adding a second durable writer between them
795
+ * would only race the one that already owns the record.
796
+ *
797
+ * Colocated with the gate rather than inlined at the wiring site, for the same
798
+ * reason `retireInlineBlockerAndResyncGuard` is: the claim is testable without
799
+ * booting the extension.
800
+ */
801
+ export function resyncGitGuardAfterInlinePolicy(args) {
802
+ const changed = args.runtime.applyInlineBlockerPolicyVerdicts(args.suppressedFilePaths);
803
+ // Nothing left the gate's view of the map and nothing rejoined it. This is
804
+ // what keeps the recompute off every ordinary turn end: `updateGitGuardStatus`
805
+ // re-derives the latch from the map, and a turn that changed no verdict has
806
+ // no business re-deriving anything.
807
+ if (args.suppressedFilePaths.length === 0 && changed === 0)
808
+ return;
809
+ // Exactly as the retire path re-derives it: `false` is this caller's own
810
+ // contribution, never a blanket clear — a sibling file's surviving blocker
811
+ // is still in the map and still sets the latch.
812
+ args.runtime.updateGitGuardStatus(false, "");
813
+ }
672
814
  export function syncGitGuardRecord(runtime, cacheManager, cwd, editedFilePath) {
673
- const entries = runtime.getInlineBlockersSnapshot?.() ?? [];
815
+ // #3248: a record the turn-end policy fully suppressed is not a blocker the
816
+ // commit gate may persist — the agent was shown nothing for it. The entry
817
+ // stays in the map so the next turn re-derives the verdict from current
818
+ // bytes; it just stops speaking for the gate.
819
+ const entries = (runtime.getInlineBlockersSnapshot?.() ?? []).filter((entry) => !entry.policySuppressed);
674
820
  const inspection = cacheManager.inspectCache("turn-end-findings", cwd);
675
821
  const existing = cacheRecord(cacheManager, cwd);
676
822
  if (existing &&
@@ -689,10 +835,10 @@ export function syncGitGuardRecord(runtime, cacheManager, cwd, editedFilePath) {
689
835
  }
690
836
  const inlineFiles = entries.map((entry) => resolveGuardPath(entry.filePath, cwd));
691
837
  const existingBlockingFiles = existing?.blockingFiles ?? [];
692
- const provenanceComplete = existing?.blockerContent
693
- ? hasCompleteBlockingProvenance(existing.blockerContent, existingBlockingFiles, cwd)
694
- : true;
695
- if (existing?.blockerContent && !provenanceComplete) {
838
+ const blockerSections = existing?.blockerContent
839
+ ? blockingProvenance(existing.blockerContent, existingBlockingFiles, cwd)
840
+ : undefined;
841
+ if (existing?.blockerContent && !blockerSections) {
696
842
  markCacheUnknown(runtime, "blocking_provenance_untrusted");
697
843
  return;
698
844
  }
@@ -711,16 +857,22 @@ export function syncGitGuardRecord(runtime, cacheManager, cwd, editedFilePath) {
711
857
  affectedFiles = affectedFiles.filter((file) => guardPathKey(file, cwd) !== editedKey);
712
858
  }
713
859
  }
714
- // A clean per-file dispatch is authoritative for that file. When the
715
- // persisted record has explicit blocking-file provenance and the last such
716
- // file just reconciled clean, retaining blockerContent would resurrect a
717
- // stale blocker on every later git-guard lookup. Records without that
718
- // provenance remain fail-closed: they cannot be safely cleared here.
860
+ // A clean per-file dispatch is authoritative for that file. When every
861
+ // blocker section the persisted record still carries is THIS file's,
862
+ // retaining `blockerContent` would resurrect a blocker the dispatch just
863
+ // cleared on every later git-guard lookup. Records whose text cannot be
864
+ // attributed remain fail-closed: they returned above.
865
+ //
866
+ // The question is asked of the SECTIONS, not of `blockingFiles` (#3282): the
867
+ // turn-end composer persists `blockingFiles: affectedFiles`
868
+ // (`clients/runtime-turn.ts:4292`) — every file the turn touched, blocking or
869
+ // not — so "the list has shrunk to nothing" never became true for a turn that
870
+ // edited a clean file alongside a blocking one, and the fixed blocker was
871
+ // quoted back at every commit for the rest of the session.
719
872
  const clearedLastKnownBlocker = !entries.length &&
720
873
  !!editedKey &&
721
- provenanceComplete &&
722
- existingBlockingFiles.length > 0 &&
723
- remainingBlockingFiles.length === 0;
874
+ !!blockerSections &&
875
+ blockerSections.every((key) => key === editedKey);
724
876
  const blockerContent = entries.length > 0
725
877
  ? entries.map((entry) => `${entry.filePath}: ${entry.summary}`).join("\n")
726
878
  : clearedLastKnownBlocker
@@ -736,6 +888,13 @@ export function syncGitGuardRecord(runtime, cacheManager, cwd, editedFilePath) {
736
888
  .join("\n\n");
737
889
  if (!hasBlockers && !content) {
738
890
  cacheManager.clearCache("turn-end-findings", cwd);
891
+ // #3282 acceptance 5: this branch is a COMPLETE recomputation of the
892
+ // gate's state that happens to find nothing left to record, so it owns the
893
+ // unknown reason exactly as `writeGitGuardRecord` does (`:800`). Without
894
+ // this, a reason latched earlier in the session outlived the record it
895
+ // described and refused every commit after it, with no re-run able to
896
+ // clear it.
897
+ runtime.clearGitGuardCacheUnknown();
739
898
  return;
740
899
  }
741
900
  writeGitGuardRecord(cacheManager, runtime, cwd, {
@@ -820,6 +979,49 @@ export function clearGitGuardTestFailure(cacheManager, cwd, runtime, passedFiles
820
979
  fileContentHashes: {},
821
980
  });
822
981
  }
982
+ /**
983
+ * The first suppression verdict in the blocker map that no longer describes the
984
+ * file it was computed against, if any (#3248 round 2, GG-3283-01).
985
+ *
986
+ * `policySuppressed` is a statement about BYTES: the turn-end policy read the
987
+ * file's current content, re-derived the record's blockers through the
988
+ * content-bound disposition anchors, and found every one of them suppressed. It
989
+ * is therefore only true while those bytes are still there. Everything that
990
+ * changes a file THROUGH pi-lens re-establishes the verdict on its own — a
991
+ * dispatch replaces the record wholesale, and the turn-end freshness sweep
992
+ * demotes a self-drifted record before the policy loop can suppress it, which
993
+ * clears the verdict. Bytes that move OUTSIDE dispatch (an external formatter, a
994
+ * `git checkout`, an editor write) trigger neither, and the commit gate can be
995
+ * reached with no turn end in between at all — so the gate, the one reader that
996
+ * can let a verdict OPEN a commit, confirms it here instead of trusting the
997
+ * latch memo.
998
+ *
999
+ * Unverifiable is not clean: a record whose dispatch could not fingerprint the
1000
+ * file (`inlineBlockerFileContent` absent, `clients/pipeline.ts:1632`) carries
1001
+ * nothing to confirm, so it fails CLOSED — the same rule
1002
+ * `retireInlineBlockerOnConfirmedClean` applies to unknown provenance.
1003
+ *
1004
+ * Cost: nothing at all unless a verdict is live (the common case), then one
1005
+ * read per suppressed record — on commit/push attempts, the path that already
1006
+ * fingerprints every affected file below. A size/mtime fast path was written
1007
+ * first and deleted: `fileFingerprint` decides the same question on its own, so
1008
+ * the tiers were unmutatable branches, not guards. `fileFingerprint` also
1009
+ * returns `"missing"`/`"unreadable:<code>"` rather than throwing, and neither
1010
+ * can equal a recorded hash, so an unreadable file is expired too.
1011
+ */
1012
+ function firstExpiredSuppressionVerdict(runtime) {
1013
+ for (const entry of runtime.getInlineBlockersSnapshot?.() ?? []) {
1014
+ if (!entry.policySuppressed)
1015
+ continue;
1016
+ if (entry.recordedHash === undefined) {
1017
+ return { filePath: entry.filePath, tier: "no_baseline" };
1018
+ }
1019
+ if (fileFingerprint(entry.filePath) !== entry.recordedHash) {
1020
+ return { filePath: entry.filePath, tier: "content" };
1021
+ }
1022
+ }
1023
+ return undefined;
1024
+ }
823
1025
  export function evaluateGitGuard(runtime, cacheManager, cwd, host = "pi") {
824
1026
  // #2535 F3: a known tool with no mapping on this host resolves to
825
1027
  // undefined — rephrase rather than naming a dead tool. `lens_diagnostics`
@@ -840,6 +1042,17 @@ export function evaluateGitGuard(runtime, cacheManager, cwd, host = "pi") {
840
1042
  reason: `🔴 COMMIT BLOCKED (--lens-guard): unresolved blockers must be fixed before commit/push.${detail}\n${inspectLine}`,
841
1043
  };
842
1044
  }
1045
+ // #3248 round 2 (GG-3283-01): the latch above is a MEMO of the last policy
1046
+ // pass, and a pass can only speak for the bytes it read. A verdict whose file
1047
+ // has moved since is no answer at all, so it must not be the reason this
1048
+ // commit is allowed.
1049
+ const expired = firstExpiredSuppressionVerdict(runtime);
1050
+ if (expired) {
1051
+ return unknown(cwd, "inline_policy_stale", {
1052
+ file: expired.filePath,
1053
+ tier: expired.tier,
1054
+ });
1055
+ }
843
1056
  if (runtime.gitGuardCacheUnknownReason) {
844
1057
  return unknown(cwd, runtime.gitGuardCacheUnknownReason);
845
1058
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ktfmt Gradle-plugin style carriage (#2468).
2
+ * ktfmt Gradle-plugin and Spotless style carriage (#2468/#2481).
3
3
  *
4
4
  * The `com.ncorti.ktfmt.gradle` plugin's `ktfmt { }` extension block lets a
5
5
  * project select `googleStyle()` or `kotlinLangStyle()` (verified against
@@ -9,17 +9,21 @@
9
9
  * `blockIndent`/`continuationIndent`/`trailingCommaManagementStrategy`, so a
10
10
  * body calling both is LAST-CALL-WINS, not first).
11
11
  *
12
- * None of this is read by ktfmt's own CLI from `build.gradle` — verified
13
- * against `ParsedArgs.kt` in `facebook/ktfmt` (now mirrored at
12
+ * Spotless uses a second DSL shape, `spotless { kotlin { ktfmt(...).googleStyle()
13
+ * } }`, with `kotlinlangStyle()` and `dropboxStyle()` as fluent methods.
14
+ *
15
+ * None of these selections are read by ktfmt's own CLI from `build.gradle` —
16
+ * verified against `ParsedArgs.kt` in `facebook/ktfmt` (now mirrored at
14
17
  * `Kotlin/ktfmt`) at tag v0.63, the exact version `clients/installer/
15
18
  * index.ts` pins for pi-lens's managed ktfmt install. That CLI accepts
16
19
  * `--google-style` / `--kotlinlang-style` (plus the default `--meta-style`)
17
20
  * — so the Gradle-declared choice has to be translated to the matching CLI
18
21
  * flag by something on our side; ktfmt itself never bridges `build.gradle`
19
- * to argv.
22
+ * to argv. Spotless's Dropbox style has no equivalent flag, so that case logs
23
+ * and falls back to bare ktfmt.
20
24
  *
21
25
  * Reuses the exact lexical pre-pass (`stripGradleCommentsAndStrings` /
22
- * `namedGradleBlockRanges`) that `clients/tool-policy.ts`'s
26
+ * `gradleBlockRanges`) that `clients/tool-policy.ts`'s
23
27
  * `getSpotlessKotlinFormatter` already applies to Gradle files, instead of a
24
28
  * second hand-rolled Gradle-block parser (#2468 AC) — same
25
29
  * single-source-of-truth reuse `clients/cargo-manifest.ts` did for TOML
@@ -57,6 +61,8 @@
57
61
  * - `subprojects { ktfmt { … } }` → DESCENDANT directories only, never the
58
62
  * declaring one. `subprojects` configures the children and nothing else.
59
63
  * - `allprojects { ktfmt { … } }` → the declaring directory AND descendants.
64
+ * - `spotless { kotlin { … } }` excludes the Spotless DSL wrapper from scope
65
+ * classification, while any outer project wrapper keeps its existing scope.
60
66
  * - anything else enclosing it — `configure(subprojects.filter { … }) { }`,
61
67
  * `project(":app") { }`, `tasks.register(…) { }`, a convention-plugin
62
68
  * wrapper, or more than one nested block — reaches NEITHER scope. pi-lens
@@ -81,6 +87,8 @@
81
87
  import * as os from "node:os";
82
88
  import * as path from "node:path";
83
89
  import { readTextFileOrUndefined } from "./cargo-manifest.js";
90
+ import { incrementDegradationCount } from "./degradation-ledger.js";
91
+ import { logExtension } from "./extension-log.js";
84
92
  import { findNearestMarkerRoot } from "./path-utils.js";
85
93
  import { gradleBlockRanges, stripGradleCommentsAndStrings, } from "./tool-policy.js";
86
94
  /** Nearest-first: a Kotlin script build file wins over its Groovy sibling. */
@@ -99,6 +107,8 @@ const KTFMT_GRADLE_ROOT_FILES = [
99
107
  * long before this.
100
108
  */
101
109
  const MAX_GRADLE_ANCESTOR_HOPS = 16;
110
+ /** Bound malformed Spotless calls without rejecting ordinary nested arguments. */
111
+ const MAX_SPOTLESS_KTFMT_CALL_CHARS = 4096;
102
112
  /** The CLI flags ktfmt v0.63 actually defines (`ParsedArgs.kt`, verified above). */
103
113
  const KTFMT_STYLE_CLI_FLAG = {
104
114
  google: "--google-style",
@@ -131,6 +141,46 @@ function styleFromKtfmtBlockBody(body) {
131
141
  }
132
142
  return style;
133
143
  }
144
+ /** Find a bounded matching close parenthesis for a Spotless ktfmt call. */
145
+ function findBalancedCallEnd(source, openingParen) {
146
+ let depth = 0;
147
+ const limit = Math.min(source.length, openingParen + MAX_SPOTLESS_KTFMT_CALL_CHARS);
148
+ for (let index = openingParen; index < limit; index += 1) {
149
+ if (source[index] === "(")
150
+ depth += 1;
151
+ if (source[index] !== ")")
152
+ continue;
153
+ depth -= 1;
154
+ if (depth === 0)
155
+ return index + 1;
156
+ }
157
+ return undefined;
158
+ }
159
+ /** Read a Spotless `ktfmt(...).style()` fluent chain from one kotlin body. */
160
+ function styleFromSpotlessKotlinBody(body) {
161
+ let style;
162
+ for (const call of body.matchAll(/\bktfmt\s*(?=\()/g)) {
163
+ const callOpenParen = call.index + call[0].length;
164
+ const closeParen = findBalancedCallEnd(body, callOpenParen);
165
+ if (closeParen === undefined)
166
+ continue;
167
+ const chain = /^\s*((?:\.\s*(?:googleStyle|kotlinlangStyle|dropboxStyle)\s*\(\s*\)\s*)+)/.exec(body.slice(closeParen));
168
+ if (!chain)
169
+ continue;
170
+ const chainBody = chain[1];
171
+ if (!chainBody)
172
+ continue;
173
+ for (const method of chainBody.matchAll(/\.\s*(googleStyle|kotlinlangStyle|dropboxStyle)\s*\(\s*\)/g)) {
174
+ if (method[1] === "googleStyle")
175
+ style = "google";
176
+ if (method[1] === "kotlinlangStyle")
177
+ style = "kotlinlang";
178
+ if (method[1] === "dropboxStyle")
179
+ style = "spotless-dropbox-unsupported";
180
+ }
181
+ }
182
+ return style;
183
+ }
134
184
  /**
135
185
  * The Gradle blocks whose scope is exactly expressible here. Every other
136
186
  * enclosing block — including one we simply have not heard of — is a scope
@@ -141,9 +191,9 @@ const SCOPE_BY_ENCLOSING_BLOCK = {
141
191
  allprojects: "both",
142
192
  };
143
193
  /**
144
- * Classify one `ktfmt { }` block by the block that encloses it.
194
+ * Classify one Gradle style block by the block that encloses it.
145
195
  *
146
- * `blocks` is every brace pair in the stripped source, so "enclosing" is
196
+ * `ranges` is every brace pair in the stripped source, so "enclosing" is
147
197
  * literal containment, not a guess from a name test: `range` strictly inside
148
198
  * `outer` means `outer.start < range.start` (an enclosing body always opens
149
199
  * before the body it contains) and `outer.end >= range.end`.
@@ -153,17 +203,49 @@ const SCOPE_BY_ENCLOSING_BLOCK = {
153
203
  * exactly the kind of build-script evaluation this lexical pass cannot do,
154
204
  * and the cost of the conservative answer is the pre-#2468 bare invocation.
155
205
  */
156
- function scopeOfKtfmtBlock(range, blocks) {
157
- const enclosing = blocks.filter((outer) => outer.start < range.start && outer.end >= range.end);
206
+ function scopeOfGradleStyleBlock(range, ranges) {
207
+ const enclosing = ranges.filter((outer) => outer.start < range.start && outer.end >= range.end);
158
208
  if (enclosing.length === 0)
159
209
  return "own";
160
210
  if (enclosing.length > 1)
161
211
  return undefined;
162
212
  return SCOPE_BY_ENCLOSING_BLOCK[enclosing[0].name];
163
213
  }
214
+ function styleSelectionForGradleBlock(block, source, allBlockRanges, nonSpotlessRanges) {
215
+ if (block.name === "ktfmt") {
216
+ const scope = scopeOfGradleStyleBlock(block, allBlockRanges);
217
+ if (!scope)
218
+ return undefined;
219
+ const style = styleFromKtfmtBlockBody(source.slice(block.start, block.end));
220
+ return style ? { scope, style } : undefined;
221
+ }
222
+ if (block.name !== "kotlin")
223
+ return undefined;
224
+ const enclosingSpotless = [];
225
+ for (const outer of allBlockRanges) {
226
+ if (outer.name === "spotless" &&
227
+ outer.start < block.start &&
228
+ outer.end >= block.end) {
229
+ enclosingSpotless.push(outer);
230
+ }
231
+ }
232
+ if (enclosingSpotless.length !== 1)
233
+ return undefined;
234
+ const scope = scopeOfGradleStyleBlock(block, nonSpotlessRanges);
235
+ if (!scope)
236
+ return undefined;
237
+ const style = styleFromSpotlessKotlinBody(source.slice(block.start, block.end));
238
+ return style ? { scope, style } : undefined;
239
+ }
164
240
  function stylesFromGradleContent(content) {
165
241
  const stripped = stripGradleCommentsAndStrings(content);
166
242
  const blocks = gradleBlockRanges(stripped);
243
+ // The Spotless block is the target DSL, not a project-scope wrapper.
244
+ const scopeRanges = [];
245
+ for (const range of blocks) {
246
+ if (range.name !== "spotless")
247
+ scopeRanges.push(range);
248
+ }
167
249
  const styles = {};
168
250
  // Source order, so a later block overwrites an earlier one for the scope
169
251
  // it reaches — the same last-call-wins rule that applies inside one body.
@@ -172,18 +254,13 @@ function stylesFromGradleContent(content) {
172
254
  // over a single slot, or the source order of unrelated scopes decides
173
255
  // which project gets the wrong flag.
174
256
  for (const range of blocks) {
175
- if (range.name !== "ktfmt")
176
- continue;
177
- const scope = scopeOfKtfmtBlock(range, blocks);
178
- if (!scope)
179
- continue;
180
- const style = styleFromKtfmtBlockBody(stripped.slice(range.start, range.end));
181
- if (!style)
257
+ const selection = styleSelectionForGradleBlock(range, stripped, blocks, scopeRanges);
258
+ if (!selection)
182
259
  continue;
183
- if (scope !== "descendants")
184
- styles.own = style;
185
- if (scope !== "own")
186
- styles.descendants = style;
260
+ if (selection.scope !== "descendants")
261
+ styles.own = selection.style;
262
+ if (selection.scope !== "own")
263
+ styles.descendants = selection.style;
187
264
  }
188
265
  return styles;
189
266
  }
@@ -220,11 +297,11 @@ async function stylesForGradleDir(gradleDir) {
220
297
  * the climb only continues past a gradle directory that declares NO style
221
298
  * applying to this file, so nearest-wins is unchanged where a nearer
222
299
  * declaration exists.
223
- * - Returns `undefined` on any miss (no gradle file found, no `ktfmt { }`
224
- * block, or no recognized style call in one): callers fall back to their
225
- * pre-existing default argv rather than guessing. `dropboxStyle()` — the
226
- * call ktfmt-gradle removed in 0.19.0, never a ktfmt CLI flag — is just
227
- * such a miss and never becomes a guessed flag.
300
+ * - Returns `undefined` on any miss (no gradle file found, no style block, or
301
+ * no recognized style call): callers fall back to their pre-existing default
302
+ * argv rather than guessing. Spotless `dropboxStyle()` is recognized as a
303
+ * standalone-CLI limitation, records a bounded notice, and still falls back
304
+ * without becoming a guessed flag.
228
305
  *
229
306
  * `homeDir` defaults to `os.homedir()` and exists as a parameter so tests can
230
307
  * inject a nearer ceiling and prove the guard actually stops a climb (mirrors
@@ -241,6 +318,25 @@ export async function resolveKtfmtGradleStyle(filePath, homeDir = os.homedir())
241
318
  return undefined;
242
319
  const styles = await stylesForGradleDir(gradleDir);
243
320
  const style = hop === 0 ? styles.own : styles.descendants;
321
+ if (style === "spotless-dropbox-unsupported") {
322
+ const reason = "Spotless ktfmt dropboxStyle(): pi-lens standalone CLI cannot express " +
323
+ "the style; falling back to bare ktfmt";
324
+ const firstOccurrence = incrementDegradationCount({
325
+ kind: "formatter-skip",
326
+ subject: "ktfmt:spotless-dropbox",
327
+ reason,
328
+ metadata: { filePath, gradleDir },
329
+ });
330
+ if (firstOccurrence) {
331
+ logExtension({
332
+ subsystem: "format",
333
+ level: "debug",
334
+ message: reason,
335
+ metadata: { filePath, gradleDir },
336
+ });
337
+ }
338
+ return undefined;
339
+ }
244
340
  if (style)
245
341
  return KTFMT_STYLE_CLI_FLAG[style];
246
342
  const parent = path.dirname(gradleDir);