diff --git a/AGENT.md b/AGENT.md index 568bf88..e5a1e3f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -7,7 +7,7 @@ Bring Your Own Keys (BYOK) — no managed API, no cloud service. Runs locally ag diffs, scans, or branches. Includes a built-in symbol index, call graph, and hybrid semantic search engine (Brain Mode). -- **License:** MIT +- **License:** Apache-2.0 - **Edition:** Rust 2024 (MSRV 1.85) - **Repo:** `codecoradev/cora-code` - **Default branch:** `develop` @@ -457,7 +457,7 @@ When submitting cora to directories, aggregators, or showcases (Trendshift, etc. - CI checks (10) - GitHub Marketplace action published - MCP server with 15 tools -- MIT license +- Apache-2.0 license - Active development cadence ### Pre-Submission Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a28f1d..0682144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Relicensed from MIT to Apache-2.0.** All 18 CodeCoraDev repositories now + standardize on Apache-2.0 for patent grant protection and open-core model + compatibility. Added CLA (Individual + Corporate) for contributor copyright + and patent grants. +- **Added Contributor License Agreement (CLA).** Individual and Corporate CLA + documents added (`CLA_INDIVIDUAL.md`, `CLA_CORPORATE.md`). CLA includes + SIAC arbitration, patent retaliation, moral rights acknowledgment, and + no-compensation clause. +- **Updated CONTRIBUTING.md.** Added Contribution Terms section with + no-compensation notice, CLA requirement, and Apache-2.0 license reference. +- **Updated README license badge** from MIT to Apache-2.0. + ## [0.13.0] ### Added diff --git a/CLA_CORPORATE.md b/CLA_CORPORATE.md new file mode 100644 index 0000000..c69cc0b --- /dev/null +++ b/CLA_CORPORATE.md @@ -0,0 +1,154 @@ +# CodeCoraDev Corporate Contributor License Agreement + +Based on the Apache Software Foundation Corporate CLA. + +This agreement is for **entities** (companies, organizations) that submit +contributions to CodeCoraDev projects on behalf of their employees or contractors. + +--- + +## Definitions + +- **"You"** / **"Corporation"** — the entity submitting the Contribution. +- **"Contribution"** — any original work of authorship, including source code, + documentation, tests, configurations, design assets, or other materials that + are intentionally submitted by the Corporation to CodeCoraDev for inclusion in + any CodeCoraDev-owned project. +- **"CodeCoraDev"** — the project maintainer (Anaz S. Aji / @ajianaz), and their + successors, assigns, affiliates, and any legal entity they establish to operate + the Project, operating the repositories under the + [github.com/codecoradev](https://github.com/codecoradev) organization. +- **"Project"** — any open-source project maintained by CodeCoraDev. +- **"Designated Individual(s)"** — the employee(s) or agent(s) authorized to + submit Contributions on behalf of the Corporation. + +--- + +## 1. Copyright License + +Corporation grants CodeCoraDev a **perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable** copyright license to reproduce, prepare derivative +works of, publicly display, publicly perform, sublicense (including under +proprietary and commercial licenses), and distribute the Contributions of the +Designated Individual(s). + +For the avoidance of doubt, CodeCoraDev may exercise this license under the +Project's current license, or under any other license of CodeCoraDev's choosing, +including but not limited to proprietary, commercial, or copyleft licenses. + +## 2. Patent License + +Corporation grants CodeCoraDev a **perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable** patent license to make, have made, use, offer to sell, +sell, import, and otherwise transfer the Contributions of the Designated +Individual(s), where such patent license applies only to those patent claims +licensable by Corporation that are necessarily infringed by the Contribution(s) +alone or by combination of the Contribution(s) with the Project. + +If any entity institutes patent litigation against Corporation or any other entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Contribution, or the Project to which the Contribution was submitted, constitutes +direct or contributory patent infringement, then **all patent licenses granted +under this Agreement to that entity** shall terminate as of the date such +litigation is filed. + +This patent license does not extend to any patents of third parties. Corporation +makes no representation that Contributions are free from infringement of +third-party patents. + +## 3. Authority + +Corporation represents that each Designated Individual is authorized to submit +Contributions on Corporation's behalf, and that Corporation has the right to grant +the licenses in Sections 1 and 2. + +## 4. Original Work + +Corporation represents that each Contribution is the original work of the +Designated Individual(s). Corporation represents that Contribution submissions +include complete details of any third-party license or other restriction +(including, but not limited to, related patents and trademarks) of which +Corporation knows or has reason to know and which are associated with any part of +the Contributions. + +## 5. No Compensation + +Corporation acknowledges that CodeCoraDev is a community-driven, open-source +project. **All contributions are voluntary and unpaid.** CodeCoraDev does not +offer bounties, payments, equity, or other compensation for contributions unless +explicitly stated in a separate written agreement. + +Nothing in this Agreement creates an employment, agency, partnership, or joint +venture relationship between Corporation and CodeCoraDev. Corporation acknowledges +that CodeCoraDev may commercially exploit the Project (including through +proprietary or cloud-based products) without any obligation of accounting, +profit-sharing, or additional compensation to Corporation. + +## 6. Moral Rights + +To the extent permitted by applicable law, Corporation waives any moral rights of +its Designated Individual(s) to the extent necessary to enable CodeCoraDev to +exercise the licenses granted in Section 1, and Corporation agrees not to assert +moral rights against CodeCoraDev or its licensees. + +## 7. No Obligation + +CodeCoraDev is not obligated to accept, use, or maintain any Contribution. +CodeCoraDev reserves the right to remove, modify, or relicense Contributions at +its discretion. + +## 8. Disclaimer + +Except for the express warranties in Sections 3 and 4, Corporation provides +Contributions on an **"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND**, either express or implied, including, without limitation, any warranties +or conditions of title, non-infringement, merchantability, or fitness for a +particular purpose. + +In no event shall CodeCoraDev be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages arising out of or relating to this +Agreement or the use of Contributions, even if CodeCoraDev has been advised of the +possibility of such damages. + +## 9. Governing Law and Dispute Resolution + +(a) This Agreement shall be governed by and construed in accordance with the laws +of the **Republic of Indonesia**, without regard to its conflict of laws provisions. + +(b) Any dispute, controversy, or claim arising out of or relating to this +Agreement, including the breach, termination, or validity thereof, shall be +finally resolved by arbitration administered by the **Singapore International +Arbitration Centre (SIAC)** in accordance with the SIAC Rules of Arbitration in +force at the time of the commencement of the arbitration. The seat of arbitration +shall be Singapore. The number of arbitrators shall be one. The language of the +arbitration shall be English. + +(c) Notwithstanding the foregoing, CodeCoraDev may seek interim or injunctive +relief in any court of competent jurisdiction to protect its intellectual property +rights. + +(d) Judgment upon the award rendered by the arbitrator(s) may be entered in any +court having jurisdiction thereof. + +--- + +## Signing + +Corporation may sign this agreement electronically through CLAassistant or +equivalent system when configured on the repository. + +For offline signing, contact: hello@codecora.dev + +--- + +## Miscellaneous + +This Agreement constitutes the entire agreement between the parties regarding its +subject matter. If any provision is held unenforceable, the remainder shall remain +in effect. CodeCoraDev may amend this Agreement; amended terms apply prospectively +to new Contributions. This Agreement may be assigned by CodeCoraDev to any +successor or affiliate. Grants made prior to termination survive in perpetuity. + +--- + +*Version 1.1 — August 2026* diff --git a/CLA_INDIVIDUAL.md b/CLA_INDIVIDUAL.md new file mode 100644 index 0000000..540ad10 --- /dev/null +++ b/CLA_INDIVIDUAL.md @@ -0,0 +1,162 @@ +# CodeCoraDev Individual Contributor License Agreement + +Based on the Apache Software Foundation Individual CLA. + +Thank you for your interest in CodeCoraDev projects. This agreement clarifies the +terms under which you contribute code, documentation, and other materials to +CodeCoraDev-owned open-source projects. + +This is a **license agreement**, not a copyright assignment. You retain ownership +of your contributions. + +--- + +## Definitions + +- **"You"** — the individual submitting the Contribution. +- **"Contribution"** — any original work of authorship, including source code, + documentation, tests, configurations, design assets, or other materials that + are intentionally submitted by You to CodeCoraDev for inclusion in any + CodeCoraDev-owned project. +- **"CodeCoraDev"** — the project maintainer (Anaz S. Aji / @ajianaz), and their + successors, assigns, affiliates, and any legal entity they establish to operate + the Project, operating the repositories under the + [github.com/codecoradev](https://github.com/codecoradev) organization. +- **"Project"** — any open-source project maintained by CodeCoraDev, including but + not limited to Cora, Uteke, Trapfall, Rungu, and others. + +--- + +## 1. Copyright License + +You grant CodeCoraDev a **perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable** copyright license to reproduce, prepare derivative +works of, publicly display, publicly perform, sublicense (including under +proprietary and commercial licenses), and distribute your Contributions and such +derivative works. + +For the avoidance of doubt, CodeCoraDev may exercise this license under the +Project's current license, or under any other license of CodeCoraDev's choosing, +including but not limited to proprietary, commercial, or copyleft licenses. + +## 2. Patent License + +You grant CodeCoraDev a **perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable** patent license to make, have made, use, offer to sell, +sell, import, and otherwise transfer your Contributions, where such patent license +applies only to those patent claims licensable by you that are necessarily +infringed by your Contribution(s) alone or by combination of your Contribution(s) +with the Project to which such Contribution(s) was submitted. + +If any entity institutes patent litigation against you or any other entity +(including a cross-claim or counterclaim in a lawsuit) alleging that your +Contribution, or the Project to which you submitted the Contribution, constitutes +direct or contributory patent infringement, then **all patent licenses granted +under this Agreement to that entity** shall terminate as of the date such +litigation is filed. + +This patent license is personal to You and does not extend to any patents of +third parties. You make no representation that your Contributions are free from +infringement of third-party patents. + +## 3. Original Work and Employer Authorization + +You represent that each of your Contributions is your original work. You represent +that your Contribution submissions include complete details of any third-party +license or other restriction (including, but not limited to, related patents and +trademarks) of which you are personally aware and which are associated with any +part of your Contributions. + +If you are employed, you represent that your employer has consented to or does not +claim ownership of your Contributions, or that you are submitting with your +employer's authorization. + +## 4. No Compensation + +You acknowledge that CodeCoraDev is a community-driven, open-source project +maintained by a solo founder. **All contributions are voluntary and unpaid.** +CodeCoraDev does not offer bounties, payments, equity, or other compensation for +contributions unless explicitly stated in a separate written agreement. + +Nothing in this Agreement creates an employment, agency, partnership, or joint +venture relationship between You and CodeCoraDev. You acknowledge that +CodeCoraDev may commercially exploit the Project (including through proprietary +or cloud-based products) without any obligation of accounting, profit-sharing, +or additional compensation to You. + +## 5. Moral Rights + +You retain all moral rights in your Contributions. However, you waive any moral +rights to the extent necessary to enable CodeCoraDev to exercise the licenses +granted in Section 1, including the right to make derivative works and to +distribute Contributions under proprietary or commercial licenses, and you agree +not to assert moral rights against CodeCoraDev or its licensees. + +## 6. No Obligation + +CodeCoraDev is not obligated to accept, use, or maintain any Contribution. +CodeCoraDev reserves the right to remove, modify, or relicense Contributions at +its discretion. + +## 7. Disclaimer + +Except for the express warranties in Sections 3 and 4, you provide your +Contributions on an **"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND**, either express or implied, including, without limitation, any warranties +or conditions of title, non-infringement, merchantability, or fitness for a +particular purpose. + +In no event shall CodeCoraDev be liable for any direct, indirect, incidental, +special, exemplary, or consequential damages arising out of or relating to this +Agreement or the use of your Contributions, even if CodeCoraDev has been advised +of the possibility of such damages. + +## 8. Governing Law and Dispute Resolution + +(a) This Agreement shall be governed by and construed in accordance with the laws +of the **Republic of Indonesia**, without regard to its conflict of laws provisions. + +(b) Any dispute, controversy, or claim arising out of or relating to this +Agreement, including the breach, termination, or validity thereof, shall be +finally resolved by arbitration administered by the **Singapore International +Arbitration Centre (SIAC)** in accordance with the SIAC Rules of Arbitration in +force at the time of the commencement of the arbitration. The seat of arbitration +shall be Singapore. The number of arbitrators shall be one. The language of the +arbitration shall be English. + +(c) Notwithstanding the foregoing, CodeCoraDev may seek interim or injunctive +relief in any court of competent jurisdiction to protect its intellectual property +rights. + +(d) Judgment upon the award rendered by the arbitrator(s) may be entered in any +court having jurisdiction thereof. + +--- + +## Signing + +By submitting a pull request, issue, code contribution, or other material to any +CodeCoraDev repository, **and including a `Signed-off-by:` line in your commit +message**, you agree to the terms of this agreement. The `Signed-off-by:` line +shall be in the format: `Signed-off-by: Your Name ` and +constitutes your electronic signature. + +If a Contributor License Agreement bot (e.g., CLAassistant) is configured on the +repository, you may be asked to sign this agreement electronically through that +system. + +--- + +## Miscellaneous + +This Agreement constitutes the entire agreement between the parties regarding its +subject matter. If any provision is held unenforceable, the remainder shall remain +in effect. CodeCoraDev may amend this Agreement; amended terms apply prospectively +to new Contributions. This Agreement may be assigned by CodeCoraDev to any +successor or affiliate. Grants made prior to termination survive in perpetuity. +You may terminate this Agreement with 30 days written notice; existing grants +survive termination. + +--- + +*Version 1.1 — August 2026* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37b2658..0297831 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,14 @@ Cora is a solo-maintained project with a strong product direction. Contributions This document helps you decide *whether* and *how* to contribute in a way that's likely to get merged, so neither of us wastes time. +> ### 📋 Contribution Terms +> +> By submitting a contribution, you agree to the terms in [CLA_INDIVIDUAL.md](CLA_INDIVIDUAL.md) (for individuals) or [CLA_CORPORATE.md](CLA_CORPORATE.md) (for employees contributing on behalf of their employer). +> +> **TL;DR:** You retain copyright. You grant CodeCoraDev a license to use your contribution — including in commercial products. Contributions are voluntary and unpaid. If you prefer not to sign, that's okay — but your PR cannot be merged. +> +> All commits must include `Signed-off-by: Your Name ` (same format as `git commit -s`). + ## How this project is run - Cora has one active maintainer ([@ajianaz](https://github.com/ajianaz)). @@ -278,6 +286,32 @@ Don't file them as public issues. See [SECURITY.md](SECURITY.md). See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). +## Contribution Terms + +CodeCoraDev projects are open-source and community-driven. By submitting a +contribution (pull request, issue report, documentation, or any other creative +work), you agree that: + +1. **No Compensation:** Contributions are voluntary and unpaid. CodeCoraDev + does not offer bounties, payments, or equity for contributions unless + explicitly stated in a separate written agreement. + +2. **License Grant:** You grant CodeCoraDev a perpetual, worldwide, + non-exclusive, royalty-free license to use, modify, and distribute your + contribution under the project's open-source license and any future license + that CodeCoraDev may adopt (including proprietary/commercial licenses). + +3. **Original Work:** Your contribution is your original work or properly + attributed third-party work under a compatible license. + +4. **No Obligation:** CodeCoraDev is not obligated to accept, use, or maintain + any contribution. + +A Contributor License Agreement (CLA) is required for contributions to be +merged. See [CLA_INDIVIDUAL.md](CLA_INDIVIDUAL.md) or +[CLA_CORPORATE.md](CLA_CORPORATE.md) for details. + ## License -By contributing you agree your work is licensed under [MIT](LICENSE). No CLA required. +By contributing you agree your work is licensed under [Apache-2.0](LICENSE). +A CLA is required — see [CLA_INDIVIDUAL.md](CLA_INDIVIDUAL.md). diff --git a/Cargo.lock b/Cargo.lock index e8aafe7..a5c93ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -306,6 +312,7 @@ dependencies = [ "clap_complete", "colored", "dirs", + "flate2", "fs2", "futures-util", "git2", @@ -321,6 +328,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sha2", + "tar", "tempfile", "thiserror", "tokio", @@ -369,6 +377,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -566,12 +583,32 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1170,6 +1207,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1715,6 +1762,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1803,6 +1856,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2674,6 +2738,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 1570d1d..b5dfb2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "cora-code" version = "0.13.0" edition = "2024" description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks" -license = "MIT" +license = "Apache-2.0" repository = "https://github.com/codecoradev/cora-code" readme = "README.md" keywords = ["cli", "code-review", "ai", "git", "pre-commit"] @@ -74,6 +74,10 @@ rusqlite = { version = "0.31", features = ["bundled"] } usearch = "2" fs2 = "0.4" +# Self-update (upgrade command) +flate2 = "1" +tar = "0.4" + # Tree-sitter AST parsing (optional — enable with --features tree-sitter) tree-sitter = { version = "0.26", optional = true } tree-sitter-rust = { version = "0.24", optional = true } diff --git a/LICENSE b/LICENSE index 488fbb0..5fad465 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,191 @@ -MIT License - -Copyright (c) 2026 Anaz S Aji - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 ajianaz + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 2187692..f0445a5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![GitHub stars](https://img.shields.io/github/stars/codecoradev/cora-code?style=social)](https://github.com/codecoradev/cora-code/stargazers) [![CI](https://github.com/codecoradev/cora-code/actions/workflows/ci.yml/badge.svg)](https://github.com/codecoradev/cora-code/actions/workflows/ci.yml) [![Crates.io](https://img.shields.io/crates/v/cora-code.svg)](https://crates.io/crates/cora-code) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Rust](https://img.shields.io/badge/Rust-1.85+-orange.svg)](https://www.rust-lang.org/) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index cc1c5f8..7067d26 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -90,6 +90,13 @@ Complete command reference for the cora CLI. | `cora scan --batch-files` `N` | Max files per LLM batch (default: 20) | | `cora scan --no-continue-on-batch-error` | Abort on batch failure (default: skip and continue) | +Scan notes: + +- **Deterministic index findings** — when a symbol index exists, `cora scan` adds no-LLM findings (unused imports, dead code) from the index alongside LLM results. +- **Brain context** — with `review.context_chain.use_brain: true`, scan batches are enriched with the same symbol-index intelligence as review (impact analysis, affected tests, semantic search). +- **Persistence** — scan results are persisted to the global `cora.db` and fingerprinted; findings from previous scans that no longer reproduce are auto-resolved. +- **Exit code 2** — also returned when `hook.mode: block` and a finding exceeds `hook.min_severity`. + ### Code Intelligence See [Code Intelligence](./code-intelligence) for detailed usage. diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 4b0c37c..720bafa 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -33,8 +33,8 @@ Scans your project, extracts symbol definitions (functions, structs, enums, trai | Component | Technology | Storage | |-----------|-----------|---------| -| Symbol table (regex) | Regex extractors (15 languages) | SQLite FTS5 | -| Symbol table (AST) | Tree-sitter grammars (12 languages, opt-in) | SQLite FTS5 | +| Symbol table (regex) | Regex extractors (18 languages) | SQLite FTS5 | +| Symbol table (AST) | Tree-sitter grammars (12+ languages, enabled by default) | SQLite FTS5 | | Vector embeddings | Static hashing (256d) or pretrained nomic (768d), runtime-selectable | usearch HNSW index | | Call graph | Regex scope tracking + tree-sitter AST edges | SQLite `edges` table | @@ -48,7 +48,7 @@ cora index --prune # Remove symbols from deleted files ### Supported Languages -Cora extracts symbols using two strategies — **regex** (always available) and **tree-sitter AST** (opt-in via `--features tree-sitter` at build time): +Cora extracts symbols using two strategies — **regex** (always available) and **tree-sitter AST** (enabled by default in release builds; can be disabled at build time): | | Regex Extraction | AST (Tree-sitter) | |--|-----------------|-------------------| @@ -71,7 +71,7 @@ Cora extracts symbols using two strategies — **regex** (always available) and | **Lua** | ✅ | — | | **Zig** | ✅ | — | -**Regex-only** languages work out of the box. For **AST-accurate** extraction (full call graph, precise imports/exports), build with tree-sitter: +**Regex-only** languages work out of the box. Tree-sitter AST extraction is **enabled by default** in normal builds (`default = ["tree-sitter"]` in `Cargo.toml`); only minimal/no-default-feature builds fall back to regex-only. To explicitly enable it: ```bash cargo install --git https://github.com/codecoradev/cora-code --features tree-sitter @@ -83,7 +83,7 @@ All projects share a **single global database**: ``` ~/.codecora/cora-code/ -├── graph.db # SQLite — symbols, FTS5 index, call edges +├── cora.db # SQLite — symbols, FTS5 index, call edges └── cora_index.usearch # usearch HNSW — 256d vector embeddings ``` @@ -195,16 +195,30 @@ Trace execution paths through the call graph. ```bash cora trace "main" # Trace outward (callees) -cora trace "handle_request" --incoming # Trace inward (callers) +cora trace "handle_request" --direction incoming # Trace inward (callers) cora trace "process" --depth 4 # Limit traversal depth cora trace --json # JSON output ``` -Requires schema v3 edges table. When built with `--features tree-sitter`, Cora uses AST-based edge extraction for more accurate call graphs. Regex-only builds still generate edges via scope tracking. +Requires schema v3 edges table. Cora uses tree-sitter AST-based edge extraction for more accurate call graphs (enabled by default). Regex-only builds (no-default-features) still generate edges via scope tracking. + +### Call Graph Semantics & Limitations + +**Cross-file and multi-level.** The call graph is stored per-edge with file and line, and `impact`/`trace` traverse it with BFS plus cycle protection. A caller three abstraction layers away from the symbol — through any number of intermediate files — is reachable by raising `--depth` (see [Impact Depth Guidance](/configuration#impact-depth-guidance)). + +**Cross-project fallback.** If a symbol has no callers in the current project's scope, `cora callers` automatically falls back to a lookup across **all indexed projects** and reports which project each match came from. Useful when a symbol is defined in one repo and consumed from another (e.g. a shared crate consumed via workspace or git dependency). + +**Static analysis — what it cannot see.** Edges are extracted statically, so they can be missing for: + +- **Dynamic dispatch** — trait objects (`dyn Trait`), virtual/interface calls. The concrete callee is only known at runtime. +- **Callbacks and function pointers** — a function passed as an argument and invoked elsewhere has no direct edge to its eventual call-site. +- **Reflection / string-based dispatch** — unresolvable statically by definition. + +In these cases the traversal chain can end early, and `cora impact` may under-report the blast radius. For dynamic-dispatch-heavy code, treat the call graph as a lower bound and confirm with tests or manual tracing. ```bash # Build with tree-sitter for best call graph accuracy -cargo install --git https://github.com/codecoradev/cora-code --features tree-sitter +cargo install --git https://github.com/codecoradev/cora-code # tree-sitter included by default cora index --rebuild # Re-index to get AST edges ``` @@ -297,8 +311,7 @@ All code intelligence features are available as MCP tools for AI coding agents: │ ├── reviews # Review history for tech debt tracking │ └── findings # Review findings + finding_events └── cora_index.usearch # usearch HNSW vector index - ├── cora_index.usearch.keys # Key-to-symbol-id mapping - └── cora_index.usearch.lock # File lock (fs2) + └── cora_index.keys # Key-to-symbol-id mapping (lock is held on the .usearch file itself, fs2) ``` To reset everything: diff --git a/docs/configuration.md b/docs/configuration.md index 3e7e87a..327f61f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,9 +86,9 @@ quality_gate: max_security: 0 bundling: - max_chars_per_group: 12000 + max_chars_per_group: 60000 max_files_per_group: 20 - strategy: directory # directory | language + strategy: smart # smart | flat coalesce_by_directory: true coalesce_by_language: true @@ -217,11 +217,30 @@ review: | `max_context_tokens` | `5000` | Approx. 20 KB of code injected. | | `follow_depth` | `1` | Outbound recursion depth (`1` = direct references). | | `include_tests` | `true` | Map changed source to its test files. | -| `include_callers` | `true` | Inbound caller resolution. Scans source files (gitignore-aware — `target/`, `node_modules/` are never scanned), bounded to ≤400 files and ≤3 call-sites per symbol. | +| `include_callers` | `true` | Inbound caller resolution. When the symbol index is unavailable (regex fallback): gitignore-aware file scan bounded to ≤400 files and ≤3 call-sites per symbol. When the index is available (default path, `prefer_index: true`): up to 20 call-sites per symbol, filtered by `ignore.files` patterns. | | `use_brain` | `true` | Enrich prompt with symbol-index intelligence (impact analysis, affected tests, semantic search). Only active when `cora index` has been run. | -| `impact_depth` | `2` | Blast-radius traversal depth (`1` = direct callers, `2` = callers of callers). | +| `impact_depth` | `2` | Blast-radius traversal depth. See [Impact depth guidance](#impact-depth-guidance) below. | | `prefer_index` | `true` | Prefer symbol index (FTS5 + call graph) over regex scanning for outbound resolution. | +### Impact Depth Guidance + +`impact_depth` controls how many levels **up** the call graph the blast-radius traversal follows: + +- `1` — direct callers only. Cheapest, misses indirect breakage. +- `2` — callers of callers. **Recommended default** — covers the common layered case (handler → service → helper). +- `3` — deep blast radius for strongly layered codebases (handler → service → repository → helper). Higher token cost. +- `4+` — rarely worth it. The traversal is BFS with cycle protection, so it is always safe, but on real codebases the caller count grows quickly with depth: heavily-used utility functions and entry points turn into hubs with hundreds of callers, and the injected context fills with noise rather than signal. + +**Why not "unlimited"?** Setting a very large depth is technically valid — traversal stops on its own once every reachable caller is visited — but on any non-trivial repo it surfaces the entire transitive caller closure. The prompt budget (`max_context_tokens`) then truncates the output arbitrarily, so you pay full traversal cost for context the LLM never sees. + +**How to choose:** + +- Small / flat repo → leave at `2`. +- Layered repo (handler/service/repository split) where bugs manifest several layers from the root cause → `3` for that repo's `.cora.yaml`. +- Want *precision* rather than *reach* → keep `2` and rely on `cora impact` interactively with `--depth` to explore specific symbols on demand. + +> **Note:** `impact_depth` only governs the *automatic* blast-radius context injected during `cora review`. The interactive `cora impact` / `cora trace` commands default to `--depth 3` and are unaffected by this setting. + ## Quality Gate Quality gate evaluates review findings against configurable thresholds to produce a **PASS/FAIL** result. This is useful for CI enforcement — block merges when code quality drops below your standards. @@ -413,7 +432,7 @@ Write your own regex-based rules in `.cora.yaml`: rules: - id: no-unwrap pattern: "\\.unwrap\\(\\)" - severity: warning + severity: minor message: "Avoid unwrap() in production code — use proper error handling" languages: ["rust"] exclude: ["tests/**"] @@ -528,7 +547,7 @@ review: ## Bundling -Control how multiple files are grouped into LLM batches during `cora scan`. Cora automatically chunks large file sets into groups that fit within provider token limits. +> **Deprecation notice:** as of the current release, `cora scan` does **not** read this section — batch sizing is governed by `--batch-files` (default 20) and an internal ~60,000-character batch budget. These keys are parsed but have no effect on `cora scan`. They are kept for backward compatibility; do not rely on them. ```yaml bundling: diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 9c09be9..1108caf 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -109,7 +109,7 @@ fn execute_auth_login_noninteractive( println!( "{} API key saved to {}", "✅".green().bold(), - "~/.cora/auth.toml".green() + "~/.codecora/cora-code/auth.toml".green() ); println!( "{} Provider: {} | Model: {} | Base: {}", @@ -336,7 +336,7 @@ fn print_saved(provider: &str, model: &str, base_url: &str) { println!( "{} API key saved to {}", "✅".green().bold(), - "~/.cora/auth.toml".green() + "~/.codecora/cora-code/auth.toml".green() ); println!( "{} Provider: {} | Model: {} | Base: {}", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index aca7103..4c6d46a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -15,5 +15,7 @@ pub mod review; pub mod routes; pub mod scan; pub mod serve; +pub mod update_check; +pub mod upgrade; pub mod upload; pub mod watch; diff --git a/src/commands/update_check.rs b/src/commands/update_check.rs new file mode 100644 index 0000000..f98f0c9 --- /dev/null +++ b/src/commands/update_check.rs @@ -0,0 +1,141 @@ +//! Background update notification — non-blocking check on startup. +//! +//! When `cora` runs any command, this module checks (in a background thread) +//! whether a newer release exists on GitHub. Results are cached for 24 hours +//! at `~/.codecora/cora-code/update-cache.json` to avoid rate-limiting. +//! +//! The notification is printed to stderr after the main command finishes, +//! so it never interferes with command output or piping. + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::data_dir; + +const REPO: &str = "codecoradev/cora-code"; +const CACHE_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours + +#[derive(Serialize, Deserialize)] +struct UpdateCache { + latest_version: String, + has_update: bool, + checked_at: u64, +} + +/// Spawn a background thread to check for updates. +/// +/// Call this right after `Cli::parse()`, before command dispatch. +/// The thread is detached — it will print to stderr when done. +/// +/// `enabled` = false (from config `update_check: false`) skips the check entirely. +pub fn spawn_background_check(enabled: bool) { + if !enabled { + return; + } + + // Check cache first — if fresh (< 24h), just print cached result + if let Some(cache) = read_cache() { + if cache.has_update { + print_update_banner(&cache.latest_version); + } + return; // Cache is still fresh, no need to re-check + } + + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + std::thread::spawn(move || { + let latest = match fetch_latest_version() { + Ok(v) => v, + Err(_) => return, // Silent — never show error for background check + }; + + let has_update = latest != current_version; + + // Write cache + write_cache(&latest, has_update); + + if has_update { + print_update_banner(&latest); + } + }); +} + +fn print_update_banner(latest: &str) { + let current = env!("CARGO_PKG_VERSION"); + eprintln!(); + eprintln!(" ⚠ Update available: {} (currently {})", latest, current); + eprintln!(" Run `cora upgrade` to update."); + eprintln!(" https://github.com/{}/releases/tag/{}", REPO, latest); + eprintln!(); +} + +fn cache_path() -> PathBuf { + data_dir::update_cache_path() +} + +fn read_cache() -> Option { + let path = cache_path(); + let content = fs::read_to_string(&path).ok()?; + let cache: UpdateCache = serde_json::from_str(&content).ok()?; + + // Check freshness + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + + if now.saturating_sub(cache.checked_at) > CACHE_TTL_SECS { + return None; // Expired + } + + Some(cache) +} + +fn write_cache(latest_version: &str, has_update: bool) { + // Ensure data dir exists + let _ = data_dir::ensure_data_dir(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let cache = UpdateCache { + latest_version: latest_version.to_string(), + has_update, + checked_at: now, + }; + + let path = cache_path(); + if let Ok(json) = serde_json::to_string(&cache) { + let _ = fs::write(&path, json); + } +} + +/// Fetch latest release tag from GitHub. +/// +/// Uses 302 redirect parsing (no API rate limit). +fn fetch_latest_version() -> Result { + let rt = tokio::runtime::Runtime::new().map_err(|e| format!("Runtime error: {e}"))?; + + rt.block_on(async { + let client = reqwest::Client::new(); + + let resp = client + .head(format!("https://github.com/{REPO}/releases/latest")) + .send() + .await + .map_err(|e| format!("HTTP error: {e}"))?; + + if let Some(location) = resp.headers().get("location") { + let loc = location.to_str().unwrap_or_default(); + if let Some(tag) = loc.rsplit('/').next() { + if tag.starts_with('v') { + return Ok(tag.trim_end_matches('?').to_string()); + } + } + } + + Err("Failed to parse latest version from redirect".into()) + }) +} diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs new file mode 100644 index 0000000..f2bf492 --- /dev/null +++ b/src/commands/upgrade.rs @@ -0,0 +1,389 @@ +//! `cora upgrade` — check for updates and self-upgrade. +//! +//! Detects OS/arch, fetches latest release from GitHub, downloads, +//! verifies checksum, replaces the running binary. +//! +//! Uses a blocking tokio runtime for the HTTP calls (reqwest is async-only +//! in cora-code, unlike uteke which uses reqwest::blocking). + +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use colored::Colorize; +use sha2::{Digest, Sha256}; + +const REPO: &str = "codecoradev/cora-code"; +const BINARY_NAME: &str = "cora"; + +/// Entry point for `cora upgrade`. +/// +/// `check_only` = true corresponds to `cora upgrade --check`: +/// only print whether an update is available, do not download. +pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { + let current_version = env!("CARGO_PKG_VERSION"); + println!("{} Current version: {current_version}", "[INFO]".green()); + + // Detect current binary path + let current_exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!( + "{} Cannot determine current binary path: {e}", + "[ERROR]".red() + ); + eprintln!(" If installed via cargo, run: cargo install --path ."); + return Ok(1); + } + }; + + // Detect OS and architecture + let os = detect_os(); + let arch = detect_arch(); + + // Get latest release version + let latest_version = match get_latest_version().await { + Ok(v) => v, + Err(e) => { + eprintln!("{} {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + // Normalize: strip leading 'v' from GitHub tag for comparison + let latest_clean = latest_version.trim_start_matches('v'); + + // Check if already up to date + if latest_clean == current_version { + println!( + "{} Already up to date ({current_version})", + "[INFO]".green() + ); + return Ok(0); + } + + println!("{} Latest version: {latest_version}", "[INFO]".cyan()); + println!( + "{} Release notes: https://github.com/{REPO}/releases/tag/{latest_version}", + "[INFO]".dimmed() + ); + + if check_only { + return Ok(0); + } + + // Confirm (unless --yes) + if !yes { + print!("? Update to {latest_version}? [y/N] "); + io::stdout() + .flush() + .map_err(|e| anyhow::anyhow!("stdout flush: {e}"))?; + let mut input = String::new(); + io::stdin() + .lock() + .read_line(&mut input) + .map_err(|e| anyhow::anyhow!("stdin read: {e}"))?; + let input = input.trim().to_lowercase(); + if input != "y" && input != "yes" { + println!("{} Update cancelled.", "[INFO]".dimmed()); + return Ok(0); + } + } + + // Build target and download + let target = match get_target(&os, &arch) { + Ok(t) => t, + Err(e) => { + eprintln!("{} {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + let archive_name = format!("{BINARY_NAME}-{target}-{latest_version}.tar.gz"); + let download_url = + format!("https://github.com/{REPO}/releases/download/{latest_version}/{archive_name}"); + + println!("{} Downloading {archive_name} ...", "[INFO]".green()); + + let temp_dir = std::env::temp_dir().join(format!("cora-update-{latest_version}")); + fs::create_dir_all(&temp_dir).map_err(|e| anyhow::anyhow!("Failed to create temp dir: {e}"))?; + let archive_path = temp_dir.join(&archive_name); + + // Download using a blocking tokio runtime (reqwest is async-only) + let archive_bytes = match download_async(&download_url).await { + Ok(b) => b, + Err(e) => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Download failed: {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + fs::write(&archive_path, &archive_bytes) + .map_err(|e| anyhow::anyhow!("Failed to write archive: {e}"))?; + + // Verify checksum + let checksums_url = format!( + "https://github.com/{REPO}/releases/download/{latest_version}/checksums-sha256.txt" + ); + + println!("{} Verifying checksum ...", "[INFO]".green()); + + let skip_checksum = std::env::var("CORA_UPGRADE_SKIP_CHECKSUM") + .map(|v| v == "1" || v == "true") + .unwrap_or(false); + + if skip_checksum { + println!( + "{} Checksum verification skipped (CORA_UPGRADE_SKIP_CHECKSUM=1)", + "[WARN]".yellow() + ); + } else { + let checksums_text = match download_async(&checksums_url).await { + Ok(b) => String::from_utf8_lossy(&b).to_string(), + Err(e) => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Failed to download checksums: {e}", "[ERROR]".red()); + eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to skip."); + return Ok(1); + } + }; + + let expected = match parse_checksum(&checksums_text, &archive_name) { + Some(h) => h, + None => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Checksum for '{archive_name}' not found in checksums file.", + "[ERROR]".red() + ); + eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to bypass."); + return Ok(1); + } + }; + + let actual = sha256_file(&archive_path)?; + if actual != expected { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Checksum mismatch! Expected: {expected}, got: {actual}", + "[ERROR]".red() + ); + return Ok(1); + } + println!("{} Checksum verified: {actual}", "[INFO]".green()); + } + + // Verify archive integrity (path traversal check) + let file = fs::File::open(&archive_path) + .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + for entry in archive + .entries() + .map_err(|e| anyhow::anyhow!("Failed to read archive entries: {e}"))? + { + let entry = entry.map_err(|e| anyhow::anyhow!("Failed to read archive entry: {e}"))?; + let path = entry + .path() + .map_err(|e| anyhow::anyhow!("Archive path error: {e}"))?; + let path_str = path.to_string_lossy(); + if path_str.starts_with('/') || path_str.contains("..") { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Archive contains unsafe paths — refusing to extract", + "[ERROR]".red() + ); + return Ok(1); + } + } + drop(archive); + + // Extract + println!("{} Extracting ...", "[INFO]".green()); + let file = fs::File::open(&archive_path) + .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + archive + .unpack(&temp_dir) + .map_err(|e| anyhow::anyhow!("Failed to extract archive: {e}"))?; + + // Find and replace binary + let extracted_binary = temp_dir.join(BINARY_NAME); + if !extracted_binary.exists() { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Binary '{BINARY_NAME}' not found in archive", + "[ERROR]".red() + ); + return Ok(1); + } + + let install_dir = current_exe + .parent() + .ok_or_else(|| anyhow::anyhow!("Cannot determine install directory"))?; + + // Copy to temp file first, then rename (atomic on POSIX) + let temp_new = install_dir.join(format!("{BINARY_NAME}.new")); + fs::copy(&extracted_binary, &temp_new) + .map_err(|e| anyhow::anyhow!("Failed to copy new binary: {e}"))?; + + // Verify the new binary runs + match std::process::Command::new(&temp_new) + .arg("--version") + .output() + { + Ok(output) if output.status.success() => { + let new_version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let extracted_version = new_version.split_whitespace().nth(1).unwrap_or("unknown"); + println!( + "{} Verified new binary: {extracted_version}", + "[INFO]".green() + ); + } + Ok(output) => { + let _ = fs::remove_file(&temp_new); + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} New binary failed to run: {}", + "[ERROR]".red(), + String::from_utf8_lossy(&output.stderr) + ); + return Ok(1); + } + Err(e) => { + let _ = fs::remove_file(&temp_new); + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Failed to verify new binary: {e}", "[ERROR]".red()); + return Ok(1); + } + } + + // Atomic rename + fs::rename(&temp_new, ¤t_exe) + .map_err(|e| anyhow::anyhow!("Failed to replace binary: {e}"))?; + + // Cleanup + let _ = fs::remove_dir_all(&temp_dir); + + println!( + "{} Update complete. ({current_version} → {latest_version})", + "[INFO]".green().bold() + ); + + Ok(0) +} + +/// Download a URL using the current tokio runtime. +/// +/// reqwest in cora-code is async-only (no `blocking` feature). +/// This must be called from within a tokio runtime context. +async fn download_async(url: &str) -> anyhow::Result> { + let resp = reqwest::get(url) + .await + .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {status}: {body}"); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| anyhow::anyhow!("Failed to read response body: {e}"))?; + Ok(bytes.to_vec()) +} + +fn detect_os() -> String { + match std::env::consts::OS { + "linux" => "linux".to_string(), + "macos" => "darwin".to_string(), + os => os.to_string(), + } +} + +fn detect_arch() -> String { + match std::env::consts::ARCH { + "x86_64" => "x86_64".to_string(), + "aarch64" => "aarch64".to_string(), + arch => arch.to_string(), + } +} + +fn get_target(os: &str, arch: &str) -> Result { + match (os, arch) { + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".into()), + ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu".into()), + ("darwin", "aarch64") => Ok("aarch64-apple-darwin".into()), + ("darwin", "x86_64") => Ok("x86_64-apple-darwin".into()), + _ => Err(format!("Unsupported platform: {os} {arch}")), + } +} + +/// Get latest release tag from GitHub. +/// +/// Primary: parse 302 redirect (no API call, no rate limit). +/// Fallback: GitHub REST API. +async fn get_latest_version() -> Result { + let client = reqwest::Client::new(); + + // Primary: HEAD request, parse Location header redirect + let resp = client + .head(format!("https://github.com/{REPO}/releases/latest")) + .send() + .await + .map_err(|e| format!("Failed to check latest release: {e}"))?; + + if let Some(location) = resp.headers().get("location") { + let loc = location.to_str().unwrap_or_default(); + // Redirect URL: https://github.com/codecoradev/cora-code/releases/tag/v0.14.0 + if let Some(tag) = loc.rsplit('/').next() { + if tag.starts_with('v') { + return Ok(tag.trim_end_matches('?').to_string()); + } + } + } + + // Fallback: GitHub API + let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest"); + let resp = client + .get(&api_url) + .header("User-Agent", "cora-upgrade") + .send() + .await + .map_err(|e| format!("GitHub API failed: {e}"))?; + + if resp.status().is_success() { + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse GitHub API response: {e}"))?; + if let Some(tag) = json["tag_name"].as_str() { + return Ok(tag.to_string()); + } + } + + Err(format!( + "Failed to determine latest version. Check https://github.com/{REPO}/releases" + )) +} + +fn parse_checksum(checksums_text: &str, archive_name: &str) -> Option { + for line in checksums_text.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[1].contains(archive_name) { + return Some(parts[0].to_string()); + } + } + None +} + +fn sha256_file(path: &PathBuf) -> anyhow::Result { + let mut hasher = Sha256::new(); + let mut file = fs::File::open(path).map_err(|e| anyhow::anyhow!("Failed to open file: {e}"))?; + io::copy(&mut file, &mut hasher).map_err(|e| anyhow::anyhow!("Failed to read file: {e}"))?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/config/loader.rs b/src/config/loader.rs index 7e168d2..bdc50f3 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -54,7 +54,7 @@ pub fn find_cora_file(start: &Path) -> std::result::Result std::result::Result, CoraError> { let dir = cora_dir()?; @@ -69,9 +69,9 @@ fn load_global_config() -> std::result::Result, CoraError> { Ok(Some(cora)) } -/// Migrate old `~/.cora/config.toml` to the new format if it exists. -/// - Non-secret keys → `~/.cora/config.yaml` -/// - `api_key` → `~/.cora/auth.toml` +/// Migrate old `config.toml` to the new format if it exists. +/// - Non-secret keys → `config.yaml` +/// - `api_key` → `auth.toml` /// - Delete the old file after successful migration. /// - Creates `.migrated` marker to prevent re-running. #[allow( @@ -216,7 +216,7 @@ fn migrate_old_config() { } else { // Create marker to prevent re-running migration let _ = std::fs::write(dir.join(MIGRATION_MARKER), ""); - debug!("migrated ~/.cora/config.toml to new format"); + debug!("migrated config.toml to new format"); } } @@ -256,7 +256,7 @@ pub fn load_config( debug!("provider migration failed: {}", e); }); - // 1. Load global config (~/.cora/config.yaml) + // 1. Load global config (~/.codecora/cora-code/config.yaml) if let Some(cora) = load_global_config()? { cora.merge_into(&mut config)?; } @@ -307,13 +307,13 @@ pub fn load_config( } /// Build an `LLMConfig` from the resolved `Config`, fetching the API key -/// from: CLI flag / CORA_API_KEY env → ~/.cora/auth.toml → provider-specific env vars. +/// from: CLI flag / CORA_API_KEY env → ~/.codecora/cora-code/auth.toml → provider-specific env vars. /// /// If none of those are set, auto-detect from known provider env vars (`OPENAI_API_KEY`, etc.) /// and configure `provider/model/base_url` from the matching preset. /// /// Provider/model/base_url resolution: -/// CORA_* env vars > .cora.yaml (project) > ~/.cora/config.yaml (global) > auto-detect > defaults +/// CORA_* env vars > .cora.yaml (project) > ~/.codecora/cora-code/config.yaml (global) > auto-detect > defaults pub fn build_llm_config( config: &Config, cli_api_key: Option<&str>, @@ -408,14 +408,90 @@ pub fn build_llm_config( }) } -/// Get the cora config directory: ~/.cora/ +/// Get the cora config/data directory. +/// +/// Returns `~/.codecora/cora-code/` (or `CODECORA_HOME` override). +/// On first call, migrates files from legacy `~/.cora/` if the new directory is empty. +/// +/// ```text +/// Default: $HOME/.codecora/cora-code/ +/// CODECORA_HOME set: $CODECORA_HOME/cora-code/ +/// ``` pub fn cora_dir() -> std::result::Result { - let home = dirs::home_dir() - .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; - Ok(home.join(".cora")) + let new_dir = crate::data_dir::cora_data_dir(); + + // One-time migration from legacy ~/.cora/ → ~/.codecora/cora-code/ + migrate_legacy_cora_dir(&new_dir); + + Ok(new_dir) +} + +/// Migrate files from legacy `~/.cora/` to the new `~/.codecora/cora-code/` directory. +/// +/// Runs only once — a `.migrated` marker file prevents re-running. +/// Does NOT delete old files; users can clean up manually. +fn migrate_legacy_cora_dir(new_dir: &std::path::Path) { + let marker = new_dir.join(".migrated-to-codecora"); + if marker.is_file() { + return; + } + + let Some(home) = dirs::home_dir() else { + return; + }; + let old_dir = home.join(".cora"); + if !old_dir.is_dir() { + return; + } + + // Ensure new directory exists + if let Err(e) = std::fs::create_dir_all(new_dir) { + debug!("skip migration, cannot create new dir: {e}"); + return; + } + + // Files to migrate (copy, don't move — user can clean up old ones) + let files_to_migrate = ["config.yaml", "auth.toml", "config.toml"]; + let mut migrated_any = false; + + for filename in &files_to_migrate { + let old_path = old_dir.join(filename); + let new_path = new_dir.join(filename); + + if old_path.is_file() && !new_path.exists() { + match std::fs::copy(&old_path, &new_path) { + Ok(_) => { + debug!("migrated {filename} from ~/.cora/ to ~/.codecora/cora-code/"); + migrated_any = true; + } + Err(e) => { + debug!("failed to migrate {filename}: {e}"); + } + } + } + } + + // Migrate old marker so we don't re-run the TOML→YAML migration + let old_marker = old_dir.join(".migrated"); + if old_marker.is_file() { + let new_marker = new_dir.join(".migrated"); + if !new_marker.exists() { + let _ = std::fs::copy(&old_marker, &new_marker); + } + } + + // Write our marker so this migration never runs again + let _ = std::fs::write(&marker, "1"); + + if migrated_any { + eprintln!( + "ℹ️ Migrated cora config from ~/.cora/ to ~/.codecora/cora-code/. \ + Old files are kept as backup — safe to remove after verifying." + ); + } } -/// Read the stored API key from ~/.cora/auth.toml. +/// Read the stored API key from auth.toml. pub fn load_api_key_from_auth_file() -> std::result::Result, CoraError> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); @@ -468,7 +544,7 @@ pub fn load_api_key_from_auth_file() -> std::result::Result, Cora Ok(key) } -/// Save an API key to ~/.cora/auth.toml. +/// Save an API key to auth.toml. pub fn save_api_key(key: &str) -> std::result::Result<(), CoraError> { let dir = cora_dir()?; std::fs::create_dir_all(&dir).map_err(|e| CoraError::AuthError(e.to_string()))?; @@ -507,7 +583,7 @@ pub fn save_api_key(key: &str) -> std::result::Result<(), CoraError> { Ok(()) } -/// Remove the stored API key from ~/.cora/auth.toml. +/// Remove the stored API key from auth.toml. pub fn remove_api_key() -> std::result::Result<(), CoraError> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); @@ -528,7 +604,7 @@ pub fn auth_status() -> std::result::Result { has_key: true, }); } - // ~/.cora/auth.toml + // auth.toml in cora_data_dir if load_api_key_from_auth_file()?.is_some() { let dir = cora_dir()?; return Ok(AuthStatus { @@ -556,7 +632,7 @@ pub struct ProviderInfo { pub model: String, } -/// Save provider info (name, base_url, model) to `~/.cora/config.yaml` +/// Save provider info (name, base_url, model) to config.yaml /// (global config, not secrets). pub fn save_provider_info( provider: &str, @@ -675,7 +751,7 @@ fn migrate_provider_info_from_auth() -> std::result::Result<(), CoraError> { Ok(()) } -/// Load stored provider info from `~/.cora/config.yaml`. +/// Load stored provider info from config.yaml. /// Returns `None` if no provider info is stored. pub fn load_provider_info() -> std::result::Result, CoraError> { // Migrate auth.toml provider info → config.yaml if needed @@ -736,7 +812,7 @@ pub fn load_provider_info() -> std::result::Result, CoraErr })) } -/// Remove stored provider info from `~/.cora/config.yaml` +/// Remove stored provider info from config.yaml /// while keeping other settings. pub fn remove_provider_info() -> std::result::Result<(), CoraError> { let dir = cora_dir()?; diff --git a/src/config/schema.rs b/src/config/schema.rs index 624f2b4..8add627 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -59,6 +59,19 @@ pub struct Config { /// Brain Mode configuration — embedding backend selection. #[serde(default, skip_serializing_if = "is_default")] pub brain: BrainConfig, + /// Enable background update check on startup (default: true). + #[serde(default = "default_update_check", skip_serializing_if = "is_true")] + pub update_check: bool, +} + +/// Default value for `update_check` config field. +fn default_update_check() -> bool { + true +} + +/// serde helper: skip serializing when value is `true` (the default). +fn is_true(b: &bool) -> bool { + *b } /// Provider configuration. @@ -145,6 +158,7 @@ impl Default for Config { profile: None, analysis: AnalysisConfig::default(), brain: BrainConfig::default(), + update_check: true, } } } diff --git a/src/data_dir.rs b/src/data_dir.rs index e22f8c7..9f802e7 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -61,6 +61,13 @@ pub fn graph_db_path() -> PathBuf { new_db } +/// Path to the update check cache file. +/// +/// Stored at `~/.codecora/cora-code/update-cache.json`. +pub fn update_cache_path() -> PathBuf { + cora_data_dir().join("update-cache.json") +} + /// Ensure the cora-code data directory exists. pub fn ensure_data_dir() -> anyhow::Result { let dir = cora_data_dir(); diff --git a/src/engine/cache.rs b/src/engine/cache.rs index bbb3ce8..eee2817 100644 --- a/src/engine/cache.rs +++ b/src/engine/cache.rs @@ -6,11 +6,71 @@ use tracing::debug; use crate::engine::types::ReviewResponse; -/// Get the cache directory: ~/.cache/cora/reviews/ +/// Get the cache directory: ~/.codecora/cora-code/cache/reviews/ fn cache_dir() -> std::result::Result { - let home = dirs::home_dir() - .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; - Ok(home.join(".cache").join("cora").join("reviews")) + let dir = crate::data_dir::cora_data_dir() + .join("cache") + .join("reviews"); + + // One-time migration from legacy ~/.cache/cora/reviews/ + migrate_legacy_cache_dir(&dir); + + Ok(dir) +} + +/// Migrate review cache from legacy `~/.cache/cora/reviews/` to the new location. +fn migrate_legacy_cache_dir(new_dir: &std::path::Path) { + let marker = new_dir + .parent() + .map(|p| p.join(".migrated-to-codecora")) + .unwrap_or_else(|| new_dir.join(".migrated-to-codecora")); + if marker.is_file() { + return; + } + + let Some(home) = dirs::home_dir() else { + return; + }; + let old_dir = home.join(".cache").join("cora").join("reviews"); + if !old_dir.is_dir() { + // No legacy cache — write marker so we never check again + let _ = std::fs::create_dir_all(new_dir); + let _ = std::fs::write(&marker, "1"); + return; + } + + // Ensure new directory exists + if let Err(e) = std::fs::create_dir_all(new_dir) { + debug!("skip cache migration, cannot create new dir: {e}"); + return; + } + + // Move all .json cache files + let entries = match std::fs::read_dir(&old_dir) { + Ok(e) => e, + Err(_) => return, + }; + + let mut count = 0; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let filename = path.file_name().unwrap_or_default(); + let new_path = new_dir.join(filename); + if !new_path.exists() && std::fs::rename(&path, &new_path).is_ok() { + count += 1; + } + } + } + + // Write marker + let _ = std::fs::write(&marker, "1"); + + if count > 0 { + debug!( + "migrated {count} cache files from ~/.cache/cora/reviews/ to ~/.codecora/cora-code/cache/reviews/" + ); + } } /// Compute SHA-256 hex digest of the diff content + config parameters. diff --git a/src/engine/llm.rs b/src/engine/llm.rs index 74af42b..56d8525 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -198,6 +198,11 @@ CRITICAL CONSTRAINTS: When in doubt, downgrade severity rather than omitting — a borderline concern is a valid minor/info finding. 4. Common patterns to always check: unvalidated inputs, missing error handling, resource leaks, race conditions, off-by-one errors, unchecked edge cases. +LANGUAGE-SPECIFIC FALSE POSITIVE AWARENESS: +- In Rust, `Vec::retain()`, `Vec::append()`, `Vec::retain_mut()`, `Vec::splice()`, `Vec::dedup()`, `Vec::sort()`, `Vec::sort_by()` mutate the vector IN-PLACE. Do NOT flag code as "missing assignment" or "result ignored" when these methods are called — the mutation is the intended side effect. +- In Rust, `Err` arms that return early (e.g. `Err(e) => return error_response(...)`) are ERROR HANDLING paths. Do NOT flag them for missing post-conditions (like "filter not applied") — no data flows through error paths. +- In general, distinguish happy paths from error/early-return paths. Post-conditions (filters, transformations, validations) only need to hold on the happy path, not on every match arm. + SEVERITY LEVELS: - "critical": Security vulnerabilities, crashes, data loss, breaking bugs - "major": Bugs that affect functionality, logic errors, missing error handling, significant problems diff --git a/src/engine/review.rs b/src/engine/review.rs index cb83f7f..5121ff9 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -3,7 +3,7 @@ use tracing::{debug, instrument}; use crate::config::schema::Config; use crate::engine::llm; -use crate::engine::types::{LLMConfig, ReviewIssue, ReviewResponse}; +use crate::engine::types::{LLMConfig, ReviewIssue, ReviewResponse, Severity}; /// Load a custom system prompt from a file path. /// Returns the file content, or None if the file doesn't exist, can't be read, @@ -433,6 +433,11 @@ async fn review_diff_inner( // Apply ignore rules: filter out issues matching ignored patterns response.issues = apply_ignore_rules(response.issues, &config.ignore.rules); + // Drop low-severity findings on unchanged (context) lines — these are + // pre-existing code that appeared in the diff due to surrounding changes, + // not new code introduced by the PR (#507 Pattern #3). + response.issues = apply_context_line_filter(response.issues, &diff_chunks); + // Calculate should_block based on min_severity let min_severity = config.hook.min_severity_level(); // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above @@ -656,6 +661,75 @@ fn apply_ignore_rules(mut issues: Vec, ignore_rules: &[String]) -> issues } +/// Drop findings on unchanged (context) or removed lines (#507 Pattern #3). +/// +/// The LLM sometimes flags pre-existing code that appears in the diff purely +/// because surrounding lines changed. These findings are not about code the PR +/// introduces — they are noise. +/// +/// **Policy:** Only drop `Minor` and `Info` severity findings on context/removed +/// lines. `Critical` and `Major` findings are kept regardless, because they may +/// represent real risks worth surfacing even in pre-existing code. +fn apply_context_line_filter( + mut issues: Vec, + diff_chunks: &[crate::engine::diff_parser::FileChunk], +) -> Vec { + use crate::engine::diff_parser::DiffLineType; + + // Build lookup: (file, new_line_no) -> is_added + // Only includes lines present in the diff (Add or Context). Lines not in + // the diff at all are left alone (LLM line numbers can be imprecise). + let mut line_kinds: std::collections::HashMap<(String, u32), DiffLineType> = + std::collections::HashMap::new(); + for chunk in diff_chunks { + let path = chunk + .new_path + .as_deref() + .or(chunk.old_path.as_deref()) + .unwrap_or(""); + for hunk in &chunk.chunks { + for line in &hunk.lines { + if let Some(ln) = line.new_line_no { + line_kinds.insert((path.to_string(), ln), line.line_type); + } + } + } + } + + let before = issues.len(); + issues.retain(|issue| { + // Keep findings without a concrete line number + let Some(ln) = issue.line else { + return true; + }; + + // Only filter if we can resolve this (file, line) to a diff line + let Some(kind) = line_kinds.get(&(issue.file.clone(), ln)) else { + return true; // not in diff — can't determine, keep + }; + + match kind { + DiffLineType::Add => true, // genuinely new code — always keep + DiffLineType::Context | DiffLineType::Remove => { + // Pre-existing code — only keep if severity is high enough + // Ord: Critical(0) < Major(1) < Minor(2) < Info(3) + issue.severity <= Severity::Major + } + } + }); + + let dropped = before - issues.len(); + if dropped > 0 { + debug!( + dropped, + remaining = issues.len(), + "removed low-severity findings on unchanged diff context lines (#507)" + ); + } + + issues +} + /// Check if a file path from an LLM issue matches any of the valid diff file paths. /// Uses exact match only — the LLM should report paths exactly as they appear in the diff. fn is_valid_file_path(issue_file: &str, valid_files: &[String]) -> bool { diff --git a/src/main.rs b/src/main.rs index 74c2bc2..507da76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -545,6 +545,17 @@ enum Command { /// Start MCP server with auto-reindex on startup Serve, + + /// Check for updates and self-upgrade the cora binary + Upgrade { + /// Skip confirmation prompt (useful for CI/automation) + #[clap(long)] + yes: bool, + + /// Only check if an update is available, do not download + #[clap(long)] + check: bool, + }, } #[derive(Subcommand, Debug)] @@ -662,6 +673,16 @@ async fn main() -> Result<()> { colored::control::set_override(false); } + // Background update check (non-blocking, cached 24h). + // Skip for `upgrade` command itself and when `--no-update-check` is set. + let skip_update_check = matches!(cli.command, Command::Upgrade { .. }) + || std::env::var("CORA_NO_UPDATE_CHECK") + .map(|v| v == "1" || v == "true") + .unwrap_or(false); + if !skip_update_check { + commands::update_check::spawn_background_check(true); + } + // Dispatch based on subcommand let exit_code = match cli.command { Command::Index { @@ -1637,6 +1658,7 @@ async fn main() -> Result<()> { commands::serve::execute_serve()?; 0 } + Command::Upgrade { yes, check } => commands::upgrade::run(yes, check).await?, }; std::process::exit(exit_code);