diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e32f0ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + rust: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --locked --all-targets + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo run -- check crates + - uses: taiki-e/install-action@cargo-llvm-cov + - run: cargo llvm-cov --workspace --locked --fail-under-lines 89 + + gitleaks: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan repository history + run: >- + docker run --rm + -v "$PWD:/repo" + ghcr.io/gitleaks/gitleaks:v8.30.1 + detect --source /repo --config /repo/.gitleaks.toml + --no-banner --redact --verbose + + osv-scanner: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: google/osv-scanner-action/osv-scanner-action@v2.5.1 + with: + scan-args: |- + --config=osv-scanner.toml + --lockfile=Cargo.lock diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc6916a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,297 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - uses: Swatinem/rust-cache@v2 + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..5474857 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,2 @@ +[extend] +useDefault = true diff --git a/AGENTS.md b/AGENTS.md index 95563b6..534b47d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,16 @@ silently. - The binary gates its own code: `cargo run -- check crates` must pass. - Workspace policy: read `../AGENTS.md` (Pickforge workspace) and use the `plan-issue` workflow — GitHub Issues track plan and progress. + +## Lessons from review + +- `--changed` takes its file set straight from Git (diff post-image + untracked) + and resolves against the repo root; never derive it by walking the cwd, and + never let `.gitignore` filter it. +- Never classify syntax by text prefixes (`starts_with("default")`, operator + substrings); use node kinds, fields, or token children. Text scans over-count + in string literals and under-count real identifiers. +- Coverage floors are actual line coverage rounded down (ratchet rule), not + actual minus a margin. +- Every metric rule needs a golden case per language, including the negative + case (operator inside a string, unbraced `else `, `_ when` guard). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4c8830 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,729 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "complexity-gate" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "complexity-gate-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "complexity-gate-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "globset", + "ignore", + "serde", + "serde_json", + "tempfile", + "tree-sitter", + "tree-sitter-dart", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-svelte-ng", + "tree-sitter-typescript", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tree-sitter" +version = "0.26.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-dart" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325dd1e24ee9ee21111e9c43680ae7d6010aaa9f282b048a99b9c7163c1cf553" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-svelte-ng" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef0a71f9cf5e94373cc86c64893630c8a29bb25d3390a248268d08af2165fa37" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ef2aa8e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = ["crates/core", "crates/cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/pickforge/complexity-gate" + +[workspace.dependencies] +anyhow = "=1.0.104" +clap = { version = "=4.6.6", features = ["derive"] } +dirs = "=6.0.0" +globset = "=0.4.20" +ignore = "=0.4.33" +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +tempfile = "=3.27.0" +tree-sitter = "=0.26.13" +tree-sitter-dart = "=0.2.0" +tree-sitter-go = "=0.25.0" +tree-sitter-javascript = "=0.25.0" +tree-sitter-python = "=0.25.0" +tree-sitter-rust = "=0.24.2" +tree-sitter-svelte-ng = "=1.0.2" +tree-sitter-typescript = "=0.23.2" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee0fcc2 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# complexity-gate + +Deterministic per-function complexity checks for JavaScript, TypeScript, TSX, +Svelte, Dart, Rust, Python, and Go. It measures cyclomatic complexity, control +flow depth, significant lines, and parameters without external linters. + +## Install + +Download the archive for your platform from +[GitHub Releases](https://github.com/pickforge/complexity-gate/releases), verify +its checksum, and place `complexity-gate` on `PATH`. To build the current Git +version instead: + +```sh +cargo install --git https://github.com/pickforge/complexity-gate --package complexity-gate --locked +``` + +## Use + +```sh +complexity-gate check src +complexity-gate check --changed +complexity-gate check --format json . +complexity-gate doctor --coverage +``` + +`check` exits 0 when clean, 1 for violations, and 2 for usage/runtime errors. +Unsupported extensions are reported as `UNVERIFIED` without failing. + +Run `complexity-gate init` to write `.complexity-gate.json`. Resolution order is +built-in defaults, user config, nearest repo config, then `--config`; later +values win. Defaults and language overrides are documented in +[`docs/spec.md`](docs/spec.md). + +## Hooks + +Use `complexity-gate hook claude` or `complexity-gate hook codex` as the command +for each harness's `PostToolUse` and `Stop` events. Field mappings, output +contracts, state location, and the Codex patch limitation are in +[`docs/hooks.md`](docs/hooks.md). diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e6ca968 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +cognitive-complexity-threshold = 15 +too-many-lines-threshold = 100 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..0c33835 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "complexity-gate" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "complexity-gate" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +complexity-gate-core = { path = "../core", version = "=0.1.0" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/cli/src/hooks.rs b/crates/cli/src/hooks.rs new file mode 100644 index 0000000..c0e18fb --- /dev/null +++ b/crates/cli/src/hooks.rs @@ -0,0 +1,273 @@ +use std::{ + env, fs, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use complexity_gate_core::{ScanOptions, changed_files, load_config, scan}; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Harness { + Claude, + Codex, +} + +#[derive(Debug, Deserialize)] +struct HookInput { + hook_event_name: String, + #[serde(default)] + session_id: String, + #[serde(default = "default_cwd")] + cwd: PathBuf, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_input: Value, +} + +enum Event { + Ignore, + File(PathBuf), + Changed, + Stop, +} + +pub fn run(harness: Harness) -> Result { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("cannot read hook input")?; + let input: HookInput = serde_json::from_str(&input).context("invalid hook JSON")?; + handle(harness, &input)?; + Ok(0) +} + +fn handle(harness: Harness, input: &HookInput) -> Result<()> { + match classify(harness, input) { + Event::Ignore => Ok(()), + Event::File(path) => report_post_edit(input, &[path]), + Event::Changed => report_post_changed(input), + Event::Stop => report_stop(input), + } +} + +fn classify(harness: Harness, input: &HookInput) -> Event { + match input.hook_event_name.as_str() { + "Stop" => Event::Stop, + "PostToolUse" if post_tool(harness, input.tool_name.as_deref()) => { + file_path(&input.tool_input).map_or(Event::Changed, Event::File) + } + _ => Event::Ignore, + } +} + +fn post_tool(harness: Harness, tool: Option<&str>) -> bool { + match harness { + Harness::Claude => matches!(tool, Some("Edit" | "Write" | "MultiEdit")), + Harness::Codex => matches!(tool, Some("apply_patch" | "Edit" | "Write")), + } +} + +fn file_path(input: &Value) -> Option { + input + .get("file_path") + .and_then(Value::as_str) + .map(PathBuf::from) +} + +fn report_post_edit(input: &HookInput, paths: &[PathBuf]) -> Result<()> { + let result = scan(&ScanOptions { + cwd: &input.cwd, + paths, + explicit_config: None, + changed: None, + })?; + if result.violations.is_empty() { + return Ok(()); + } + emit_block(&text_report(&result.violations)) +} + +fn report_post_changed(input: &HookInput) -> Result<()> { + let changes = changed_files(&input.cwd)?; + let result = scan(&ScanOptions { + cwd: &input.cwd, + paths: &[], + explicit_config: None, + changed: Some(&changes), + })?; + if result.violations.is_empty() { + return Ok(()); + } + emit_block(&text_report(&result.violations)) +} + +fn report_stop(input: &HookInput) -> Result<()> { + let changes = changed_files(&input.cwd)?; + let result = scan(&ScanOptions { + cwd: &input.cwd, + paths: &[], + explicit_config: None, + changed: Some(&changes), + })?; + if result.violations.is_empty() { + reset_counter(&input.session_id)?; + return Ok(()); + } + let report = text_report(&result.violations); + let max = load_config(&input.cwd, None)?.config.hook.max_blocks; + if increment_counter(&input.session_id)? > max { + eprintln!("UNRESOLVED {report}"); + return Ok(()); + } + emit_block(&format!( + "{report}\nRefactor the listed functions (see the complexity-gate skill), then finish." + )) +} + +fn emit_block(reason: &str) -> Result<()> { + println!( + "{}", + serde_json::to_string(&json!({"decision":"block", "reason":reason}))? + ); + Ok(()) +} + +fn text_report(violations: &[complexity_gate_core::Violation]) -> String { + violations + .iter() + .map(|item| { + format!( + "FAIL {}:{} {} {} {} > {}", + item.file.display(), + item.line, + item.function, + item.metric, + item.value, + item.limit + ) + }) + .collect::>() + .join("\n") +} + +pub fn state_dir() -> Result { + if let Some(path) = env::var_os("COMPLEXITY_GATE_HOME") { + return Ok(PathBuf::from(path)); + } + let home = dirs_home().context("cannot determine home directory")?; + Ok(home.join(".pickforge/complexity-gate")) +} + +fn dirs_home() -> Option { + env::var_os("HOME").map(PathBuf::from) +} + +fn default_cwd() -> PathBuf { + env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn state_file(session_id: &str) -> Result { + Ok(state_dir()?.join(format!("{}.count", sanitized_session_id(session_id)))) +} + +fn sanitized_session_id(session_id: &str) -> String { + let sanitized: String = session_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + } + }) + .take(128) + .collect(); + if sanitized.is_empty() { + "unkeyed".to_owned() + } else { + sanitized + } +} + +fn increment_counter(session_id: &str) -> Result { + let path = state_file(session_id)?; + let current = read_counter(&path).saturating_add(1); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, current.to_string()) + .with_context(|| format!("cannot write state {}", path.display()))?; + Ok(current) +} + +fn read_counter(path: &Path) -> usize { + fs::read_to_string(path) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(0) +} + +fn reset_counter(session_id: &str) -> Result<()> { + let path = state_file(session_id)?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(event: &str, tool: Option<&str>, tool_input: Value) -> HookInput { + HookInput { + hook_event_name: event.to_owned(), + session_id: "s".to_owned(), + cwd: PathBuf::from("/tmp"), + tool_name: tool.map(str::to_owned), + tool_input, + } + } + + #[test] + fn claude_post_edit_uses_file_path() { + let event = classify( + Harness::Claude, + &input("PostToolUse", Some("Edit"), json!({"file_path":"a.rs"})), + ); + assert!(matches!(event, Event::File(path) if path == Path::new("a.rs"))); + } + + #[test] + fn codex_apply_patch_without_path_falls_back_to_changed() { + let event = classify( + Harness::Codex, + &input( + "PostToolUse", + Some("apply_patch"), + json!({"command":"patch"}), + ), + ); + assert!(matches!(event, Event::Changed)); + } + + #[test] + fn optional_hook_fields_default_before_classification() { + let parsed: HookInput = serde_json::from_str(r#"{"hook_event_name":"Other"}"#).unwrap(); + assert!(parsed.session_id.is_empty()); + assert!(!parsed.cwd.as_os_str().is_empty()); + assert!(matches!(classify(Harness::Claude, &parsed), Event::Ignore)); + } + + #[test] + fn session_ids_are_sanitized_and_bounded() { + assert_eq!(sanitized_session_id("agent/a:b"), "agent_a_b"); + assert_eq!(sanitized_session_id(""), "unkeyed"); + assert_eq!(sanitized_session_id(&"x".repeat(200)).len(), 128); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..56e462a --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,203 @@ +#![deny(clippy::cognitive_complexity, clippy::too_many_lines)] + +mod hooks; + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand, ValueEnum}; +use complexity_gate_core::{ + ScanOptions, changed_files, coverage_unknowns, grammar_inventory, load_config, scan, +}; +use serde::Serialize; + +#[derive(Parser)] +#[command(name = "complexity-gate", version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Check { + #[arg(long)] + changed: bool, + #[arg(long, value_enum, default_value = "text")] + format: Format, + #[arg(long)] + config: Option, + paths: Vec, + }, + Hook { + #[command(subcommand)] + harness: Harness, + }, + Init, + Doctor { + #[arg(long)] + coverage: bool, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum Format { + Text, + Json, +} + +#[derive(Subcommand)] +enum Harness { + Claude, + Codex, +} + +fn main() -> ExitCode { + match run(Cli::parse()) { + Ok(code) => ExitCode::from(code), + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> Result { + match cli.command { + Command::Check { + changed, + format, + config, + paths, + } => run_check(changed, format, config.as_deref(), &paths), + Command::Hook { harness } => hooks::run(match harness { + Harness::Claude => hooks::Harness::Claude, + Harness::Codex => hooks::Harness::Codex, + }), + Command::Init => init(), + Command::Doctor { coverage } => doctor(coverage), + } +} + +fn run_check( + changed: bool, + format: Format, + config: Option<&Path>, + paths: &[PathBuf], +) -> Result { + let cwd = env::current_dir().context("cannot determine current directory")?; + let changes = changed.then(|| changed_files(&cwd)).transpose()?; + if changes.as_ref().is_some_and(|item| item.fallback) { + eprintln!( + "note: --changed requires a Git repository with HEAD; checking all selected paths" + ); + } + let result = scan(&ScanOptions { + cwd: &cwd, + paths, + explicit_config: config, + changed: changes.as_ref(), + })?; + match format { + Format::Text => { + for note in &result.notes { + eprintln!("note: {note}"); + } + print_text(&result); + } + Format::Json => print_json(&result)?, + } + Ok(u8::from(!result.violations.is_empty())) +} + +fn print_text(result: &complexity_gate_core::ScanResult) { + for violation in &result.violations { + println!( + "FAIL {}:{} {} {} {} > {}", + violation.file.display(), + violation.line, + violation.function, + violation.metric, + violation.value, + violation.limit + ); + } + for item in &result.unverified { + println!("UNVERIFIED {} {}", item.file.display(), item.reason); + } +} + +#[derive(Serialize)] +struct JsonReport<'a> { + version: &'static str, + checked: usize, + violations: &'a [complexity_gate_core::Violation], + unverified: &'a [complexity_gate_core::Unverified], + notes: &'a [String], +} + +fn print_json(result: &complexity_gate_core::ScanResult) -> Result<()> { + let report = JsonReport { + version: env!("CARGO_PKG_VERSION"), + checked: result.checked, + violations: &result.violations, + unverified: &result.unverified, + notes: &result.notes, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn init() -> Result { + let path = env::current_dir()?.join(".complexity-gate.json"); + if path.exists() { + anyhow::bail!("{} already exists", path.display()); + } + let config = load_config(path.parent().unwrap_or(Path::new(".")), None)?.config; + fs::write( + &path, + format!("{}\n", serde_json::to_string_pretty(&config)?), + ) + .with_context(|| format!("cannot write {}", path.display()))?; + println!("wrote {}", path.display()); + Ok(0) +} + +fn doctor(coverage: bool) -> Result { + let cwd = env::current_dir()?; + let resolved = load_config(&cwd, None)?; + println!("complexity-gate {}", env!("CARGO_PKG_VERSION")); + println!("config chain:"); + for path in resolved.chain { + println!(" {}", path.display()); + } + println!( + "effective config: {}", + serde_json::to_string(&resolved.config)? + ); + println!("state directory: {}", hooks::state_dir()?.display()); + for grammar in grammar_inventory() { + println!( + "{}: {} {}", + grammar.language, grammar.grammar, grammar.version + ); + } + if coverage { + println!("coverage candidates:"); + for (language, kinds) in coverage_unknowns() { + println!( + " {language}: {}", + if kinds.is_empty() { + "none".to_owned() + } else { + kinds.join(", ") + } + ); + } + } + Ok(0) +} diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs new file mode 100644 index 0000000..4159baf --- /dev/null +++ b/crates/cli/tests/cli.rs @@ -0,0 +1,390 @@ +use std::{ + fs, + io::Write, + path::Path, + process::{Command, Output, Stdio}, +}; + +fn binary() -> Command { + Command::new(env!("CARGO_BIN_EXE_complexity-gate")) +} + +#[test] +fn check_exit_codes_follow_contract() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("note.txt"), "unverified").unwrap(); + assert_eq!( + binary() + .current_dir(dir.path()) + .args(["check", "note.txt"]) + .status() + .unwrap() + .code(), + Some(0) + ); + fs::write(dir.path().join("bad.js"), complex_function()).unwrap(); + assert_eq!( + binary() + .current_dir(dir.path()) + .args(["check", "bad.js"]) + .status() + .unwrap() + .code(), + Some(1) + ); + fs::write(dir.path().join("config.json"), r#"{"unknown":true}"#).unwrap(); + assert_eq!( + binary() + .current_dir(dir.path()) + .args(["check", "--config", "config.json", "bad.js"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .code(), + Some(2) + ); + fs::write( + dir.path().join("config.json"), + r#"{"tests":{"exempt":["depth"]}}"#, + ) + .unwrap(); + let invalid_exempt = + command_output(dir.path(), &["check", "--config", "config.json", "bad.js"]); + assert_eq!(invalid_exempt.status.code(), Some(2)); + let error = String::from_utf8_lossy(&invalid_exempt.stderr); + assert!(error.contains("tests.exempt") && error.contains("depth")); + assert_eq!( + binary() + .current_dir(dir.path()) + .args(["check", "missing.js"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .code(), + Some(2) + ); +} + +#[test] +fn changed_results_are_repo_root_keyed_from_nested_cwd() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("src/sub"); + fs::create_dir_all(&nested).unwrap(); + fs::write( + dir.path().join(".complexity-gate.json"), + r#"{"limits":{"depth":0}}"#, + ) + .unwrap(); + let top = dir.path().join("top.js"); + let tracked = nested.join("x.js"); + fs::write(&top, "function top() { return 1; }\n").unwrap(); + fs::write(&tracked, "function tracked() { return 1; }\n").unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-qm", "initial"]); + let complex = "function changed(x) { if (x) return 1; return 0; }\n"; + fs::write(&top, complex).unwrap(); + fs::write(&tracked, complex).unwrap(); + fs::write(nested.join("new.js"), complex).unwrap(); + fs::write(dir.path().join(".gitignore"), "src/\n").unwrap(); + git(dir.path(), &["config", "diff.noprefix", "true"]); + git(dir.path(), &["config", "diff.external", "/bin/false"]); + + let root = command_output(dir.path(), &["check", "--changed"]); + let child = command_output(&nested, &["check", "--changed"]); + let root_text = String::from_utf8(root.stdout).unwrap(); + let child_text = String::from_utf8(child.stdout).unwrap(); + for expected in ["top.js", "src/sub/x.js"] { + assert!( + root_text.contains(expected), + "missing {expected}; stdout: {root_text}; stderr: {}", + String::from_utf8_lossy(&root.stderr) + ); + } + for expected in ["../../top.js", "x.js"] { + assert!( + child_text.contains(expected), + "missing {expected}; stdout: {child_text}; stderr: {}", + String::from_utf8_lossy(&child.stderr) + ); + } + assert!(!root_text.contains("new.js"), "stdout: {root_text}"); + assert!(!child_text.contains("new.js"), "stdout: {child_text}"); +} + +#[test] +fn changed_explicit_paths_normalize_parent_components() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + fs::create_dir(&src).unwrap(); + fs::write(src.join("tracked.js"), "function tracked() { return 1; }\n").unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(dir.path().join("untracked.js"), complex_function()).unwrap(); + + for path in ["../untracked.js", ".."] { + let output = command_output(&src, &["check", "--changed", path]); + assert_eq!(output.status.code(), Some(1), "path: {path}"); + assert!( + String::from_utf8_lossy(&output.stdout).contains("../untracked.js"), + "path: {path}; stdout: {}; stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn changed_config_is_noted_in_text_and_json_reports() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join(".complexity-gate.json"); + fs::write(&config, r#"{"limits":{"depth":4}}"#).unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(&config, r#"{"limits":{"depth":3}}"#).unwrap(); + + let text = command_output(dir.path(), &["check", "--changed"]); + assert!( + String::from_utf8_lossy(&text.stderr) + .contains("note: .complexity-gate.json changed in this diff") + ); + let json = command_output(dir.path(), &["check", "--changed", "--format", "json"]); + let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!( + report["notes"], + serde_json::json!([".complexity-gate.json changed in this diff"]) + ); +} + +#[test] +fn changed_ignored_paths_are_filtered_before_language_lookup() { + let dir = tempfile::tempdir().unwrap(); + for path in ["build/Bar.kt", "target/Foo.kt"] { + let file = dir.path().join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "fun clean() = 1\n").unwrap(); + } + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "-f", "build/Bar.kt", "target/Foo.kt"]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(dir.path().join("build/Bar.kt"), "fun changed() = 2\n").unwrap(); + fs::write(dir.path().join("target/Foo.kt"), "fun changed() = 2\n").unwrap(); + + let output = command_output(dir.path(), &["check", "--changed"]); + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "stdout: {}", String::from_utf8_lossy(&output.stdout)); +} + +#[test] +fn changed_non_utf8_diff_does_not_abort_check_or_stop_hook() { + let dir = tempfile::tempdir().unwrap(); + let state = tempfile::tempdir().unwrap(); + let file = dir.path().join("staged.js"); + fs::write(&file, "function staged() { return 1; }\n").unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(&file, b"function staged() { return '\xff'; }\n").unwrap(); + git(dir.path(), &["add", "staged.js"]); + + let check = command_output(dir.path(), &["check", "--changed"]); + assert!(check.status.success(), "stderr: {}", String::from_utf8_lossy(&check.stderr)); + assert_eq!( + String::from_utf8_lossy(&check.stdout), + "UNVERIFIED staged.js not valid UTF-8\n" + ); + + let input = serde_json::json!({ + "hook_event_name":"Stop", "session_id":"non-utf8", "cwd":dir.path() + }) + .to_string(); + let stop = hook_output(state.path(), &input); + assert!(stop.status.success(), "stderr: {}", String::from_utf8_lossy(&stop.stderr)); + assert!(stop.stdout.is_empty()); +} + +#[test] +fn directory_noise_is_silent_and_invalid_utf8_is_unverified() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("README.md"), "docs\n").unwrap(); + fs::write(dir.path().join("bad.py"), b"def f():\n return '\xff'\n").unwrap(); + + let walked = command_output(dir.path(), &["check", "."]); + let text = String::from_utf8(walked.stdout).unwrap(); + assert_eq!(text, "UNVERIFIED bad.py not valid UTF-8\n"); + assert!(walked.status.success()); +} + +#[test] +fn test_patterns_and_ignores_use_repository_relative_paths() { + let dir = tempfile::tempdir().unwrap(); + let tests = dir.path().join("pkg/test"); + fs::create_dir_all(&tests).unwrap(); + fs::write( + dir.path().join(".complexity-gate.json"), + r#"{"ignore":["**/ignored.js"]}"#, + ) + .unwrap(); + let long = format!("function big() {{\n{}\n}}\n", "return 1;\n".repeat(101)); + fs::write(tests.join("big.js"), long).unwrap(); + fs::write(tests.join("ignored.js"), complex_function()).unwrap(); + + for paths in [["check", "."], ["check", "big.js"]] { + let output = command_output(&tests, &paths); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn test_patterns_use_the_common_scan_root_outside_git() { + let dir = tempfile::tempdir().unwrap(); + let nog = dir.path().join("nog"); + let tests = nog.join("test"); + fs::create_dir_all(&tests).unwrap(); + let long = format!("function big() {{\n{}\n}}\n", "return 1;\n".repeat(101)); + fs::write(tests.join("b.js"), long).unwrap(); + + for (cwd, path) in [(&nog, "test/b.js"), (&tests, "b.js")] { + let output = command_output(cwd, &["check", path]); + assert!( + output.status.success() && output.stdout.is_empty(), + "stdout: {}; stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn stop_loop_guard_blocks_three_then_releases_without_reset() { + let dir = tempfile::tempdir().unwrap(); + let state = tempfile::tempdir().unwrap(); + let file = dir.path().join("bad.js"); + fs::write(&file, "function bad(x) { return x; }\n").unwrap(); + fs::write( + dir.path().join(".complexity-gate.json"), + r#"{"limits":{"depth":0},"hook":{"max_blocks":3}}"#, + ) + .unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(&file, "function bad(x) { if (x) return 1; return 0; }\n").unwrap(); + let input = serde_json::json!({ + "hook_event_name":"Stop", "session_id":"same/session", "cwd":dir.path() + }) + .to_string(); + + for index in 0..5 { + let output = hook_output(state.path(), &input); + assert!(output.status.success()); + if index < 3 { + assert!(String::from_utf8_lossy(&output.stdout).contains(r#""decision":"block""#)); + assert!(output.stderr.is_empty()); + } else { + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).starts_with("UNRESOLVED FAIL")); + } + } + assert_eq!( + fs::read_to_string(state.path().join("same_session.count")).unwrap(), + "5" + ); +} + +#[test] +fn both_hook_commands_parse_current_post_tool_input() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("bad.js"); + fs::write(&file, "function bad(x) { return x; }\n").unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "bad.js"]); + git(dir.path(), &["commit", "-qm", "initial"]); + fs::write(&file, complex_function()).unwrap(); + for harness in ["claude", "codex"] { + let (tool, tool_input) = if harness == "codex" { + ("apply_patch", serde_json::json!({"command":"patch"})) + } else { + ("Edit", serde_json::json!({"file_path":file})) + }; + let input = serde_json::json!({"hook_event_name":"PostToolUse", "session_id":"test", + "cwd":dir.path(), "tool_name":tool, "tool_input":tool_input}) + .to_string(); + let mut child = binary() + .args(["hook", harness]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["decision"], "block"); + } +} + +fn command_output(cwd: &Path, args: &[&str]) -> Output { + binary().current_dir(cwd).args(args).output().unwrap() +} + +fn hook_output(state: &Path, input: &str) -> Output { + let mut child = binary() + .args(["hook", "claude"]) + .env("COMPLEXITY_GATE_HOME", state) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +fn git(cwd: &Path, args: &[&str]) { + assert!( + Command::new("git") + .current_dir(cwd) + .args(args) + .status() + .unwrap() + .success() + ); +} + +fn complex_function() -> String { + let decisions = (0..15) + .map(|index| format!("if (x === {index}) x++;")) + .collect::>() + .join("\n"); + format!("function bad(x) {{\n{decisions}\nreturn x;\n}}\n") +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..3ab2fa1 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "complexity-gate-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow.workspace = true +dirs.workspace = true +globset.workspace = true +ignore.workspace = true +serde.workspace = true +serde_json.workspace = true +tree-sitter.workspace = true +tree-sitter-dart.workspace = true +tree-sitter-go.workspace = true +tree-sitter-javascript.workspace = true +tree-sitter-python.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-svelte-ng.workspace = true +tree-sitter-typescript.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..97bd15b --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,323 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +const DEFAULTS: &str = include_str!("../../../config.default.json"); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Limits { + pub complexity: usize, + pub depth: usize, + pub lines: usize, + pub params: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TestsConfig { + pub patterns: Vec, + pub exempt: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HookConfig { + pub max_blocks: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +pub struct LimitOverrides { + pub complexity: Option, + pub depth: Option, + pub lines: Option, + pub params: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +pub struct LanguageConfig { + #[serde(default)] + pub limits: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub limits: Limits, + pub tests: TestsConfig, + pub ignore: Vec, + pub languages: BTreeMap, + pub hook: HookConfig, +} + +#[derive(Clone, Debug)] +pub struct ConfigResolution { + pub config: Config, + pub chain: Vec, +} + +pub fn load_config(start: &Path, explicit: Option<&Path>) -> Result { + let mut value: Value = serde_json::from_str(DEFAULTS).context("invalid embedded defaults")?; + let mut chain = vec![PathBuf::from("")]; + if let Some(user) = user_config_path().filter(|path| path.is_file()) { + merge_file(&mut value, &user)?; + chain.push(user); + } + let repo = explicit + .map(Path::to_path_buf) + .or_else(|| nearest_repo_config(start)); + if let Some(path) = repo { + merge_file(&mut value, &path)?; + chain.push(path); + } + let mut config: Config = serde_json::from_value(value).context("invalid configuration")?; + validate_languages(&config)?; + config.hook.max_blocks = config.hook.max_blocks.max(1); + Ok(ConfigResolution { config, chain }) +} + +fn user_config_path() -> Option { + if let Some(root) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(root).join("complexity-gate/config.json")); + } + dirs::config_dir().map(|root| root.join("complexity-gate/config.json")) +} + +fn nearest_repo_config(start: &Path) -> Option { + let start = if start.is_file() { + start.parent()? + } else { + start + }; + start + .ancestors() + .map(|dir| dir.join(".complexity-gate.json")) + .find(|path| path.is_file()) +} + +fn merge_file(base: &mut Value, path: &Path) -> Result<()> { + let text = fs::read_to_string(path) + .with_context(|| format!("cannot read config {}", path.display()))?; + let patch: Value = serde_json::from_str(&text) + .with_context(|| format!("invalid JSON in {}", path.display()))?; + validate_keys(&patch, path)?; + shallow_merge(base, patch); + Ok(()) +} + +fn shallow_merge(base: &mut Value, patch: Value) { + let (Some(base), Value::Object(patch)) = (base.as_object_mut(), patch) else { + return; + }; + for (key, value) in patch { + match (base.get_mut(&key), value) { + (Some(Value::Object(current)), Value::Object(next)) => current.extend(next), + (_, next) => { + base.insert(key, next); + } + } + } +} + +fn validate_keys(value: &Value, path: &Path) -> Result<()> { + let object = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("config {} must be an object", path.display()))?; + allowed( + object, + &["limits", "tests", "ignore", "languages", "hook"], + "", + path, + )?; + nested_keys( + object, + "limits", + &["complexity", "depth", "lines", "params"], + path, + )?; + nested_keys(object, "tests", &["patterns", "exempt"], path)?; + validate_test_exempt(object, path)?; + nested_keys(object, "hook", &["max_blocks"], path)?; + validate_language_keys(object, path) +} + +fn nested_keys(root: &Map, key: &str, keys: &[&str], path: &Path) -> Result<()> { + if let Some(value) = root.get(key) { + let object = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("{key} in {} must be an object", path.display()))?; + allowed(object, keys, key, path)?; + } + Ok(()) +} + +fn validate_test_exempt(root: &Map, path: &Path) -> Result<()> { + let Some(exempt) = root + .get("tests") + .and_then(Value::as_object) + .and_then(|tests| tests.get("exempt")) + .and_then(Value::as_array) + else { + return Ok(()); + }; + for value in exempt { + if value.as_str() != Some("lines") { + bail!( + "unsupported tests.exempt key `{}` in {}", + value.as_str().unwrap_or(""), + path.display() + ); + } + } + Ok(()) +} + +fn validate_language_keys(root: &Map, path: &Path) -> Result<()> { + let Some(value) = root.get("languages") else { + return Ok(()); + }; + let languages = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("languages in {} must be an object", path.display()))?; + for (name, value) in languages { + let object = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("languages.{name} must be an object"))?; + allowed(object, &["limits"], &format!("languages.{name}"), path)?; + nested_keys( + object, + "limits", + &["complexity", "depth", "lines", "params"], + path, + )?; + } + Ok(()) +} + +fn allowed(object: &Map, keys: &[&str], prefix: &str, path: &Path) -> Result<()> { + for key in object.keys() { + if !keys.contains(&key.as_str()) { + let full = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + bail!("unknown config key `{full}` in {}", path.display()); + } + } + Ok(()) +} + +fn validate_languages(config: &Config) -> Result<()> { + const LANGUAGES: &[&str] = &[ + "javascript", + "typescript", + "svelte", + "dart", + "rust", + "python", + "go", + ]; + for name in config.languages.keys() { + if !LANGUAGES.contains(&name.as_str()) { + bail!("unknown config key `languages.{name}`"); + } + } + Ok(()) +} + +impl Config { + pub fn limits_for(&self, language: &str) -> Limits { + let Some(overrides) = self + .languages + .get(language) + .and_then(|entry| entry.limits.as_ref()) + else { + return self.limits.clone(); + }; + Limits { + complexity: overrides.complexity.unwrap_or(self.limits.complexity), + depth: overrides.depth.unwrap_or(self.limits.depth), + lines: overrides.lines.unwrap_or(self.limits.lines), + params: overrides.params.unwrap_or(self.limits.params), + } + } + + pub fn matcher(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + builder.add(Glob::new(pattern)?); + } + Ok(builder.build()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_top_level_objects_merge() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"limits":{"complexity":7}}"#).unwrap(); + let resolved = load_config(dir.path(), Some(&path)).unwrap(); + assert_eq!(resolved.config.limits.complexity, 7); + assert_eq!(resolved.config.limits.depth, 4); + } + + #[test] + fn partial_language_limits_inherit_global_values() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write( + &path, + r#"{"languages":{"rust":{"limits":{"complexity":4}}}}"#, + ) + .unwrap(); + let config = load_config(dir.path(), Some(&path)).unwrap().config; + let limits = config.limits_for("rust"); + assert_eq!(limits.complexity, 4); + assert_eq!(limits.depth, 4); + } + + #[test] + fn test_exempt_rejects_metrics_other_than_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"tests":{"exempt":["depth"]}}"#).unwrap(); + let error = load_config(dir.path(), Some(&path)) + .unwrap_err() + .to_string(); + assert!(error.contains("tests.exempt") && error.contains("depth")); + } + + #[test] + fn hook_max_blocks_is_clamped_to_one() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"hook":{"max_blocks":0}}"#).unwrap(); + let config = load_config(dir.path(), Some(&path)).unwrap().config; + assert_eq!(config.hook.max_blocks, 1); + } + + #[test] + fn unknown_nested_key_names_full_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"hook":{"blocks":2}}"#).unwrap(); + let error = load_config(dir.path(), Some(&path)) + .unwrap_err() + .to_string(); + assert!(error.contains("hook.blocks")); + } +} diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs new file mode 100644 index 0000000..3055c8a --- /dev/null +++ b/crates/core/src/diff.rs @@ -0,0 +1,211 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{Context, Result, bail}; + +const REMOVED_GIT_ENV: &[&str] = &[ + "GIT_EXTERNAL_DIFF", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG_COUNT", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineRange { + pub start: usize, + pub end: usize, +} + +impl LineRange { + pub fn intersects(self, start: usize, end: usize) -> bool { + self.start <= end && start <= self.end + } +} + +#[derive(Clone, Debug, Default)] +pub struct ChangedFiles { + pub repo_root: PathBuf, + pub spans: BTreeMap>, + pub untracked: Vec, + pub fallback: bool, +} + +pub fn changed_files(cwd: &Path) -> Result { + let Some(repo_root) = repository_root(cwd)? else { + return Ok(ChangedFiles { + repo_root: cwd.to_path_buf(), + fallback: true, + ..ChangedFiles::default() + }); + }; + if !git_ok(&repo_root, &["rev-parse", "--verify", "HEAD"]) { + return Ok(ChangedFiles { + repo_root, + fallback: true, + ..ChangedFiles::default() + }); + } + let output = git_command(&repo_root) + .args([ + "-c", + "core.quotePath=false", + "-c", + "diff.mnemonicPrefix=false", + "-c", + "diff.noprefix=false", + "-c", + "diff.srcPrefix=a/", + "-c", + "diff.dstPrefix=b/", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--unified=0", + "HEAD", + "--", + ]) + .output() + .context("failed to execute git diff")?; + if !output.status.success() { + bail!( + "git diff failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut changed = ChangedFiles { + repo_root: repo_root.clone(), + spans: parse_diff_hunks(&text), + ..ChangedFiles::default() + }; + changed.untracked = untracked(&repo_root)?; + Ok(changed) +} + +pub fn repository_root(cwd: &Path) -> Result> { + let output = git_command(cwd) + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("failed to locate Git repository root")?; + if !output.status.success() { + return Ok(None); + } + let root = String::from_utf8(output.stdout).context("Git repository root was not UTF-8")?; + Ok(Some(PathBuf::from(root.trim()))) +} + +fn git_ok(cwd: &Path, args: &[&str]) -> bool { + git_command(cwd) + .args(args) + .output() + .is_ok_and(|output| output.status.success()) +} + +fn git_command(cwd: &Path) -> Command { + let mut command = Command::new("git"); + command.current_dir(cwd).arg("--no-pager").args([ + "-c", + "core.fsmonitor=false", + "-c", + "core.useBuiltinFSMonitor=false", + "-c", + "diff.external=", + "-c", + "core.hooksPath=/dev/null", + ]); + for name in REMOVED_GIT_ENV { + command.env_remove(name); + } + command +} + +fn untracked(cwd: &Path) -> Result> { + let output = git_command(cwd) + .args(["ls-files", "--others", "--exclude-standard", "-z"]) + .output() + .context("failed to list untracked files")?; + if !output.status.success() { + bail!("git ls-files failed") + } + Ok(output + .stdout + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| PathBuf::from(String::from_utf8_lossy(part).as_ref())) + .collect()) +} + +pub fn parse_diff_hunks(diff: &str) -> BTreeMap> { + let mut result = BTreeMap::new(); + let mut file = None; + for line in diff.lines() { + if let Some(path) = line.strip_prefix("+++ ") { + file = diff_path(path); + if let Some(path) = &file { + result.entry(path.clone()).or_insert_with(Vec::new); + } + continue; + } + if !line.starts_with("@@") { + continue; + } + let Some(path) = file.as_ref() else { continue }; + if let Some(range) = post_image_range(line) { + result + .entry(path.clone()) + .or_insert_with(Vec::new) + .push(range); + } + } + result +} + +fn diff_path(value: &str) -> Option { + if value == "/dev/null" { + return None; + } + let value = value.split('\t').next().unwrap_or(value); + let path = value.strip_prefix("b/")?; + Some(PathBuf::from(path)) +} + +fn post_image_range(header: &str) -> Option { + let plus = header + .split_whitespace() + .find(|part| part.starts_with('+'))?; + let mut values = plus.trim_start_matches('+').split(','); + let start = values.next()?.parse().ok()?; + let count = values + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(1); + (count > 0).then_some(LineRange { + start, + end: start + count - 1, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_hunks_use_post_image_and_skip_deletions() { + let diff = "diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\t\n@@ -2,2 +2,3 @@\n@@ -10,2 +11,0 @@\n@@ -20 +19 @@\n"; + let spans = parse_diff_hunks(diff); + assert_eq!( + spans[Path::new("a.rs")], + vec![ + LineRange { start: 2, end: 4 }, + LineRange { start: 19, end: 19 }, + ] + ); + } +} diff --git a/crates/core/src/language.rs b/crates/core/src/language.rs new file mode 100644 index 0000000..356f05c --- /dev/null +++ b/crates/core/src/language.rs @@ -0,0 +1,1163 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tree_sitter::{Language as TsLanguage, Node, Parser, Tree}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Language { + JavaScript, + TypeScript, + Tsx, + Svelte, + Dart, + Rust, + Python, + Go, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct FunctionMetrics { + pub function: String, + pub line: usize, + pub end_line: usize, + pub complexity: usize, + pub depth: usize, + pub lines: usize, + pub params: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GrammarInfo { + pub language: &'static str, + pub grammar: &'static str, + pub version: &'static str, +} + +impl Language { + pub fn from_path(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript), + "ts" | "mts" | "cts" => Some(Self::TypeScript), + "tsx" => Some(Self::Tsx), + "svelte" => Some(Self::Svelte), + "dart" => Some(Self::Dart), + "rs" => Some(Self::Rust), + "py" | "pyi" => Some(Self::Python), + "go" => Some(Self::Go), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::JavaScript => "javascript", + Self::TypeScript | Self::Tsx => "typescript", + Self::Svelte => "svelte", + Self::Dart => "dart", + Self::Rust => "rust", + Self::Python => "python", + Self::Go => "go", + } + } + + fn grammar(self) -> TsLanguage { + match self { + Self::JavaScript | Self::Svelte => tree_sitter_javascript::LANGUAGE.into(), + Self::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Self::Dart => tree_sitter_dart::LANGUAGE.into(), + Self::Rust => tree_sitter_rust::LANGUAGE.into(), + Self::Python => tree_sitter_python::LANGUAGE.into(), + Self::Go => tree_sitter_go::LANGUAGE.into(), + } + } +} + +pub fn grammar_inventory() -> Vec { + vec![ + GrammarInfo { + language: "javascript", + grammar: "tree-sitter-javascript", + version: "0.25.0", + }, + GrammarInfo { + language: "typescript/tsx", + grammar: "tree-sitter-typescript", + version: "0.23.2", + }, + GrammarInfo { + language: "svelte", + grammar: "tree-sitter-svelte-ng", + version: "1.0.2", + }, + GrammarInfo { + language: "dart", + grammar: "tree-sitter-dart", + version: "0.2.0", + }, + GrammarInfo { + language: "rust", + grammar: "tree-sitter-rust", + version: "0.24.2", + }, + GrammarInfo { + language: "python", + grammar: "tree-sitter-python", + version: "0.25.0", + }, + GrammarInfo { + language: "go", + grammar: "tree-sitter-go", + version: "0.25.0", + }, + ] +} + +pub fn coverage_unknowns() -> Vec<(&'static str, Vec)> { + let languages = [ + ("javascript", Language::JavaScript), + ("typescript", Language::TypeScript), + ("tsx", Language::Tsx), + ("dart", Language::Dart), + ("rust", Language::Rust), + ("python", Language::Python), + ("go", Language::Go), + ]; + let mut result: Vec<_> = languages + .into_iter() + .map(|(name, language)| (name, unknown_kinds(language.grammar()))) + .collect(); + result.push(( + "svelte", + unknown_kinds(tree_sitter_svelte_ng::LANGUAGE.into()), + )); + result +} + +fn unknown_kinds(grammar: TsLanguage) -> Vec { + const NEEDLES: &[&str] = &[ + "if", + "for", + "while", + "loop", + "match", + "switch", + "case", + "catch", + "except", + "conditional", + "ternary", + "binary", + "logical", + ]; + let mut kinds = Vec::new(); + for id in 0..grammar.node_kind_count() { + let Some(kind) = grammar.node_kind_for_id(id as u16) else { + continue; + }; + if NEEDLES.iter().any(|needle| kind.contains(needle)) && !coverage_classified(kind) { + kinds.push(kind.to_owned()); + } + } + kinds.sort(); + kinds.dedup(); + kinds +} + +fn coverage_classified(kind: &str) -> bool { + matches!( + kind, + "if" | "else if" + | "elif" + | "for" + | "while" + | "loop" + | "match" + | "switch" + | "case" + | "catch" + | "except" + | "if_statement" + | "if_expression" + | "if_element" + | "elif_clause" + | "for_statement" + | "for_in_statement" + | "for_expression" + | "for_element" + | "for_in_clause" + | "if_clause" + | "while_statement" + | "while_expression" + | "loop_expression" + | "switch_statement" + | "switch_expression" + | "expression_switch_statement" + | "type_switch_statement" + | "switch_statement_case" + | "switch_statement_default" + | "switch_case" + | "switch_default" + | "switch_expression_case" + | "match_statement" + | "match_expression" + | "match_arm" + | "case_clause" + | "case_pattern" + | "expression_case" + | "type_case" + | "communication_case" + | "catch_clause" + | "except_clause" + | "conditional_expression" + | "ternary_expression" + | "binary_expression" + | "binary_operator" + | "boolean_operator" + | "logical_and_expression" + | "logical_or_expression" + | "if_null_expression" + | "if_start" + | "else_if_start" + | "each_start" + | "await_start" + | "catch_start" + ) || coverage_ignored(kind) +} + +fn coverage_ignored(kind: &str) -> bool { + kind.starts_with('_') + || kind.contains("_repeat") + || kind.contains("identifier") + || kind.contains("parameter") + || kind.contains("modifier") + || kind.contains("specifier") + || matches!( + kind, + "accessibility_modifier" + | "catch_block" + | "conditional_type" + | "default_case" + | "else_if_block" + | "except_clause_repeat1" + | "for_clause" + | "for_lifetimes" + | "foreign_mod_item" + | "for_in_clause_repeat1" + | "format_expression" + | "format_specifier" + | "if_end" + | "if_statement_repeat1" + | "import_specification" + | "lifetime" + | "match_block" + | "match_pattern" + | "qualified" + | "qualified_type" + | "shift_expression" + | "switch_block" + | "switch_body" + | "type_case_repeat1" + ) +} + +pub fn parse_source(language: Language, source: &str) -> Result> { + if language == Language::Svelte { + return parse_svelte(source); + } + parse_with_offset(language, source, 0) +} + +fn parse_with_offset( + language: Language, + source: &str, + line_offset: usize, +) -> Result> { + let mut parser = Parser::new(); + parser + .set_language(&language.grammar()) + .context("incompatible tree-sitter grammar")?; + let tree = parser + .parse(source, None) + .context("tree-sitter parser returned no tree")?; + let mut functions = Vec::new(); + collect_functions( + tree.root_node(), + language, + source, + line_offset, + &mut functions, + ); + functions.sort_by_key(|item| (item.line, item.end_line)); + Ok(functions) +} + +fn collect_functions( + node: Node<'_>, + language: Language, + source: &str, + offset: usize, + output: &mut Vec, +) { + if is_function(language, node.kind()) { + output.push(measure_function(node, language, source, offset)); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_functions(child, language, source, offset, output); + } +} + +fn measure_function( + node: Node<'_>, + language: Language, + source: &str, + offset: usize, +) -> FunctionMetrics { + let mut score = Score { + complexity: 1, + depth: 0, + }; + let body = node.child_by_field_name("body").unwrap_or(node); + measure_node(body, node.id(), language, source, 0, &mut score); + let start = node.start_position().row + 1; + let end = node.end_position().row + 1; + FunctionMetrics { + function: function_name(node, language, source), + line: start + offset, + end_line: end + offset, + complexity: score.complexity, + depth: score.depth, + lines: significant_lines(source, start, end, node), + params: parameter_count(node, language, source), + } +} + +struct Score { + complexity: usize, + depth: usize, +} + +fn measure_node( + node: Node<'_>, + root_id: usize, + language: Language, + source: &str, + depth: usize, + score: &mut Score, +) { + if node.id() != root_id && is_function(language, node.kind()) { + return; + } + if is_decision(node, language, source) { + score.complexity += 1; + } + let opens = opens_depth(node, language) && !is_else_if(node); + let next_depth = depth + usize::from(opens); + score.depth = score.depth.max(next_depth); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + measure_node(child, root_id, language, source, next_depth, score); + } +} + +fn is_function(language: Language, kind: &str) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => matches!( + kind, + "function_declaration" + | "function_expression" + | "arrow_function" + | "method_definition" + | "generator_function" + | "generator_function_declaration" + ), + Language::Dart => matches!( + kind, + "function_declaration" + | "local_function_declaration" + | "function_expression" + | "method_declaration" + | "getter_declaration" + | "setter_declaration" + ), + Language::Rust => matches!(kind, "function_item" | "closure_expression"), + Language::Python => matches!(kind, "function_definition" | "lambda"), + Language::Go => matches!( + kind, + "function_declaration" | "method_declaration" | "func_literal" + ), + Language::Svelte => false, + } +} + +fn is_decision(node: Node<'_>, language: Language, source: &str) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => js_decision(node, source), + Language::Dart => dart_decision(node, source), + Language::Rust => rust_decision(node, source), + Language::Python => python_decision(node, source), + Language::Go => go_decision(node, source), + Language::Svelte => false, + } +} + +fn js_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "for_statement" | "for_in_statement" | "while_statement" + | "do_statement" | "switch_case" | "catch_clause" | "ternary_expression" => true, + "binary_expression" | "augmented_assignment_expression" => { + has_operator(node, source, &["&&", "||", "??", "&&=", "||=", "??="]) + } + _ => false, + } +} + +fn dart_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" + | "if_element" + | "for_statement" + | "for_element" + | "while_statement" + | "do_statement" + | "switch_statement_case" + | "catch_clause" + | "conditional_expression" => true, + "switch_expression_case" => !is_default_arm(node, source), + "logical_and_expression" | "logical_or_expression" | "if_null_expression" => true, + "assignment_expression" => has_operator(node, source, &["??="]), + _ => false, + } +} + +fn rust_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_expression" | "for_expression" | "while_expression" | "loop_expression" => true, + "match_arm" => !is_default_arm(node, source), + "let_declaration" => node.child_by_field_name("alternative").is_some(), + "binary_expression" => has_operator(node, source, &["&&", "||"]), + _ => false, + } +} + +fn python_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" + | "elif_clause" + | "for_statement" + | "while_statement" + | "except_clause" + | "conditional_expression" + | "for_in_clause" + | "if_clause" => true, + "case_clause" => !is_default_arm(node, source), + "boolean_operator" => has_operator(node, source, &["and", "or"]), + _ => false, + } +} + +fn go_decision(node: Node<'_>, source: &str) -> bool { + match node.kind() { + "if_statement" | "for_statement" | "expression_case" | "type_case" + | "communication_case" => true, + "binary_expression" => has_operator(node, source, &["&&", "||"]), + _ => false, + } +} + +fn has_operator(node: Node<'_>, source: &str, wanted: &[&str]) -> bool { + if let Some(operator) = node.child_by_field_name("operator") { + return wanted.contains(&node_text(operator, source)); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| !child.is_named()) + .any(|child| wanted.contains(&node_text(child, source))) +} + +fn is_default_arm(node: Node<'_>, source: &str) -> bool { + let pattern = node.child_by_field_name("pattern").or_else(|| { + let mut cursor = node.walk(); + node.named_children(&mut cursor).next() + }); + pattern.is_some_and(|pattern| { + let wildcard = node_text(pattern, source).trim() == "_" + || pattern + .child_by_field_name("pattern") + .is_some_and(|inner| node_text(inner, source).trim() == "_"); + let mut cursor = node.walk(); + wildcard + && !node + .children(&mut cursor) + .any(|child| node_text(child, source) == "when") + }) +} + +fn opens_depth(node: Node<'_>, language: Language) -> bool { + match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => matches!( + node.kind(), + "if_statement" + | "for_statement" + | "for_in_statement" + | "while_statement" + | "do_statement" + | "switch_statement" + | "try_statement" + ), + Language::Dart => matches!( + node.kind(), + "if_statement" + | "for_statement" + | "while_statement" + | "do_statement" + | "switch_statement" + | "switch_expression" + | "try_statement" + ), + Language::Rust => matches!( + node.kind(), + "if_expression" + | "for_expression" + | "while_expression" + | "loop_expression" + | "match_expression" + ), + Language::Python => matches!( + node.kind(), + "if_statement" + | "for_statement" + | "while_statement" + | "match_statement" + | "try_statement" + | "with_statement" + ), + Language::Go => matches!( + node.kind(), + "if_statement" + | "for_statement" + | "expression_switch_statement" + | "type_switch_statement" + | "select_statement" + ), + Language::Svelte => false, + } +} + +fn is_else_if(node: Node<'_>) -> bool { + if !matches!(node.kind(), "if_statement" | "if_expression") { + return false; + } + let Some(parent) = node.parent() else { + return false; + }; + if matches!(parent.kind(), "if_statement" | "if_expression") { + return parent + .child_by_field_name("alternative") + .is_some_and(|item| item.id() == node.id()); + } + parent.kind() == "else_clause" + && parent + .parent() + .is_some_and(|item| matches!(item.kind(), "if_statement" | "if_expression")) +} + +fn function_name(node: Node<'_>, language: Language, source: &str) -> String { + if let Some(name) = direct_name(node, source) { + return qualify_method(node, language, name, source); + } + let mut child = node; + while let Some(item) = child.parent() { + if is_function(language, item.kind()) { + break; + } + if let Some(name) = binding_name(item, child, source) { + return name; + } + if !binding_wrapper(item.kind()) { + break; + } + child = item; + } + "".to_owned() +} + +fn direct_name<'source>(node: Node<'_>, source: &'source str) -> Option<&'source str> { + if let Some(named) = node.child_by_field_name("name") { + return named.utf8_text(source.as_bytes()).ok(); + } + matches!( + node.kind(), + "function_declaration" + | "local_function_declaration" + | "method_declaration" + | "getter_declaration" + | "setter_declaration" + ) + .then(|| descendant_field(node, "name")) + .flatten() + .and_then(|named| named.utf8_text(source.as_bytes()).ok()) +} + +fn descendant_field<'a>(node: Node<'a>, field: &str) -> Option> { + if let Some(found) = node.child_by_field_name(field) { + return Some(found); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind().contains("body") { + continue; + } + if let Some(found) = descendant_field(child, field) { + return Some(found); + } + } + None +} + +fn qualify_method(node: Node<'_>, language: Language, name: &str, source: &str) -> String { + let method = matches!( + node.kind(), + "method_definition" | "method_declaration" | "getter_declaration" | "setter_declaration" + ) || language == Language::Python + && node.kind() == "function_definition" + && has_ancestor(node, "class_definition") + || language == Language::Rust + && node.kind() == "function_item" + && has_ancestor(node, "impl_item"); + if !method { + return name.to_owned(); + } + if language == Language::Go { + return node + .child_by_field_name("receiver") + .and_then(|receiver| receiver_type(receiver, source)) + .map_or_else(|| name.to_owned(), |owner| format!("{owner}.{name}")); + } + let type_kinds = match language { + Language::JavaScript | Language::TypeScript | Language::Tsx => { + &["class_declaration", "class"] as &[&str] + } + Language::Dart => &[ + "class_declaration", + "extension_declaration", + "extension_type_declaration", + ], + Language::Python => &["class_definition"], + Language::Rust => &["impl_item"], + _ => return name.to_owned(), + }; + let mut parent = node.parent(); + while let Some(item) = parent { + if type_kinds.contains(&item.kind()) + && let Some(owner) = type_owner(item, language, source) + { + return format!("{owner}.{name}"); + } + parent = item.parent(); + } + name.to_owned() +} + +fn type_owner<'a>(node: Node<'_>, language: Language, source: &'a str) -> Option<&'a str> { + if language == Language::Rust { + return node + .child_by_field_name("type")? + .utf8_text(source.as_bytes()) + .ok(); + } + direct_name(node, source) +} + +fn has_ancestor(node: Node<'_>, kind: &str) -> bool { + let mut parent = node.parent(); + while let Some(item) = parent { + if item.kind() == kind { + return true; + } + parent = item.parent(); + } + false +} + +fn receiver_type(node: Node<'_>, source: &str) -> Option { + node_text(node, source) + .split(|character: char| !character.is_alphanumeric() && character != '_') + .rfind(|word| !word.is_empty()) + .map(str::to_owned) +} + +fn binding_name(node: Node<'_>, value: Node<'_>, source: &str) -> Option { + let (name_field, value_field) = match node.kind() { + "variable_declarator" | "initialized_variable_definition" => ("name", "value"), + "let_declaration" => ("pattern", "value"), + "pair" => ("key", "value"), + "assignment_expression" | "assignment_statement" => ("left", "right"), + "short_var_declaration" => ("left", "right"), + _ => return None, + }; + let assigned = node.child_by_field_name(value_field)?; + if assigned.id() != value.id() { + return None; + } + let name = node.child_by_field_name(name_field)?; + let text = node_text(name, source).trim(); + (!text.is_empty() && !text.contains([' ', '\n'])).then(|| text.to_owned()) +} + +fn binding_wrapper(kind: &str) -> bool { + matches!(kind, "parenthesized_expression" | "expression_list") +} + +fn parameter_count(node: Node<'_>, language: Language, source: &str) -> usize { + let params = node + .child_by_field_name("parameters") + .or_else(|| descendant_field(node, "parameters")); + let Some(params) = params else { + return usize::from(node.child_by_field_name("parameter").is_some()); + }; + let mut cursor = params.walk(); + let children: Vec<_> = params.named_children(&mut cursor).collect(); + match language { + Language::Go => children + .iter() + .map(|child| go_parameter_count(*child)) + .sum(), + _ => children + .iter() + .filter(|child| !receiver_parameter(**child, source)) + .count(), + } +} + +fn go_parameter_count(node: Node<'_>) -> usize { + if !matches!( + node.kind(), + "parameter_declaration" | "variadic_parameter_declaration" + ) { + return 1; + } + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .count() + .saturating_sub(1) + .max(1) +} + +fn receiver_parameter(node: Node<'_>, source: &str) -> bool { + let text = node_text(node, source).trim(); + matches!(node.kind(), "self_parameter") + || matches!(text, "self" | "&self" | "&mut self" | "this") + || text.starts_with("self:") + || text.starts_with("this:") +} + +fn significant_lines(source: &str, start: usize, end: usize, node: Node<'_>) -> usize { + let mut comments = Vec::new(); + collect_comments(node, &mut comments); + let mut offset = 0; + source + .split_inclusive('\n') + .enumerate() + .filter_map(|(index, line)| { + let line_start = offset; + offset += line.len(); + (index + 1 >= start && index < end).then_some((line_start, line)) + }) + .filter(|(line_start, line)| line_has_code(*line_start, line, &comments)) + .count() +} + +fn collect_comments(node: Node<'_>, comments: &mut Vec<(usize, usize)>) { + if node.kind().ends_with("comment") { + comments.push((node.start_byte(), node.end_byte())); + return; + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_comments(child, comments); + } +} + +fn line_has_code(line_start: usize, line: &str, comments: &[(usize, usize)]) -> bool { + line.bytes().enumerate().any(|(index, byte)| { + !byte.is_ascii_whitespace() + && !comments + .iter() + .any(|(start, end)| *start <= line_start + index && line_start + index < *end) + }) +} + +fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str { + node.utf8_text(source.as_bytes()).unwrap_or("") +} + +fn parse_svelte(source: &str) -> Result> { + let tree = parse_svelte_tree(source)?; + let blocks = svelte_blocks(source); + let mut functions = Vec::new(); + for block in blocks.iter().filter(|block| block.kind == "script") { + let language = + if block.opening.contains("lang=\"ts\"") || block.opening.contains("lang='ts'") { + Language::TypeScript + } else { + Language::JavaScript + }; + functions.extend(parse_with_offset( + language, + block.content, + block.start_line, + )?); + } + functions.push(measure_template(tree.root_node(), source)); + functions.sort_by_key(|item| (item.line, item.end_line)); + Ok(functions) +} + +fn parse_svelte_tree(source: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_svelte_ng::LANGUAGE.into()) + .context("incompatible Svelte grammar")?; + parser + .parse(source, None) + .context("Svelte parser returned no tree") +} + +struct SvelteBlock<'a> { + kind: &'static str, + opening: &'a str, + content: &'a str, + start_line: usize, +} + +fn svelte_blocks(source: &str) -> Vec> { + let mut blocks = Vec::new(); + for kind in ["script", "style"] { + let mut cursor = 0; + while let Some(relative) = source[cursor..].find(&format!("<{kind}")) { + let start = cursor + relative; + let Some(open_end_rel) = source[start..].find('>') else { + break; + }; + let open_end = start + open_end_rel + 1; + let Some(close_rel) = source[open_end..].find(&format!("")) else { + break; + }; + let end = open_end + close_rel; + blocks.push(SvelteBlock { + kind, + opening: &source[start..open_end], + content: &source[open_end..end], + start_line: source[..open_end] + .bytes() + .filter(|byte| *byte == b'\n') + .count(), + }); + cursor = end + kind.len() + 3; + } + } + blocks +} + +fn measure_template(root: Node<'_>, source: &str) -> FunctionMetrics { + let mut score = Score { + complexity: 1, + depth: 0, + }; + measure_svelte_node(root, source, 0, &mut score); + FunctionMetrics { + function: "