diff --git a/.github/workflows/main-estate-audit.yml b/.github/workflows/main-estate-audit.yml index 119ec96..9ca1f93 100644 --- a/.github/workflows/main-estate-audit.yml +++ b/.github/workflows/main-estate-audit.yml @@ -1,4 +1,3 @@ -# SPDX-License-Identifier: MPL-2.0 name: Central Estate CI/CD Audit on: @@ -6,87 +5,8 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_call: jobs: - estate-audit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7.0.1 - - - name: Required Files Gate - uses: hyperpolymath/cicd-suite/actions/required-files-check@main - - - name: Code Hygiene Gate - uses: hyperpolymath/cicd-suite/actions/code-hygiene-check@main - - - name: Manifest Validation Gate - uses: hyperpolymath/cicd-suite/actions/manifest-check@main - - - name: Idris2 ABI Purity Gate - uses: hyperpolymath/cicd-suite/actions/idris2-abi-check@main - - - name: Zig Hexadeca API Gate - uses: hyperpolymath/cicd-suite/actions/zig-hexadeca-check@main - - - name: Contractile Validation Gate - uses: hyperpolymath/cicd-suite/actions/contractile-validation-check@main - - - name: Recipes Set Validation Gate - uses: hyperpolymath/cicd-suite/actions/recipes-set-check@main - - - name: Affirmation Document Gate - uses: hyperpolymath/cicd-suite/actions/affirmation-check@main - - - name: Academic Referencing Gate - uses: hyperpolymath/cicd-suite/actions/referencing-check@main - - - name: Semantic Audit Gate - uses: hyperpolymath/cicd-suite/actions/semantic-audit-check@main - - - name: SPDX License Gate - uses: hyperpolymath/cicd-suite/actions/spdx-license-check@main - - - name: Proof Runner Gate - uses: hyperpolymath/cicd-suite/actions/proof-runner-check@main - - - name: PRAT Testing Gate - uses: hyperpolymath/cicd-suite/actions/prat-check@main - - - name: Panic Attack & Pons Gate - uses: hyperpolymath/cicd-suite/actions/custom-tools-check@main - - - name: WWW & Well-Known Compliance Gate - uses: hyperpolymath/cicd-suite/actions/www-compliance-check@main - - - name: BoJ Cartridge Validation Gate - uses: hyperpolymath/cicd-suite/actions/boj-cartridge-check@main - - - name: Formatting Validation Gate - uses: hyperpolymath/cicd-suite/actions/formatting-check@main - - - name: Accreditations & Badges Gate - uses: hyperpolymath/cicd-suite/actions/badges-check@main - - - name: Metrics Extraction Gate - uses: hyperpolymath/cicd-suite/actions/metrics-check@main - - - name: Linguist & Banned Languages Gate - uses: hyperpolymath/cicd-suite/actions/linguist-check@main - - - name: Test & Benchmarks Dashboard Gate - uses: hyperpolymath/cicd-suite/actions/tests-benches-check@main - - - name: Hosting & Site Status Gate - uses: hyperpolymath/cicd-suite/actions/hosting-check@main - - - name: Git-Sea Analytics Gate - uses: hyperpolymath/cicd-suite/actions/gitsea-check@main - - - name: Trust & Humans Validation Gate - uses: hyperpolymath/cicd-suite/actions/trust-humans-check@main - - - name: Are We UnAPI Gate (Secret Scanning) - uses: hyperpolymath/cicd-suite/actions/secrets-check@main - - - name: Reasonably Good Token Validation Gate - uses: hyperpolymath/cicd-suite/actions/vaulted-tokens-check@main + call-estate-audit: + uses: hyperpolymath/cicd-suite/.github/workflows/main-estate-audit.yml@feat/cicd-workflow-call diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..30cd5be --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: MPL-2.0 +name: rust-ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: fmt + run: cargo fmt --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + env: + RUSTFLAGS: "-D warnings" + - name: test + run: cargo test --workspace diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..075673c --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Scorecard + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [main, master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + security-events: write + id-token: write + +jobs: + scorecard: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@1f3eef621985b2dd0e7e2d3ac265c0f0ba6b0a78 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index a519b87..69f8695 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -16,5 +16,5 @@ permissions: contents: read jobs: secret-scan: - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@1f3eef621985b2dd0e7e2d3ac265c0f0ba6b0a78 secrets: inherit diff --git a/.gitignore b/.gitignore index c5b12a7..881d11f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,90 @@ /target **/*.rs.bk -Cargo.lock +# Cargo.lock # kept: pons-cli builds a binary, so the lockfile is committed .DS_Store *.log +# SPDX-License-Identifier: MPL-2.0 +# RSR-compliant .gitignore + +# OS & Editor +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +# Build (unanchored to match nested monorepo paths) +target/ +_build/ +/build/ +/dist/ +/out/ +zig-out/ +zig-cache/ +.zig-cache/ +# Dependencies +/node_modules/ +/vendor/ +/deps/ +/.elixir_ls/ +# Rust +# Cargo.lock # Keep for binaries +# Elixir +/cover/ +/doc/ +*.ez +erl_crash.dump +# Julia +*.jl.cov +*.jl.mem +/Manifest.toml +# ReScript +/lib/bs/ +/.bsb.lock +# Python (SaltStack only) +__pycache__/ +*.py[cod] +.venv/ +# Ada/SPARK +*.ali +/obj/ +/bin/ +# Haskell +/.stack-work/ +/dist-newstyle/ +# Chapel +*.chpl.tmp.* +# Secrets +.env +.env.* +*.pem +*.key +secrets/ +# Test/Coverage +/coverage/ +htmlcov/ +# Logs +/logs/ +# Machine-readable locks +.machine_readable/.locks/ +# Temp +/tmp/ +*.tmp +*.bak +# ReScript/OCaml compiler artifacts +*.cmt +*.cmti +*.cmi +# asdf version manager +.tool-versions +# Rust build artefacts (innervation tools) +inline-annotations/extractor/target/ +k9-coordination-protocol/tools/k9-init/target/ +hooks/playbook-to-recipe/target/ +inline-annotations/extractor/Cargo.lock +k9-coordination-protocol/tools/k9-init/Cargo.lock +hooks/playbook-to-recipe/Cargo.lock +.verisimdb/ecosystem-ingest/target/ +.verisimdb/ecosystem-ingest/Cargo.lock +# Backup/scratch files (never commit) +*.backup diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e4ff537 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,529 @@ +# 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 = "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[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 = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +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 = "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_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pons-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "pons-core", + "pons-rules", +] + +[[package]] +name = "pons-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "ignore", + "serde", + "tree-sitter", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "pons-protocols" +version = "0.1.0" + +[[package]] +name = "pons-rules" +version = "0.1.0" +dependencies = [ + "anyhow", + "pons-core", + "tree-sitter", +] + +[[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 = "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 = "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tree-sitter" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +dependencies = [ + "cc", + "regex", + "serde_json", + "streaming-iterator", + "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[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-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 = "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..72ddf89 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +[workspace] +resolver = "2" +members = [ + "crates/pons-core", + "crates/pons-protocols", + "crates/pons-rules", + "crates/pons-cli", +] + +[workspace.package] +edition = "2021" +license = "MPL-2.0" +authors = ["Jonathan D.A. Jewell"] + +[workspace.dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +walkdir = "2" +ignore = "0.4" +clap = { version = "4", features = ["derive"] } diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..34a95f8 --- /dev/null +++ b/Justfile @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 + +build: + cargo build --workspace + +test: + cargo test --workspace + +fmt: + cargo fmt --check + +lint: + RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets -- -D warnings + +falsify: + cargo test -p pons-rules --test falsifier + +check: fmt lint test diff --git a/crates/pons-cli/Cargo.toml b/crates/pons-cli/Cargo.toml new file mode 100644 index 0000000..f7c1496 --- /dev/null +++ b/crates/pons-cli/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "pons-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "pons" +path = "src/main.rs" + +[dependencies] +pons-core = { path = "../pons-core" } +pons-rules = { path = "../pons-rules" } +anyhow = { workspace = true } +clap = { workspace = true } diff --git a/crates/pons-cli/src/main.rs b/crates/pons-cli/src/main.rs new file mode 100644 index 0000000..afe5ffd --- /dev/null +++ b/crates/pons-cli/src/main.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand, ValueEnum}; +use pons_core::engine::Engine; +use pons_core::finding::{EvidenceClass, Severity}; +use pons_core::report::human; +use pons_rules::registry::RuleRegistry; + +#[derive(Parser)] +#[command( + name = "pons", + about = "pons asinorum — a falsifier-first static analyzer" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Scan a directory and report findings. + Scan { + path: PathBuf, + /// Exit 1 if any non-speculative finding meets or exceeds this + /// severity. Without this flag, `scan` always exits 0. + #[arg(long, value_enum)] + fail_on: Option, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum FailOn { + Info, + Warn, + Error, +} + +impl FailOn { + fn rank(self) -> u8 { + match self { + FailOn::Info => 0, + FailOn::Warn => 1, + FailOn::Error => 2, + } + } +} + +fn severity_rank(s: Severity) -> u8 { + match s { + Severity::Info => 0, + Severity::Warn => 1, + Severity::Error => 2, + } +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + match cli.command { + Command::Scan { path, fail_on } => run_scan(&path, fail_on), + } +} + +fn run_scan(path: &Path, fail_on: Option) -> ExitCode { + if !path.exists() { + eprintln!("pons: no such path: {}", path.display()); + return ExitCode::from(2); + } + + let engine = Engine::new(RuleRegistry::all()); + let findings = match engine.scan(path) { + Ok(findings) => findings, + Err(e) => { + eprintln!("pons: scan failed: {e}"); + return ExitCode::from(2); + } + }; + + print!("{}", human::render(&findings)); + + // SPECULATIVE findings can never trigger a non-zero exit, regardless of + // --fail-on: the tool never dresses a heuristic as a verdict. + let should_fail = fail_on.is_some_and(|threshold| { + findings.iter().any(|f| { + f.evidence() != EvidenceClass::Speculative + && severity_rank(f.severity()) >= threshold.rank() + }) + }); + + if should_fail { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} diff --git a/crates/pons-core/Cargo.toml b/crates/pons-core/Cargo.toml new file mode 100644 index 0000000..35decbb --- /dev/null +++ b/crates/pons-core/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "pons-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +anyhow = { workspace = true } +ignore.workspace = true +serde = { workspace = true } +tree-sitter = "=0.27.0" +tree-sitter-javascript = "=0.25.0" +tree-sitter-python = "=0.25.0" +tree-sitter-rust = "=0.24.2" +tree-sitter-typescript = "=0.23.2" diff --git a/crates/pons-core/src/engine.rs b/crates/pons-core/src/engine.rs new file mode 100644 index 0000000..56a4391 --- /dev/null +++ b/crates/pons-core/src/engine.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::path::Path; + +use tree_sitter::Tree; + +use crate::finding::{Finding, RawFinding}; +use crate::lang::Lang; +use crate::{parse, source}; + +/// Everything a [`Rule`] needs to inspect one source file. +pub struct RuleCtx<'a> { + pub path: &'a Path, + pub lang: Lang, + pub text: &'a str, + pub tree: &'a Tree, +} + +/// A single check. Implementors live in `pons-rules`; `pons-core` stays +/// rule-agnostic. `check` returns [`RawFinding`], not [`Finding`] — a rule +/// has no way to set its own `rule_id`; only [`Engine::scan`] can, from +/// `id()`, so a rule/id mismatch cannot happen. +pub trait Rule: Send + Sync { + fn id(&self) -> &'static str; + fn languages(&self) -> &'static [Lang]; + fn check(&self, ctx: &RuleCtx) -> Vec; +} + +/// Orchestrates discovery, parsing, and rule execution over a directory. +pub struct Engine { + rules: Vec>, +} + +impl Engine { + pub fn new(rules: Vec>) -> Self { + Self { rules } + } + + pub fn scan(&self, root: &Path) -> anyhow::Result> { + let mut findings = Vec::new(); + + for src in source::discover(root)? { + let tree = parse::parse(&src)?; + let ctx = RuleCtx { + path: &src.path, + lang: src.lang, + text: &src.text, + tree: &tree, + }; + + for rule in &self.rules { + if rule.languages().contains(&src.lang) { + findings.extend( + rule.check(&ctx) + .into_iter() + .map(|raw| raw.into_finding(rule.id())), + ); + } + } + } + + Ok(findings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::finding::{Severity, Tier}; + + struct StubRule; + + impl Rule for StubRule { + fn id(&self) -> &'static str { + "stub-rule" + } + + fn languages(&self) -> &'static [Lang] { + &[Lang::Python] + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + vec![RawFinding::new( + Tier::T0, + Severity::Warn, + crate::finding::Location { + file: ctx.path.display().to_string(), + byte_start: 0, + byte_end: 1, + line_start: 1, + col_start: 1, + line_end: 1, + col_end: 2, + }, + "stub finding", + "stub evidence", + None, + )] + } + } + + #[test] + fn scan_stamps_rule_id_from_the_rule_never_from_the_finding() { + let dir = std::env::temp_dir().join(format!("pons-engine-test-{}", nanos())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.py"), "x = 1\n").unwrap(); + + let engine = Engine::new(vec![Box::new(StubRule)]); + let findings = engine.scan(&dir).unwrap(); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].rule_id(), "stub-rule"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn scan_with_zero_rules_yields_zero_findings() { + let dir = std::env::temp_dir().join(format!("pons-engine-test-{}", nanos())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.py"), "x = 1\n").unwrap(); + + let engine = Engine::new(Vec::new()); + let findings = engine.scan(&dir).unwrap(); + assert!(findings.is_empty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn nanos() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + } +} diff --git a/crates/pons-core/src/finding.rs b/crates/pons-core/src/finding.rs new file mode 100644 index 0000000..1a08027 --- /dev/null +++ b/crates/pons-core/src/finding.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MPL-2.0 + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Tier { + T0, + T1, + T2, + T3, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceClass { + Heuristic, + Dataflow, + Protocol, + Speculative, +} + +impl From for EvidenceClass { + fn from(tier: Tier) -> Self { + match tier { + Tier::T0 => EvidenceClass::Heuristic, + Tier::T1 => EvidenceClass::Dataflow, + Tier::T2 => EvidenceClass::Protocol, + Tier::T3 => EvidenceClass::Speculative, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Location { + pub file: String, + pub byte_start: usize, + pub byte_end: usize, + /// 1-based; tree-sitter's `Point` is 0-based, so callers must add 1. + pub line_start: usize, + pub col_start: usize, + pub line_end: usize, + pub col_end: usize, +} + +impl Location { + /// Builds a [`Location`] from a tree-sitter node, converting its + /// 0-based `Point`s to the 1-based line/column convention every rule + /// must use — this is the one place that conversion happens, so no + /// rule re-derives it by hand. + pub fn from_node(file: impl Into, node: &tree_sitter::Node) -> Self { + let start = node.start_position(); + let end = node.end_position(); + Self { + file: file.into(), + byte_start: node.start_byte(), + byte_end: node.end_byte(), + line_start: start.row + 1, + col_start: start.column + 1, + line_end: end.row + 1, + col_end: end.column + 1, + } + } +} + +/// All fields are private: `tier` and `evidence` must never disagree, and +/// the only way to guarantee that is to deny direct construction and direct +/// mutation alike — see ADR-0001. `new()` is the sole constructor; `Finding` +/// is otherwise read-only via the accessors below. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "FindingWire")] +pub struct Finding { + rule_id: String, + tier: Tier, + evidence: EvidenceClass, + severity: Severity, + location: Location, + message: String, + evidence_note: String, + counter_condition: Option, +} + +/// Deserialization target. Deliberately has no `evidence` field: an +/// incoming `evidence` key (e.g. from a previously-serialized `Finding`) is +/// dropped by serde's default unknown-field handling rather than trusted — +/// `Finding::from` below re-derives it from `tier` unconditionally. +#[derive(Deserialize)] +struct FindingWire { + rule_id: String, + tier: Tier, + severity: Severity, + location: Location, + message: String, + evidence_note: String, + counter_condition: Option, +} + +impl From for Finding { + fn from(w: FindingWire) -> Self { + Finding::new( + w.rule_id, + w.tier, + w.severity, + w.location, + w.message, + w.evidence_note, + w.counter_condition, + ) + } +} + +impl Finding { + /// The only way to construct a `Finding`, and deliberately + /// crate-private: rules (in `pons-rules`, a different crate) build a + /// [`RawFinding`] instead and never see this constructor, so a rule can + /// never set its own `rule_id` — only [`Engine::scan`][crate::engine::Engine::scan] + /// can, via [`RawFinding::into_finding`], using `Rule::id()` as the sole + /// source of truth. `evidence` is likewise derived from `tier`, never + /// taken as a parameter — see ADR-0001. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + rule_id: impl Into, + tier: Tier, + severity: Severity, + location: Location, + message: impl Into, + evidence_note: impl Into, + counter_condition: Option, + ) -> Self { + Self { + rule_id: rule_id.into(), + tier, + evidence: EvidenceClass::from(tier), + severity, + location, + message: message.into(), + evidence_note: evidence_note.into(), + counter_condition, + } + } + + pub fn rule_id(&self) -> &str { + &self.rule_id + } + + pub fn tier(&self) -> Tier { + self.tier + } + + pub fn evidence(&self) -> EvidenceClass { + self.evidence + } + + pub fn severity(&self) -> Severity { + self.severity + } + + pub fn location(&self) -> &Location { + &self.location + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn evidence_note(&self) -> &str { + &self.evidence_note + } + + pub fn counter_condition(&self) -> Option<&str> { + self.counter_condition.as_deref() + } +} + +/// What a [`Rule`][crate::engine::Rule] builds. Has no `rule_id` field at +/// all — a rule cannot report an id other than its own, because it has no +/// way to report one; only [`Engine::scan`][crate::engine::Engine::scan] +/// assigns `rule_id`, from `Rule::id()`, when it turns each `RawFinding` +/// into a [`Finding`]. This makes a rule/id mismatch a type error rather +/// than a typo waiting in one of the T0 catalogue's 8 rule files. +pub struct RawFinding { + tier: Tier, + severity: Severity, + location: Location, + message: String, + evidence_note: String, + counter_condition: Option, +} + +impl RawFinding { + pub fn new( + tier: Tier, + severity: Severity, + location: Location, + message: impl Into, + evidence_note: impl Into, + counter_condition: Option, + ) -> Self { + Self { + tier, + severity, + location, + message: message.into(), + evidence_note: evidence_note.into(), + counter_condition, + } + } + + pub(crate) fn into_finding(self, rule_id: impl Into) -> Finding { + Finding::new( + rule_id, + self.tier, + self.severity, + self.location, + self.message, + self.evidence_note, + self.counter_condition, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_node_converts_zero_based_point_to_one_based_location() { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_python::LANGUAGE.into()) + .unwrap(); + let src = "x = 1\ny = a / 0\n"; + let tree = parser.parse(src, None).unwrap(); + + // The division is on source line 2 (0-based row 1); tree-sitter's + // `Node::start_position` reports row 1 for it. + let module = tree.root_node(); + let second_stmt = module.child(1).unwrap(); + let assignment = second_stmt.child(0).unwrap(); + let rhs = assignment.child_by_field_name("right").unwrap(); + + let loc = Location::from_node("y.py", &rhs); + assert_eq!(loc.line_start, 2); + } + + fn loc() -> Location { + Location { + file: "x.py".into(), + byte_start: 0, + byte_end: 1, + line_start: 1, + col_start: 1, + line_end: 1, + col_end: 2, + } + } + + #[test] + fn evidence_class_is_derived_from_tier() { + let f = Finding::new("r", Tier::T3, Severity::Warn, loc(), "msg", "note", None); + assert_eq!(f.evidence(), EvidenceClass::Speculative); + } + + #[test] + fn deserializing_never_trusts_a_wire_evidence_value() { + // FindingWire carries no `evidence` field at all — this constructs + // the wire type directly (both live in this module) to prove the + // `From` impl re-derives `evidence` from `tier` unconditionally, + // with no path for a stale or tampered wire value to survive. + let wire = FindingWire { + rule_id: "r".into(), + tier: Tier::T0, + severity: Severity::Warn, + location: loc(), + message: "msg".into(), + evidence_note: "note".into(), + counter_condition: None, + }; + let f = Finding::from(wire); + assert_eq!(f.evidence(), EvidenceClass::Heuristic); + } +} diff --git a/crates/pons-core/src/lang.rs b/crates/pons-core/src/lang.rs new file mode 100644 index 0000000..7445b00 --- /dev/null +++ b/crates/pons-core/src/lang.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 + +use tree_sitter::Language; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Lang { + Python, + JavaScript, + TypeScript, + Tsx, + Rust, +} + +impl Lang { + /// Per `docs/PLAN.adoc` Appendix F: `.jsx` maps to `JavaScript` — the + /// `tree-sitter-javascript` grammar parses JSX natively, and TSX is + /// reserved for files with actual TypeScript type annotations. + pub fn from_extension(ext: &str) -> Option { + match ext { + "py" => Some(Lang::Python), + "js" | "mjs" | "cjs" | "jsx" => Some(Lang::JavaScript), + "ts" => Some(Lang::TypeScript), + "tsx" => Some(Lang::Tsx), + "rs" => Some(Lang::Rust), + _ => None, + } + } + + pub fn tree_sitter_language(&self) -> Language { + match self { + Lang::Python => tree_sitter_python::LANGUAGE.into(), + Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(), + Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Lang::Rust => tree_sitter_rust::LANGUAGE.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jsx_maps_to_javascript_not_tsx() { + assert_eq!(Lang::from_extension("jsx"), Some(Lang::JavaScript)); + } + + #[test] + fn unknown_extension_maps_to_none() { + assert_eq!(Lang::from_extension("txt"), None); + } + + #[test] + fn every_lang_variant_produces_a_loadable_grammar() { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, + ] { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&lang.tree_sitter_language()) + .unwrap_or_else(|e| panic!("{lang:?} grammar failed to load: {e}")); + } + } +} diff --git a/crates/pons-core/src/lib.rs b/crates/pons-core/src/lib.rs new file mode 100644 index 0000000..c33b5f4 --- /dev/null +++ b/crates/pons-core/src/lib.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 + +pub mod engine; +pub mod finding; +pub mod lang; +pub mod parse; +pub mod report; +pub mod source; +pub mod suppress; diff --git a/crates/pons-core/src/parse.rs b/crates/pons-core/src/parse.rs new file mode 100644 index 0000000..06841e0 --- /dev/null +++ b/crates/pons-core/src/parse.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 + +use tree_sitter::{Parser, Tree}; + +use crate::source::SourceFile; + +/// Parse a [`SourceFile`] with the tree-sitter grammar matching its +/// [`crate::lang::Lang`]. +pub fn parse(source: &SourceFile) -> anyhow::Result { + let mut parser = Parser::new(); + parser.set_language(&source.lang.tree_sitter_language())?; + parser + .parse(&source.text, None) + .ok_or_else(|| anyhow::anyhow!("tree-sitter produced no tree for {:?}", source.path)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::lang::Lang; + + #[test] + fn parses_a_small_python_snippet() { + let source = SourceFile { + path: PathBuf::from("example.py"), + lang: Lang::Python, + text: "x = 1\n".to_string(), + }; + + let tree = parse(&source).unwrap(); + assert_eq!(tree.root_node().kind(), "module"); + assert!(!tree.root_node().has_error()); + } +} diff --git a/crates/pons-core/src/report/human.rs b/crates/pons-core/src/report/human.rs new file mode 100644 index 0000000..fa1d96c --- /dev/null +++ b/crates/pons-core/src/report/human.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::fmt::Write as _; + +use crate::finding::{EvidenceClass, Finding, Severity}; + +/// A colour-independent suffix demoting `SPECULATIVE`-tier findings from a +/// verdict to a heuristic — colour is lost in piped CI output, so this text +/// is the only signal that must survive. +const SPECULATIVE_SUFFIX: &str = " (heuristic — not a verdict)"; + +/// Deterministic, colour-free rendering of findings for terminal/CI output. +pub fn render(findings: &[Finding]) -> String { + let mut out = String::new(); + for f in findings { + render_one(f, &mut out); + } + out +} + +fn render_one(f: &Finding, out: &mut String) { + let severity = match f.severity() { + Severity::Info => "INFO", + Severity::Warn => "WARN", + Severity::Error => "ERROR", + }; + + let suffix = if f.evidence() == EvidenceClass::Speculative { + SPECULATIVE_SUFFIX + } else { + "" + }; + + let loc = f.location(); + let _ = writeln!( + out, + "{}:{}:{} [{}] {}: {}{}", + loc.file, + loc.line_start, + loc.col_start, + severity, + f.rule_id(), + f.message(), + suffix + ); + let _ = writeln!(out, " evidence: {}", f.evidence_note()); + if let Some(cond) = f.counter_condition() { + let _ = writeln!(out, " when fine: {cond}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::finding::{Location, Tier}; + + fn loc() -> Location { + Location { + file: "x.py".into(), + byte_start: 0, + byte_end: 1, + line_start: 3, + col_start: 5, + line_end: 3, + col_end: 6, + } + } + + #[test] + fn speculative_findings_are_demoted_in_the_message_line() { + let f = Finding::new( + "speculative-rule", + Tier::T3, + Severity::Warn, + loc(), + "looks suspicious", + "no hard evidence", + None, + ); + + let rendered = render(&[f]); + assert!(rendered.contains("looks suspicious (heuristic — not a verdict)")); + } + + #[test] + fn heuristic_findings_are_not_demoted() { + let f = Finding::new( + "t0-rule", + Tier::T0, + Severity::Error, + loc(), + "divide by literal zero", + "constant zero on the RHS", + Some("never".to_string()), + ); + + let rendered = render(&[f]); + assert!(!rendered.contains("heuristic — not a verdict")); + assert!(rendered.contains("x.py:3:5 [ERROR] t0-rule: divide by literal zero")); + assert!(rendered.contains("when fine: never")); + } + + #[test] + fn missing_counter_condition_omits_the_when_fine_line() { + let f = Finding::new("r", Tier::T0, Severity::Info, loc(), "msg", "note", None); + let rendered = render(&[f]); + assert!(!rendered.contains("when fine")); + } +} diff --git a/crates/pons-core/src/report/mod.rs b/crates/pons-core/src/report/mod.rs new file mode 100644 index 0000000..a80263a --- /dev/null +++ b/crates/pons-core/src/report/mod.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 + +pub mod human; diff --git a/crates/pons-core/src/source.rs b/crates/pons-core/src/source.rs new file mode 100644 index 0000000..5b09adf --- /dev/null +++ b/crates/pons-core/src/source.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::path::{Path, PathBuf}; + +use crate::lang::Lang; + +/// Directories skipped unconditionally, mirroring panic-attack's house +/// behaviour — these are never source we want to analyse, `.ponsignore` or +/// not. +const SKIP_DIRS: &[&str] = &[ + ".git", + "target", + "node_modules", + "external_corpora", + "third_party", + "corpus", +]; + +pub struct SourceFile { + pub path: PathBuf, + pub lang: Lang, + pub text: String, +} + +/// Recursively discover source files under `root`, honouring `.gitignore` +/// and a custom `.ponsignore` file, and skipping [`SKIP_DIRS`]. +pub fn discover(root: &Path) -> anyhow::Result> { + let mut files = Vec::new(); + + let walker = ignore::WalkBuilder::new(root) + .add_custom_ignore_filename(".ponsignore") + .filter_entry(|entry| { + entry + .file_name() + .to_str() + .map(|name| !SKIP_DIRS.contains(&name)) + .unwrap_or(true) + }) + .build(); + + for entry in walker { + let entry = entry?; + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let path = entry.path(); + let Some(lang) = path + .extension() + .and_then(|ext| ext.to_str()) + .and_then(Lang::from_extension) + else { + continue; + }; + + let bytes = std::fs::read(path)?; + let text = String::from_utf8(bytes).unwrap_or_else(|e| { + // Latin-1 fallback: every byte is a valid Unicode scalar value + // in 0..=255, so this never fails, unlike UTF-8 decoding. + e.into_bytes().into_iter().map(char::from).collect() + }); + + files.push(SourceFile { + path: path.to_path_buf(), + lang, + text, + }); + } + + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skips_configured_directories() { + let dir = tempfile_dir(); + std::fs::create_dir_all(dir.join("target")).unwrap(); + std::fs::write(dir.join("target/ignored.py"), "x = 1").unwrap(); + std::fs::write(dir.join("kept.py"), "x = 1").unwrap(); + + let found = discover(&dir).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].path.ends_with("kept.py")); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn unknown_extensions_are_skipped() { + let dir = tempfile_dir(); + std::fs::write(dir.join("notes.txt"), "hello").unwrap(); + + let found = discover(&dir).unwrap(); + assert!(found.is_empty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn tempfile_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("pons-source-test-{}", uuid_ish())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn uuid_ish() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + } +} diff --git a/crates/pons-core/src/suppress.rs b/crates/pons-core/src/suppress.rs new file mode 100644 index 0000000..1fe9717 --- /dev/null +++ b/crates/pons-core/src/suppress.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! Suppression mechanics (M7 scope). Empty stub for now — see `docs/PLAN.adoc`. diff --git a/crates/pons-core/tests/smoke.rs b/crates/pons-core/tests/smoke.rs new file mode 100644 index 0000000..43c9282 --- /dev/null +++ b/crates/pons-core/tests/smoke.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +//! ADR-0001 residual smoke test: each pinned grammar must load and answer a +//! trivial query. A failure here means fix the pin table, not the substrate. + +use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator}; + +fn assert_parses_and_queries(language: Language, source: &str, query_src: &str) { + let mut parser = Parser::new(); + parser + .set_language(&language) + .expect("grammar failed to load under the pinned tree-sitter core"); + + let tree = parser.parse(source, None).expect("parse produced no tree"); + + let query = Query::new(&language, query_src).expect("query failed to compile"); + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); + + let mut count = 0; + while matches.next().is_some() { + count += 1; + } + assert!(count >= 1, "expected at least one match, got {count}"); +} + +#[test] +fn python_grammar_loads_and_queries() { + assert_parses_and_queries( + tree_sitter_python::LANGUAGE.into(), + "def foo():\n return 1\n", + "(identifier) @id", + ); +} + +#[test] +fn javascript_grammar_loads_and_queries() { + assert_parses_and_queries( + tree_sitter_javascript::LANGUAGE.into(), + "function foo() { return 1; }", + "(identifier) @id", + ); +} + +#[test] +fn typescript_grammar_loads_and_queries() { + assert_parses_and_queries( + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "function foo(): number { return 1; }", + "(identifier) @id", + ); +} + +#[test] +fn tsx_grammar_loads_and_queries() { + assert_parses_and_queries( + tree_sitter_typescript::LANGUAGE_TSX.into(), + "const x =
hi
;", + "(identifier) @id", + ); +} + +#[test] +fn rust_grammar_loads_and_queries() { + assert_parses_and_queries( + tree_sitter_rust::LANGUAGE.into(), + "fn foo() -> i32 { 1 }", + "(identifier) @id", + ); +} diff --git a/crates/pons-protocols/Cargo.toml b/crates/pons-protocols/Cargo.toml new file mode 100644 index 0000000..ba36144 --- /dev/null +++ b/crates/pons-protocols/Cargo.toml @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "pons-protocols" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] diff --git a/crates/pons-protocols/src/lib.rs b/crates/pons-protocols/src/lib.rs new file mode 100644 index 0000000..f521cb8 --- /dev/null +++ b/crates/pons-protocols/src/lib.rs @@ -0,0 +1 @@ +// SPDX-License-Identifier: MPL-2.0 diff --git a/crates/pons-rules/Cargo.toml b/crates/pons-rules/Cargo.toml new file mode 100644 index 0000000..34e5c9b --- /dev/null +++ b/crates/pons-rules/Cargo.toml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "pons-rules" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +pons-core = { path = "../pons-core" } +anyhow = { workspace = true } +tree-sitter = "=0.27.0" diff --git a/crates/pons-rules/rules/constant-condition/javascript.scm b/crates/pons-rules/rules/constant-condition/javascript.scm new file mode 100644 index 0000000..441c868 --- /dev/null +++ b/crates/pons-rules/rules/constant-condition/javascript.scm @@ -0,0 +1,4 @@ +(if_statement condition: (parenthesized_expression (true) @cond)) @stmt +(if_statement condition: (parenthesized_expression (false) @cond)) @stmt +(while_statement condition: (parenthesized_expression (true) @cond)) @stmt +(while_statement condition: (parenthesized_expression (false) @cond)) @stmt diff --git a/crates/pons-rules/rules/constant-condition/python.scm b/crates/pons-rules/rules/constant-condition/python.scm new file mode 100644 index 0000000..ffc0fe1 --- /dev/null +++ b/crates/pons-rules/rules/constant-condition/python.scm @@ -0,0 +1,4 @@ +(if_statement condition: (true) @cond) @stmt +(if_statement condition: (false) @cond) @stmt +(while_statement condition: (true) @cond) @stmt +(while_statement condition: (false) @cond) @stmt diff --git a/crates/pons-rules/rules/constant-condition/rust.scm b/crates/pons-rules/rules/constant-condition/rust.scm new file mode 100644 index 0000000..9ca0a80 --- /dev/null +++ b/crates/pons-rules/rules/constant-condition/rust.scm @@ -0,0 +1,2 @@ +(if_expression condition: (boolean_literal) @cond) @stmt +(while_expression condition: (boolean_literal) @cond) @stmt diff --git a/crates/pons-rules/rules/constant-condition/tsx.scm b/crates/pons-rules/rules/constant-condition/tsx.scm new file mode 100644 index 0000000..441c868 --- /dev/null +++ b/crates/pons-rules/rules/constant-condition/tsx.scm @@ -0,0 +1,4 @@ +(if_statement condition: (parenthesized_expression (true) @cond)) @stmt +(if_statement condition: (parenthesized_expression (false) @cond)) @stmt +(while_statement condition: (parenthesized_expression (true) @cond)) @stmt +(while_statement condition: (parenthesized_expression (false) @cond)) @stmt diff --git a/crates/pons-rules/rules/constant-condition/typescript.scm b/crates/pons-rules/rules/constant-condition/typescript.scm new file mode 100644 index 0000000..441c868 --- /dev/null +++ b/crates/pons-rules/rules/constant-condition/typescript.scm @@ -0,0 +1,4 @@ +(if_statement condition: (parenthesized_expression (true) @cond)) @stmt +(if_statement condition: (parenthesized_expression (false) @cond)) @stmt +(while_statement condition: (parenthesized_expression (true) @cond)) @stmt +(while_statement condition: (parenthesized_expression (false) @cond)) @stmt diff --git a/crates/pons-rules/rules/div-by-literal-zero/javascript.scm b/crates/pons-rules/rules/div-by-literal-zero/javascript.scm new file mode 100644 index 0000000..2deed86 --- /dev/null +++ b/crates/pons-rules/rules/div-by-literal-zero/javascript.scm @@ -0,0 +1,21 @@ +; SPDX-License-Identifier: MPL-2.0 + +(binary_expression + left: (_) @lhs + operator: "/" @op + right: (number) @rhs) @expr + +(binary_expression + left: (_) @lhs + operator: "%" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "/=" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "%=" @op + right: (number) @rhs) @expr diff --git a/crates/pons-rules/rules/div-by-literal-zero/python.scm b/crates/pons-rules/rules/div-by-literal-zero/python.scm new file mode 100644 index 0000000..c3eddab --- /dev/null +++ b/crates/pons-rules/rules/div-by-literal-zero/python.scm @@ -0,0 +1,21 @@ +; SPDX-License-Identifier: MPL-2.0 + +(binary_operator + left: (_) @lhs + operator: "/" @op + right: [(integer) (float)] @rhs) @expr + +(binary_operator + left: (_) @lhs + operator: "%" @op + right: [(integer) (float)] @rhs) @expr + +(augmented_assignment + left: (_) @lhs + operator: "/=" @op + right: [(integer) (float)] @rhs) @expr + +(augmented_assignment + left: (_) @lhs + operator: "%=" @op + right: [(integer) (float)] @rhs) @expr diff --git a/crates/pons-rules/rules/div-by-literal-zero/rust.scm b/crates/pons-rules/rules/div-by-literal-zero/rust.scm new file mode 100644 index 0000000..e740c62 --- /dev/null +++ b/crates/pons-rules/rules/div-by-literal-zero/rust.scm @@ -0,0 +1,21 @@ +; SPDX-License-Identifier: MPL-2.0 + +(binary_expression + left: (_) @lhs + operator: "/" @op + right: [(integer_literal) (float_literal)] @rhs) @expr + +(binary_expression + left: (_) @lhs + operator: "%" @op + right: [(integer_literal) (float_literal)] @rhs) @expr + +(compound_assignment_expr + left: (_) @lhs + operator: "/=" @op + right: [(integer_literal) (float_literal)] @rhs) @expr + +(compound_assignment_expr + left: (_) @lhs + operator: "%=" @op + right: [(integer_literal) (float_literal)] @rhs) @expr diff --git a/crates/pons-rules/rules/div-by-literal-zero/tsx.scm b/crates/pons-rules/rules/div-by-literal-zero/tsx.scm new file mode 100644 index 0000000..2deed86 --- /dev/null +++ b/crates/pons-rules/rules/div-by-literal-zero/tsx.scm @@ -0,0 +1,21 @@ +; SPDX-License-Identifier: MPL-2.0 + +(binary_expression + left: (_) @lhs + operator: "/" @op + right: (number) @rhs) @expr + +(binary_expression + left: (_) @lhs + operator: "%" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "/=" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "%=" @op + right: (number) @rhs) @expr diff --git a/crates/pons-rules/rules/div-by-literal-zero/typescript.scm b/crates/pons-rules/rules/div-by-literal-zero/typescript.scm new file mode 100644 index 0000000..2deed86 --- /dev/null +++ b/crates/pons-rules/rules/div-by-literal-zero/typescript.scm @@ -0,0 +1,21 @@ +; SPDX-License-Identifier: MPL-2.0 + +(binary_expression + left: (_) @lhs + operator: "/" @op + right: (number) @rhs) @expr + +(binary_expression + left: (_) @lhs + operator: "%" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "/=" @op + right: (number) @rhs) @expr + +(augmented_assignment_expression + left: (_) @lhs + operator: "%=" @op + right: (number) @rhs) @expr diff --git a/crates/pons-rules/rules/empty-effect-loop/javascript.scm b/crates/pons-rules/rules/empty-effect-loop/javascript.scm new file mode 100644 index 0000000..c581864 --- /dev/null +++ b/crates/pons-rules/rules/empty-effect-loop/javascript.scm @@ -0,0 +1,4 @@ +(while_statement body: (statement_block) @body) @stmt +(for_statement body: (statement_block) @body) @stmt +(for_in_statement body: (statement_block) @body) @stmt +(do_statement body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/rules/empty-effect-loop/python.scm b/crates/pons-rules/rules/empty-effect-loop/python.scm new file mode 100644 index 0000000..c4a37de --- /dev/null +++ b/crates/pons-rules/rules/empty-effect-loop/python.scm @@ -0,0 +1,2 @@ +(while_statement body: (block) @body) @stmt +(for_statement body: (block) @body) @stmt diff --git a/crates/pons-rules/rules/empty-effect-loop/rust.scm b/crates/pons-rules/rules/empty-effect-loop/rust.scm new file mode 100644 index 0000000..090a61b --- /dev/null +++ b/crates/pons-rules/rules/empty-effect-loop/rust.scm @@ -0,0 +1,3 @@ +(loop_expression body: (block) @body) @stmt +(while_expression body: (block) @body) @stmt +(for_expression body: (block) @body) @stmt diff --git a/crates/pons-rules/rules/empty-effect-loop/tsx.scm b/crates/pons-rules/rules/empty-effect-loop/tsx.scm new file mode 100644 index 0000000..c581864 --- /dev/null +++ b/crates/pons-rules/rules/empty-effect-loop/tsx.scm @@ -0,0 +1,4 @@ +(while_statement body: (statement_block) @body) @stmt +(for_statement body: (statement_block) @body) @stmt +(for_in_statement body: (statement_block) @body) @stmt +(do_statement body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/rules/empty-effect-loop/typescript.scm b/crates/pons-rules/rules/empty-effect-loop/typescript.scm new file mode 100644 index 0000000..c581864 --- /dev/null +++ b/crates/pons-rules/rules/empty-effect-loop/typescript.scm @@ -0,0 +1,4 @@ +(while_statement body: (statement_block) @body) @stmt +(for_statement body: (statement_block) @body) @stmt +(for_in_statement body: (statement_block) @body) @stmt +(do_statement body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/rules/self-assignment/javascript.scm b/crates/pons-rules/rules/self-assignment/javascript.scm new file mode 100644 index 0000000..f980eb7 --- /dev/null +++ b/crates/pons-rules/rules/self-assignment/javascript.scm @@ -0,0 +1,3 @@ +(assignment_expression + left: (identifier) @left + right: (identifier) @right) @stmt diff --git a/crates/pons-rules/rules/self-assignment/python.scm b/crates/pons-rules/rules/self-assignment/python.scm new file mode 100644 index 0000000..5c9a1a1 --- /dev/null +++ b/crates/pons-rules/rules/self-assignment/python.scm @@ -0,0 +1,3 @@ +(assignment + left: (identifier) @left + right: (identifier) @right) @stmt diff --git a/crates/pons-rules/rules/self-assignment/rust.scm b/crates/pons-rules/rules/self-assignment/rust.scm new file mode 100644 index 0000000..f980eb7 --- /dev/null +++ b/crates/pons-rules/rules/self-assignment/rust.scm @@ -0,0 +1,3 @@ +(assignment_expression + left: (identifier) @left + right: (identifier) @right) @stmt diff --git a/crates/pons-rules/rules/self-assignment/tsx.scm b/crates/pons-rules/rules/self-assignment/tsx.scm new file mode 100644 index 0000000..f980eb7 --- /dev/null +++ b/crates/pons-rules/rules/self-assignment/tsx.scm @@ -0,0 +1,3 @@ +(assignment_expression + left: (identifier) @left + right: (identifier) @right) @stmt diff --git a/crates/pons-rules/rules/self-assignment/typescript.scm b/crates/pons-rules/rules/self-assignment/typescript.scm new file mode 100644 index 0000000..f980eb7 --- /dev/null +++ b/crates/pons-rules/rules/self-assignment/typescript.scm @@ -0,0 +1,3 @@ +(assignment_expression + left: (identifier) @left + right: (identifier) @right) @stmt diff --git a/crates/pons-rules/rules/string-concat-in-loop/javascript.scm b/crates/pons-rules/rules/string-concat-in-loop/javascript.scm new file mode 100644 index 0000000..33d2eed --- /dev/null +++ b/crates/pons-rules/rules/string-concat-in-loop/javascript.scm @@ -0,0 +1,9 @@ +(augmented_assignment_expression + left: (identifier) @target + right: (_) @addend) @assign + +(assignment_expression + left: (identifier) @target + right: (binary_expression + left: (identifier) @addend_base + right: (_) @addend) @binop) @assign diff --git a/crates/pons-rules/rules/string-concat-in-loop/python.scm b/crates/pons-rules/rules/string-concat-in-loop/python.scm new file mode 100644 index 0000000..c8f7d42 --- /dev/null +++ b/crates/pons-rules/rules/string-concat-in-loop/python.scm @@ -0,0 +1,9 @@ +(augmented_assignment + left: (identifier) @target + right: (_) @addend) @assign + +(assignment + left: (identifier) @target + right: (binary_operator + left: (identifier) @addend_base + right: (_) @addend) @binop) @assign diff --git a/crates/pons-rules/rules/string-concat-in-loop/tsx.scm b/crates/pons-rules/rules/string-concat-in-loop/tsx.scm new file mode 100644 index 0000000..33d2eed --- /dev/null +++ b/crates/pons-rules/rules/string-concat-in-loop/tsx.scm @@ -0,0 +1,9 @@ +(augmented_assignment_expression + left: (identifier) @target + right: (_) @addend) @assign + +(assignment_expression + left: (identifier) @target + right: (binary_expression + left: (identifier) @addend_base + right: (_) @addend) @binop) @assign diff --git a/crates/pons-rules/rules/string-concat-in-loop/typescript.scm b/crates/pons-rules/rules/string-concat-in-loop/typescript.scm new file mode 100644 index 0000000..33d2eed --- /dev/null +++ b/crates/pons-rules/rules/string-concat-in-loop/typescript.scm @@ -0,0 +1,9 @@ +(augmented_assignment_expression + left: (identifier) @target + right: (_) @addend) @assign + +(assignment_expression + left: (identifier) @target + right: (binary_expression + left: (identifier) @addend_base + right: (_) @addend) @binop) @assign diff --git a/crates/pons-rules/rules/swallowed-error/javascript.scm b/crates/pons-rules/rules/swallowed-error/javascript.scm new file mode 100644 index 0000000..277f37f --- /dev/null +++ b/crates/pons-rules/rules/swallowed-error/javascript.scm @@ -0,0 +1,2 @@ +(catch_clause + body: (statement_block) @body) @clause diff --git a/crates/pons-rules/rules/swallowed-error/python.scm b/crates/pons-rules/rules/swallowed-error/python.scm new file mode 100644 index 0000000..c184cbe --- /dev/null +++ b/crates/pons-rules/rules/swallowed-error/python.scm @@ -0,0 +1,2 @@ +(except_clause + (block) @body) @clause diff --git a/crates/pons-rules/rules/swallowed-error/tsx.scm b/crates/pons-rules/rules/swallowed-error/tsx.scm new file mode 100644 index 0000000..277f37f --- /dev/null +++ b/crates/pons-rules/rules/swallowed-error/tsx.scm @@ -0,0 +1,2 @@ +(catch_clause + body: (statement_block) @body) @clause diff --git a/crates/pons-rules/rules/swallowed-error/typescript.scm b/crates/pons-rules/rules/swallowed-error/typescript.scm new file mode 100644 index 0000000..277f37f --- /dev/null +++ b/crates/pons-rules/rules/swallowed-error/typescript.scm @@ -0,0 +1,2 @@ +(catch_clause + body: (statement_block) @body) @clause diff --git a/crates/pons-rules/rules/unreachable-after-jump/javascript.scm b/crates/pons-rules/rules/unreachable-after-jump/javascript.scm new file mode 100644 index 0000000..eab8478 --- /dev/null +++ b/crates/pons-rules/rules/unreachable-after-jump/javascript.scm @@ -0,0 +1,4 @@ +(statement_block (return_statement) @jump . (_) @next) +(statement_block (throw_statement) @jump . (_) @next) +(statement_block (break_statement) @jump . (_) @next) +(statement_block (continue_statement) @jump . (_) @next) diff --git a/crates/pons-rules/rules/unreachable-after-jump/python.scm b/crates/pons-rules/rules/unreachable-after-jump/python.scm new file mode 100644 index 0000000..2634c8b --- /dev/null +++ b/crates/pons-rules/rules/unreachable-after-jump/python.scm @@ -0,0 +1,4 @@ +(block (return_statement) @jump . (_) @next) +(block (raise_statement) @jump . (_) @next) +(block (break_statement) @jump . (_) @next) +(block (continue_statement) @jump . (_) @next) diff --git a/crates/pons-rules/rules/unreachable-after-jump/rust.scm b/crates/pons-rules/rules/unreachable-after-jump/rust.scm new file mode 100644 index 0000000..eb0d3fd --- /dev/null +++ b/crates/pons-rules/rules/unreachable-after-jump/rust.scm @@ -0,0 +1,3 @@ +(block (expression_statement (return_expression)) @jump . (_) @next) +(block (expression_statement (break_expression)) @jump . (_) @next) +(block (expression_statement (continue_expression)) @jump . (_) @next) diff --git a/crates/pons-rules/rules/unreachable-after-jump/tsx.scm b/crates/pons-rules/rules/unreachable-after-jump/tsx.scm new file mode 100644 index 0000000..eab8478 --- /dev/null +++ b/crates/pons-rules/rules/unreachable-after-jump/tsx.scm @@ -0,0 +1,4 @@ +(statement_block (return_statement) @jump . (_) @next) +(statement_block (throw_statement) @jump . (_) @next) +(statement_block (break_statement) @jump . (_) @next) +(statement_block (continue_statement) @jump . (_) @next) diff --git a/crates/pons-rules/rules/unreachable-after-jump/typescript.scm b/crates/pons-rules/rules/unreachable-after-jump/typescript.scm new file mode 100644 index 0000000..eab8478 --- /dev/null +++ b/crates/pons-rules/rules/unreachable-after-jump/typescript.scm @@ -0,0 +1,4 @@ +(statement_block (return_statement) @jump . (_) @next) +(statement_block (throw_statement) @jump . (_) @next) +(statement_block (break_statement) @jump . (_) @next) +(statement_block (continue_statement) @jump . (_) @next) diff --git a/crates/pons-rules/rules/while-true-no-break/javascript.scm b/crates/pons-rules/rules/while-true-no-break/javascript.scm new file mode 100644 index 0000000..b3f1c72 --- /dev/null +++ b/crates/pons-rules/rules/while-true-no-break/javascript.scm @@ -0,0 +1,3 @@ +(while_statement + condition: (parenthesized_expression (true)) + body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/rules/while-true-no-break/python.scm b/crates/pons-rules/rules/while-true-no-break/python.scm new file mode 100644 index 0000000..5d8802d --- /dev/null +++ b/crates/pons-rules/rules/while-true-no-break/python.scm @@ -0,0 +1,3 @@ +(while_statement + condition: (true) + body: (block) @body) @stmt diff --git a/crates/pons-rules/rules/while-true-no-break/rust.scm b/crates/pons-rules/rules/while-true-no-break/rust.scm new file mode 100644 index 0000000..f62a981 --- /dev/null +++ b/crates/pons-rules/rules/while-true-no-break/rust.scm @@ -0,0 +1,5 @@ +(loop_expression body: (block) @body) @stmt + +(while_expression + condition: (boolean_literal) @cond + body: (block) @body) @stmt diff --git a/crates/pons-rules/rules/while-true-no-break/tsx.scm b/crates/pons-rules/rules/while-true-no-break/tsx.scm new file mode 100644 index 0000000..b3f1c72 --- /dev/null +++ b/crates/pons-rules/rules/while-true-no-break/tsx.scm @@ -0,0 +1,3 @@ +(while_statement + condition: (parenthesized_expression (true)) + body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/rules/while-true-no-break/typescript.scm b/crates/pons-rules/rules/while-true-no-break/typescript.scm new file mode 100644 index 0000000..b3f1c72 --- /dev/null +++ b/crates/pons-rules/rules/while-true-no-break/typescript.scm @@ -0,0 +1,3 @@ +(while_statement + condition: (parenthesized_expression (true)) + body: (statement_block) @body) @stmt diff --git a/crates/pons-rules/src/lib.rs b/crates/pons-rules/src/lib.rs new file mode 100644 index 0000000..2d92958 --- /dev/null +++ b/crates/pons-rules/src/lib.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MPL-2.0 + +pub mod registry; +pub mod t0; diff --git a/crates/pons-rules/src/registry.rs b/crates/pons-rules/src/registry.rs new file mode 100644 index 0000000..225e031 --- /dev/null +++ b/crates/pons-rules/src/registry.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 + +use pons_core::engine::Rule; + +use crate::t0::constant_condition::ConstantCondition; +use crate::t0::div_by_literal_zero::DivByLiteralZero; +use crate::t0::empty_effect_loop::EmptyEffectLoop; +use crate::t0::self_assignment::SelfAssignment; +use crate::t0::string_concat_in_loop::StringConcatInLoop; +use crate::t0::swallowed_error::SwallowedError; +use crate::t0::unreachable_after_jump::UnreachableAfterJump; +use crate::t0::while_true_no_break::WhileTrueNoBreak; + +/// Builds the full set of registered rules — the single source of truth +/// both `pons-cli` and the falsifier gate (`tests/falsifier.rs`) read from, +/// so a rule that exists but was never registered here fails loudly rather +/// than silently never running. +pub struct RuleRegistry; + +impl RuleRegistry { + pub fn all() -> Vec> { + vec![ + Box::new(DivByLiteralZero::new()), + Box::new(SwallowedError::new()), + Box::new(SelfAssignment::new()), + Box::new(ConstantCondition::new()), + Box::new(WhileTrueNoBreak::new()), + Box::new(EmptyEffectLoop::new()), + Box::new(UnreachableAfterJump::new()), + Box::new(StringConcatInLoop::new()), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_registers_the_t0_catalogue_as_it_lands() { + let ids: Vec<&str> = RuleRegistry::all().iter().map(|r| r.id()).collect(); + assert_eq!( + ids, + vec![ + "div-by-literal-zero", + "swallowed-error", + "self-assignment", + "constant-condition", + "while-true-no-break", + "empty-effect-loop", + "unreachable-after-jump", + "string-concat-in-loop" + ] + ); + } +} diff --git a/crates/pons-rules/src/t0/constant_condition.rs b/crates/pons-rules/src/t0/constant_condition.rs new file mode 100644 index 0000000..fab0ddf --- /dev/null +++ b/crates/pons-rules/src/t0/constant_condition.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/constant-condition/python.scm"), + Lang::JavaScript => include_str!("../../rules/constant-condition/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/constant-condition/typescript.scm"), + Lang::Tsx => include_str!("../../rules/constant-condition/tsx.scm"), + Lang::Rust => include_str!("../../rules/constant-condition/rust.scm"), + } +} + +/// `if (true)`, `if (false)`, `while (false)` — a literal boolean in the +/// condition slot. `while (true)` / `loop` is deliberately excluded here — +/// that's `while-true-no-break`'s territory (rule 5), which judges it by +/// reachable exits rather than flagging the literal outright. +pub struct ConstantCondition { + queries: HashMap, +} + +impl ConstantCondition { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("constant-condition query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for ConstantCondition { + fn default() -> Self { + Self::new() + } +} + +impl Rule for ConstantCondition { + fn id(&self) -> &'static str { + "constant-condition" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut cond = None; + let mut stmt = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "cond" => cond = Some(cap.node), + "stmt" => stmt = Some(cap.node), + _ => {} + } + } + let (Some(cond), Some(stmt)) = (cond, stmt) else { + continue; + }; + + let is_while = stmt.kind().contains("while"); + let cond_text = cond.utf8_text(ctx.text.as_bytes()).unwrap_or_default(); + let is_true = cond_text.eq_ignore_ascii_case("true"); + + // `while (true)` / `loop` is rule 5's territory, not this rule's. + if is_while && is_true { + continue; + } + + let keyword = if is_while { "while" } else { "if" }; + let literal = if is_true { "true" } else { "false" }; + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &stmt), + "constant condition", + format!("`{keyword} ({literal})` — the condition can never vary"), + Some("a debug or feature-flag constant that gets edited in place".to_string()), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = ConstantCondition::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_if_true() { + let findings = findings_for(Lang::Python, "if True:\n pass\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_python_while_false() { + let findings = findings_for(Lang::Python, "while False:\n pass\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_while_true() { + let findings = findings_for(Lang::Python, "while True:\n pass\n"); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_python_variable_condition() { + let findings = findings_for(Lang::Python, "if x:\n pass\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_if_false() { + let findings = findings_for(Lang::JavaScript, "if (false) { f(); }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_while_true() { + let findings = findings_for(Lang::JavaScript, "while (true) { f(); }\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_rust_if_true() { + let findings = findings_for(Lang::Rust, "fn f() { if true { g(); } }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_rust_while_true() { + let findings = findings_for(Lang::Rust, "fn f() { while true { g(); } }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/src/t0/div_by_literal_zero.rs b/crates/pons-rules/src/t0/div_by_literal_zero.rs new file mode 100644 index 0000000..d2e650b --- /dev/null +++ b/crates/pons-rules/src/t0/div_by_literal_zero.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/div-by-literal-zero/python.scm"), + Lang::JavaScript => include_str!("../../rules/div-by-literal-zero/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/div-by-literal-zero/typescript.scm"), + Lang::Tsx => include_str!("../../rules/div-by-literal-zero/tsx.scm"), + Lang::Rust => include_str!("../../rules/div-by-literal-zero/rust.scm"), + } +} + +/// `/` or `%` (and their augmented-assignment forms) whose right-hand side +/// is a literal zero. Provably dead code the local analysis can't see may +/// make this safe, so it never exceeds `WARN` — see the severity ceiling in +/// `wiki/Rule-Catalogue.asciidoc`. +pub struct DivByLiteralZero { + queries: HashMap, +} + +impl DivByLiteralZero { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("div-by-literal-zero query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for DivByLiteralZero { + fn default() -> Self { + Self::new() + } +} + +impl Rule for DivByLiteralZero { + fn id(&self) -> &'static str { + "div-by-literal-zero" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut lhs = None; + let mut rhs = None; + let mut expr = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "lhs" => lhs = Some(cap.node), + "rhs" => rhs = Some(cap.node), + "expr" => expr = Some(cap.node), + _ => {} + } + } + let (Some(lhs), Some(rhs), Some(expr)) = (lhs, rhs, expr) else { + continue; + }; + + // Python's `%` doubles as string formatting: `"%d" % 0` is not + // modulo-by-zero at all. + if ctx.lang == Lang::Python && lhs.kind() == "string" { + continue; + } + + let rhs_text = rhs.utf8_text(ctx.text.as_bytes()).unwrap_or_default(); + if !is_literal_zero(rhs_text) { + continue; + } + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &expr), + "division or modulo by a literal zero", + format!("right-hand side `{rhs_text}` is a constant zero"), + Some( + "sits in provably dead code the local analysis can't see — stays a \ + heuristic, never a hard error" + .to_string(), + ), + )); + } + + findings + } +} + +/// True if `text` is a numeric literal whose value is zero, across every +/// grammar this rule covers: plain decimals and floats (all languages), +/// hex/octal/binary integers and Rust's `iN`/`uN`/`fN`/`size` suffixes, and +/// JavaScript's trailing `n` bigint suffix. +fn is_literal_zero(text: &str) -> bool { + let cleaned = text.replace('_', "").to_ascii_lowercase(); + let without_bigint = cleaned.strip_suffix('n').unwrap_or(&cleaned); + + const RUST_SUFFIXES: &[&str] = &[ + "usize", "isize", "u128", "i128", "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8", + "f64", "f32", + ]; + let mut body = without_bigint; + for suffix in RUST_SUFFIXES { + if let Some(stripped) = body.strip_suffix(suffix) { + body = stripped; + break; + } + } + + if let Some(hex) = body.strip_prefix("0x") { + return !hex.is_empty() && i128::from_str_radix(hex, 16).is_ok_and(|v| v == 0); + } + if let Some(oct) = body.strip_prefix("0o") { + return !oct.is_empty() && i128::from_str_radix(oct, 8).is_ok_and(|v| v == 0); + } + if let Some(bin) = body.strip_prefix("0b") { + return !bin.is_empty() && i128::from_str_radix(bin, 2).is_ok_and(|v| v == 0); + } + + body.parse::().is_ok_and(|v| v == 0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_zero_in_every_literal_shape() { + for text in [ + "0", "0.0", "0.", ".0", "0e0", "00", "0x0", "0X0", "0o0", "0b0", "0u32", "0_i64", + "0f64", "0n", + ] { + assert!(is_literal_zero(text), "expected {text:?} to be zero"); + } + } + + #[test] + fn does_not_recognise_nonzero_literals() { + for text in ["1", "0.1", "10", "0x1", "1u32", "2n", "0.0001"] { + assert!(!is_literal_zero(text), "expected {text:?} to be nonzero"); + } + } +} diff --git a/crates/pons-rules/src/t0/empty_effect_loop.rs b/crates/pons-rules/src/t0/empty_effect_loop.rs new file mode 100644 index 0000000..c82d731 --- /dev/null +++ b/crates/pons-rules/src/t0/empty_effect_loop.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Node, Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/empty-effect-loop/python.scm"), + Lang::JavaScript => include_str!("../../rules/empty-effect-loop/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/empty-effect-loop/typescript.scm"), + Lang::Tsx => include_str!("../../rules/empty-effect-loop/tsx.scm"), + Lang::Rust => include_str!("../../rules/empty-effect-loop/rust.scm"), + } +} + +/// Node kinds that count as a no-op statement — a block containing only +/// these (or nothing at all) has no effect. Rust has no bare no-op statement +/// kind, so only a literally empty block counts there. +fn noop_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["pass_statement"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &["empty_statement"], + Lang::Rust => &[], + } +} + +fn body_is_effect_free(body: Node, noops: &[&str]) -> bool { + let mut cursor = body.walk(); + let result = body + .named_children(&mut cursor) + .all(|child| noops.contains(&child.kind())); + result +} + +/// A loop whose body is empty or contains only no-ops — entered and exited +/// for nothing. Deliberately overlaps `while-true-no-break` on `loop {}` / +/// `while (true) {}` (both fire) — that's documented, not a bug: an empty +/// infinite loop is both "no break" and "no effect" at once. +pub struct EmptyEffectLoop { + queries: HashMap, +} + +impl EmptyEffectLoop { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("empty-effect-loop query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for EmptyEffectLoop { + fn default() -> Self { + Self::new() + } +} + +impl Rule for EmptyEffectLoop { + fn id(&self) -> &'static str { + "empty-effect-loop" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + let noops = noop_kinds(ctx.lang); + + while let Some(m) = matches.next() { + let mut body = None; + let mut stmt = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "body" => body = Some(cap.node), + "stmt" => stmt = Some(cap.node), + _ => {} + } + } + let (Some(body), Some(stmt)) = (body, stmt) else { + continue; + }; + + if !body_is_effect_free(body, noops) { + continue; + } + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &stmt), + "empty-effect-loop", + "loop body is empty or a no-op — entered and exited for nothing".to_string(), + Some("a deliberate spin-wait on a volatile/side-effecting condition".to_string()), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = EmptyEffectLoop::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_while_pass() { + let findings = findings_for(Lang::Python, "while True:\n pass\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_python_for_pass() { + let findings = findings_for(Lang::Python, "for x in y:\n pass\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_loop_with_effect() { + let findings = findings_for(Lang::Python, "while x:\n do_thing()\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_empty_while() { + let findings = findings_for(Lang::JavaScript, "while (x) {}\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_loop_with_effect() { + let findings = findings_for(Lang::JavaScript, "while (x) { doThing(); }\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_rust_empty_loop() { + let findings = findings_for(Lang::Rust, "fn f() { loop {} }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn overlaps_while_true_no_break_on_empty_infinite_loop() { + let findings = findings_for(Lang::Rust, "fn f() { while true {} }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_rust_loop_with_effect() { + let findings = findings_for(Lang::Rust, "fn f(x: bool) { loop { if x { break; } } }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/src/t0/mod.rs b/crates/pons-rules/src/t0/mod.rs new file mode 100644 index 0000000..a61fe0e --- /dev/null +++ b/crates/pons-rules/src/t0/mod.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 + +pub mod constant_condition; +pub mod div_by_literal_zero; +pub mod empty_effect_loop; +pub mod self_assignment; +pub mod string_concat_in_loop; +pub mod swallowed_error; +pub mod unreachable_after_jump; +pub mod while_true_no_break; diff --git a/crates/pons-rules/src/t0/self_assignment.rs b/crates/pons-rules/src/t0/self_assignment.rs new file mode 100644 index 0000000..297bc6f --- /dev/null +++ b/crates/pons-rules/src/t0/self_assignment.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/self-assignment/python.scm"), + Lang::JavaScript => include_str!("../../rules/self-assignment/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/self-assignment/typescript.scm"), + Lang::Tsx => include_str!("../../rules/self-assignment/tsx.scm"), + Lang::Rust => include_str!("../../rules/self-assignment/rust.scm"), + } +} + +/// `x = x` — a plain identifier assigned to itself. Restricting the query to +/// bare identifiers on both sides naturally excludes property/attribute +/// setters with effects (`obj.p = obj.p`, a different node shape) and, in +/// Rust, `let x = x;` (a `let_declaration` shadow, not an +/// `assignment_expression`) — see the counter-condition in +/// `wiki/Rule-Catalogue.asciidoc`. "Volatile reads" is not syntactically +/// checkable at T0 and is out of scope. +pub struct SelfAssignment { + queries: HashMap, +} + +impl SelfAssignment { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("self-assignment query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for SelfAssignment { + fn default() -> Self { + Self::new() + } +} + +impl Rule for SelfAssignment { + fn id(&self) -> &'static str { + "self-assignment" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut left = None; + let mut right = None; + let mut stmt = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "left" => left = Some(cap.node), + "right" => right = Some(cap.node), + "stmt" => stmt = Some(cap.node), + _ => {} + } + } + let (Some(left), Some(right), Some(stmt)) = (left, right, stmt) else { + continue; + }; + + let text = ctx.text.as_bytes(); + let left_name = left.utf8_text(text).unwrap_or_default(); + let right_name = right.utf8_text(text).unwrap_or_default(); + if left_name != right_name { + continue; + } + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &stmt), + "self-assignment", + format!("`{left_name}` is assigned to itself — this has no effect"), + Some( + "a property/attribute setter with side effects, or a volatile read \ + (not checkable here)" + .to_string(), + ), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = SelfAssignment::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_self_assign() { + let findings = findings_for(Lang::Python, "x = 1\nx = x\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_attribute_self_assign() { + let findings = findings_for(Lang::Python, "obj.p = obj.p\n"); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_python_different_names() { + let findings = findings_for(Lang::Python, "x = y\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_self_assign() { + let findings = findings_for(Lang::JavaScript, "let x = 1;\nx = x;\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_rust_self_assign() { + let findings = findings_for(Lang::Rust, "fn f(mut x: i32) { x = x; }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_rust_let_shadow() { + let findings = findings_for(Lang::Rust, "fn f() { let x = 1; let x = x; }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/src/t0/string_concat_in_loop.rs b/crates/pons-rules/src/t0/string_concat_in_loop.rs new file mode 100644 index 0000000..adfc460 --- /dev/null +++ b/crates/pons-rules/src/t0/string_concat_in_loop.rs @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Node, Query, QueryCursor, StreamingIterator}; + +/// No Rust variant: `String += &str` is amortised O(1) there, so the pattern +/// this rule targets (quadratic rebuild from an immutable string type) does +/// not apply. +const LANGUAGES: &[Lang] = &[Lang::Python, Lang::JavaScript, Lang::TypeScript, Lang::Tsx]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/string-concat-in-loop/python.scm"), + Lang::JavaScript => include_str!("../../rules/string-concat-in-loop/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/string-concat-in-loop/typescript.scm"), + Lang::Tsx => include_str!("../../rules/string-concat-in-loop/tsx.scm"), + Lang::Rust => unreachable!("string-concat-in-loop has no Rust variant"), + } +} + +fn loop_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["for_statement", "while_statement"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &[ + "for_statement", + "for_in_statement", + "while_statement", + "do_statement", + ], + Lang::Rust => &[], + } +} + +/// A function/closure boundary walked through on the way up from the +/// assignment to a loop means the assignment executes once per *call*, not +/// once per loop iteration — that's not the quadratic-rebuild pattern this +/// rule targets, so the walk stops there rather than crediting the loop. +fn boundary_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["function_definition", "lambda"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &[ + "function_declaration", + "function_expression", + "arrow_function", + "generator_function_declaration", + "generator_function", + "method_definition", + ], + Lang::Rust => &[], + } +} + +/// String-literal node kinds a syntactic (T0) check can recognise directly. +/// This is the "string-typed-RHS narrowing" needed so `count += 1` never +/// matches: without it, any augmented assignment would qualify. +fn string_literal_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["string"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &["string", "template_string"], + Lang::Rust => &[], + } +} + +fn subtree_has_string_literal(node: Node, string_kinds: &[&str]) -> bool { + if string_kinds.contains(&node.kind()) { + return true; + } + let mut cursor = node.walk(); + let result = node + .children(&mut cursor) + .any(|child| subtree_has_string_literal(child, string_kinds)); + result +} + +/// Walks up from `node` looking for an enclosing loop, refusing to cross a +/// function/closure boundary first (see [`boundary_kinds`]). +fn enclosed_by_loop(node: Node, loops: &[&str], boundaries: &[&str]) -> bool { + let mut current = node.parent(); + while let Some(n) = current { + let kind = n.kind(); + if loops.contains(&kind) { + return true; + } + if boundaries.contains(&kind) { + return false; + } + current = n.parent(); + } + false +} + +/// True if `node` is a statement that assigns `target_name` a value whose +/// subtree contains a string literal — i.e. it looks like the accumulator's +/// own string-typed initialisation (`s = ''`, `let s = "";`), not the loop +/// body's `+=`. +fn declares_target_as_string( + node: Node, + target_name: &str, + text: &[u8], + lang: Lang, + string_kinds: &[&str], +) -> bool { + let text_of = |n: Node| n.utf8_text(text).unwrap_or_default(); + + match lang { + Lang::Python => { + let candidate = if node.kind() == "expression_statement" { + node.named_child(0) + } else { + Some(node) + }; + let Some(candidate) = candidate.filter(|c| c.kind() == "assignment") else { + return false; + }; + let (Some(left), Some(right)) = ( + candidate.child_by_field_name("left"), + candidate.child_by_field_name("right"), + ) else { + return false; + }; + text_of(left) == target_name && subtree_has_string_literal(right, string_kinds) + } + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => { + if node.kind() == "lexical_declaration" || node.kind() == "variable_declaration" { + let mut cursor = node.walk(); + let result = node.named_children(&mut cursor).any(|declarator| { + declarator.kind() == "variable_declarator" + && declarator + .child_by_field_name("name") + .is_some_and(|n| text_of(n) == target_name) + && declarator + .child_by_field_name("value") + .is_some_and(|v| subtree_has_string_literal(v, string_kinds)) + }); + return result; + } + let candidate = if node.kind() == "expression_statement" { + node.named_child(0) + } else { + Some(node) + }; + let Some(candidate) = candidate.filter(|c| c.kind() == "assignment_expression") else { + return false; + }; + let (Some(left), Some(right)) = ( + candidate.child_by_field_name("left"), + candidate.child_by_field_name("right"), + ) else { + return false; + }; + text_of(left) == target_name && subtree_has_string_literal(right, string_kinds) + } + Lang::Rust => false, + } +} + +/// Finds the block that scopes `node` — the body of the nearest enclosing +/// function/closure (see [`boundary_kinds`]), or `fallback_root` (the whole +/// file) if there is none. Declarations outside this block can't be the +/// accumulator's own initialisation without risking a same-named variable +/// in an unrelated function. +fn enclosing_scope<'a>(node: Node<'a>, boundaries: &[&str], fallback_root: Node<'a>) -> Node<'a> { + let mut current = node.parent(); + while let Some(n) = current { + if boundaries.contains(&n.kind()) { + return n.child_by_field_name("body").unwrap_or(n); + } + current = n.parent(); + } + fallback_root +} + +/// Searches `scope` for a statement textually before `before_byte` that +/// declares `target_name` as a string, without descending into a nested +/// function/closure boundary — a same-named variable local to an inner +/// function is a different variable, not this accumulator's declaration. +fn scope_has_earlier_string_declaration( + scope: Node, + target_name: &str, + before_byte: usize, + boundaries: &[&str], + text: &[u8], + lang: Lang, + string_kinds: &[&str], +) -> bool { + let mut cursor = scope.walk(); + let result = scope.children(&mut cursor).any(|child| { + if boundaries.contains(&child.kind()) { + return false; + } + (child.start_byte() < before_byte + && declares_target_as_string(child, target_name, text, lang, string_kinds)) + || scope_has_earlier_string_declaration( + child, + target_name, + before_byte, + boundaries, + text, + lang, + string_kinds, + ) + }); + result +} + +/// Immutable-string `+=` / `x = x + ...` accumulation inside a loop: each +/// append rebuilds the whole string, so an N-iteration loop does O(N^2) +/// work. Detection is structural: query for the augmented-assignment (or +/// self-referential `+`) shape directly, then walk *up* the parent chain +/// from the matched node to check for an enclosing loop — the inverse +/// direction from `while-true-no-break`/`empty-effect-loop`'s walk down +/// from a loop to its body, per `docs/PLAN.adoc` Appendix D. +/// +/// String-typed-RHS narrowing (so `count += 1` never matches): the +/// accumulation counts as string-typed if *either* the appended value's own +/// subtree contains a string literal (`s += "x"`), *or* the target was +/// itself declared with a string initialiser earlier in its enclosing scope +/// (`s = ""` ... `s += x`) — the latter is what makes the common +/// "append each loop item onto an accumulator string" shape detectable even +/// though the appended item itself is a plain identifier, not a literal. +pub struct StringConcatInLoop { + queries: HashMap, +} + +impl StringConcatInLoop { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("string-concat-in-loop query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for StringConcatInLoop { + fn default() -> Self { + Self::new() + } +} + +impl Rule for StringConcatInLoop { + fn id(&self) -> &'static str { + "string-concat-in-loop" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let loops = loop_kinds(ctx.lang); + let boundaries = boundary_kinds(ctx.lang); + let string_kinds = string_literal_kinds(ctx.lang); + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut assign = None; + let mut target = None; + let mut addend = None; + let mut addend_base = None; + let mut binop = None; + + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "assign" => assign = Some(cap.node), + "target" => target = Some(cap.node), + "addend" => addend = Some(cap.node), + "addend_base" => addend_base = Some(cap.node), + "binop" => binop = Some(cap.node), + _ => {} + } + } + let (Some(assign), Some(target), Some(addend)) = (assign, target, addend) else { + continue; + }; + + // The `binop`/`addend_base` captures are only present for the + // self-referential `x = x + ...` shape; when present, the + // operator must be `+` and the base must be the same identifier + // as the assignment target, or this isn't accumulation. + if let Some(binop) = binop { + let is_plus = binop + .child_by_field_name("operator") + .is_some_and(|op| op.kind() == "+"); + let same_target = addend_base.is_some_and(|base| { + base.utf8_text(ctx.text.as_bytes()) == target.utf8_text(ctx.text.as_bytes()) + }); + if !is_plus || !same_target { + continue; + } + } else { + let is_augmented_plus = assign + .child_by_field_name("operator") + .is_some_and(|op| op.kind() == "+="); + if !is_augmented_plus { + continue; + } + } + + let target_name = target.utf8_text(ctx.text.as_bytes()).unwrap_or_default(); + let scope = enclosing_scope(assign, boundaries, ctx.tree.root_node()); + let looks_stringy = subtree_has_string_literal(addend, string_kinds) + || scope_has_earlier_string_declaration( + scope, + target_name, + assign.start_byte(), + boundaries, + ctx.text.as_bytes(), + ctx.lang, + string_kinds, + ); + if !looks_stringy { + continue; + } + + if !enclosed_by_loop(assign, loops, boundaries) { + continue; + } + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &assign), + "string-concat-in-loop", + "string accumulation inside a loop rebuilds the whole string each iteration \ + (quadratic)" + .to_string(), + Some("tiny bounded loops; languages with mutable strings / ropes".to_string()), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = StringConcatInLoop::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_augmented_string_concat_in_for_loop() { + let findings = findings_for( + Lang::Python, + "def f(items):\n s = ''\n for x in items:\n s += 'a'\n return s\n", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_python_self_referential_concat_in_while_loop() { + let findings = findings_for( + Lang::Python, + "def f(y):\n s = ''\n while y:\n s = s + 'a'\n return s\n", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_numeric_accumulation() { + let findings = findings_for( + Lang::Python, + "def f(items):\n n = 0\n for x in items:\n n += 1\n return n\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_python_concat_outside_loop() { + let findings = findings_for( + Lang::Python, + "def f():\n s = ''\n s += 'a'\n return s\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_numeric_target_with_identifier_addend() { + let findings = findings_for( + Lang::Python, + "def f(deltas):\n n = 0\n for d in deltas:\n n += d\n return n\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_across_a_nested_function_boundary() { + let findings = findings_for( + Lang::Python, + "def f(items):\n for x in items:\n def g():\n t = ''\n t += 'z'\n return t\n g()\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_augmented_string_concat_in_for_of_loop() { + let findings = findings_for( + Lang::JavaScript, + "function f(items) { let s = ''; for (const x of items) { s += 'a'; } return s; }", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_python_augmented_concat_of_identifier_onto_string_typed_target() { + let findings = findings_for( + Lang::Python, + "def f(items):\n s = ''\n for x in items:\n s += x\n return s\n", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_numeric_accumulation() { + let findings = findings_for( + Lang::JavaScript, + "function f(items) { let n = 0; for (const x of items) { n += 1; } return n; }", + ); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_typescript_self_referential_concat_in_while_loop() { + let findings = findings_for( + Lang::TypeScript, + "function f(y: boolean): string { let s = ''; while (y) { s = s + 'a'; } return s; }", + ); + assert_eq!(findings.len(), 1); + } +} diff --git a/crates/pons-rules/src/t0/swallowed_error.rs b/crates/pons-rules/src/t0/swallowed_error.rs new file mode 100644 index 0000000..6b42e1a --- /dev/null +++ b/crates/pons-rules/src/t0/swallowed_error.rs @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[Lang::Python, Lang::JavaScript, Lang::TypeScript, Lang::Tsx]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/swallowed-error/python.scm"), + Lang::JavaScript => include_str!("../../rules/swallowed-error/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/swallowed-error/typescript.scm"), + Lang::Tsx => include_str!("../../rules/swallowed-error/tsx.scm"), + Lang::Rust => unreachable!("swallowed-error does not run on Rust — no exceptions"), + } +} + +/// A `catch` / `except` whose body is empty (or, in Python, only `pass`). +/// Rust has no exceptions and is excluded entirely. +pub struct SwallowedError { + queries: HashMap, +} + +impl SwallowedError { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("swallowed-error query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for SwallowedError { + fn default() -> Self { + Self::new() + } +} + +impl Rule for SwallowedError { + fn id(&self) -> &'static str { + "swallowed-error" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut body = None; + let mut clause = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "body" => body = Some(cap.node), + "clause" => clause = Some(cap.node), + _ => {} + } + } + let (Some(body), Some(clause)) = (body, clause) else { + continue; + }; + + if is_swallowed(&body, ctx.lang) { + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &clause), + "empty or pass-only exception handler", + "the caught exception is discarded with no handling and no note", + Some( + "a documented, intentional swallow (a comment, or a re-raise \ + elsewhere)" + .to_string(), + ), + )); + } + } + + findings + } +} + +/// True if `body` (the handler's block/statement_block) has no effective +/// content — ignoring a Python `pass` (the only way to write an empty +/// Python block) — and carries no comment documenting the swallow as +/// intentional. +fn is_swallowed(body: &tree_sitter::Node, lang: Lang) -> bool { + let mut has_comment = false; + let mut statements = 0usize; + let mut only_pass = true; + + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if !child.is_named() { + continue; + } + if child.kind() == "comment" { + has_comment = true; + continue; + } + statements += 1; + if child.kind() != "pass_statement" { + only_pass = false; + } + } + + if has_comment { + return false; + } + + match lang { + Lang::Python => statements == 1 && only_pass, + _ => statements == 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = SwallowedError::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_except_pass() { + let findings = findings_for( + Lang::Python, + "try:\n risky()\nexcept Exception:\n pass\n", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_except_pass_with_comment() { + let findings = findings_for( + Lang::Python, + "try:\n risky()\nexcept Exception:\n pass # intentional\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_empty_catch() { + let findings = findings_for(Lang::JavaScript, "try { risky(); } catch (e) {}\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_catch_with_comment() { + let findings = findings_for( + Lang::JavaScript, + "try { risky(); } catch (e) {\n // intentional\n}\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_js_catch_that_handles_the_error() { + let findings = findings_for(Lang::JavaScript, "try { risky(); } catch (e) { log(e); }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/src/t0/unreachable_after_jump.rs b/crates/pons-rules/src/t0/unreachable_after_jump.rs new file mode 100644 index 0000000..ceddc9f --- /dev/null +++ b/crates/pons-rules/src/t0/unreachable_after_jump.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/unreachable-after-jump/python.scm"), + Lang::JavaScript => include_str!("../../rules/unreachable-after-jump/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/unreachable-after-jump/typescript.scm"), + Lang::Tsx => include_str!("../../rules/unreachable-after-jump/tsx.scm"), + Lang::Rust => include_str!("../../rules/unreachable-after-jump/rust.scm"), + } +} + +fn jump_keyword(kind: &str) -> &'static str { + if kind.contains("return") { + "return" + } else if kind.contains("throw") { + "throw" + } else if kind.contains("raise") { + "raise" + } else if kind.contains("break") { + "break" + } else if kind.contains("continue") { + "continue" + } else { + "jump" + } +} + +/// Statements textually after `return`/`throw`/`raise`/`break`/`continue` in +/// the same block. The `.` anchor between captures in each `.scm` pattern +/// asserts the following statement is the *immediate* next named sibling — +/// tree-sitter proves adjacency directly, no manual sibling-walk needed. +pub struct UnreachableAfterJump { + queries: HashMap, +} + +impl UnreachableAfterJump { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("unreachable-after-jump query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for UnreachableAfterJump { + fn default() -> Self { + Self::new() + } +} + +impl Rule for UnreachableAfterJump { + fn id(&self) -> &'static str { + "unreachable-after-jump" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut jump = None; + let mut next = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "jump" => jump = Some(cap.node), + "next" => next = Some(cap.node), + _ => {} + } + } + let (Some(jump), Some(next)) = (jump, next) else { + continue; + }; + + let keyword = jump_keyword(jump.kind()); + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &next), + "unreachable-after-jump", + format!("unreachable code — this can never run after `{keyword}`"), + Some("a label or fallthrough construct the walker doesn't see".to_string()), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = UnreachableAfterJump::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_code_after_return() { + let findings = findings_for(Lang::Python, "def f():\n return 1\n do_thing()\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_return_at_block_end() { + let findings = findings_for(Lang::Python, "def f():\n do_thing()\n return 1\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_js_code_after_break() { + let findings = findings_for(Lang::JavaScript, "for (;;) { break; doThing(); }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_code_in_separate_block() { + let findings = findings_for( + Lang::JavaScript, + "function f(x) { if (x) { return 1; } doThing(); }\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_rust_code_after_return() { + let findings = findings_for(Lang::Rust, "fn f() -> i32 { return 1; do_thing(); }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_rust_return_as_tail_expression() { + let findings = findings_for(Lang::Rust, "fn f() -> i32 { do_thing(); return 1 }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/src/t0/while_true_no_break.rs b/crates/pons-rules/src/t0/while_true_no_break.rs new file mode 100644 index 0000000..5d4854b --- /dev/null +++ b/crates/pons-rules/src/t0/while_true_no_break.rs @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::finding::{Location, RawFinding, Severity, Tier}; +use pons_core::lang::Lang; +use tree_sitter::{Node, Query, QueryCursor, StreamingIterator}; + +const LANGUAGES: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Tsx, + Lang::Rust, +]; + +fn query_source(lang: Lang) -> &'static str { + match lang { + Lang::Python => include_str!("../../rules/while-true-no-break/python.scm"), + Lang::JavaScript => include_str!("../../rules/while-true-no-break/javascript.scm"), + Lang::TypeScript => include_str!("../../rules/while-true-no-break/typescript.scm"), + Lang::Tsx => include_str!("../../rules/while-true-no-break/tsx.scm"), + Lang::Rust => include_str!("../../rules/while-true-no-break/rust.scm"), + } +} + +/// Node kinds that count as an exit from the loop: `break`, `return`, and the +/// language's raise/throw equivalent. Found anywhere in the body (even inside +/// a nested `for`/inner loop whose own `break` wouldn't really reach the +/// outer loop) is deliberately treated as "has an exit" — this rule would +/// rather under-flag a loop that turns out to still be infinite than +/// false-positive on one that isn't; see the module doc comment. +fn exit_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["break_statement", "return_statement", "raise_statement"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => { + &["break_statement", "return_statement", "throw_statement"] + } + Lang::Rust => &["break_expression", "return_expression"], + } +} + +/// Node kinds that introduce a new function scope — the walker stops +/// descending here, since a `break`/`return`/`throw` inside a nested +/// function or closure exits *that* function, not the loop being checked. +fn boundary_kinds(lang: Lang) -> &'static [&'static str] { + match lang { + Lang::Python => &["function_definition", "lambda"], + Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &[ + "function_declaration", + "function_expression", + "arrow_function", + "method_definition", + "generator_function", + "generator_function_declaration", + ], + Lang::Rust => &["closure_expression", "function_item"], + } +} + +fn body_has_exit(node: Node, exits: &[&str], boundaries: &[&str]) -> bool { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + let kind = child.kind(); + if exits.contains(&kind) { + return true; + } + if boundaries.contains(&kind) { + continue; + } + if body_has_exit(child, exits, boundaries) { + return true; + } + } + false +} + +/// `while (true)` / `loop` with no reachable `break`/`return`/`throw` inside +/// it — a plausible infinite loop. `while (false)`/`if (true|false)` are +/// `constant-condition`'s territory (rule 4), not this rule's. +pub struct WhileTrueNoBreak { + queries: HashMap, +} + +impl WhileTrueNoBreak { + pub fn new() -> Self { + let queries = LANGUAGES + .iter() + .map(|&lang| { + let query = Query::new(&lang.tree_sitter_language(), query_source(lang)) + .unwrap_or_else(|e| { + panic!("while-true-no-break query for {lang:?} failed to compile: {e}") + }); + (lang, query) + }) + .collect(); + Self { queries } + } +} + +impl Default for WhileTrueNoBreak { + fn default() -> Self { + Self::new() + } +} + +impl Rule for WhileTrueNoBreak { + fn id(&self) -> &'static str { + "while-true-no-break" + } + + fn languages(&self) -> &'static [Lang] { + LANGUAGES + } + + fn check(&self, ctx: &RuleCtx) -> Vec { + let Some(query) = self.queries.get(&ctx.lang) else { + return Vec::new(); + }; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, ctx.tree.root_node(), ctx.text.as_bytes()); + let mut findings = Vec::new(); + + while let Some(m) = matches.next() { + let mut cond = None; + let mut body = None; + let mut stmt = None; + for cap in m.captures() { + match query.capture_names()[cap.index as usize] { + "cond" => cond = Some(cap.node), + "body" => body = Some(cap.node), + "stmt" => stmt = Some(cap.node), + _ => {} + } + } + let (Some(body), Some(stmt)) = (body, stmt) else { + continue; + }; + + // Rust's `while_expression` pattern matches both `while true` and + // `while false` (one `boolean_literal` kind covers both) — + // `while false` is `constant-condition`'s territory, not this + // rule's. + if let Some(cond) = cond { + let cond_text = cond.utf8_text(ctx.text.as_bytes()).unwrap_or_default(); + if cond_text != "true" { + continue; + } + } + + if body_has_exit(body, exit_kinds(ctx.lang), boundary_kinds(ctx.lang)) { + continue; + } + + findings.push(RawFinding::new( + Tier::T0, + Severity::Warn, + Location::from_node(ctx.path.display().to_string(), &stmt), + "while-true-no-break", + "infinite loop with no reachable break/return/throw".to_string(), + Some( + "an intentional daemon/event loop that exits via an external \ + signal rather than a break" + .to_string(), + ), + )); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pons_core::parse; + use pons_core::source::SourceFile; + use std::path::PathBuf; + + fn findings_for(lang: Lang, text: &str) -> Vec { + let rule = WhileTrueNoBreak::new(); + let source = SourceFile { + path: PathBuf::from("test"), + lang, + text: text.to_string(), + }; + let tree = parse::parse(&source).unwrap(); + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) + } + + #[test] + fn fires_on_python_while_true_no_break() { + let findings = findings_for(Lang::Python, "while True:\n do_thing()\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_python_while_true_with_break() { + let findings = findings_for( + Lang::Python, + "while True:\n if x:\n break\n do_thing()\n", + ); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_python_while_true_with_nested_def_break() { + let findings = findings_for( + Lang::Python, + "while True:\n def inner():\n break\n do_thing()\n", + ); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_js_while_true_no_break() { + let findings = findings_for(Lang::JavaScript, "while (true) { doThing(); }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_js_while_true_with_throw() { + let findings = findings_for(Lang::JavaScript, "while (true) { throw new Error(); }\n"); + assert!(findings.is_empty()); + } + + #[test] + fn fires_on_rust_loop_no_break() { + let findings = findings_for(Lang::Rust, "fn f() { loop { do_thing(); } }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn fires_on_rust_while_true_no_break() { + let findings = findings_for(Lang::Rust, "fn f() { while true { do_thing(); } }\n"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn does_not_fire_on_rust_loop_with_break() { + let findings = findings_for(Lang::Rust, "fn f() { loop { if x { break; } } }\n"); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_fire_on_rust_while_false() { + let findings = findings_for(Lang::Rust, "fn f() { while false { do_thing(); } }\n"); + assert!(findings.is_empty()); + } +} diff --git a/crates/pons-rules/tests/falsifier.rs b/crates/pons-rules/tests/falsifier.rs new file mode 100644 index 0000000..2dec9cc --- /dev/null +++ b/crates/pons-rules/tests/falsifier.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! The M2 CI gate: every registered rule must be falsifiable against its own +//! fixture corpus, per `docs/PLAN.adoc` Appendix C. `just falsify` runs this. + +use std::fs; +use std::path::{Path, PathBuf}; + +use pons_core::engine::{Rule, RuleCtx}; +use pons_core::lang::Lang; +use pons_core::parse; +use pons_core::source::SourceFile; +use pons_rules::registry::RuleRegistry; + +fn fixtures_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures") +} + +/// A fixture file resolved to its language and parsed tree, checked for +/// tree-sitter error recovery — a broken fixture must fail loudly rather +/// than vacuously pass by firing nothing. +struct Fixture { + path: PathBuf, + lang: Lang, +} + +fn load_fixtures(dir: &Path, rule_id: &str, side: &str) -> Vec { + let entries = fs::read_dir(dir) + .unwrap_or_else(|e| panic!("{rule_id}: cannot read {side} fixture dir {dir:?}: {e}")); + + let mut fixtures = Vec::new(); + for entry in entries { + let entry = entry.unwrap(); + if !entry.file_type().unwrap().is_file() { + continue; + } + let path = entry.path(); + let lang = path + .extension() + .and_then(|ext| ext.to_str()) + .and_then(Lang::from_extension) + .unwrap_or_else(|| { + panic!( + "{rule_id}: {side} fixture {path:?} doesn't resolve to a known \ + language — rename or remove it" + ) + }); + fixtures.push(Fixture { path, lang }); + } + fixtures +} + +fn check_fixture(rule: &dyn Rule, fixture: &Fixture) -> Vec { + let text = fs::read_to_string(&fixture.path).unwrap(); + let source = SourceFile { + path: fixture.path.clone(), + lang: fixture.lang, + text, + }; + let tree = parse::parse(&source).unwrap(); + assert!( + !tree.root_node().has_error(), + "{}: fixture {:?} has a tree-sitter parse error — fix the fixture, \ + it cannot exercise the rule while broken", + rule.id(), + fixture.path, + ); + + let ctx = RuleCtx { + path: &source.path, + lang: source.lang, + text: &source.text, + tree: &tree, + }; + rule.check(&ctx) +} + +#[test] +fn every_rule_is_proven_by_its_own_fixture_corpus() { + for rule in RuleRegistry::all() { + let rule_id = rule.id(); + let rule_dir = fixtures_root().join(rule_id); + assert!( + rule_dir.is_dir(), + "{rule_id}: no fixture directory at {rule_dir:?}" + ); + + let positive = load_fixtures(&rule_dir.join("positive"), rule_id, "positive"); + let negative = load_fixtures(&rule_dir.join("negative"), rule_id, "negative"); + + assert!( + !negative.is_empty(), + "{rule_id}: empty or missing negative corpus — a rule with no \ + negative fixtures cannot be falsified" + ); + assert!( + !positive.is_empty(), + "{rule_id}: empty or missing positive corpus" + ); + + for fixture in &positive { + let findings = check_fixture(rule.as_ref(), fixture); + assert!( + !findings.is_empty(), + "{rule_id}: positive fixture {:?} produced no findings", + fixture.path + ); + } + + for fixture in &negative { + let findings = check_fixture(rule.as_ref(), fixture); + assert!( + findings.is_empty(), + "{rule_id}: negative fixture {:?} produced {} finding(s), expected 0", + fixture.path, + findings.len() + ); + } + + for &lang in rule.languages() { + assert!( + positive.iter().any(|f| f.lang == lang), + "{rule_id}: declares {lang:?} in languages() but has no positive \ + fixture in that language" + ); + assert!( + negative.iter().any(|f| f.lang == lang), + "{rule_id}: declares {lang:?} in languages() but has no negative \ + fixture in that language" + ); + } + } +} diff --git a/docs/PLAN.adoc b/docs/PLAN.adoc index 996f147..2c4340c 100644 --- a/docs/PLAN.adoc +++ b/docs/PLAN.adoc @@ -301,18 +301,29 @@ truth thereafter. [cols="2,2,2", options="header"] |=== | crate | purpose | pin (fill at M0) -| `tree-sitter` | core parsing/query API | `=` -| `tree-sitter-python` | Python grammar (T0 + T1 + T2) | `=` -| `tree-sitter-javascript` | JS grammar (T0) | `=` -| `tree-sitter-typescript` | TS + TSX grammars (T0) | `=` -| `tree-sitter-rust` | Rust grammar (T0) | `=` -| `serde` / `serde_json` | JSON + SARIF serialisation | `=` -| `toml` | protocol + `pons.toml` parsing | `=` -| `clap` (derive) | CLI | `=` -| `anyhow` | error handling (house style) | `=` -| `ignore` / `walkdir` | file discovery + `.ponsignore` | `=` +| `tree-sitter` | core parsing/query API | `=0.27.0` +| `tree-sitter-python` | Python grammar (T0 + T1 + T2) | `=0.25.0` +| `tree-sitter-javascript` | JS grammar (T0) | `=0.25.0` +| `tree-sitter-typescript` | TS + TSX grammars (T0) | `=0.23.2` +| `tree-sitter-rust` | Rust grammar (T0) | `=0.24.2` +| `serde` / `serde_json` | JSON + SARIF serialisation | `=1.0.229` / `=1.0.151` (M1+) +| `toml` | protocol + `pons.toml` parsing | not yet added (M5 scope) +| `clap` (derive) | CLI | `=4.6.6` (added, unused until M1's real CLI surface) +| `anyhow` | error handling (house style) | `=1.0.104` +| `ignore` / `walkdir` | file discovery + `.ponsignore` | `=0.4.33` (ignore, direct); `walkdir` `2.5.0` pulled in transitively, not a direct dependency |=== +Resolved 2026-09-14 via `cargo add tree-sitter tree-sitter-python +tree-sitter-javascript tree-sitter-typescript tree-sitter-rust -p pons-core` +against the real workspace; confirmed no duplicate `tree-sitter` core-version +pulls via `cargo tree -d` (only unrelated `serde_core` duplication, harmless). +`serde`, `clap`, `anyhow` pins above are the versions resolved when those +crates were added to the workspace/`pons-core`/`pons-cli` manifests at M0; +`ignore` was added at M1 for `source.rs`'s recursive file discovery (pulls in +`walkdir` transitively — no direct `walkdir` dependency needed). `toml` +remains unresolved until M5, per the foundations-before-scaffolding build +discipline — do not add a dependency before the code that uses it. + Extension → `Lang` table (source.rs / lang.rs): `.py`→Python; `.js`/`.mjs`/ `.cjs`→JavaScript; `.ts`→TypeScript; `.tsx`→TSX; `.jsx`→JavaScript (or TSX — decide at M1, document); `.rs`→Rust. Unknown extensions are skipped silently diff --git a/docs/adr/0005-cli-exit-code-contract.adoc b/docs/adr/0005-cli-exit-code-contract.adoc new file mode 100644 index 0000000..8aaf999 --- /dev/null +++ b/docs/adr/0005-cli-exit-code-contract.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += ADR-0005: CLI exit-code contract +:status: ACCEPTED +:date: 2026-09-14 + +*Status:* ACCEPTED. Implemented in `pons-cli/src/main.rs` (M1); this ADR +records the decision after the fact. + +== Context + +`pons scan ` needed an exit-code contract before M1 could be called +done, and none of the source docs (kickoff, PLAN.adoc, OWNER-DECISIONS.adoc) +specify one. The obvious default for a lint-shaped tool — exit non-zero +whenever findings exist — collides with two facts specific to pons: + +* Suppression (M7) does not exist yet. A tool that fails CI on every + legitimate, intentional pattern (e.g. `while-true-no-break` on a daemon + loop, `WARN` by design) with no way to silence the noise would break real + codebases the moment it is wired into CI, long before suppression ships. +* `SPECULATIVE`-tier findings are, by pons's own founding proposition (ADR- + 0001), heuristics rather than verdicts. Letting them fail a build would + contradict the one guarantee the engine exists to make. + +== Decision + +* `pons scan ` exits `0` by default, even when findings are reported. +* `--fail-on ` is opt-in. When set, the process exits `1` + if any finding meets or exceeds the given severity — but only among + findings whose `EvidenceClass` is not `Speculative`. +* Exit `2` is reserved for operational errors: a scan path that does not + exist, or `Engine::scan` returning `Err` (I/O or parse failure). Findings, + however many or however severe, never produce exit `2`. + +== Rationale + +This extends the "never dress a heuristic as a verdict" honesty rule (ADR- +0001) from the reporter's message text to the process's exit status: a +`SPECULATIVE` finding already prints demoted (`report/human.rs`'s fixed +suffix); it would be incoherent for that same finding to be able to fail a +build via `--fail-on` while its own output disclaims verdict status. + +Defaulting to exit `0` is the honest option given the current state of the +tool, not a permanent stance: once M7 suppression lands, projects gain a way +to silence accepted findings, and `--fail-on` becomes safe to default to a +non-`none` value in CI templates. That default flip is out of scope for this +ADR. + +== Consequences + +* A CI job wanting pons to gate merges must pass `--fail-on ` + explicitly — silence is not enforcement. +* Exit codes are two-valued in intent (`0`/`1` = tool ran, `2` = tool + couldn't run) plus the severity threshold as a modifier on the first — + scripts should treat `2` as "fix the invocation," not "findings exist." +* If a future rule tier introduces a fifth `EvidenceClass`, this contract's + `!= Speculative` check must be revisited — it currently assumes + `Speculative` is the only class ADR-0001's honesty rule needs to exempt. diff --git a/fixtures/constant-condition/README.adoc b/fixtures/constant-condition/README.adoc new file mode 100644 index 0000000..9ab4bb4 --- /dev/null +++ b/fixtures/constant-condition/README.adoc @@ -0,0 +1,7 @@ += Fixtures: constant-condition + +Positive fixtures: `if (true)`, `if (false)`, `while (false)` — a literal +boolean in the condition slot. + +Negative fixtures: a variable condition, and `while (true)` — deliberately +out of this rule's scope (that's `while-true-no-break`'s territory). diff --git a/fixtures/constant-condition/negative/basic.js b/fixtures/constant-condition/negative/basic.js new file mode 100644 index 0000000..8c900db --- /dev/null +++ b/fixtures/constant-condition/negative/basic.js @@ -0,0 +1,10 @@ +let x = 1; + +if (x) { + f(); +} + +while (true) { + x -= 1; + if (x === 0) break; +} diff --git a/fixtures/constant-condition/negative/basic.py b/fixtures/constant-condition/negative/basic.py new file mode 100644 index 0000000..595250e --- /dev/null +++ b/fixtures/constant-condition/negative/basic.py @@ -0,0 +1,9 @@ +x = 1 + +if x: + pass + +while True: + x -= 1 + if x == 0: + break diff --git a/fixtures/constant-condition/negative/basic.rs b/fixtures/constant-condition/negative/basic.rs new file mode 100644 index 0000000..675cd18 --- /dev/null +++ b/fixtures/constant-condition/negative/basic.rs @@ -0,0 +1,13 @@ +fn f(x: i32) -> i32 { + if x > 0 { + return x; + } + let mut n = x; + while true { + n -= 1; + if n == 0 { + break; + } + } + n +} diff --git a/fixtures/constant-condition/negative/basic.ts b/fixtures/constant-condition/negative/basic.ts new file mode 100644 index 0000000..a8b4dab --- /dev/null +++ b/fixtures/constant-condition/negative/basic.ts @@ -0,0 +1,10 @@ +let x: number = 1; + +if (x) { + f(); +} + +while (true) { + x -= 1; + if (x === 0) break; +} diff --git a/fixtures/constant-condition/negative/basic.tsx b/fixtures/constant-condition/negative/basic.tsx new file mode 100644 index 0000000..533e3ba --- /dev/null +++ b/fixtures/constant-condition/negative/basic.tsx @@ -0,0 +1,6 @@ +function Comp({ x }: { x: boolean }) { + if (x) { + f(); + } + return
; +} diff --git a/fixtures/constant-condition/positive/basic.js b/fixtures/constant-condition/positive/basic.js new file mode 100644 index 0000000..2250922 --- /dev/null +++ b/fixtures/constant-condition/positive/basic.js @@ -0,0 +1,7 @@ +if (true) { + f(); +} + +if (false) { + f(); +} diff --git a/fixtures/constant-condition/positive/basic.py b/fixtures/constant-condition/positive/basic.py new file mode 100644 index 0000000..3472b71 --- /dev/null +++ b/fixtures/constant-condition/positive/basic.py @@ -0,0 +1,5 @@ +if True: + pass + +while False: + pass diff --git a/fixtures/constant-condition/positive/basic.rs b/fixtures/constant-condition/positive/basic.rs new file mode 100644 index 0000000..72c0ed7 --- /dev/null +++ b/fixtures/constant-condition/positive/basic.rs @@ -0,0 +1,9 @@ +fn f(x: i32) -> i32 { + if true { + return x; + } + if false { + return x + 1; + } + x +} diff --git a/fixtures/constant-condition/positive/basic.ts b/fixtures/constant-condition/positive/basic.ts new file mode 100644 index 0000000..2250922 --- /dev/null +++ b/fixtures/constant-condition/positive/basic.ts @@ -0,0 +1,7 @@ +if (true) { + f(); +} + +if (false) { + f(); +} diff --git a/fixtures/constant-condition/positive/basic.tsx b/fixtures/constant-condition/positive/basic.tsx new file mode 100644 index 0000000..b5c1680 --- /dev/null +++ b/fixtures/constant-condition/positive/basic.tsx @@ -0,0 +1,6 @@ +function Comp() { + if (true) { + f(); + } + return
; +} diff --git a/fixtures/div-by-literal-zero/README.adoc b/fixtures/div-by-literal-zero/README.adoc new file mode 100644 index 0000000..326a17a --- /dev/null +++ b/fixtures/div-by-literal-zero/README.adoc @@ -0,0 +1,10 @@ += Fixtures: div-by-literal-zero + +Positive fixtures: `/` or `%` (or their augmented-assignment forms) against a +literal zero right-hand side, across every language the rule covers +(Python, JavaScript, TypeScript, Tsx, Rust). + +Negative fixtures: the same operators against a non-zero or non-literal +right-hand side, plus Python's `%` string-formatting operator +(`string_format.py`), which parses with the identical AST shape as modulo but +is not modulo at all. diff --git a/fixtures/div-by-literal-zero/negative/basic.js b/fixtures/div-by-literal-zero/negative/basic.js new file mode 100644 index 0000000..152411b --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/basic.js @@ -0,0 +1,3 @@ +function ratio(x, divisor) { + return x / divisor; +} diff --git a/fixtures/div-by-literal-zero/negative/basic.py b/fixtures/div-by-literal-zero/negative/basic.py new file mode 100644 index 0000000..715d021 --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/basic.py @@ -0,0 +1,3 @@ +divisor = get_divisor() +x = 10 / divisor +y = "%d apples" % 0 diff --git a/fixtures/div-by-literal-zero/negative/basic.rs b/fixtures/div-by-literal-zero/negative/basic.rs new file mode 100644 index 0000000..31d626c --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/basic.rs @@ -0,0 +1,3 @@ +fn ratio(x: u32, divisor: u32) -> u32 { + x / divisor +} diff --git a/fixtures/div-by-literal-zero/negative/basic.ts b/fixtures/div-by-literal-zero/negative/basic.ts new file mode 100644 index 0000000..052814e --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/basic.ts @@ -0,0 +1,3 @@ +function ratio(x: number, divisor: number): number { + return x / divisor; +} diff --git a/fixtures/div-by-literal-zero/negative/basic.tsx b/fixtures/div-by-literal-zero/negative/basic.tsx new file mode 100644 index 0000000..7b86726 --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/basic.tsx @@ -0,0 +1,4 @@ +function Ratio({ x, divisor }: { x: number; divisor: number }) { + const r = x / divisor; + return {r}; +} diff --git a/fixtures/div-by-literal-zero/negative/string_format.py b/fixtures/div-by-literal-zero/negative/string_format.py new file mode 100644 index 0000000..1b4f120 --- /dev/null +++ b/fixtures/div-by-literal-zero/negative/string_format.py @@ -0,0 +1 @@ +label = "%d apples remain" % 0 diff --git a/fixtures/div-by-literal-zero/positive/basic.js b/fixtures/div-by-literal-zero/positive/basic.js new file mode 100644 index 0000000..5bf8f28 --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/basic.js @@ -0,0 +1,3 @@ +function ratio(x) { + return x / 0; +} diff --git a/fixtures/div-by-literal-zero/positive/basic.py b/fixtures/div-by-literal-zero/positive/basic.py new file mode 100644 index 0000000..13951fc --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/basic.py @@ -0,0 +1,2 @@ +x = 1 +y = x / 0 diff --git a/fixtures/div-by-literal-zero/positive/basic.rs b/fixtures/div-by-literal-zero/positive/basic.rs new file mode 100644 index 0000000..a2ab9f8 --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/basic.rs @@ -0,0 +1,3 @@ +fn ratio(x: u32) -> u32 { + x / 0u32 +} diff --git a/fixtures/div-by-literal-zero/positive/basic.ts b/fixtures/div-by-literal-zero/positive/basic.ts new file mode 100644 index 0000000..1f37830 --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/basic.ts @@ -0,0 +1,3 @@ +function ratio(x: number): number { + return x / 0; +} diff --git a/fixtures/div-by-literal-zero/positive/basic.tsx b/fixtures/div-by-literal-zero/positive/basic.tsx new file mode 100644 index 0000000..ba0fe79 --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/basic.tsx @@ -0,0 +1,4 @@ +function Ratio({ x }: { x: number }) { + const r = x / 0; + return {r}; +} diff --git a/fixtures/div-by-literal-zero/positive/modulo.py b/fixtures/div-by-literal-zero/positive/modulo.py new file mode 100644 index 0000000..0102ffd --- /dev/null +++ b/fixtures/div-by-literal-zero/positive/modulo.py @@ -0,0 +1,2 @@ +count = 10 +remainder = count % 0 diff --git a/fixtures/empty-effect-loop/README.adoc b/fixtures/empty-effect-loop/README.adoc new file mode 100644 index 0000000..719cc77 --- /dev/null +++ b/fixtures/empty-effect-loop/README.adoc @@ -0,0 +1,11 @@ += Fixtures: empty-effect-loop + +Positive fixtures: a loop (`while`/`for`/`loop`) whose body is empty or +contains only no-ops (Python `pass`; JS/TS/Tsx an empty statement; Rust has +no bare no-op statement, so only a literally empty block counts there). + +Negative fixtures: the same loop shapes with a real statement in the body. + +Deliberately not tested here (documented, not a bug): `loop {}` / +`while (true) {}` also fires `while-true-no-break` — an empty infinite loop +is both "no break" and "no effect" at once. diff --git a/fixtures/empty-effect-loop/negative/basic.js b/fixtures/empty-effect-loop/negative/basic.js new file mode 100644 index 0000000..c93efcd --- /dev/null +++ b/fixtures/empty-effect-loop/negative/basic.js @@ -0,0 +1,11 @@ +function f(x) { + while (x) { + doThing(); + } +} + +function g(y) { + for (const x of y) { + doThing(x); + } +} diff --git a/fixtures/empty-effect-loop/negative/basic.py b/fixtures/empty-effect-loop/negative/basic.py new file mode 100644 index 0000000..7283316 --- /dev/null +++ b/fixtures/empty-effect-loop/negative/basic.py @@ -0,0 +1,8 @@ +def f(x): + while x: + do_thing() + + +def g(y): + for x in y: + do_thing(x) diff --git a/fixtures/empty-effect-loop/negative/basic.rs b/fixtures/empty-effect-loop/negative/basic.rs new file mode 100644 index 0000000..8be68f4 --- /dev/null +++ b/fixtures/empty-effect-loop/negative/basic.rs @@ -0,0 +1,13 @@ +fn f(x: bool) { + while x { + do_thing(); + } +} + +fn g(x: bool) { + loop { + if x { + break; + } + } +} diff --git a/fixtures/empty-effect-loop/negative/basic.ts b/fixtures/empty-effect-loop/negative/basic.ts new file mode 100644 index 0000000..4796d93 --- /dev/null +++ b/fixtures/empty-effect-loop/negative/basic.ts @@ -0,0 +1,5 @@ +function f(x: boolean): void { + while (x) { + doThing(); + } +} diff --git a/fixtures/empty-effect-loop/negative/basic.tsx b/fixtures/empty-effect-loop/negative/basic.tsx new file mode 100644 index 0000000..4796d93 --- /dev/null +++ b/fixtures/empty-effect-loop/negative/basic.tsx @@ -0,0 +1,5 @@ +function f(x: boolean): void { + while (x) { + doThing(); + } +} diff --git a/fixtures/empty-effect-loop/positive/basic.js b/fixtures/empty-effect-loop/positive/basic.js new file mode 100644 index 0000000..b2e5c94 --- /dev/null +++ b/fixtures/empty-effect-loop/positive/basic.js @@ -0,0 +1,7 @@ +function f(x) { + while (x) {} +} + +function g(y) { + for (const x of y) {} +} diff --git a/fixtures/empty-effect-loop/positive/basic.py b/fixtures/empty-effect-loop/positive/basic.py new file mode 100644 index 0000000..0371115 --- /dev/null +++ b/fixtures/empty-effect-loop/positive/basic.py @@ -0,0 +1,8 @@ +def f(): + while True: + pass + + +def g(y): + for x in y: + pass diff --git a/fixtures/empty-effect-loop/positive/basic.rs b/fixtures/empty-effect-loop/positive/basic.rs new file mode 100644 index 0000000..62ab613 --- /dev/null +++ b/fixtures/empty-effect-loop/positive/basic.rs @@ -0,0 +1,7 @@ +fn f(x: bool) { + while x {} +} + +fn g() { + loop {} +} diff --git a/fixtures/empty-effect-loop/positive/basic.ts b/fixtures/empty-effect-loop/positive/basic.ts new file mode 100644 index 0000000..ec5518f --- /dev/null +++ b/fixtures/empty-effect-loop/positive/basic.ts @@ -0,0 +1,3 @@ +function f(x: boolean): void { + while (x) {} +} diff --git a/fixtures/empty-effect-loop/positive/basic.tsx b/fixtures/empty-effect-loop/positive/basic.tsx new file mode 100644 index 0000000..ec5518f --- /dev/null +++ b/fixtures/empty-effect-loop/positive/basic.tsx @@ -0,0 +1,3 @@ +function f(x: boolean): void { + while (x) {} +} diff --git a/fixtures/self-assignment/README.adoc b/fixtures/self-assignment/README.adoc new file mode 100644 index 0000000..695ed0c --- /dev/null +++ b/fixtures/self-assignment/README.adoc @@ -0,0 +1,9 @@ += Fixtures: self-assignment + +Positive fixtures: a bare identifier assigned to itself (`x = x`). + +Negative fixtures: an assignment between two different identifiers, a +property/attribute self-assignment (`obj.p = obj.p` — has a different node +shape and may carry setter side effects), and, in Rust, `let x = x;` — a +*shadow* (`let_declaration`), not an `assignment_expression`, so it is a +structurally different node the query never matches. diff --git a/fixtures/self-assignment/negative/basic.js b/fixtures/self-assignment/negative/basic.js new file mode 100644 index 0000000..617ff95 --- /dev/null +++ b/fixtures/self-assignment/negative/basic.js @@ -0,0 +1,4 @@ +let x = 1; +let y = x; + +obj.p = obj.p; diff --git a/fixtures/self-assignment/negative/basic.py b/fixtures/self-assignment/negative/basic.py new file mode 100644 index 0000000..40565ba --- /dev/null +++ b/fixtures/self-assignment/negative/basic.py @@ -0,0 +1,4 @@ +x = 1 +y = x + +obj.p = obj.p diff --git a/fixtures/self-assignment/negative/basic.rs b/fixtures/self-assignment/negative/basic.rs new file mode 100644 index 0000000..984f501 --- /dev/null +++ b/fixtures/self-assignment/negative/basic.rs @@ -0,0 +1,8 @@ +fn f(mut x: i32, y: i32) { + x = y; +} + +fn g() { + let x = 1; + let x = x; +} diff --git a/fixtures/self-assignment/negative/basic.ts b/fixtures/self-assignment/negative/basic.ts new file mode 100644 index 0000000..b068ad8 --- /dev/null +++ b/fixtures/self-assignment/negative/basic.ts @@ -0,0 +1,4 @@ +let x: number = 1; +let y: number = x; + +obj.p = obj.p; diff --git a/fixtures/self-assignment/negative/basic.tsx b/fixtures/self-assignment/negative/basic.tsx new file mode 100644 index 0000000..eae5037 --- /dev/null +++ b/fixtures/self-assignment/negative/basic.tsx @@ -0,0 +1,4 @@ +function Comp({ x }: { x: number }) { + let y = x; + return
{y}
; +} diff --git a/fixtures/self-assignment/positive/basic.js b/fixtures/self-assignment/positive/basic.js new file mode 100644 index 0000000..90d31d8 --- /dev/null +++ b/fixtures/self-assignment/positive/basic.js @@ -0,0 +1,2 @@ +let x = 1; +x = x; diff --git a/fixtures/self-assignment/positive/basic.py b/fixtures/self-assignment/positive/basic.py new file mode 100644 index 0000000..4450a97 --- /dev/null +++ b/fixtures/self-assignment/positive/basic.py @@ -0,0 +1,2 @@ +x = 1 +x = x diff --git a/fixtures/self-assignment/positive/basic.rs b/fixtures/self-assignment/positive/basic.rs new file mode 100644 index 0000000..81b926d --- /dev/null +++ b/fixtures/self-assignment/positive/basic.rs @@ -0,0 +1,3 @@ +fn f(mut x: i32) { + x = x; +} diff --git a/fixtures/self-assignment/positive/basic.ts b/fixtures/self-assignment/positive/basic.ts new file mode 100644 index 0000000..a79b31d --- /dev/null +++ b/fixtures/self-assignment/positive/basic.ts @@ -0,0 +1,2 @@ +let x: number = 1; +x = x; diff --git a/fixtures/self-assignment/positive/basic.tsx b/fixtures/self-assignment/positive/basic.tsx new file mode 100644 index 0000000..37617b0 --- /dev/null +++ b/fixtures/self-assignment/positive/basic.tsx @@ -0,0 +1,5 @@ +function Comp({ x }: { x: number }) { + let y = x; + y = y; + return
{y}
; +} diff --git a/fixtures/string-concat-in-loop/README.adoc b/fixtures/string-concat-in-loop/README.adoc new file mode 100644 index 0000000..5b2273f --- /dev/null +++ b/fixtures/string-concat-in-loop/README.adoc @@ -0,0 +1,22 @@ += Fixtures: string-concat-in-loop + +Positive fixtures: `+=` or self-referential `x = x + ...` string accumulation +inside a `for`/`while`/`do` loop — each append rebuilds the whole string, so +an N-iteration loop does O(N^2) work. + +Negative fixtures cover three cases the string-typed-RHS narrowing and the +loop-ancestor walk must both get right: + +- Purely numeric accumulation (`n += x`) — never flagged, regardless of + loop nesting, since the RHS subtree has no string-literal node. +- Accumulation outside any loop — a single `+=` before or after the loop + body is not quadratic. +- Accumulation inside a function/closure defined *inside* the loop — the + assignment only runs once per call to that inner function, not once per + outer-loop iteration, so the parent-walk stops at the function boundary + before crediting the enclosing loop (see the rule's `boundary_kinds` doc + comment). + +No Rust fixtures: `String += &str` is amortised O(1) in Rust (it grows the +buffer geometrically), so the pattern this rule targets does not exist there +— `string-concat-in-loop` is the one T0 rule with no Rust variant. diff --git a/fixtures/string-concat-in-loop/negative/basic.js b/fixtures/string-concat-in-loop/negative/basic.js new file mode 100644 index 0000000..74b77a8 --- /dev/null +++ b/fixtures/string-concat-in-loop/negative/basic.js @@ -0,0 +1,27 @@ +function sumNumbers(items) { + let n = 0; + for (const x of items) { + n += x; + } + return n; +} + +function buildOnce(items) { + let s = ""; + s += "prefix"; + for (const x of items) { + doThing(x); + } + return s; +} + +function buildInNestedFunction(items) { + for (const x of items) { + const render = () => { + let t = ""; + t += "z"; + return t; + }; + render(); + } +} diff --git a/fixtures/string-concat-in-loop/negative/basic.py b/fixtures/string-concat-in-loop/negative/basic.py new file mode 100644 index 0000000..6014120 --- /dev/null +++ b/fixtures/string-concat-in-loop/negative/basic.py @@ -0,0 +1,23 @@ +def sum_numbers(items): + n = 0 + for x in items: + n += x + return n + + +def build_once(items): + s = "" + s += "prefix" + for x in items: + do_thing(x) + return s + + +def build_in_nested_function(items): + for x in items: + def render(): + t = "" + t += "z" + return t + + render() diff --git a/fixtures/string-concat-in-loop/negative/basic.ts b/fixtures/string-concat-in-loop/negative/basic.ts new file mode 100644 index 0000000..64696ad --- /dev/null +++ b/fixtures/string-concat-in-loop/negative/basic.ts @@ -0,0 +1,27 @@ +function sumNumbers(items: number[]): number { + let n = 0; + for (const x of items) { + n += x; + } + return n; +} + +function buildOnce(items: string[]): string { + let s = ""; + s += "prefix"; + for (const x of items) { + doThing(x); + } + return s; +} + +function buildInNestedFunction(items: string[]): void { + for (const x of items) { + const render = (): string => { + let t = ""; + t += "z"; + return t; + }; + render(); + } +} diff --git a/fixtures/string-concat-in-loop/negative/basic.tsx b/fixtures/string-concat-in-loop/negative/basic.tsx new file mode 100644 index 0000000..ad05055 --- /dev/null +++ b/fixtures/string-concat-in-loop/negative/basic.tsx @@ -0,0 +1,16 @@ +function sumNumbers(items: number[]): number { + let n = 0; + for (const x of items) { + n += x; + } + return n; +} + +function Label({ items }: { items: string[] }) { + let s = ""; + s += "prefix"; + for (const x of items) { + doThing(x); + } + return {s}; +} diff --git a/fixtures/string-concat-in-loop/positive/basic.js b/fixtures/string-concat-in-loop/positive/basic.js new file mode 100644 index 0000000..1e0a6e7 --- /dev/null +++ b/fixtures/string-concat-in-loop/positive/basic.js @@ -0,0 +1,17 @@ +function buildAugmented(items) { + let s = ""; + for (const x of items) { + s += x; + } + return s; +} + +function buildSelfReferential(items) { + let s = ""; + let i = 0; + while (i < items.length) { + s = s + items[i]; + i += 1; + } + return s; +} diff --git a/fixtures/string-concat-in-loop/positive/basic.py b/fixtures/string-concat-in-loop/positive/basic.py new file mode 100644 index 0000000..f71ca8d --- /dev/null +++ b/fixtures/string-concat-in-loop/positive/basic.py @@ -0,0 +1,12 @@ +def build_augmented(items): + s = "" + for x in items: + s += x + return s + + +def build_self_referential(items): + s = "" + while items: + s = s + items.pop() + return s diff --git a/fixtures/string-concat-in-loop/positive/basic.ts b/fixtures/string-concat-in-loop/positive/basic.ts new file mode 100644 index 0000000..084c1af --- /dev/null +++ b/fixtures/string-concat-in-loop/positive/basic.ts @@ -0,0 +1,17 @@ +function buildAugmented(items: string[]): string { + let s = ""; + for (const x of items) { + s += x; + } + return s; +} + +function buildSelfReferential(items: string[]): string { + let s = ""; + let i = 0; + while (i < items.length) { + s = s + items[i]; + i += 1; + } + return s; +} diff --git a/fixtures/string-concat-in-loop/positive/basic.tsx b/fixtures/string-concat-in-loop/positive/basic.tsx new file mode 100644 index 0000000..499f171 --- /dev/null +++ b/fixtures/string-concat-in-loop/positive/basic.tsx @@ -0,0 +1,17 @@ +function buildAugmented(items: string[]): string { + let s = ""; + for (const x of items) { + s += x; + } + return s; +} + +function Label({ items }: { items: string[] }) { + let s = ""; + let i = 0; + while (i < items.length) { + s = s + items[i]; + i += 1; + } + return {s}; +} diff --git a/fixtures/swallowed-error/README.adoc b/fixtures/swallowed-error/README.adoc new file mode 100644 index 0000000..8cd85ec --- /dev/null +++ b/fixtures/swallowed-error/README.adoc @@ -0,0 +1,8 @@ += Fixtures: swallowed-error + +Positive fixtures: a `catch` / `except` whose body is empty (JS/TS/Tsx) or +only `pass` (Python — the one way to write an empty Python block). + +Negative fixtures: the same shape but with either a comment documenting the +swallow as intentional, or a handler that actually does something with the +error (logging it). Rust is excluded entirely — it has no exceptions. diff --git a/fixtures/swallowed-error/negative/basic.js b/fixtures/swallowed-error/negative/basic.js new file mode 100644 index 0000000..335e039 --- /dev/null +++ b/fixtures/swallowed-error/negative/basic.js @@ -0,0 +1,15 @@ +function load() { + try { + risky(); + } catch (e) { + // intentional: best-effort load, caller checks the result + } +} + +function loadAndLog() { + try { + risky(); + } catch (e) { + console.error(e); + } +} diff --git a/fixtures/swallowed-error/negative/basic.py b/fixtures/swallowed-error/negative/basic.py new file mode 100644 index 0000000..8275dbf --- /dev/null +++ b/fixtures/swallowed-error/negative/basic.py @@ -0,0 +1,12 @@ +def load(): + try: + risky() + except Exception: + pass # intentional: best-effort load, caller checks the result + + +def load_and_log(): + try: + risky() + except Exception: + log.exception("risky() failed") diff --git a/fixtures/swallowed-error/negative/basic.ts b/fixtures/swallowed-error/negative/basic.ts new file mode 100644 index 0000000..7932207 --- /dev/null +++ b/fixtures/swallowed-error/negative/basic.ts @@ -0,0 +1,15 @@ +function load(): void { + try { + risky(); + } catch (e) { + // intentional: best-effort load, caller checks the result + } +} + +function loadAndLog(): void { + try { + risky(); + } catch (e) { + console.error(e); + } +} diff --git a/fixtures/swallowed-error/negative/basic.tsx b/fixtures/swallowed-error/negative/basic.tsx new file mode 100644 index 0000000..9065100 --- /dev/null +++ b/fixtures/swallowed-error/negative/basic.tsx @@ -0,0 +1,8 @@ +function Load({ risky }: { risky: () => void }) { + try { + risky(); + } catch (e) { + console.error(e); + } + return
; +} diff --git a/fixtures/swallowed-error/positive/basic.js b/fixtures/swallowed-error/positive/basic.js new file mode 100644 index 0000000..a94352f --- /dev/null +++ b/fixtures/swallowed-error/positive/basic.js @@ -0,0 +1,5 @@ +function load() { + try { + risky(); + } catch (e) {} +} diff --git a/fixtures/swallowed-error/positive/basic.py b/fixtures/swallowed-error/positive/basic.py new file mode 100644 index 0000000..3c27f28 --- /dev/null +++ b/fixtures/swallowed-error/positive/basic.py @@ -0,0 +1,5 @@ +def load(): + try: + risky() + except Exception: + pass diff --git a/fixtures/swallowed-error/positive/basic.ts b/fixtures/swallowed-error/positive/basic.ts new file mode 100644 index 0000000..9912cc2 --- /dev/null +++ b/fixtures/swallowed-error/positive/basic.ts @@ -0,0 +1,5 @@ +function load(): void { + try { + risky(); + } catch (e) {} +} diff --git a/fixtures/swallowed-error/positive/basic.tsx b/fixtures/swallowed-error/positive/basic.tsx new file mode 100644 index 0000000..74fd8ac --- /dev/null +++ b/fixtures/swallowed-error/positive/basic.tsx @@ -0,0 +1,6 @@ +function Load({ risky }: { risky: () => void }) { + try { + risky(); + } catch (e) {} + return
; +} diff --git a/fixtures/unreachable-after-jump/README.adoc b/fixtures/unreachable-after-jump/README.adoc new file mode 100644 index 0000000..9136a26 --- /dev/null +++ b/fixtures/unreachable-after-jump/README.adoc @@ -0,0 +1,8 @@ += Fixtures: unreachable-after-jump + +Positive fixtures: a statement immediately following `return`/`throw`/`raise`/ +`break`/`continue` in the same block — never reachable. + +Negative fixtures: the jump statement is the last statement in its block, or +a jump inside a nested block (`if`) followed by more code in the *outer* +block (not the same block as the jump, so not unreachable). diff --git a/fixtures/unreachable-after-jump/negative/basic.js b/fixtures/unreachable-after-jump/negative/basic.js new file mode 100644 index 0000000..a262718 --- /dev/null +++ b/fixtures/unreachable-after-jump/negative/basic.js @@ -0,0 +1,11 @@ +function f() { + doThing(); + return 1; +} + +function g(x) { + if (x) { + return 1; + } + doOtherThing(); +} diff --git a/fixtures/unreachable-after-jump/negative/basic.py b/fixtures/unreachable-after-jump/negative/basic.py new file mode 100644 index 0000000..b3ddf14 --- /dev/null +++ b/fixtures/unreachable-after-jump/negative/basic.py @@ -0,0 +1,9 @@ +def f(): + do_thing() + return 1 + + +def g(x): + if x: + return 1 + do_other_thing() diff --git a/fixtures/unreachable-after-jump/negative/basic.rs b/fixtures/unreachable-after-jump/negative/basic.rs new file mode 100644 index 0000000..26398c8 --- /dev/null +++ b/fixtures/unreachable-after-jump/negative/basic.rs @@ -0,0 +1,9 @@ +fn f() -> i32 { + do_thing(); + return 1; +} + +fn g() -> i32 { + do_thing(); + return 1 +} diff --git a/fixtures/unreachable-after-jump/negative/basic.ts b/fixtures/unreachable-after-jump/negative/basic.ts new file mode 100644 index 0000000..eebf024 --- /dev/null +++ b/fixtures/unreachable-after-jump/negative/basic.ts @@ -0,0 +1,4 @@ +function f(): number { + doThing(); + return 1; +} diff --git a/fixtures/unreachable-after-jump/negative/basic.tsx b/fixtures/unreachable-after-jump/negative/basic.tsx new file mode 100644 index 0000000..eebf024 --- /dev/null +++ b/fixtures/unreachable-after-jump/negative/basic.tsx @@ -0,0 +1,4 @@ +function f(): number { + doThing(); + return 1; +} diff --git a/fixtures/unreachable-after-jump/positive/basic.js b/fixtures/unreachable-after-jump/positive/basic.js new file mode 100644 index 0000000..374cb61 --- /dev/null +++ b/fixtures/unreachable-after-jump/positive/basic.js @@ -0,0 +1,11 @@ +function f() { + return 1; + doThing(); +} + +function g() { + for (;;) { + break; + doOtherThing(); + } +} diff --git a/fixtures/unreachable-after-jump/positive/basic.py b/fixtures/unreachable-after-jump/positive/basic.py new file mode 100644 index 0000000..e76bdd0 --- /dev/null +++ b/fixtures/unreachable-after-jump/positive/basic.py @@ -0,0 +1,9 @@ +def f(): + return 1 + do_thing() + + +def g(x): + for y in x: + break + do_other_thing() diff --git a/fixtures/unreachable-after-jump/positive/basic.rs b/fixtures/unreachable-after-jump/positive/basic.rs new file mode 100644 index 0000000..ac10f5c --- /dev/null +++ b/fixtures/unreachable-after-jump/positive/basic.rs @@ -0,0 +1,13 @@ +fn f() -> i32 { + return 1; + do_thing(); +} + +fn g(x: bool) { + loop { + if x { + break; + do_other_thing(); + } + } +} diff --git a/fixtures/unreachable-after-jump/positive/basic.ts b/fixtures/unreachable-after-jump/positive/basic.ts new file mode 100644 index 0000000..8e0936e --- /dev/null +++ b/fixtures/unreachable-after-jump/positive/basic.ts @@ -0,0 +1,4 @@ +function f(): number { + return 1; + doThing(); +} diff --git a/fixtures/unreachable-after-jump/positive/basic.tsx b/fixtures/unreachable-after-jump/positive/basic.tsx new file mode 100644 index 0000000..8e0936e --- /dev/null +++ b/fixtures/unreachable-after-jump/positive/basic.tsx @@ -0,0 +1,4 @@ +function f(): number { + return 1; + doThing(); +} diff --git a/fixtures/while-true-no-break/README.adoc b/fixtures/while-true-no-break/README.adoc new file mode 100644 index 0000000..5d7d0b9 --- /dev/null +++ b/fixtures/while-true-no-break/README.adoc @@ -0,0 +1,9 @@ += Fixtures: while-true-no-break + +Positive fixtures: `while (true)` / `loop` whose body has no reachable +`break`/`return`/`throw` (or `raise` in Python) anywhere in it — a plausible +infinite loop. + +Negative fixtures: the same loop shape with a `break`/`throw` present, and +(Rust only) `while false`/`loop` disambiguation via a `while false` case that +must stay silent here (that's `constant-condition`'s territory). diff --git a/fixtures/while-true-no-break/negative/basic.js b/fixtures/while-true-no-break/negative/basic.js new file mode 100644 index 0000000..fc912db --- /dev/null +++ b/fixtures/while-true-no-break/negative/basic.js @@ -0,0 +1,14 @@ +function f(x) { + while (true) { + if (x) { + break; + } + doThing(); + } +} + +function g() { + while (true) { + throw new Error("bail"); + } +} diff --git a/fixtures/while-true-no-break/negative/basic.py b/fixtures/while-true-no-break/negative/basic.py new file mode 100644 index 0000000..0624ea4 --- /dev/null +++ b/fixtures/while-true-no-break/negative/basic.py @@ -0,0 +1,10 @@ +def f(x): + while True: + if x: + break + do_thing() + + +def g(): + while False: + do_thing() diff --git a/fixtures/while-true-no-break/negative/basic.rs b/fixtures/while-true-no-break/negative/basic.rs new file mode 100644 index 0000000..17e7129 --- /dev/null +++ b/fixtures/while-true-no-break/negative/basic.rs @@ -0,0 +1,14 @@ +fn f(x: bool) { + loop { + if x { + break; + } + do_thing(); + } +} + +fn g() { + while false { + do_thing(); + } +} diff --git a/fixtures/while-true-no-break/negative/basic.ts b/fixtures/while-true-no-break/negative/basic.ts new file mode 100644 index 0000000..d72624d --- /dev/null +++ b/fixtures/while-true-no-break/negative/basic.ts @@ -0,0 +1,8 @@ +function f(x: boolean): void { + while (true) { + if (x) { + break; + } + doThing(); + } +} diff --git a/fixtures/while-true-no-break/negative/basic.tsx b/fixtures/while-true-no-break/negative/basic.tsx new file mode 100644 index 0000000..d72624d --- /dev/null +++ b/fixtures/while-true-no-break/negative/basic.tsx @@ -0,0 +1,8 @@ +function f(x: boolean): void { + while (true) { + if (x) { + break; + } + doThing(); + } +} diff --git a/fixtures/while-true-no-break/positive/basic.js b/fixtures/while-true-no-break/positive/basic.js new file mode 100644 index 0000000..c3109f7 --- /dev/null +++ b/fixtures/while-true-no-break/positive/basic.js @@ -0,0 +1,5 @@ +function f() { + while (true) { + doThing(); + } +} diff --git a/fixtures/while-true-no-break/positive/basic.py b/fixtures/while-true-no-break/positive/basic.py new file mode 100644 index 0000000..5fd0611 --- /dev/null +++ b/fixtures/while-true-no-break/positive/basic.py @@ -0,0 +1,3 @@ +def f(): + while True: + do_thing() diff --git a/fixtures/while-true-no-break/positive/basic.rs b/fixtures/while-true-no-break/positive/basic.rs new file mode 100644 index 0000000..79848a8 --- /dev/null +++ b/fixtures/while-true-no-break/positive/basic.rs @@ -0,0 +1,11 @@ +fn f() { + loop { + do_thing(); + } +} + +fn g() { + while true { + do_thing(); + } +} diff --git a/fixtures/while-true-no-break/positive/basic.ts b/fixtures/while-true-no-break/positive/basic.ts new file mode 100644 index 0000000..cd2df9e --- /dev/null +++ b/fixtures/while-true-no-break/positive/basic.ts @@ -0,0 +1,5 @@ +function f(): void { + while (true) { + doThing(); + } +} diff --git a/fixtures/while-true-no-break/positive/basic.tsx b/fixtures/while-true-no-break/positive/basic.tsx new file mode 100644 index 0000000..cd2df9e --- /dev/null +++ b/fixtures/while-true-no-break/positive/basic.tsx @@ -0,0 +1,5 @@ +function f(): void { + while (true) { + doThing(); + } +}