@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.
- package/README.md +5 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
- package/node_modules/pi-lens/CHANGELOG.md +498 -0
- package/node_modules/pi-lens/README.md +45 -2
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
- package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
- package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
- package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
- package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
- package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
- package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
- package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
- package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
- package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
- package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
- package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
- package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
- package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
- package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
- package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
- package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
- package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
- package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
- package/node_modules/pi-lens/dist/index.js +5695 -3733
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
- package/node_modules/pi-lens/dist/mcp/server.js +33 -21
- package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
- package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
- package/node_modules/pi-lens/docs/adr/README.md +26 -0
- package/node_modules/pi-lens/docs/agent-tools.md +18 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
- package/node_modules/pi-lens/docs/configuration.md +55 -1
- package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
- package/node_modules/pi-lens/docs/environment-variables.md +61 -4
- package/node_modules/pi-lens/docs/features.md +2 -2
- package/node_modules/pi-lens/docs/globalconfig.md +6 -1
- package/node_modules/pi-lens/docs/language-coverage.md +17 -0
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
- package/node_modules/pi-lens/docs/settings.md +12 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
- package/node_modules/pi-lens/package.json +5 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
- package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
- package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
- package/node_modules/pi-mcp-adapter/README.md +12 -5
- package/node_modules/pi-mcp-adapter/config.ts +31 -8
- package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
- package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
- package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
- package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
- package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +37 -20
- package/node_modules/pi-mcp-adapter/init.ts +14 -15
- package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
- package/node_modules/pi-mcp-adapter/package.json +3 -2
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
- package/node_modules/pi-mcp-adapter/types.ts +2 -2
- package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
- package/node_modules/pi-mcp-adapter/utils.ts +11 -3
- package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
- package/node_modules/pi-next-cue/LICENSE +21 -0
- package/node_modules/pi-next-cue/README.md +81 -0
- package/node_modules/pi-next-cue/index.ts +358 -0
- package/node_modules/pi-next-cue/package.json +28 -0
- 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
|
-
|
|
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
|
-
|
|
1843
|
-
if (!stripped)
|
|
1845
|
+
if (isDecorativeLine(line))
|
|
1844
1846
|
continue;
|
|
1845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
125
|
+
return undefined;
|
|
43
126
|
}
|
|
44
127
|
const provenanceKeys = blockingFiles.map((file) => guardPathKey(file, cwd));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
693
|
-
?
|
|
694
|
-
:
|
|
695
|
-
if (existing?.blockerContent && !
|
|
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
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
194
|
+
* Classify one Gradle style block by the block that encloses it.
|
|
145
195
|
*
|
|
146
|
-
* `
|
|
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
|
|
157
|
-
const enclosing =
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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);
|