From 16a6557336a047f952df80f4ea5c0c3d40cd9813 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Sat, 1 Aug 2026 20:45:04 +0100 Subject: [PATCH 1/4] feat: add Oxlint linter integration (#1148) ## What? Add an Oxlint integration for JavaScript, TypeScript, JSX, and TSX: - Run Oxlint with SARIF output for diagnostics. - Run Oxfmt as the formatter. - Recognize the documented Oxlint and Oxfmt configuration files. - Track configuration inputs that affect lint and format results. - Add lint and format integration tests with pinned-version snapshots. - Document Oxlint in the supported-linters list. ## Why? Oxlint and Oxfmt provide fast, dedicated linting and formatting with strong ESLint and Prettier compatibility. This integration installs both tools hermetically and follows their current configuration and CLI conventions. ## Validation - `npm test -- linters/oxlint` - `PLUGINS_TEST_LINTER_VERSION=KnownGoodVersion npm test -- linters/oxlint` - `npm test -- tests/repo_tests/config_check.test.ts --runInBand` - `npm test -- tests/repo_tests` (Graphite submit hook) - `trunk check ...` on all changed source files - Snyk Code scan: no findings Co-authored-by: Eli Schleifer <1265982+EliSchleifer@users.noreply.github.com> --- README.md | 4 +- linters/oxlint/oxlint.test.ts | 4 ++ linters/oxlint/plugin.yaml | 56 ++++++++++++++++++ linters/oxlint/test_data/basic.in.ts | 3 + linters/oxlint/test_data/format.in.ts | 3 + .../test_data/oxlint_v1.71.0_basic.check.shot | 58 +++++++++++++++++++ .../test_data/oxlint_v1.71.0_format.fmt.shot | 8 +++ 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 linters/oxlint/oxlint.test.ts create mode 100644 linters/oxlint/plugin.yaml create mode 100644 linters/oxlint/test_data/basic.in.ts create mode 100644 linters/oxlint/test_data/format.in.ts create mode 100644 linters/oxlint/test_data/oxlint_v1.71.0_basic.check.shot create mode 100644 linters/oxlint/test_data/oxlint_v1.71.0_format.fmt.shot diff --git a/README.md b/README.md index dc94d9102..068ee8384 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ trunk check enable {linter} | HAML | [haml-lint] | | HTML Templates | [djlint] | | Java | [google-java-format], [pmd], [semgrep] | -| Javascript | [biome], [deno], [eslint], [prettier], [rome], [semgrep] | +| Javascript | [biome], [deno], [eslint], [oxlint], [prettier], [rome], [semgrep] | | JSON | [biome], [deno], [eslint], [prettier], [semgrep] | | Kotlin | [detekt], [ktlint] | | Kubernetes | [kube-linter] | @@ -88,6 +88,7 @@ trunk check enable {linter} | Terraform | [terraform] (validate and fmt), [checkov], [tflint], [tfsec], [terrascan], [tofu] | | Terragrunt | [terragrunt] | | Textproto | [txtpbfmt] | +| Typescript | [deno], [eslint], [oxlint], [prettier], [rome], [semgrep] | | TOML | [taplo], [toml-tidy] | | Typescript | [deno], [eslint], [prettier], [rome], [semgrep] | | YAML | [prettier], [semgrep], [yamlfmt], [yamllint] | @@ -149,6 +150,7 @@ trunk check enable {linter} [opa]: https://www.openpolicyagent.org/docs/latest/cli/ [osv-scanner]: https://trunk.io/linters/security/osv-scanner [oxipng]: https://github.com/shssoichiro/oxipng#readme +[oxlint]: https://oxc.rs/docs/guide/usage/linter.html [perlcritic]: https://metacpan.org/pod/Perl::Critic [perltidy]: https://metacpan.org/dist/Perl-Tidy/view/bin/perltidy [pinact]: https://github.com/suzuki-shunsuke/pinact#readme diff --git a/linters/oxlint/oxlint.test.ts b/linters/oxlint/oxlint.test.ts new file mode 100644 index 000000000..a559fc4e5 --- /dev/null +++ b/linters/oxlint/oxlint.test.ts @@ -0,0 +1,4 @@ +import { linterCheckTest, linterFmtTest } from "tests"; + +linterCheckTest({ linterName: "oxlint", namedTestPrefixes: ["basic"] }); +linterFmtTest({ linterName: "oxlint", namedTestPrefixes: ["format"] }); diff --git a/linters/oxlint/plugin.yaml b/linters/oxlint/plugin.yaml new file mode 100644 index 000000000..192677bde --- /dev/null +++ b/linters/oxlint/plugin.yaml @@ -0,0 +1,56 @@ +version: 0.1 +tools: + definitions: + - name: oxlint + runtime: node + package: oxlint + shims: [oxlint] + known_good_version: 1.71.0 + - name: oxfmt + runtime: node + package: oxfmt + shims: [oxfmt] + known_good_version: 0.56.0 +lint: + definitions: + - name: oxlint + files: [javascript, javascript-xml, typescript, typescript-xml] + main_tool: oxlint + tools: [oxfmt] + description: Fast JavaScript and TypeScript linting and formatting + commands: + - name: lint + output: sarif + run: oxlint --format sarif ${target} + success_codes: [0, 1] + read_output_from: stdout + batch: true + cache_results: true + - name: format + output: rewrite + run: oxfmt --write ${target} + success_codes: [0] + batch: true + cache_results: true + formatter: true + in_place: true + suggest_if: config_present + direct_configs: + - .oxlintrc.json + - .oxlintrc.jsonc + - .oxfmtrc.json + - .oxfmtrc.jsonc + - .prettierignore + - oxlint.config.ts + - oxlint.config.mts + - oxfmt.config.ts + - oxfmt.config.mts + affects_cache: + - .editorconfig + - .eslintignore + - package.json + - tsconfig.json + known_good_version: 1.71.0 + version_command: + parse_regex: ${semver} + run: oxlint --version diff --git a/linters/oxlint/test_data/basic.in.ts b/linters/oxlint/test_data/basic.in.ts new file mode 100644 index 000000000..6e7e80985 --- /dev/null +++ b/linters/oxlint/test_data/basic.in.ts @@ -0,0 +1,3 @@ +const unused = 1; + +console.log("hello"); diff --git a/linters/oxlint/test_data/format.in.ts b/linters/oxlint/test_data/format.in.ts new file mode 100644 index 000000000..a33034416 --- /dev/null +++ b/linters/oxlint/test_data/format.in.ts @@ -0,0 +1,3 @@ +const value={name:"trunk",enabled:true}; + +console.log(value); diff --git a/linters/oxlint/test_data/oxlint_v1.71.0_basic.check.shot b/linters/oxlint/test_data/oxlint_v1.71.0_basic.check.shot new file mode 100644 index 000000000..ea0334cae --- /dev/null +++ b/linters/oxlint/test_data/oxlint_v1.71.0_basic.check.shot @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter oxlint test basic 1`] = ` +{ + "issues": [ + { + "code": "eslint(no-unused-vars)", + "column": "7", + "file": "test_data/basic.in.ts", + "issueClass": "ISSUE_CLASS_EXISTING", + "level": "LEVEL_MEDIUM", + "line": "1", + "linter": "oxlint", + "message": "Variable 'unused' is declared but never used. Unused variables should start with a '_'.", + "ranges": [ + { + "filePath": "test_data/basic.in.ts", + "length": "6", + "offset": "6", + }, + ], + "targetType": "typescript", + }, + ], + "lintActions": [ + { + "command": "format", + "fileGroupName": "typescript", + "linter": "oxlint", + "paths": [ + "test_data/basic.in.ts", + ], + "verb": "TRUNK_VERB_FMT", + }, + { + "command": "lint", + "fileGroupName": "typescript", + "linter": "oxlint", + "paths": [ + "test_data/basic.in.ts", + ], + "verb": "TRUNK_VERB_CHECK", + }, + { + "command": "lint", + "fileGroupName": "typescript", + "linter": "oxlint", + "paths": [ + "test_data/basic.in.ts", + ], + "upstream": true, + "verb": "TRUNK_VERB_CHECK", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; diff --git a/linters/oxlint/test_data/oxlint_v1.71.0_format.fmt.shot b/linters/oxlint/test_data/oxlint_v1.71.0_format.fmt.shot new file mode 100644 index 000000000..3d68ac9e7 --- /dev/null +++ b/linters/oxlint/test_data/oxlint_v1.71.0_format.fmt.shot @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing formatter oxlint test format 1`] = ` +"const value = { name: "trunk", enabled: true }; + +console.log(value); +" +`; From 85638ff0e0512538c21cd7db95dc75015a0c1a8b Mon Sep 17 00:00:00 2001 From: Marcus Boerger Date: Sat, 1 Aug 2026 20:53:01 +0100 Subject: [PATCH 2/4] clang-format: bump default and known_good_version to 20.1.0 (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Move clang-format's default `version` from `14.0.1` and the lint `known_good_version` from `16.0.3` to `20.1.0`, the newest patch the trunk.io CDN actually hosts. - Add a header comment in `plugin.yaml` enumerating the patches available per platform on the trunk.io CDN so future contributors can pick a working version without trial-and-error. The `downloads:` block is structurally unchanged — only the default and the documented `known_good_version` move. ## Test plan - [x] Pre-push hook runs `tests/repo_tests/{valid_package_download,config_check}.test.ts` — passed (228 tests, 1 snapshot). - [ ] Snapshot update: the lint test currently uses `clang_format_v16.0.3_*.shot`; running with `known_good_version: 20.1.0` will regenerate snapshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Eli Schleifer <1265982+EliSchleifer@users.noreply.github.com> --- linters/clang-format/plugin.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/linters/clang-format/plugin.yaml b/linters/clang-format/plugin.yaml index 518a4fa3d..1392fa5aa 100644 --- a/linters/clang-format/plugin.yaml +++ b/linters/clang-format/plugin.yaml @@ -1,7 +1,16 @@ version: 0.1 +# Binaries are hosted on the trunk.io CDN (small, clang-format-only tarballs). +# Available versions per platform: +# 14.0.1 — linux-x86_64, macos-x86_64, macos-arm64 +# (no linux-aarch64 build was published) +# 16.0.3, 16.0.6 — all four platforms +# 17.0.1, 17.0.6 — all four platforms +# 18.1.8 — all four platforms +# 20.1.0 — all four platforms +# Any other version (e.g. 15.x, 19.x, 20.1.x>0, 21+) will 404. downloads: - name: clang-format - version: 14.0.1 + version: 20.1.0 downloads: # macos arm64 was introduced after this version. - os: macos @@ -47,7 +56,7 @@ lint: tools: [clang-format] suggest_if: config_present direct_configs: [.clang-format] - known_good_version: 16.0.3 + known_good_version: 20.1.0 version_command: parse_regex: ${semver} run: clang-format --version From 8ef30503581e33c5d9d2f41d834ae1d6f847b40c Mon Sep 17 00:00:00 2001 From: Eli Schleifer <1265982+EliSchleifer@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:02:44 -0700 Subject: [PATCH 3/4] Use prebuilt shfmt binaries (#1155) Avoid taking a go runtime dependency - use prebuilt binaries --- linters/shfmt/plugin.yaml | 25 +++++++++++---- .../test_data/shfmt_v3.13.1_basic.check.shot | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 linters/shfmt/test_data/shfmt_v3.13.1_basic.check.shot diff --git a/linters/shfmt/plugin.yaml b/linters/shfmt/plugin.yaml index efbb30e64..39d2a4b6a 100644 --- a/linters/shfmt/plugin.yaml +++ b/linters/shfmt/plugin.yaml @@ -1,12 +1,26 @@ version: 0.1 +downloads: + - name: shfmt + executable: true + downloads: + - os: + linux: linux + macos: darwin + cpu: + x86_64: amd64 + arm_64: arm64 + url: https://github.com/mvdan/sh/releases/download/v${version}/shfmt_v${version}_${os}_${cpu} + - os: + windows: windows + cpu: + x86_64: amd64 + url: https://github.com/mvdan/sh/releases/download/v${version}/shfmt_v${version}_${os}_${cpu}.exe tools: definitions: - name: shfmt - runtime: go - package: mvdan.cc/sh/v${major_version}/cmd/shfmt + download: shfmt shims: [shfmt] - # shfmt releases are not auto-queriable with our current setup, so we will bump this fixed version from time to time - known_good_version: 3.6.0 + known_good_version: 3.13.1 lint: definitions: - name: shfmt @@ -24,8 +38,7 @@ lint: tools: [shfmt] suggest_if: files_present affects_cache: [.editorconfig] - # shfmt releases are not auto-queriable with our current setup, so we will bump this fixed version from time to time - known_good_version: 3.6.0 + known_good_version: 3.13.1 version_command: parse_regex: ${semver} run: shfmt --version diff --git a/linters/shfmt/test_data/shfmt_v3.13.1_basic.check.shot b/linters/shfmt/test_data/shfmt_v3.13.1_basic.check.shot new file mode 100644 index 000000000..cae133234 --- /dev/null +++ b/linters/shfmt/test_data/shfmt_v3.13.1_basic.check.shot @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing linter shfmt test basic 1`] = ` +{ + "issues": [ + { + "code": "parse", + "column": "24", + "file": "test_data/basic.in.sh", + "issueClass": "ISSUE_CLASS_EXISTING", + "level": "LEVEL_HIGH", + "line": "3", + "linter": "shfmt", + "message": "\`then\` must be followed by a statement list", + "targetType": "shell", + }, + ], + "lintActions": [ + { + "command": "format", + "fileGroupName": "shell", + "linter": "shfmt", + "paths": [ + "test_data/basic.in.sh", + ], + "verb": "TRUNK_VERB_FMT", + }, + ], + "taskFailures": [], + "unformattedFiles": [], +} +`; From d1e3af5752059371e206fe8f242b15d7f4205555 Mon Sep 17 00:00:00 2001 From: Eli Schleifer <1265982+EliSchleifer@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:02:57 -0700 Subject: [PATCH 4/4] pinact: fix SARIF fixes corrupting lines on apply (#1154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `pinact` linter's autofix corrupts every `uses:` line it pins. Running `trunk check --fix` (or fmt-on-save) turns: ```yaml uses: actions/checkout@v4 ``` into: ```yaml uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@v4 ``` The pinned ref is written, but the original `uses: …@v4` is left dangling on the same line (falling past the `#` into a comment). Every pinned action is affected. ### Root cause The plugin passes pinact's SARIF straight to Trunk's fix applier. pinact describes each fix with a **line-only** region: ```json "deletedRegion": { "startLine": 40 }, "insertedContent": { "text": " uses: actions/checkout@ # v4.4.0" } ``` Trunk reads a `deletedRegion` with no `endLine`/columns as a **zero-width insertion point at column 1**, so it *inserts* the pinned line and never deletes the original — concatenating both. The `ruff`/`sqlfluff`/etc. converters in this repo don't hit this because they always emit a fully-specified region (`startLine`+`startColumn`+`endLine`+`endColumn`). ### Fix `pinact_run.py` now post-processes pinact's SARIF before emitting it (`normalize_fix_regions`): each fix's `deletedRegion` is widened to span the whole original line (`startColumn: 1` → `endColumn: len(line)+1`, `endLine = startLine`), matching the convention Trunk applies correctly. It's a no-op for any region pinact ever fully specifies (guards on `endColumn`/`endLine`/`charLength`/`charOffset`). ### Coverage gap this exposes None of the existing snapshot tests ever applied a pinact fix — the driver's `runCheck` forces `-n` (`--no-fix`), so every snapshot only covered parse-errors. Added a regression test that applies a real fix and asserts the line is pinned to a SHA **without** the concatenation corruption. Verified it fails without the `pinact_run.py` change and passes with it. It's online-gated (a resolvable SHA is required) and version-independent (does not snapshot the volatile SHA). ## Test plan - [x] `trunk fmt` + `trunk check` on both changed files — no new issues - [x] New regression test passes with the fix, fails without it - [x] End-to-end: applied against a repo with ~170 pinned `uses:` lines — 0 mangled, byte-for-byte identical to a direct `pinact run` - [x] Pre-push repo tests pass (232/232) Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor --- eslint.config.cjs | 2 + linters/pinact/pinact.test.ts | 197 +++++++++++++++++++++++++++++++++- linters/pinact/pinact_run.py | 72 ++++++++++++- 3 files changed, 267 insertions(+), 4 deletions(-) diff --git a/eslint.config.cjs b/eslint.config.cjs index a1e9fb120..a666c340a 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -126,6 +126,8 @@ module.exports = [ }, rules: { ...jestPlugin.configs.recommended.rules, + // conditionalTest (tests/utils) is the repo's it()/it.skip() wrapper. + "jest/no-standalone-expect": ["error", { additionalTestBlockFunctions: ["conditionalTest"] }], }, }, { diff --git a/linters/pinact/pinact.test.ts b/linters/pinact/pinact.test.ts index fd6ed7648..a5861be02 100644 --- a/linters/pinact/pinact.test.ts +++ b/linters/pinact/pinact.test.ts @@ -1,8 +1,10 @@ +import { execFileSync } from "child_process"; import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; -import { customLinterCheckTest } from "tests"; +import { customLinterCheckTest, setupLintDriver } from "tests"; import { TrunkLintDriver } from "tests/driver"; -import { TEST_DATA } from "tests/utils"; +import { conditionalTest, TEST_DATA } from "tests/utils"; const moveWorkflowFile = (filename: string, disableGhAuth = false) => @@ -58,6 +60,18 @@ const skipIfMissingGitHubToken = () => { return false; }; +const resolvePython = (): string | undefined => { + for (const bin of ["python3", "python"]) { + try { + execFileSync(bin, ["--version"], { stdio: "ignore" }); + return bin; + } catch { + // Try the next candidate. + } + } + return undefined; +}; + const preCheckBadConfig = async (driver: TrunkLintDriver) => { process.env.PINACT_DISABLE_GH_AUTH = "1"; driver.moveFile(path.join(TEST_DATA, "bad.pinact.yaml"), path.join(".pinact.yaml")); @@ -104,3 +118,182 @@ customLinterCheckTest({ preCheck: enablePinactCommand("upgrade", moveWorkflowFile("unpinned.in.yaml")), skipTestIf: skipIfMissingGitHubToken, }); + +// The snapshot tests above never apply a fix (the driver's runCheck forces +// `-n`), so none of them caught pinact SARIF whose `deletedRegion` was +// line-only: Trunk read that as a zero-width insert and concatenated the pinned +// `uses:` with the original one on a single line. This applies the fix for real +// and asserts the rewrite is clean. Kept online (a resolvable SHA is required to +// exercise the fix path) and version-independent (no snapshot of the volatile +// SHA) — it only asserts the structural invariant the bug violated. +describe("Testing linter pinact fix application", () => { + const driver = setupLintDriver( + __dirname, + {}, + "pinact", + undefined, + moveWorkflowFile("unpinned.in.yaml"), + ); + + conditionalTest( + skipIfMissingGitHubToken(), + "pins to a SHA without corrupting the line", + async () => { + await driver + .runTrunkCmd("check --filter=pinact --fix -y --no-progress --ignore-git-state .github") + .catch(() => undefined); + + const fixed = driver.readFile(".github/workflows/unpinned.in.yaml"); + // The action is pinned to a full 40-char SHA with its version comment... + expect(fixed).toMatch(/uses: actions\/checkout@[0-9a-f]{40} # v\d/); + // ...and no line carries the concatenated ` # … ` corruption. + expect(fixed).not.toMatch(/uses:.*#.*uses:/); + // The single input `uses:` stays single — the corruption doubled it. + expect(fixed.match(/uses:/g)).toHaveLength(1); + }, + ); +}); + +interface FixRegion { + startLine: number; + startColumn?: number; + endLine?: number; + endColumn?: number; +} + +interface FixSarif { + runs: { + results: { + fixes: { artifactChanges: { replacements: { deletedRegion: FixRegion }[] }[] }[]; + }[]; + }[]; +} + +// Deterministic, offline coverage of the SARIF fix-region normalization that +// pinact_run.py applies before Trunk consumes it. pinact can only pin online +// (it resolves tags -> SHAs via the GitHub API), so the end-to-end fix test +// above is token-gated; this drives the pure transformation directly, so the +// invariant is locked on every CI run with no token or network. +describe("pinact SARIF fix-region normalization", () => { + const pythonBin = resolvePython(); + // A representative unpinned step; the trailing `@v4` is what pinact rewrites. + const line = " - uses: actions/checkout@v4"; + let sandbox: string; + + beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "pinact-normalize-")); + const workflowDir = path.join(sandbox, ".github", "workflows"); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, "wf.yaml"), `jobs:\n a:\n steps:\n${line}\n`); + }); + + afterAll(() => { + if (sandbox) { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); + + // Invoke pinact_run.normalize_fix_regions on `sarif` with cwd at the sandbox, + // so the relative artifact URI resolves to the fixture workflow above. + const normalize = (sarif: unknown): FixSarif => { + const script = + "import sys; sys.path.insert(0, sys.argv[1]); import pinact_run; " + + "sys.stdout.write(pinact_run.normalize_fix_regions(sys.stdin.read()))"; + const out = execFileSync(pythonBin ?? "python3", ["-c", script, __dirname], { + input: JSON.stringify(sarif), + cwd: sandbox, + encoding: "utf8", + }); + return JSON.parse(out) as FixSarif; + }; + + const sarifWithRegion = (deletedRegion: FixRegion) => ({ + runs: [ + { + results: [ + { + fixes: [ + { + artifactChanges: [ + { + artifactLocation: { uri: ".github/workflows/wf.yaml" }, + replacements: [ + { + deletedRegion, + insertedContent: { text: line.replace("@v4", "@ # v4.4.0") }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + const regionOf = (sarif: FixSarif): FixRegion => + sarif.runs[0].results[0].fixes[0].artifactChanges[0].replacements[0].deletedRegion; + + conditionalTest( + pythonBin === undefined, + "widens a line-only region to span the whole original line", + () => { + const out = normalize(sarifWithRegion({ startLine: 4 })); + // Full-line replacement covers columns 1..len(line); endColumn is exclusive. + expect(regionOf(out)).toEqual({ + startLine: 4, + startColumn: 1, + endLine: 4, + endColumn: line.length + 1, + }); + }, + ); + + conditionalTest( + pythonBin === undefined, + "leaves an already fully-specified region unchanged", + () => { + const region: FixRegion = { startLine: 4, startColumn: 5, endLine: 4, endColumn: 10 }; + expect(regionOf(normalize(sarifWithRegion(region)))).toEqual(region); + }, + ); + + conditionalTest( + pythonBin === undefined, + "preserves an explicit startColumn while backfilling the line end", + () => { + const out = normalize(sarifWithRegion({ startLine: 4, startColumn: 9 })); + expect(regionOf(out)).toEqual({ + startLine: 4, + startColumn: 9, + endLine: 4, + endColumn: line.length + 1, + }); + }, + ); + + conditionalTest( + pythonBin === undefined, + "backfills endColumn when only startLine and endLine are given", + () => { + const out = normalize(sarifWithRegion({ startLine: 4, endLine: 4 })); + expect(regionOf(out)).toEqual({ + startLine: 4, + startColumn: 1, + endLine: 4, + endColumn: line.length + 1, + }); + }, + ); + + conditionalTest( + pythonBin === undefined, + "leaves a line-only region untouched when the target line is out of range", + () => { + const region: FixRegion = { startLine: 999 }; + expect(regionOf(normalize(sarifWithRegion(region)))).toEqual(region); + }, + ); +}); diff --git a/linters/pinact/pinact_run.py b/linters/pinact/pinact_run.py index 79175e06b..aee8a2c04 100644 --- a/linters/pinact/pinact_run.py +++ b/linters/pinact/pinact_run.py @@ -105,6 +105,73 @@ def build_pinact_args(mode: str) -> list[str]: return args +def normalize_fix_regions(sarif_text: str) -> str: + """Backfill a concrete line end on pinact's SARIF fix regions. + + pinact emits each replacement's ``deletedRegion`` without an end column + (typically just ``{"startLine": N}``). Trunk's fix applier reads a region + with no explicit end as a zero-width insertion point, so it *prepends* the + pinned ``uses:`` and never deletes the original line — concatenating both + onto one line. Backfill only what's missing — preserving any explicit + ``startColumn``/``endLine`` — so the region carries a concrete end + (``endColumn`` at the end of its end line) that Trunk replaces rather than + inserts, matching the fully-specified regions the ruff/sqlfluff converters + already rely on. Regions that already carry an ``endColumn`` or an + offset-based span (``charOffset``/``charLength``) are left untouched. + """ + try: + sarif = json.loads(sarif_text) + except (json.JSONDecodeError, TypeError): + return sarif_text + + line_cache: dict[str, list[str]] = {} + + def lines_for(uri: str) -> list[str] | None: + if uri not in line_cache: + try: + line_cache[uri] = Path(uri).read_text(encoding="utf-8").splitlines() + except OSError: + line_cache[uri] = [] + return line_cache[uri] or None + + for run in sarif.get("runs", []): + for result in run.get("results", []): + for fix in result.get("fixes", []): + for change in fix.get("artifactChanges", []): + uri = change.get("artifactLocation", {}).get("uri") + if not uri: + continue + for replacement in change.get("replacements", []): + region = replacement.get("deletedRegion") + if not region or "startLine" not in region: + continue + # An explicit end column or an offset-based span is + # unambiguous — Trunk applies it as-is, so leave it alone. + if any( + key in region + for key in ("endColumn", "charOffset", "charLength") + ): + continue + lines = lines_for(uri) + if lines is None: + continue + start_index = region["startLine"] - 1 + end_line = region.get("endLine", region["startLine"]) + end_index = end_line - 1 + if not 0 <= start_index < len( + lines + ) or not 0 <= end_index < len(lines): + continue + # Preserve any explicit start/end line; only backfill what's + # missing so the region carries a concrete end (end of its end + # line) that Trunk won't read as a zero-width insert. + region.setdefault("startColumn", 1) + region["endLine"] = end_line + region["endColumn"] = len(lines[end_index]) + 1 + + return json.dumps(sarif, indent=2) + + def strip_ansi(text: str) -> str: return ANSI_ESCAPE.sub("", text) @@ -183,8 +250,9 @@ def run_pinact(mode: str, targets: list[str]) -> int: return 2 if stdout: - sys.stdout.write(stdout) - if not stdout.endswith("\n"): + normalized = normalize_fix_regions(stdout) + sys.stdout.write(normalized) + if not normalized.endswith("\n"): sys.stdout.write("\n") if stderr: sys.stderr.write(stderr)