diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9d917c7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,149 @@ +## Summary + + + +## Changelog + + + +### Added + +- + +### Changed + +- + +### Fixed + +- + +### Documentation + +- + +## Type of Change + + + +- [ ] `feat`: New feature (non-breaking change which adds functionality) +- [ ] `fix`: Bug fix (non-breaking change which fixes an issue) +- [ ] `refactor`: Code refactoring (no functional changes) +- [ ] `perf`: Performance improvement +- [ ] `docs`: Documentation update +- [ ] `test`: Adding or updating tests +- [ ] `chore`: Maintenance tasks (dependencies, config, etc.) +- [ ] `ci`: CI/CD configuration changes +- [ ] `style`: Code style/formatting changes +- [ ] `build`: Build system changes +- [ ] `BREAKING CHANGE`: Breaking API change (requires major version bump) + +## Related Issues/Stories + + + +- Story: +- Issue: +- Architecture: +- Related PRs: + +## Testing + + + +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Manual testing completed +- [ ] All tests passing + +**Test Summary:** + +- Unit tests: X passing +- Integration tests: Y passing +- Coverage: Z% + +## Files Modified + + + +**Modified:** + +**Created:** + +**Deleted:** + +## Key Features + + + +- + +## Benefits + + + +- + +## Breaking Changes + + + +- [ ] No breaking changes +- [ ] Breaking changes described below: + +## Deployment Notes + + + +- [ ] No special deployment steps required +- [ ] Deployment steps documented below: + +## Screenshots/Recordings + + + +## Checklist + +- [ ] Code follows project conventions and style guidelines +- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) +- [ ] Self-review of code completed +- [ ] Tests added/updated and passing +- [ ] No new warnings or errors introduced +- [ ] Changes are backward compatible (or breaking changes documented) + +## Additional Context + + + +--- + + diff --git a/.github/rulesets/protect-main.json b/.github/rulesets/protect-main.json index f03e9c8..ca20e34 100644 --- a/.github/rulesets/protect-main.json +++ b/.github/rulesets/protect-main.json @@ -58,6 +58,9 @@ }, { "context": "guard-provenance / check-provenance" + }, + { + "context": "guard-release / check-release-branch-name" } ] } diff --git a/.github/workflows/guard-release-branch.yml b/.github/workflows/guard-release-branch.yml new file mode 100644 index 0000000..9a355eb --- /dev/null +++ b/.github/workflows/guard-release-branch.yml @@ -0,0 +1,27 @@ +# Source-repo caller wrapper for the reusable guard-release-branch workflow. +# Copy to .github/workflows/guard-release-branch.yml in repos using the +# forever-dev + release/* flow. +# +# Rejects PRs to main whose head branch doesn't start with `release/`. +# Keeps `dev` off the list of PR heads, which is what makes +# `deleteBranchOnMerge: true` safe to leave on without risking `origin/dev`. +# +# Default prefix `release/` is supplied by the reusable workflow; pass +# `with: prefix: ` to override. +# +# Required caller permissions: pull-requests: read +# +# The caller's job key must be `guard-release:` so the required status +# check context is `guard-release / check-release-branch-name`. +name: Guard release branch pattern + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: read + +jobs: + guard-release: + uses: brettdavies/.github/.github/workflows/guard-release-branch.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f675c95..f029c98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ # Source repo release wrapper — calls the centralized reusable workflow. -# Copy to .github/workflows/release.yml in the tool repo. # -# Template parameters: -# agentnative -- crate name (e.g., xurl-rs, bird) -# agentnative -- binary name (e.g., xr, bird) +# crate: agentnative (crates.io name) | bin: anc (installed executable) # # Pipeline: check-version -> build (5 targets) -> crates.io -> draft release -> homebrew name: Release @@ -22,6 +19,6 @@ jobs: uses: brettdavies/.github/.github/workflows/rust-release.yml@main with: crate: agentnative - bin: agentnative + bin: anc secrets: CI_RELEASE_TOKEN: ${{ secrets.CI_RELEASE_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..229dad4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +/dist +*.pdb +.env +.env.local +*.local diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..1685e69 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,85 @@ +# Global markdownlint-cli2 configuration +# Canonical version: 2026.04.15 +# Symlinked to ~/.markdownlint-cli2.yaml via stow +# +# Per-repo copies should preserve the `Canonical version` line above and bump it on resync; +# a stale calver compared to this file is the signal that the per-repo copy has drifted. +# +# Documentation: https://github.com/DavidAnson/markdownlint-cli2 +# Rules: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + +# Configure markdownlint rules +config: + # Use all default rules + default: true + + # MD003: Heading style - use ATX style (#) + MD003: + style: "atx" + + # MD004: Unordered list style - use dashes + MD004: + style: "dash" + + # MD007: Unordered list indentation - 2 spaces + MD007: + indent: 2 + + # MD009: Trailing spaces - allow 2 spaces for line breaks + MD009: + br_spaces: 2 + + # MD013: Line length - 120 chars (more reasonable for code docs) + MD013: + line_length: 120 + code_blocks: false # Don't check code blocks + tables: false # Don't check tables + headings: false # Don't check headings + + # MD024: Allow duplicate headings in different sections + MD024: + siblings_only: true + + # MD025: Single top-level heading - allow multiple (for changelogs, etc.) + MD025: false + + # MD033: Allow inline HTML for specific elements + MD033: + allowed_elements: + - "br" + - "img" + - "a" + - "details" + - "summary" + - "sub" + - "sup" + - "kbd" + + # MD034: Bare URLs - allow (common in docs) + MD034: false + + # MD036: Emphasis used as heading - allow (stylistic choice) + MD036: false + + # MD041: First line should be top-level heading - disable (not always needed) + MD041: false + + # MD046: Code block style - fenced + MD046: + style: "fenced" + + # MD048: Code fence style - backticks + MD048: + style: "backtick" + +# Ignore patterns +ignores: + - "node_modules/**" + - "**/node_modules/**" + - "vendor/**" + - "target/**" # Rust build artifacts; harmless for non-Rust projects + - ".git/**" + - "*.min.md" + +# Fix automatically when --fix is used +fix: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..93f9459 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +## Running anc + +The crate is `agentnative`. The installed binary is `anc`. + +```bash +# Check current project — `check` is implicit when the first non-flag arg is a path +anc . + +# Resolve a command on PATH and run behavioral checks against it +anc --command ripgrep + +# JSON output for parsing +anc . --output json + +# Quiet mode (warnings and failures only) +anc . -q + +# Filter by principle (1-7) +anc . --principle 4 + +# Behavioral checks only (no source analysis) +anc . --binary + +# Source checks only (no binary execution) +anc . --source +``` + +Bare `anc` (no arguments) prints help and exits 2. This is a non-negotiable fork-bomb guard: when agentnative dogfoods +itself, children spawned without arguments must not recurse into `check .`. + +## Exit Codes + +- `0` — all checks passed +- `1` — warnings present, no failures +- `2` — failures, errors, or usage errors (bare `anc`, unknown flag, mutually exclusive flags, command not found on + PATH) + +Exit 2 is overloaded. To distinguish "ran but found problems" from "called +incorrectly", parse stderr — usage errors include `Usage:` text; check failures don't. + +## Project Structure + +- `src/check.rs` — Check trait definition +- `src/checks/behavioral/` — checks that run the compiled binary +- `src/checks/source/rust/` — ast-grep source analysis checks +- `src/checks/project/` — file and manifest inspection checks +- `src/runner.rs` — binary execution with timeout and caching +- `src/project.rs` — project discovery and source file walking +- `src/scorecard.rs` — output formatting (text and JSON) +- `src/types.rs` — CheckResult, CheckStatus, CheckGroup, CheckLayer + +## Adding a New Check + +1. Create a file in the appropriate `src/checks/` subdirectory +2. Implement the `Check` trait: `id()`, `group()`, `layer()`, `applicable()`, `run()` +3. Register in the layer's `mod.rs` (e.g., `all_rust_checks()`) +4. Add inline `#[cfg(test)]` tests + +## Testing + +```bash +cargo test # unit + integration tests +cargo test -- --ignored # fixture tests (slower) +``` + +## Spec source (principles) + +The canonical specification of the 7 agent-readiness principles lives in the vault, one file per principle. The `anc` +checks in `src/checks/` are derived **manually** from these files — there is no build-time import, no live link. When a +principle's spec changes, propagate to the relevant check(s) deliberately. + +- `~/obsidian-vault/Projects/brettdavies-agentnative/principles/index.md` — table of P1-P7 with status (draft / + under-review / locked). +- `~/obsidian-vault/Projects/brettdavies-agentnative/principles/AGENTS.md` — iteration workflow, pressure-test protocol, + per-file structure. Read before proposing a new check that stretches the existing P coverage. + +When a check is added or revised, its code or doc comment should name the principle code (`P`) it implements for +traceability. Do not embed the principle text in the check source. + +## External signal / research + +Curated external signal that informs principle iteration, check rules, and positioning lives in the sibling research +folder: + +- `~/obsidian-vault/Projects/brettdavies-agentnative/research/index.md` — top of the research tree. Lists every extract + with date, topic, and which principles it maps to. Read this before adding new checks driven by external patterns or + competitor behavior. +- `extracts/` — curated, topic-scoped files (verbatim quotes, principle mapping, recommended uses). +- `raw/` — full-text captures. + +When an extract names concrete linter-rule candidates, walk its **"Linter rule coverage audit"** or equivalent +section against existing checks in `src/checks/` before opening a new check. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ce8ead4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-04-16 + +### Added + +- Add Check trait, Project struct with automatic language detection, and BinaryRunner with timeout and caching by @brettdavies in [#1](https://github.com/brettdavies/agentnative/pull/1) +- Add 8 behavioral checks: help text, version flag, JSON output, bad-args handling, quiet mode, SIGPIPE, non-interactive mode, no-color +- Add 3 Rust source checks via ast-grep: unwrap usage, no-color support, global flags +- Add CLI with `check` and `completions` subcommands, text and JSON scorecard output +- Add 30-check agent-readiness scorecard across behavioral, source, and project layers by @brettdavies in [#2](https://github.com/brettdavies/agentnative/pull/2) +- Add 13 Rust source checks and 6 project checks +- Add complete README with principles table, examples, and CLI reference +- `--command ` flag on `check` resolves a binary from PATH and runs behavioral checks against it. Mutually exclusive with the positional path. by @brettdavies in [#12](https://github.com/brettdavies/agentnative/pull/12) +- `value_hint = ValueHint::CommandName` on `--command` so zsh, fish, and elvish completions suggest PATH commands instead of file paths. Bash is patched post-generation in `scripts/generate-completions.sh`. by @brettdavies in [#13](https://github.com/brettdavies/agentnative/pull/13) +- `after_help` text on `Cli` documenting the implicit default subcommand and the bare-invocation contract directly in `anc --help` output. +- Mutual exclusion: `--command` and `--source` now error at parse time instead of silently producing an empty result. +- Add `code-bare-except` Python source check — detects bare `except:` clauses without exception types by @brettdavies in [#15](https://github.com/brettdavies/agentnative/pull/15) +- Add `p4-sys-exit` Python source check — detects `sys.exit()` calls outside `if __name__ == "__main__":` guards and `__main__.py` files +- Add `p6-no-color` Python source check — detects NO_COLOR env var handling (Warn, not Fail — behavioral check is the primary gate) +- Add language-parameterized source helpers `has_pattern_in()`, `find_pattern_matches_in()`, and `has_string_literal_in()` supporting Python and Rust + +### Changed + +- Change `--quiet`/`-q` to a global flag so it appears in top-level `--help` for agent discoverability by @brettdavies in [#6](https://github.com/brettdavies/agentnative/pull/6) +- The installed binary is now `anc`. The crate is still `agentnative`. Homebrew users will get both `anc` and an `agentnative` symlink (formula lands in Plan 002). by @brettdavies in [#11](https://github.com/brettdavies/agentnative/pull/11) +- `check` is now the default subcommand: `anc .`, `anc -q .`, and `anc --command ripgrep` all work without typing `check` explicitly. Bare `anc` (no arguments) still prints help and exits 2. by @brettdavies in [#12](https://github.com/brettdavies/agentnative/pull/12) +- `anc -q` / `anc --quiet` (top-level flag without subcommand) now prints help and exits 2 instead of panicking via `unreachable!()` (pre-existing bug). by @brettdavies in [#13](https://github.com/brettdavies/agentnative/pull/13) +- `anc help` and `anc help check` now work — clap's auto-generated `help` subcommand was missing from our known-subcommand set and got misclassified as a path. +- `anc --command ` where NAME collides with a subcommand name (e.g. `anc --command check`) now resolves NAME as a binary on PATH instead of producing a confusing clap error. +- `anc --command rg` and `anc --output json --source` (no positional argument) now work — the pre-parser detects subcommand-scoped flags and injects `check` accordingly. +- `anc -- .` (POSIX double-dash separator) now runs check against `.` instead of producing undefined behavior. + +### Fixed + +- Fix recursive fork bomb when dogfooding `agentnative check .` against itself by @brettdavies in [#7](https://github.com/brettdavies/agentnative/pull/7) +- Fix false positive: `sys.exit()` in `__main__.py` (Python entry point) no longer flagged by @brettdavies in [#15](https://github.com/brettdavies/agentnative/pull/15) +- Fix `is_main_guard`: now handles inline comments, parenthesized guards, no-space operators, and reversed operand order (e.g. `if "__main__" == __name__:`) +- Fix `is_bare_except`: restrict parsing to first line of node text (prevents false negatives on error-recovery nodes) +- Fix `__main__.py` skip to check filename component, not path suffix (prevents false skips on files like `my__main__.py`) +- Fix TOCTOU gap in `parsed_files` lazy initialization (replaced `RefCell` with `OnceLock`) +- Remove dead `except*` branch from bare-except detection (PEP 654 makes bare `except*:` a syntax error) + +### Documentation + +- Add `RELEASES.md` documenting the dev/main/release/* workflow and the Rust release pipeline (crates.io, GitHub Releases, Homebrew dispatch). by @brettdavies in [#11](https://github.com/brettdavies/agentnative/pull/11) +- README install section now lists all five distribution channels (Homebrew, cargo install, cargo binstall, GitHub Releases, from source) and all five shell completions with canonical auto-loaded paths. +- README and AGENTS.md updated to lead with the new ergonomics and document the `[PATH]` / `--command` mutual exclusion. by @brettdavies in [#12](https://github.com/brettdavies/agentnative/pull/12) +- README and AGENTS.md exit-code tables clarify that exit 2 is overloaded (failures, errors, and usage errors all share it). Suggest parsing stderr (`Usage:` text) to distinguish. by @brettdavies in [#13](https://github.com/brettdavies/agentnative/pull/13) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..88a6d7f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# agentnative + +The agent-native CLI linter. Checks whether CLI tools follow 7 agent-readiness principles. + +## Architecture + +Two-layer check system: + +- **Behavioral checks** — run the compiled binary, language-agnostic (any CLI) +- **Source checks** — ast-grep pattern matching via bundled `ast-grep-core` crate (Rust, Python at launch) +- **Project checks** — file existence, manifest inspection + +Design doc: `~/.gstack/projects/brettdavies-agentnative/brett-main-design-20260327-214808.md` + +## Skill Routing + +When the user's request matches an available skill, ALWAYS invoke it using the Skill +tool as your FIRST action. Do NOT answer directly, do NOT use other tools first. + +**gstack skills (ideation, planning, shipping, ops):** + +- Product ideas, "is this worth building", brainstorming → invoke office-hours +- Plan review, scope challenge, "think bigger" → invoke autoplan (or plan-ceo-review, plan-eng-review) +- Ship, deploy, push, create PR → invoke ship +- Bugs, errors, "why is this broken" → invoke investigate +- What did we learn, persist learnings → invoke learn +- Weekly retro → invoke retro +- Security audit → invoke cso +- Second opinion → invoke codex + +**compound-engineering skills (code loop):** + +- Implementation plan from repo code → invoke ce-plan +- Write code following a plan → invoke ce-work +- Code review before PR → invoke ce-review +- Document solution in docs/solutions/ → invoke ce-compound + +For the full routing table, see `~/.claude/skills/docs/workflow-routing.md`. + +## Documented Solutions + +`docs/solutions/` (symlink to `~/dev/solutions-docs/`) — searchable archive of past +solutions and best practices, organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search +with `qmd query "" --collection solutions`. Relevant when implementing or debugging in documented areas. + +## gstack Project History + +This project was designed in the `brettdavies/agent-skills` repo, then moved here. gstack project data (design doc, eng +review, naming rationale, review history) has been copied to `~/.gstack/projects/brettdavies-agentnative/`. + +Key decisions already made: + +- Name: `agentnative` with `anc` alias (see naming rationale) +- Approach B: bundled ast-grep hybrid (behavioral + source checks) +- ast-grep-core v0.42.0 validated via spike (3 PoC checks, 18 tests pass) +- Eng review: CLEARED, 10 issues resolved, 1 critical gap addressed +- Codex review: 12 findings, 3 actioned + +## Conventions + +- `ast-grep-core` and `ast-grep-language` pinned to exact version (`=0.42.0`) — pre-1.0 API +- `Position` uses `.line()` / `.column(&node)` methods, not tuple access +- Pre-build `Pattern` objects for `find_all()` — `&str` rebuilds on every node +- Feature flag is `tree-sitter-rust`, not `language-rust` +- Edition 2024, dual MIT/Apache-2.0 license + +## Source Check Convention + +Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use +different helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor): + +- **Struct** implements `Check` trait with `id()`, `group()`, `layer()`, `applicable()`, `run()` +- **`check_x()` helper** takes `(source: &str)` (or `(source: &str, file: &str)` when evidence needs file location + context) and returns `CheckStatus` (not `CheckResult`) — this is the unit-testable core +- **`run()` is the sole `CheckResult` constructor** — uses `self.id()`, `self.group()`, `self.layer()` to build the + result. Never hardcode ID/group/layer string literals in `check_x()` or anywhere outside `run()` +- **Tests call `check_x()`** and match on `CheckStatus` directly, not `result.status` + +This prevents ID triplication (the same string literal in `id()`, `run()`, and `check_x()`) and ensures the `Check` +trait is the single source of truth for check metadata. + +For cross-language pattern helpers, use `source::has_pattern_in()` / `source::find_pattern_matches_in()` / +`source::has_string_literal_in()` with a `Language` parameter — do not write private per-language helpers in individual +check files. + +## Dogfooding Safety + +Behavioral checks spawn the target binary as a child process. When dogfooding (`anc check .`), the target IS +agentnative. Two rules prevent recursive fork bombs: + +1. **Bare invocation prints help** (`cli.rs`): `arg_required_else_help = true` means children spawned with no args get + instant help output instead of running `check .`. This is also correct CLI behavior (P1 principle). +2. **Safe probing only** (`json_output.rs`): Subcommands are probed with `--help`/`--version` suffixes only, never bare. + Bare `subcmd --output json` is unsafe for any CLI with side-effecting subcommands. + +**Rules for new behavioral checks:** + +- NEVER probe subcommands without `--help`/`--version` suffixes +- NEVER remove `arg_required_else_help` from `Cli` — it prevents recursive self-invocation + +## CI and Quality + +**Toolchain pin:** `rust-toolchain.toml` pins the channel to a specific `X.Y.Z` version with a trailing comment naming +the rustc commit SHA. Rustup reads this file on every `cargo` invocation — both local and CI snap to identical bits. +Rustup verifies component SHA256s from the distribution manifest, so the version pin is effectively a SHA pin (the +manifest is the toolchain's "lockfile"). Bumping the toolchain is a reviewed PR that updates `rust-toolchain.toml`; no +runtime `rustup update` anywhere. Policy: bump only after a new stable has aged ≥7 days (supply-chain quarantine). + +**Pre-push hook:** `scripts/hooks/pre-push` mirrors CI exactly: fmt, clippy with `-Dwarnings`, test, cargo-deny, and a +Windows compatibility check. Tracked in git and activated via `core.hooksPath`. After cloning, run: `git config +core.hooksPath scripts/hooks` + +**Windows compatibility:** Only `libc` belongs in `[target.'cfg(unix)'.dependencies]`. All SIGPIPE/signal code must be +inside `#[cfg(unix)]` blocks. The pre-push hook checks this statically. + +**After pushing:** Check CI status in the background with `gh run watch --exit-status` (use `run_in_background: true` so +it doesn't block). Report failures when notified. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0605753 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1038 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agentnative" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "ast-grep-core", + "ast-grep-language", + "clap", + "clap_complete", + "insta", + "libc", + "predicates", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "ast-grep-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bdde23a7460d533ac757b053ad37d59e33d6ca6cfc21707000e07d8cfc431e9" +dependencies = [ + "bit-set", + "regex", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "ast-grep-language" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1325ce42de0cf5d3ffdaf8e8c41f9eb8bad3cd278849bb746594245908370572" +dependencies = [ + "ast-grep-core", + "ignore", + "serde", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +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 = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[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 = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tree-sitter" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a6592b1aec0109df37b6bafea77eb4e61466e37b0a5a98bef4f89bfb81b7a2" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5cf0b75 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "agentnative" +version = "0.1.0" +edition = "2024" +description = "The agent-native CLI linter — check whether your CLI follows agent-readiness principles" +license = "MIT OR Apache-2.0" +repository = "https://github.com/brettdavies/agentnative" +homepage = "https://github.com/brettdavies/agentnative" +documentation = "https://docs.rs/agentnative" +keywords = ["cli", "linter", "agent", "ast-grep", "developer-tools"] +categories = ["command-line-utilities", "development-tools"] +authors = ["Brett Davies "] +readme = "README.md" +rust-version = "1.87" +exclude = [ + ".claude/", + ".context/", + ".github/", + ".markdownlint-cli2.yaml", + "cliff.toml", + "deny.toml", + "docs/", + "rustfmt.toml", + "scripts/", + "tests/", +] + +[[bin]] +name = "anc" +path = "src/main.rs" + +[dependencies] +# AST pattern matching — pinned pre-1.0 +ast-grep-core = { version = "=0.42.0" } +ast-grep-language = { version = "=0.42.0", default-features = false, features = [ + "tree-sitter-rust", + "tree-sitter-python", +] } + +# CLI framework +clap = { version = "4.4", features = ["derive", "env"] } +clap_complete = "4" + +# Manifest parsing +toml = "0.8" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +anyhow = "1" +thiserror = "2" + +# Platform +libc = "0.2" + +[dev-dependencies] +assert_cmd = "2" +insta = "1" +predicates = "3" + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/agentnative-{ target }.tar.gz" +pkg-fmt = "tgz" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/agentnative-{ target }.zip" +pkg-fmt = "zip" + +[profile.release] +strip = true +lto = true +codegen-units = 1 +panic = "abort" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..cb3a32b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,199 @@ + 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 + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + "Alarm" or "alarm" if your text editor autocorrects to "Alarm." + + Copyright 2026 Brett Davies + + 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/LICENSE b/LICENSE-MIT similarity index 97% rename from LICENSE rename to LICENSE-MIT index d1131b4..d44e431 100644 --- a/LICENSE +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Brett +Copyright (c) 2026 Brett Davies Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3b203f --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# agentnative + +The agent-native CLI linter. Checks whether your CLI follows the 7 agent-readiness principles. + +## Install + +The crate is published as `agentnative`. The binary is called `anc`. + +```bash +# Homebrew (installs anc) +brew install brettdavies/tap/agentnative + +# From crates.io +cargo install agentnative + +# Pre-built binary via cargo-binstall +cargo binstall agentnative + +# Pre-built binaries from GitHub Releases +# https://github.com/brettdavies/agentnative/releases +``` + +## Quick Start + +```bash +# Check the current project (`check` is the default subcommand) +anc . + +# Check a specific binary +anc ./target/release/mycli + +# Resolve a command on PATH and run behavioral checks against it +anc --command ripgrep + +# JSON output for CI +anc . --output json + +# Filter by principle +anc . --principle 3 + +# Quiet mode (warnings and failures only) +anc . -q +``` + +## The 7 Principles + +agentnative checks your CLI against seven agent-readiness principles: + +| # | Principle | What It Means | +| - | --------- | ------------- | +| P1 | Non-Interactive by Default | No prompts, no browser popups, stdin from `/dev/null` works | +| P2 | Structured Output | `--output json` exists and produces valid JSON | +| P3 | Progressive Help | `--help` has examples, `--version` works | +| P4 | Actionable Errors | Structured error types, named exit codes, no `.unwrap()` | +| P5 | Safe Retries | `--dry-run` for write operations | +| P6 | Composable Structure | SIGPIPE handled, NO_COLOR respected, shell completions, AGENTS.md | +| P7 | Bounded Responses | `--quiet` flag, no unbounded list output, clamped pagination | + +## Example Output + +```text +P1 — Non-Interactive by Default + [PASS] Non-interactive by default (p1-non-interactive) + [PASS] No interactive prompt dependencies (p1-non-interactive-source) + +P3 — Progressive Help + [PASS] Help flag produces useful output (p3-help) + [PASS] Version flag works (p3-version) + +P4 — Actionable Errors + [PASS] Rejects invalid arguments (p4-bad-args) + [PASS] No process::exit outside main (p4-process-exit) + +P6 — Composable Structure + [PASS] Handles SIGPIPE gracefully (p6-sigpipe) + [PASS] Respects NO_COLOR (p6-no-color) + [PASS] Shell completions support (p6-completions) + +Code Quality + [PASS] No .unwrap() in source (code-unwrap) + +30 checks: 20 pass, 8 warn, 0 fail, 2 skip, 0 error +``` + +## Three Check Layers + +agentnative uses three layers to analyze your CLI: + +- **Behavioral** — runs the compiled binary, checks `--help`, `--version`, `--output json`, SIGPIPE, NO_COLOR, exit + codes. Language-agnostic. +- **Source** — ast-grep pattern matching on source code. Detects `.unwrap()`, missing error types, naked `println!`, and + more. Currently supports Rust. +- **Project** — inspects files and manifests. Checks for AGENTS.md, recommended dependencies, dedicated error/output + modules. + +## CLI Reference + +When the first non-flag argument is not a recognized subcommand, `check` is inserted automatically. `anc .`, +`anc -q .`, and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and +exits 2 — this is deliberate fork-bomb prevention when agentnative dogfoods itself. + +```text +Usage: anc check [OPTIONS] [PATH] + +Arguments: + [PATH] Path to project directory or binary [default: .] + +Options: + --command Resolve a command from PATH and run behavioral checks against it + --binary Run only behavioral checks (skip source analysis) + --source Run only source checks (skip behavioral) + --principle Filter checks by principle number (1-7) + --output Output format [default: text] [possible values: text, json] + -q, --quiet Suppress non-essential output + --include-tests Include test code in source analysis + -h, --help Print help +``` + +`--command` and `[PATH]` are mutually exclusive — pick one. `--command` runs behavioral checks only; source and project +checks are skipped because there is no source tree to analyze. + +### Exit Codes + +| Code | Meaning | +| ---- | ------- | +| 0 | All checks passed | +| 1 | Warnings present (no failures) | +| 2 | Failures, errors, or usage errors | + +Exit 2 covers both check failures (a real `[FAIL]` or `[ERROR]` result) and usage errors (bare `anc`, unknown flag, +mutually exclusive flags). Agents distinguishing the two should parse `stderr` (usage errors print `Usage:`) or call +`anc --help` first to validate the invocation shape. + +### Shell Completions + +```bash +# Bash +anc completions bash > ~/.local/share/bash-completion/completions/anc + +# Zsh (writes to the first directory on your fpath) +anc completions zsh > "${fpath[1]}/_anc" + +# Fish +anc completions fish > ~/.config/fish/completions/anc.fish + +# PowerShell +anc completions powershell > anc.ps1 + +# Elvish +anc completions elvish > anc.elv +``` + +Pre-generated scripts are also available in `completions/`. + +## JSON Output + +```bash +anc check . --output json +``` + +Produces a scorecard with results and summary: + +```json +{ + "results": [ + { + "id": "p3-help", + "label": "Help flag produces useful output", + "group": "P3", + "layer": "behavioral", + "status": "pass", + "evidence": null + } + ], + "summary": { + "total": 30, + "pass": 20, + "warn": 8, + "fail": 0, + "skip": 2, + "error": 0 + } +} +``` + +## Contributing + +```bash +git clone https://github.com/brettdavies/agentnative +cd agentnative +cargo test +cargo run -- check . +``` + +## License + +MIT OR Apache-2.0 diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..3bcb96e --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,211 @@ +# Releasing `agentnative` + +Every change reaches production via this pipeline. Direct commits to `dev` or `main` are not permitted — every change +has a PR number in its squash commit message, which keeps the history scannable, attributable, and changelog-ready. + +```text +feature branch → PR to dev (squash merge) + → cherry-pick to release/* branch + → PR to main (squash merge) + → tag push triggers crates.io publish + GitHub Release + Homebrew dispatch +``` + +## Branches + +| Branch | Role | Lifetime | Protection | +| ------ | ---- | -------- | ---------- | +| `main` | Production. Only release commits. | Forever. | `.github/rulesets/protect-main.json` | +| `dev` | Integration. All feature PRs land here. | Forever. Never delete. | `.github/rulesets/protect-dev.json` | +| `feat/*`, `fix/*`, `chore/*`, `docs/*` | Feature work. | One PR's worth. Auto-deleted on merge. | None — squash into dev freely. | +| `release/*` | Head of a dev → main PR. | One release's worth. Auto-deleted on merge. | None. | + +`dev` is a **forever branch**. Never delete it locally or remotely, even after a `release/* → main` merge. The next +release cycle reuses the same `dev`. The repo's `deleteBranchOnMerge: true` setting doesn't touch `dev` as long as `dev` +is never the head of a PR — using a short-lived `release/*` head is what keeps the setting compatible with a forever +integration branch. + +## Daily development (feature → dev) + +```bash +git checkout dev && git pull +git checkout -b feat/short-description +# ... work ... +git push -u origin feat/short-description +gh pr create --base dev --title "feat(scope): what changed" +# CI passes → squash-merge (PR_BODY becomes the dev commit message) +``` + +- **Commit style**: [Conventional Commits](https://www.conventionalcommits.org/). +- **PR body**: follow `.github/pull_request_template.md`. The `## Changelog` section is the source of truth for + user-facing release notes — `git-cliff` extracts these bullets verbatim into `CHANGELOG.md` during release prep. + +## Releasing dev to main + +Engineering docs (`docs/plans/`, `docs/solutions/`, `docs/brainstorms/`, +`docs/reviews/`) live on `dev` only. `guard-main-docs.yml` blocks them from reaching `main`, and +`guard-release-branch.yml` rejects any PR to main whose head isn't `release/*`. Use the release-branch cherry-pick +pattern: + +**Branch naming**: `release/v` or `release/v-` (e.g. `release/v0.1.0`, +`release/v0.2.0-python-checks`). The `v` prefix is required — `scripts/generate-changelog.sh` extracts the +version from the branch name. + +```bash +# 1. Branch from main, NOT dev. Branching from dev causes add/add conflicts +# when dev and main have divergent histories (the post-squash-merge norm). +git fetch origin +git checkout -b release/v0.2.0 origin/main + +# 2. List the dev commits not yet on main: +git log --oneline dev --not origin/main + +# 3. Cherry-pick the ones you want to ship. Docs commits stay on dev. +git cherry-pick ... + +# 4. Verify no guarded paths leaked through: +git diff origin/main --stat +# If anything under docs/plans/, docs/solutions/, or docs/brainstorms/ +# shows up, you cherry-picked a docs commit by mistake — reset and redo. + +# 5. Bump version in Cargo.toml and commit: +# sed -i 's/^version = ".*"/version = "0.2.0"/' Cargo.toml +# cargo update -p agentnative # refresh Cargo.lock +# git add Cargo.toml Cargo.lock && git commit -m "chore: bump version to 0.2.0" + +# 6. Regenerate completions (catches any subcommand/flag changes missed during dev): +./scripts/generate-completions.sh +git add completions/ && git commit -m "chore: regenerate shell completions" || true + +# 7. Generate CHANGELOG.md (auto-detects version from branch name; CI enforces this): +./scripts/generate-changelog.sh +git add CHANGELOG.md && git commit -m "docs: update CHANGELOG.md for v0.2.0" + +# 8. Push and open the PR: +git push -u origin release/v0.2.0 +gh pr create --base main --head release/v0.2.0 --title "release: v0.2.0" +``` + +When the PR merges, the deploy / publish workflow picks up the push to `main`. Auto-delete removes `release/v0.2.0` from +the remote on merge. `dev` is untouched. + +### Why branch from main, not dev + +Branching from `dev` and then `gio trash`-ing the guarded paths seems simpler but produces `add/add` merge conflicts +whenever `dev` and `main` have diverged (which they always do after the first squash merge). The file appears as "added" +on both sides with different content. Always branch from `origin/main` and cherry-pick onto it. + +## Tagging and publishing + +After the `release/v → main` PR merges, tag and push: + +```bash +git checkout main && git pull +git tag -a -m "Release v0.2.0" v0.2.0 +git push origin main --tags +``` + +> Always use annotated tags (`-a -m`). Bare `git tag ` silently fails with +> `fatal: no tag message?` on machines where `tag.gpgsign=true` is set globally +> (a brettdavies dotfile default). See +> [solutions: git tag fails with tag.gpgsign — use annotated tags](https://github.com/brettdavies/solutions-docs/blob/main/best-practices/git-tag-fails-with-tag-gpgsign-use-annotated-tags-2026-04-13.md). + +The tag push triggers `.github/workflows/release.yml`, which calls the reusable +`brettdavies/.github/.github/workflows/rust-release.yml@main` and runs: + +| Step | What | +| ---- | ---- | +| `check-version` | Verify the tag matches `Cargo.toml` version (gate). | +| `audit` | `cargo deny check` (license + advisory + ban). | +| `build` | Cross-compile binaries for 5 targets: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`. Each archive includes the `anc` binary, completions, README, and licenses. | +| `publish-crate` | `cargo publish` to crates.io via Trusted Publishing (OIDC, no static token after first publish). | +| `release` | Create a **non-draft** GitHub Release with `make_latest: false` — visible immediately (so `cargo-binstall` and `/releases/latest` don't 404 during the bottle-build window) but not yet promoted to "Latest". Includes all 5 archives + `sha256sum.txt`. | +| `homebrew` | Dispatch `update-formula` to `brettdavies/homebrew-tap` (formula name: `agentnative`, installs `anc`). | + +After the homebrew-tap workflow uploads bottles to this repo's release assets, it dispatches `finalize-release` back to +this repo, which idempotently flips `make_latest: true`. End result: crate on crates.io, GitHub Release marked latest, +Homebrew formula updated with bottles, all atomically advertised. + +### First-time publish (one-time) + +The very first crate publish requires a regular crates.io API token (Trusted Publishing needs the crate to exist first). +Steps for `v0.1.0`: + +1. Verify your email on crates.io (`https://crates.io/settings/profile`). +2. `cargo publish` locally with `CARGO_REGISTRY_TOKEN` set. +3. Configure Trusted Publishing on crates.io: `https://crates.io/settings/tokens/trusted-publishing` → add + `brettdavies/agentnative`, workflow `release.yml`. +4. Enable "Enforce Trusted Publishing" to block token-based publishes. +5. Remove the `CARGO_REGISTRY_TOKEN` repository secret. + +Subsequent releases use the OIDC flow built into `release.yml` — no static token in CI. + +## PRs and changelog generation + +Every PR **must** follow `.github/pull_request_template.md`. The template has a `## Changelog` section with these +subsections: + +- `### Added` — new user-visible features or capabilities +- `### Changed` — changes to existing behavior +- `### Fixed` — bug fixes +- `### Removed` — removed features or APIs +- `### Security` — security-relevant changes + +`scripts/generate-changelog.sh` (which wraps `git-cliff` per `cliff.toml`) reads the squash-merged commit bodies for +these sections and assembles `CHANGELOG.md` entries. A PR that lands with an empty or missing `## Changelog` section +silently drops its user-facing notes from the next release changelog. + +## Branch protection + +Two rulesets are committed under `.github/rulesets/` and applied to the repo via the GitHub API: + +- `protect-main.json` — required signatures, linear history, squash-only merges via PR, required status checks (`ci / + Fmt, clippy, test`, `ci / Package check`, `ci / Security audit (bans licenses sources)`, `ci / Changelog`, `guard-docs + / check-forbidden-docs`, `guard-provenance / check-provenance`, `guard-release / check-release-branch-name`), + creation/deletion blocked, non-fast-forward blocked. +- `protect-dev.json` — required signatures, deletion blocked, non-fast-forward blocked. No PR-requirement at the ruleset + level; the PR-only norm is enforced by convention + `guard-release-branch` on the main side. + +### Applying changes + +Edit the JSON locally, then sync to the remote: + +```bash +# First apply (creating a ruleset): +gh api -X POST repos/brettdavies/agentnative/rulesets --input .github/rulesets/protect-dev.json + +# Subsequent updates (replace by ID — find via `gh api repos/brettdavies/agentnative/rulesets`): +gh api -X PUT repos/brettdavies/agentnative/rulesets/ --input .github/rulesets/protect-main.json +``` + +Committing the JSON alongside code means ruleset changes land via the same review process as workflow changes — a +`chore(ci): tighten protect-main` change goes through dev → release/* → main like anything else. + +### Status-check context pitfall + +The `required_status_checks[].context` strings in `protect-main.json` must match exactly what GitHub publishes for each +check: + +- **Inline job** (with `name:` field): published as just `` (no workflow-name prefix). +- **Reusable-workflow caller** (`uses: .../foo.yml@ref`): published as ` / `. + +Mixing these produces a stuck-but-green PR: all actual checks report green, but the ruleset waits forever on a context +that will never appear. Confirm the real contexts after a first CI run with: + +```bash +gh api repos/brettdavies/agentnative/commits//check-runs --jq '.check_runs[].name' +``` + +## Required secrets + +| Secret | Purpose | Lifecycle | +| ------ | ------- | --------- | +| `CI_RELEASE_TOKEN` | Fine-grained PAT, Contents R+W, Pull requests R+W. Used by `release.yml` to dispatch the Homebrew formula update. | Rotated annually. | +| `CARGO_REGISTRY_TOKEN` | crates.io API token. Required only for the first publish. | Remove after Trusted Publishing is configured. | + +`GITHUB_TOKEN` is automatic; CI (`ci.yml`) only needs `contents: read` and uses no extra secrets. + +## Related docs + +- [`.github/pull_request_template.md`](.github/pull_request_template.md) — PR body structure with changelog sections +- [`AGENTS.md`](AGENTS.md) — running `anc`, project structure, adding new checks +- [`README.md`](README.md) — install channels, principles, CLI reference diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..976696e --- /dev/null +++ b/cliff.toml @@ -0,0 +1,25 @@ +[changelog] +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {{ commit.message | upper_first }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif %}\ + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }}){%- endif %} + {%- endfor %} +{% endfor %}\n +""" + +[remote.github] +owner = "brettdavies" +repo = "agentnative" diff --git a/completions/anc.bash b/completions/anc.bash new file mode 100644 index 0000000..12bba8b --- /dev/null +++ b/completions/anc.bash @@ -0,0 +1,160 @@ +_anc() { + local i cur prev opts cmd + COMPREPLY=() + if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then + cur="$2" + else + cur="${COMP_WORDS[COMP_CWORD]}" + fi + prev="$3" + cmd="" + opts="" + + for i in "${COMP_WORDS[@]:0:COMP_CWORD}" + do + case "${cmd},${i}" in + ",$1") + cmd="anc" + ;; + anc,check) + cmd="anc__check" + ;; + anc,completions) + cmd="anc__completions" + ;; + anc,help) + cmd="anc__help" + ;; + anc__help,check) + cmd="anc__help__check" + ;; + anc__help,completions) + cmd="anc__help__completions" + ;; + anc__help,help) + cmd="anc__help__help" + ;; + *) + ;; + esac + done + + case "${cmd}" in + anc) + opts="-q -h -V --quiet --help --version check completions help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__check) + opts="-q -h --command --binary --source --principle --output --include-tests --quiet --help [PATH]" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --command) + COMPREPLY=($(compgen -c "${cur}")) + return 0 + ;; + --principle) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --output) + COMPREPLY=($(compgen -W "text json" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__completions) + opts="-q -h --quiet --help bash elvish fish powershell zsh" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__help) + opts="check completions help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__help__check) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__help__completions) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + anc__help__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + esac +} + +if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then + complete -F _anc -o nosort -o bashdefault -o default anc +else + complete -F _anc -o bashdefault -o default anc +fi diff --git a/completions/anc.elvish b/completions/anc.elvish new file mode 100644 index 0000000..c06d1e0 --- /dev/null +++ b/completions/anc.elvish @@ -0,0 +1,62 @@ + +use builtin; +use str; + +set edit:completion:arg-completer[anc] = {|@words| + fn spaces {|n| + builtin:repeat $n ' ' | str:join '' + } + fn cand {|text desc| + edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc + } + var command = 'anc' + for word $words[1..-1] { + if (str:has-prefix $word '-') { + break + } + set command = $command';'$word + } + var completions = [ + &'anc'= { + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand -h 'Print help' + cand --help 'Print help' + cand -V 'Print version' + cand --version 'Print version' + cand check 'Check a CLI project or binary for agent-readiness' + cand completions 'Generate shell completions' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'anc;check'= { + cand --command 'Resolve a command from PATH and run behavioral checks against it' + cand --principle 'Filter checks by principle number (1-7)' + cand --output 'Output format' + cand --binary 'Run only behavioral checks (skip source analysis)' + cand --source 'Run only source checks (skip behavioral)' + cand --include-tests 'Include test code in source analysis' + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand -h 'Print help' + cand --help 'Print help' + } + &'anc;completions'= { + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand -h 'Print help' + cand --help 'Print help' + } + &'anc;help'= { + cand check 'Check a CLI project or binary for agent-readiness' + cand completions 'Generate shell completions' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'anc;help;check'= { + } + &'anc;help;completions'= { + } + &'anc;help;help'= { + } + ] + $completions[$command] +} diff --git a/completions/anc.fish b/completions/anc.fish new file mode 100644 index 0000000..4bde369 --- /dev/null +++ b/completions/anc.fish @@ -0,0 +1,46 @@ +# Print an optspec for argparse to handle cmd's options that are independent of any subcommand. +function __fish_anc_global_optspecs + string join \n q/quiet h/help V/version +end + +function __fish_anc_needs_command + # Figure out if the current invocation already has a command. + set -l cmd (commandline -opc) + set -e cmd[1] + argparse -s (__fish_anc_global_optspecs) -- $cmd 2>/dev/null + or return + if set -q argv[1] + # Also print the command, so this can be used to figure out what it is. + echo $argv[1] + return 1 + end + return 0 +end + +function __fish_anc_using_subcommand + set -l cmd (__fish_anc_needs_command) + test -z "$cmd" + and return 1 + contains -- $cmd[1] $argv +end + +complete -c anc -n "__fish_anc_needs_command" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_needs_command" -s h -l help -d 'Print help' +complete -c anc -n "__fish_anc_needs_command" -s V -l version -d 'Print version' +complete -c anc -n "__fish_anc_needs_command" -f -a "check" -d 'Check a CLI project or binary for agent-readiness' +complete -c anc -n "__fish_anc_needs_command" -f -a "completions" -d 'Generate shell completions' +complete -c anc -n "__fish_anc_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c anc -n "__fish_anc_using_subcommand check" -l command -d 'Resolve a command from PATH and run behavioral checks against it' -r -f -a "(__fish_complete_command)" +complete -c anc -n "__fish_anc_using_subcommand check" -l principle -d 'Filter checks by principle number (1-7)' -r +complete -c anc -n "__fish_anc_using_subcommand check" -l output -d 'Output format' -r -f -a "text\t'' +json\t''" +complete -c anc -n "__fish_anc_using_subcommand check" -l binary -d 'Run only behavioral checks (skip source analysis)' +complete -c anc -n "__fish_anc_using_subcommand check" -l source -d 'Run only source checks (skip behavioral)' +complete -c anc -n "__fish_anc_using_subcommand check" -l include-tests -d 'Include test code in source analysis' +complete -c anc -n "__fish_anc_using_subcommand check" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_using_subcommand check" -s h -l help -d 'Print help' +complete -c anc -n "__fish_anc_using_subcommand completions" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_using_subcommand completions" -s h -l help -d 'Print help' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions help" -f -a "check" -d 'Check a CLI project or binary for agent-readiness' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions help" -f -a "completions" -d 'Generate shell completions' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' diff --git a/completions/anc.powershell b/completions/anc.powershell new file mode 100644 index 0000000..163093c --- /dev/null +++ b/completions/anc.powershell @@ -0,0 +1,74 @@ + +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'anc' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @(switch ($command) { + 'anc' { + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness') + [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'anc;check' { + [CompletionResult]::new('--command', '--command', [CompletionResultType]::ParameterName, 'Resolve a command from PATH and run behavioral checks against it') + [CompletionResult]::new('--principle', '--principle', [CompletionResultType]::ParameterName, 'Filter checks by principle number (1-7)') + [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output format') + [CompletionResult]::new('--binary', '--binary', [CompletionResultType]::ParameterName, 'Run only behavioral checks (skip source analysis)') + [CompletionResult]::new('--source', '--source', [CompletionResultType]::ParameterName, 'Run only source checks (skip behavioral)') + [CompletionResult]::new('--include-tests', '--include-tests', [CompletionResultType]::ParameterName, 'Include test code in source analysis') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'anc;completions' { + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'anc;help' { + [CompletionResult]::new('check', 'check', [CompletionResultType]::ParameterValue, 'Check a CLI project or binary for agent-readiness') + [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'anc;help;check' { + break + } + 'anc;help;completions' { + break + } + 'anc;help;help' { + break + } + }) + + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText +} diff --git a/completions/anc.zsh b/completions/anc.zsh new file mode 100644 index 0000000..4eb2955 --- /dev/null +++ b/completions/anc.zsh @@ -0,0 +1,138 @@ +#compdef anc + +autoload -U is-at-least + +_anc() { + typeset -A opt_args + typeset -a _arguments_options + local ret=1 + + if is-at-least 5.2; then + _arguments_options=(-s -S -C) + else + _arguments_options=(-s -C) + fi + + local context curcontext="$curcontext" state line + _arguments "${_arguments_options[@]}" : \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'-V[Print version]' \ +'--version[Print version]' \ +":: :_anc_commands" \ +"*::: :->anc" \ +&& ret=0 + case $state in + (anc) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:anc-command-$line[1]:" + case $line[1] in + (check) +_arguments "${_arguments_options[@]}" : \ +'(--source)--command=[Resolve a command from PATH and run behavioral checks against it]:NAME:_command_names -e' \ +'--principle=[Filter checks by principle number (1-7)]:PRINCIPLE:_default' \ +'--output=[Output format]:OUTPUT:(text json)' \ +'--binary[Run only behavioral checks (skip source analysis)]' \ +'--source[Run only source checks (skip behavioral)]' \ +'--include-tests[Include test code in source analysis]' \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::path -- Path to project directory or binary:_files' \ +&& ret=0 +;; +(completions) +_arguments "${_arguments_options[@]}" : \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':shell -- Shell to generate for:(bash elvish fish powershell zsh)' \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +":: :_anc__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:anc-help-command-$line[1]:" + case $line[1] in + (check) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(completions) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +} + +(( $+functions[_anc_commands] )) || +_anc_commands() { + local commands; commands=( +'check:Check a CLI project or binary for agent-readiness' \ +'completions:Generate shell completions' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'anc commands' commands "$@" +} +(( $+functions[_anc__check_commands] )) || +_anc__check_commands() { + local commands; commands=() + _describe -t commands 'anc check commands' commands "$@" +} +(( $+functions[_anc__completions_commands] )) || +_anc__completions_commands() { + local commands; commands=() + _describe -t commands 'anc completions commands' commands "$@" +} +(( $+functions[_anc__help_commands] )) || +_anc__help_commands() { + local commands; commands=( +'check:Check a CLI project or binary for agent-readiness' \ +'completions:Generate shell completions' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'anc help commands' commands "$@" +} +(( $+functions[_anc__help__check_commands] )) || +_anc__help__check_commands() { + local commands; commands=() + _describe -t commands 'anc help check commands' commands "$@" +} +(( $+functions[_anc__help__completions_commands] )) || +_anc__help__completions_commands() { + local commands; commands=() + _describe -t commands 'anc help completions commands' commands "$@" +} +(( $+functions[_anc__help__help_commands] )) || +_anc__help__help_commands() { + local commands; commands=() + _describe -t commands 'anc help help commands' commands "$@" +} + +if [ "$funcstack[1]" = "_anc" ]; then + _anc "$@" +else + compdef _anc anc +fi diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..08635f8 --- /dev/null +++ b/deny.toml @@ -0,0 +1,244 @@ +# This template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# Root options + +# The graph table configures how the dependency graph is constructed and thus +# which crates the checks are performed against +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #"x86_64-unknown-linux-musl", + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] +# When creating the dependency graph used as the source of truth when checks are +# executed, this field can be used to prune crates from the graph, removing them +# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate +# is pruned from the graph, all of its dependencies will also be pruned unless +# they are connected to another crate in the graph that hasn't been pruned, +# so it should be used with care. The identifiers are [Package ID Specifications] +# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) +#exclude = [] +# If true, metadata will be collected with `--all-features`. Note that this can't +# be toggled off if true, if you want to conditionally enable `--all-features` it +# is recommended to pass `--all-features` on the cmd line instead +all-features = false +# If true, metadata will be collected with `--no-default-features`. The same +# caveat with `all-features` applies +no-default-features = false +# If set, these feature will be enabled when collecting metadata. If `--features` +# is specified on the cmd line they will take precedence over this option. +#features = [] + +# The output table provides options for how/if diagnostics are outputted +[output] +# When outputting inclusion graphs in diagnostics that include features, this +# option can be used to specify the depth at which feature edges will be added. +# This option is included since the graphs can be quite large and the addition +# of features from the crate(s) to all of the graph roots can be far too verbose. +# This option can be overridden via `--feature-depth` on the cmd line +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory databases are cloned/fetched into +#db-path = "$CARGO_HOME/advisory-dbs" +# The url(s) of the advisory databases to use +#db-urls = ["https://github.com/rustsec/advisory-db"] +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + #"RUSTSEC-0000-0000", + #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, + #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish + #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, +] +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +#[[licenses.clarify]] +# The package spec the clarification applies to +#crate = "ring" +# The SPDX expression for the license requirements of the crate +#expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +#license-files = [ +# Each entry is a crate relative path, and the (opaque) hash of its contents +#{ path = "LICENSE", hash = 0xbd0eed23 } +#] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = false +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "allow" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# The default lint level for `default` features for crates that are members of +# the workspace that is being checked. This can be overridden by allowing/denying +# `default` on a crate-by-crate basis if desired. +workspace-default-features = "allow" +# The default lint level for `default` features for external crates that are not +# members of the workspace. This can be overridden by allowing/denying `default` +# on a crate-by-crate basis if desired. +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, +] +# If true, workspace members are automatically allowed even when using deny-by-default +# This is useful for organizations that want to deny all external dependencies by default +# but allow their own workspace crates without having to explicitly list them +allow-workspace = false +# List of crates to deny +deny = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, +] + +# List of features to allow/deny +# Each entry the name of a crate and a version range. If version is +# not specified, all versions will be matched. +#[[bans.features]] +#crate = "reqwest" +# Features to not allow +#deny = ["json"] +# Features to allow +#allow = [ +# "rustls", +# "__rustls", +# "__tls", +# "hyper-rustls", +# "rustls", +# "rustls-pemfile", +# "rustls-tls-webpki-roots", +# "tokio-rustls", +# "webpki-roots", +#] +# If true, the allowed features must exactly match the enabled feature set. If +# this is set there is no point setting `deny` +#exact = true + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite. +skip-tree = [ + #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies + #{ crate = "ansi_term@0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "warn" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "warn" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = [] + +[sources.allow-org] +# github.com organizations to allow git sources for +github = [] +# gitlab.com organizations to allow git sources for +gitlab = [] +# bitbucket.org organizations to allow git sources for +bitbucket = [] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ac0a332 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +# Supply-chain pin: rustc version is immutable; rustup verifies component SHA256s +# from the distribution manifest (the "lockfile" for a toolchain release). +# Trailing comment documents the commit SHA for audit. +# Bump via reviewed PR only after a new stable has aged ≥7 days. +[toolchain] +channel = "1.94.1" # rustc e408947bfd200af42db322daf0fadfe7e26d3bd1, released 2026-03-26 +components = ["rustfmt", "clippy"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..2083782 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +style_edition = "2024" +edition = "2024" diff --git a/scripts/generate-changelog.sh b/scripts/generate-changelog.sh new file mode 100755 index 0000000..b111442 --- /dev/null +++ b/scripts/generate-changelog.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# Generate or update CHANGELOG.md using git-cliff with PR body expansion. +# +# Usage: +# generate-changelog.sh [--tag vX.Y.Z] [repo-path] +# generate-changelog.sh --check [repo-path] +# +# Options: +# --tag vX.Y.Z Override version tag (default: extracted from branch name) +# --check Verify CHANGELOG.md has a versioned section (exit 1 if only [Unreleased]) +# +# The version tag is extracted from the branch name by matching the pattern +# release/vN.N.N (with optional suffix like release/v1.0.5:ci-migration). +# Use --tag to override when not on a release branch. +# +# Generates entries for commits since the last tag, prepends to existing +# CHANGELOG.md, then expands squash commit entries by fetching categorized +# changelog sections (### Added, ### Changed, ### Fixed, ### Documentation) +# from each PR body's ## Changelog section. +# +# Falls back to ## Changes (flat list) for PRs using the old template. +# +# Run this on a release branch before opening a PR to main. + +set -euo pipefail + +CHECK_MODE=false +REPO_PATH="." +TAG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + CHECK_MODE=true + shift + ;; + --tag) + TAG="$2" + shift 2 + ;; + *) + REPO_PATH="$1" + shift + ;; + esac +done + +cd "$REPO_PATH" + +# Verify prerequisites +if [[ ! -f cliff.toml ]]; then + echo "error: cliff.toml not found in $(pwd)" >&2 + exit 1 +fi + +if ! command -v git-cliff &>/dev/null; then + echo "error: git-cliff is not installed" >&2 + echo " Install: cargo install git-cliff" >&2 + echo " Or: brew install git-cliff" >&2 + exit 1 +fi + +if $CHECK_MODE; then + if [[ ! -f CHANGELOG.md ]]; then + echo "FAIL: CHANGELOG.md does not exist" >&2 + exit 1 + fi + + # Check for a versioned section (not just [Unreleased]) + LATEST_SECTION=$(awk '/^## \[/{print; exit}' CHANGELOG.md) + if echo "$LATEST_SECTION" | grep -q '\[Unreleased\]'; then + echo "FAIL: CHANGELOG.md has [Unreleased] instead of a versioned section" >&2 + echo "Run: generate-changelog.sh (on a release/vX.Y.Z branch)" >&2 + exit 1 + fi + + echo "OK: CHANGELOG.md has versioned section" + exit 0 +fi + +# Extract version from branch name if --tag not provided +if [[ -z "$TAG" ]]; then + BRANCH=$(git branch --show-current 2>/dev/null || true) + if [[ "$BRANCH" =~ ^release/v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + TAG="v${BASH_REMATCH[1]}" + echo "Detected version $TAG from branch $BRANCH" + else + echo "error: could not detect version from branch '$BRANCH'" >&2 + echo "Either use a release/vX.Y.Z branch or pass --tag vX.Y.Z" >&2 + exit 1 + fi +fi + +# Ensure GitHub token is available for remote integration (PR links, authors) +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then + export GITHUB_TOKEN + GITHUB_TOKEN=$(gh auth token) + fi +fi + +# Step 1: Run git-cliff to prepend entries tagged with the release version +CLIFF_ARGS=(--unreleased --tag "$TAG") +if [[ -f CHANGELOG.md ]]; then + CLIFF_ARGS+=(--prepend CHANGELOG.md) +else + CLIFF_ARGS+=(-o CHANGELOG.md) +fi +git cliff "${CLIFF_ARGS[@]}" + +# Step 2: Expand squash commit entries using PR body changelog sections +OWNER=$(awk -F'"' '/^\[remote\.github\]/{found=1} found && /^owner/{print $2; exit}' cliff.toml) +REPO=$(awk -F'"' '/^\[remote\.github\]/{found=1} found && /^repo/{print $2; exit}' cliff.toml) + +# Strip leading v for version matching in the changelog +VERSION="${TAG#v}" + +if [[ -z "$OWNER" || -z "$REPO" ]] || ! command -v gh &>/dev/null; then + echo "Updated CHANGELOG.md (skipping PR expansion — missing [remote.github] or gh CLI)" + echo "" + echo "Next steps:" + echo " git add CHANGELOG.md" + echo " git commit -m 'docs: update CHANGELOG.md'" + exit 0 +fi + +# Extract PR numbers from the new version section only +VERSION_SECTION=$(awk -v ver="$VERSION" ' + /^## \[/{ + if (found) exit + if (index($0, "[" ver "]")) found=1 + } + found{print} +' CHANGELOG.md) +PR_NUMBERS=$(echo "$VERSION_SECTION" | grep -oP '\(#\K\d+' | sort -un) + +if [[ -z "$PR_NUMBERS" ]]; then + echo "Updated CHANGELOG.md" + echo "" + echo "Next steps:" + echo " git add CHANGELOG.md" + echo " git commit -m 'docs: update CHANGELOG.md'" + exit 0 +fi + +# Pass PR numbers as comma-separated arg to python +PR_LIST=$(echo "$PR_NUMBERS" | tr '\n' ',' | sed 's/,$//') + +python3 - "$OWNER" "$REPO" "$PR_LIST" "CHANGELOG.md" "$VERSION" "$TAG" << 'PYEOF' +import json, re, subprocess, sys + +owner = sys.argv[1] +repo = sys.argv[2] +pr_numbers = [int(n) for n in sys.argv[3].split(',')] +changelog_path = sys.argv[4] +version = sys.argv[5] +tag = sys.argv[6] if len(sys.argv) > 6 else f'v{version}' +tag_prefix = 'v' if tag.startswith('v') else '' + +CATEGORIES = ['Added', 'Changed', 'Fixed', 'Documentation'] + +def fetch_pr(num): + """Fetch PR body and author from GitHub API.""" + try: + result = subprocess.run( + ['gh', 'api', f'repos/{owner}/{repo}/pulls/{num}', + '--jq', '{body: .body, author: .user.login}'], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + return json.loads(result.stdout) + except Exception: + pass + return None + +def extract_changelog_sections(body): + """Extract categorized bullets from ## Changelog section with ### subsections.""" + sections = {} + if not body: + return sections + + changelog_match = re.search(r'^## Changelog\s*$', body, re.MULTILINE) + if not changelog_match: + return sections + + rest = body[changelog_match.end():] + next_h2 = re.search(r'^## ', rest, re.MULTILINE) + changelog_content = rest[:next_h2.start()] if next_h2 else rest + + current_section = None + for line in changelog_content.split('\n'): + h3_match = re.match(r'^### (.+)', line) + if h3_match: + current_section = h3_match.group(1).strip() + if current_section not in sections: + sections[current_section] = [] + elif current_section and re.match(r'^- ', line): + sections[current_section].append(line) + elif current_section and sections.get(current_section) and re.match(r'^ \S', line): + # Continuation line (indented, part of previous bullet) — join to last bullet + sections[current_section][-1] = sections[current_section][-1].rstrip() + ' ' + line.strip() + + return sections + +def extract_flat_changes(body): + """Fallback: extract flat bullet list from ## Changes section.""" + bullets = [] + if not body: + return bullets + + changes_match = re.search(r'^## Changes\s*$', body, re.MULTILINE) + if not changes_match: + return bullets + + rest = body[changes_match.end():] + next_h2 = re.search(r'^## ', rest, re.MULTILINE) + changes_content = rest[:next_h2.start()] if next_h2 else rest + + for line in changes_content.split('\n'): + if re.match(r'^- ', line): + bullets.append(line) + elif bullets and re.match(r'^ \S', line): + # Continuation line (indented, part of previous bullet) — join to last bullet + bullets[-1] = bullets[-1].rstrip() + ' ' + line.strip() + + return bullets + +# Collect all categorized entries from PR bodies +all_entries = {} # category -> list of bullets +for num in pr_numbers: + pr_data = fetch_pr(num) + if not pr_data: + continue + + body = pr_data.get('body', '') or '' + author = pr_data.get('author', '') + attrib = f' by @{author} in [#{num}](https://github.com/{owner}/{repo}/pull/{num})' if author else '' + + # Try new template format first (## Changelog with ### subsections) + sections = extract_changelog_sections(body) + + if sections: + for category, bullets in sections.items(): + if not bullets: + continue + if category not in all_entries: + all_entries[category] = [] + first = True + for bullet in bullets: + if first and ' by @' not in bullet: + all_entries[category].append(bullet + attrib) + else: + all_entries[category].append(bullet) + first = False + else: + # Fallback: flat ## Changes section + flat = extract_flat_changes(body) + if flat: + category = 'Changed' + if category not in all_entries: + all_entries[category] = [] + first = True + for bullet in flat: + if first and ' by @' not in bullet: + all_entries[category].append(bullet + attrib) + else: + all_entries[category].append(bullet) + first = False + +if not all_entries: + sys.exit(0) + +# Read the changelog +with open(changelog_path, 'r') as f: + content = f.read() + +# Find the version section header line (preserve it with the date) +header_pattern = rf'^## \[{re.escape(version)}\].*$' +header_match = re.search(header_pattern, content, re.MULTILINE) +if not header_match: + sys.exit(0) + +header_line = header_match.group(0) + +# Build the new version section content +new_section = header_line + '\n' +for cat in CATEGORIES: + if cat in all_entries and all_entries[cat]: + new_section += f'\n### {cat}\n\n' + for bullet in all_entries[cat]: + new_section += bullet + '\n' + +# Include any categories not in the standard list +for cat in all_entries: + if cat not in CATEGORIES and all_entries[cat]: + new_section += f'\n### {cat}\n\n' + for bullet in all_entries[cat]: + new_section += bullet + '\n' + +# Find the previous version tag for the Full Changelog link +prev_match = re.search(rf'## \[{re.escape(version)}\].*?\n## \[([^\]]+)\]', content, re.DOTALL) +if prev_match: + prev_version = prev_match.group(1) + new_section += f'\n**Full Changelog**: [{tag_prefix}{prev_version}...{tag_prefix}{version}](https://github.com/{owner}/{repo}/compare/{tag_prefix}{prev_version}...{tag_prefix}{version})\n' + +# Replace the version section in the file +section_pattern = rf'## \[{re.escape(version)}\].*?(?=\n## \[|\Z)' +new_content = re.sub(section_pattern, new_section.rstrip() + '\n', content, count=1, flags=re.DOTALL) + +with open(changelog_path, 'w') as f: + f.write(new_content) +PYEOF + +echo "Updated CHANGELOG.md" +echo "" +echo "Next steps:" +echo " git add CHANGELOG.md" +echo " git commit -m 'docs: update CHANGELOG.md'" diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh new file mode 100755 index 0000000..dbdcf31 --- /dev/null +++ b/scripts/generate-completions.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# generate-completions.sh — Generate or verify shell completions for a Rust CLI. +# +# Usage: +# ./generate-completions.sh [repo-path] # generate (default: .) +# ./generate-completions.sh --check [repo-path] # verify freshness, exit 1 if stale +# +# Detects the binary name from Cargo.toml, builds in release mode, and generates +# completions for bash, zsh, fish, elvish, and powershell into completions/. +# +# Supports two completion interfaces: +# Standard: completions (subcommand) +# Non-standard: --generate-completion (hidden flag, warns) +# +# Requires: cargo, jaq (or jq) + +set -euo pipefail + +MODE="generate" +REPO_PATH="." + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) MODE="check"; shift ;; + *) REPO_PATH="$1"; shift ;; + esac +done + +cd "$REPO_PATH" + +# Detect binary name from Cargo.toml +BIN=$(cargo metadata --no-deps --format-version 1 2>/dev/null \ + | jaq -r '.packages[0].targets[] | select(.kind[] == "bin") | .name' 2>/dev/null \ + || cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[0].targets[] | select(.kind[] == "bin") | .name') + +if [[ -z "$BIN" ]]; then + echo "error: no binary target found in Cargo.toml" >&2 + exit 1 +fi + +SHELLS=(bash zsh fish elvish powershell) +BINARY="./target/release/$BIN" + +# Build if binary is missing or older than any source file +if [[ ! -x "$BINARY" ]] || [[ -n "$(find src/ Cargo.toml -newer "$BINARY" 2>/dev/null | head -1)" ]]; then + echo "Building $BIN (release)..." + cargo build --release --locked 2>&1 +fi + +# Detect completion interface +COMP_STYLE="" +if "$BINARY" completions bash &>/dev/null; then + COMP_STYLE="subcommand" +elif "$BINARY" --generate-completion bash &>/dev/null; then + COMP_STYLE="flag" + echo "WARNING: $BIN uses --generate-completion (hidden flag), not the standard 'completions' subcommand." >&2 + echo " Recommend migrating to: $BIN completions " >&2 + echo " See: ~/.claude/skills/rust-tool-release/SKILL.md#shell-completions" >&2 + echo "" >&2 +else + echo "error: $BIN has no completions interface." >&2 + echo " Expected: '$BIN completions ' or '$BIN --generate-completion '" >&2 + exit 1 +fi + +# Helper to invoke the detected completions interface +gen_raw() { + local shell="$1" + if [[ "$COMP_STYLE" == "subcommand" ]]; then + "$BINARY" completions "$shell" + else + "$BINARY" --generate-completion "$shell" + fi +} + +# Project-specific post-processing. clap_complete's bash backend ignores +# ValueHint::CommandName and emits `compgen -f` for every value-taking flag. +# zsh/fish honour the hint correctly. We patch bash so `--command ` and +# any other CommandName flags suggest PATH commands. Update the list when +# adding new flags with that hint. +# +# Reads from stdin, writes to stdout, no-op for non-bash shells. +post_process() { + local shell="$1" + if [[ "$shell" != "bash" ]]; then + cat + return + fi + local command_name_flags=("--command") + local sed_program="" + for flag in "${command_name_flags[@]}"; do + sed_program+=$'\n'"/^[[:space:]]*${flag})[[:space:]]*$/,/;;/{ s|compgen -f|compgen -c|; }" + done + if [[ -n "$sed_program" ]]; then + sed "$sed_program" + else + cat + fi +} + +gen() { + gen_raw "$1" | post_process "$1" +} + +if [[ "$MODE" == "check" ]]; then + STALE=0 + for shell in "${SHELLS[@]}"; do + FILE="completions/$BIN.$shell" + if [[ ! -f "$FILE" ]]; then + echo "MISSING: $FILE" + STALE=1 + continue + fi + FRESH=$(gen "$shell") + if ! diff -q <(echo "$FRESH") "$FILE" &>/dev/null; then + echo "STALE: $FILE" + STALE=1 + else + echo "OK: $FILE" + fi + done + if [[ $STALE -ne 0 ]]; then + echo "" + echo "Run: $(basename "$0") $(pwd)" + exit 1 + fi + echo "All completions are fresh." +else + mkdir -p completions + for shell in "${SHELLS[@]}"; do + gen "$shell" > "completions/$BIN.$shell" + echo "Generated: completions/$BIN.$shell" + done + echo "Done. Commit completions/ when ready." +fi diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100755 index 0000000..a9d2349 --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Local CI mirror — runs the same checks as the GitHub Actions CI pipeline. +# Use as a pre-push hook or run manually before pushing. +# +# Exit codes: 0 = all pass, 1 = failure +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +BOLD='\033[1m' +RESET='\033[0m' + +pass() { echo -e " ${GREEN}✓${RESET} $1"; } +fail() { echo -e " ${RED}✗${RESET} $1"; exit 1; } + +echo -e "${BOLD}Running local CI checks...${RESET}" + +# The toolchain is pinned in rust-toolchain.toml — both local and CI read the same file +# and install the same version. Rustup verifies component SHA256s from the distribution +# manifest, so the pin is effectively a SHA pin. No `rustup update` step here: bumping +# the toolchain is a reviewed PR that updates rust-toolchain.toml. +pass "toolchain ($(rustc --version 2>/dev/null | awk '{print $2}' || echo unknown))" + +# 1. Format check +cargo fmt -- --check 2>/dev/null || fail "cargo fmt -- --check" +pass "fmt" + +# 2. Clippy with warnings-as-errors (matches CI's RUSTFLAGS=-Dwarnings) +RUSTFLAGS="-Dwarnings" cargo clippy --quiet 2>&1 || fail "cargo clippy -Dwarnings" +pass "clippy" + +# 3. Tests +cargo test --quiet 2>&1 || fail "cargo test" +pass "test" + +# 4. Security audit (licenses + advisories) — skip if cargo-deny not installed +if cargo deny --version &>/dev/null; then + cargo deny check 2>&1 || fail "cargo deny check" + pass "deny" +else + echo " - deny (skipped, cargo-deny not installed)" +fi + +# 5. Windows cross-check: verify no unconditional use of unix-only APIs +# This catches the libc::SIGPIPE issue without needing a Windows toolchain. +if rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ | rg -v '#\[cfg(unix)\]' | rg -v '// *#\[cfg' | rg -qv '^$'; then + # Found libc unix calls — check they're inside #[cfg(unix)] blocks + violations=$(rg -n 'libc::(SIGPIPE|SIG_DFL|signal)' --type rust src/ 2>/dev/null || true) + if [ -n "$violations" ]; then + # Check each file for proper cfg gating + while IFS= read -r line; do + file=$(echo "$line" | cut -d: -f1) + lineno=$(echo "$line" | cut -d: -f2) + # Look for #[cfg(unix)] in the 3 lines before the libc call + before=$(sed -n "$((lineno > 3 ? lineno - 3 : 1)),${lineno}p" "$file") + if ! echo "$before" | rg -q 'cfg\(unix\)'; then + fail "libc unix-only API used without #[cfg(unix)] at $file:$lineno" + fi + done <<< "$violations" + fi + pass "windows compat" +else + pass "windows compat" +fi + +echo -e "${BOLD}${GREEN}All checks passed.${RESET}" diff --git a/src/argv.rs b/src/argv.rs new file mode 100644 index 0000000..db3b3da --- /dev/null +++ b/src/argv.rs @@ -0,0 +1,342 @@ +//! Pre-parse argv transformation that inserts `check` as the implicit default +//! subcommand. Lives separately so `main.rs` stays focused on orchestration +//! and so the injection logic is unit-testable in isolation. + +use std::collections::HashSet; +use std::ffi::OsString; + +use crate::cli::Cli; + +/// Inject `check` as the default subcommand when the first non-flag argument +/// is not a recognized subcommand. +/// +/// Bare invocation (no args beyond the program name) is left untouched so +/// clap's `arg_required_else_help` still prints help and exits 2. This is a +/// non-negotiable fork-bomb guard: when agentnative dogfoods itself, a bare +/// spawn must not recurse into `check .`. +/// +/// Flag-value pairing is essential: `anc --command check` must not be misread +/// as the explicit `check` subcommand just because `check` happens to follow a +/// value-taking flag. The scanner consults clap introspection to learn which +/// flags consume the next token. +pub fn inject_default_subcommand(args: I) -> Vec +where + I: IntoIterator, +{ + let args: Vec = args.into_iter().collect(); + if args.len() <= 1 { + return args; + } + + let cmd = ::command(); + + // Known subcommand names (including aliases). Clap auto-generates a `help` + // subcommand that is NOT returned by `get_subcommands()`, so add it + // explicitly — otherwise `anc help` is treated as a path. + let mut known: Vec = cmd + .get_subcommands() + .flat_map(|sc| { + std::iter::once(sc.get_name().to_string()).chain(sc.get_all_aliases().map(String::from)) + }) + .collect(); + known.push(String::from("help")); + + // Build two flag catalogues from clap introspection: + // - top_level_flags: long/short names defined on `Cli` itself + // (e.g. `--quiet`, `-q`, plus clap's auto `--help`/`--version`). + // - all_value_flags: every value-taking flag across `Cli` and every + // subcommand (e.g. `--command`, `--output`, `--principle`). + // Any flag whose base name is missing from `top_level_flags` is + // subcommand-scoped — its presence is a strong signal the user wants + // the implicit `check` subcommand even if no positional arg follows. + let top_level_flags: HashSet = cmd + .get_arguments() + .filter(|a| !a.is_positional()) + .flat_map(|a| { + let mut names = Vec::new(); + if let Some(l) = a.get_long() { + names.push(format!("--{l}")); + } + if let Some(s) = a.get_short() { + names.push(format!("-{s}")); + } + names + }) + // Clap auto-generates these regardless of whether they appear in + // get_arguments() at every version, so add them defensively. + .chain( + ["--help", "-h", "--version", "-V"] + .into_iter() + .map(String::from), + ) + .collect(); + let mut all_value_flags: Vec<(Option, Option)> = Vec::new(); + let mut collect_value = |c: &clap::Command| { + for arg in c.get_arguments().filter(|a| !a.is_positional()) { + if matches!( + arg.get_action(), + clap::ArgAction::Set | clap::ArgAction::Append + ) { + all_value_flags.push((arg.get_long().map(String::from), arg.get_short())); + } + } + }; + collect_value(&cmd); + for sc in cmd.get_subcommands() { + collect_value(sc); + } + + // Reduce a flag token to its canonical base form for set membership. + // `--flag` / `--flag=value` -> `--flag`. `-X` / `-Xvalue` -> `-X`. + let base_form = |token: &str| -> Option { + if let Some(rest) = token.strip_prefix("--") { + let name = rest.split('=').next().unwrap_or(rest); + return Some(format!("--{name}")); + } + if let Some(rest) = token.strip_prefix('-') { + return rest.chars().next().map(|c| format!("-{c}")); + } + None + }; + + let consumes_next = |token: &str| -> bool { + // `--flag=value` carries the value with it; the next token is independent. + if token.starts_with("--") && token.contains('=') { + return false; + } + // `-Xvalue` (concatenated short flag) — same. + if token.starts_with('-') && !token.starts_with("--") && token.len() > 2 { + return false; + } + if let Some(rest) = token.strip_prefix("--") { + return all_value_flags + .iter() + .any(|(l, _)| l.as_deref() == Some(rest)); + } + if let Some(rest) = token.strip_prefix('-') { + if let Some(c) = rest.chars().next().filter(|_| rest.len() == 1) { + return all_value_flags.iter().any(|(_, s)| *s == Some(c)); + } + } + false + }; + + let inject_check = |args: Vec| -> Vec { + let mut injected = Vec::with_capacity(args.len() + 1); + injected.push(args[0].clone()); + injected.push(OsString::from("check")); + injected.extend(args.into_iter().skip(1)); + injected + }; + + let mut i = 1; + let mut saw_subcommand_flag = false; + while i < args.len() { + let token = args[i].to_string_lossy(); + + // POSIX `--` separator: anything after is positional. Inject `check` + // before it so clap routes the remaining tokens to the Check subcommand. + if token == "--" { + return if i + 1 >= args.len() { + args + } else { + inject_check(args) + }; + } + + if token.starts_with('-') { + // Track whether this flag belongs to a subcommand rather than the + // top-level Cli. If so, the user clearly intends `check` even when + // no positional argument follows (e.g. `anc --command rg`). + if let Some(base) = base_form(&token) { + if !top_level_flags.contains(&base) { + saw_subcommand_flag = true; + } + } + i += if consumes_next(&token) { 2 } else { 1 }; + continue; + } + + return if known.iter().any(|k| k == &*token) { + args + } else { + inject_check(args) + }; + } + + // No non-flag token. Inject `check` if any subcommand-scoped flag appeared + // (e.g. `anc --command rg`, `anc --output json`). Otherwise leave the args + // alone so clap can handle bare `--help` / `--version` / `-q` natively. + if saw_subcommand_flag { + return inject_check(args); + } + + args +} + +#[cfg(test)] +mod tests { + use super::inject_default_subcommand; + use std::ffi::OsString; + + fn args(a: &[&str]) -> Vec { + a.iter().map(OsString::from).collect() + } + + fn names(v: Vec) -> Vec { + v.into_iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn bare_invocation_is_untouched() { + // Fork-bomb guard: no injection for bare `anc`. + let out = inject_default_subcommand(args(&["anc"])); + assert_eq!(names(out), vec!["anc"]); + } + + #[test] + fn dot_path_gets_check_injected() { + let out = inject_default_subcommand(args(&["anc", "."])); + assert_eq!(names(out), vec!["anc", "check", "."]); + } + + #[test] + fn global_short_flag_before_path_gets_check_injected_in_canonical_position() { + // `check` goes before the global flag so clap parses + // ["anc", "check", "-q", "."] cleanly. + let out = inject_default_subcommand(args(&["anc", "-q", "."])); + assert_eq!(names(out), vec!["anc", "check", "-q", "."]); + } + + #[test] + fn global_long_flag_before_path_gets_check_injected() { + let out = inject_default_subcommand(args(&["anc", "--quiet", "."])); + assert_eq!(names(out), vec!["anc", "check", "--quiet", "."]); + } + + #[test] + fn explicit_check_subcommand_is_untouched() { + let out = inject_default_subcommand(args(&["anc", "check", "."])); + assert_eq!(names(out), vec!["anc", "check", "."]); + } + + #[test] + fn explicit_completions_subcommand_is_untouched() { + let out = inject_default_subcommand(args(&["anc", "completions", "bash"])); + assert_eq!(names(out), vec!["anc", "completions", "bash"]); + } + + #[test] + fn help_flag_alone_is_untouched() { + // `anc --help` — no non-flag token, no injection. + let out = inject_default_subcommand(args(&["anc", "--help"])); + assert_eq!(names(out), vec!["anc", "--help"]); + } + + #[test] + fn version_flag_alone_is_untouched() { + let out = inject_default_subcommand(args(&["anc", "--version"])); + assert_eq!(names(out), vec!["anc", "--version"]); + } + + #[test] + fn quiet_flag_alone_is_untouched() { + // `anc -q` with no path — `-q` is a top-level Cli flag, not a + // subcommand flag, so we leave args alone and let the `None` arm + // in `run()` print help and exit 2. + let out = inject_default_subcommand(args(&["anc", "-q"])); + assert_eq!(names(out), vec!["anc", "-q"]); + } + + #[test] + fn help_subcommand_passes_through() { + // `anc help` — clap auto-generates the `help` subcommand. It is NOT + // returned by `get_subcommands()` so we add it explicitly. Without + // that, `help` would be misclassified as a path. + let out = inject_default_subcommand(args(&["anc", "help"])); + assert_eq!(names(out), vec!["anc", "help"]); + } + + #[test] + fn help_subcommand_with_target_passes_through() { + let out = inject_default_subcommand(args(&["anc", "help", "check"])); + assert_eq!(names(out), vec!["anc", "help", "check"]); + } + + #[test] + fn command_flag_value_matching_subcommand_name_is_paired() { + // `anc --command check` — `check` is the value of `--command`, NOT the + // explicit subcommand. The scanner pairs the value-taking flag with + // its argument and proceeds to inject `check` (because `--command` is + // a subcommand-scoped flag with no positional following). + let out = inject_default_subcommand(args(&["anc", "--command", "check"])); + assert_eq!(names(out), vec!["anc", "check", "--command", "check"]); + } + + #[test] + fn command_flag_with_no_positional_injects_check() { + // `anc --command rg` — subcommand-scoped flag with no positional. + // Without injection, clap would reject `--command` at the top level. + let out = inject_default_subcommand(args(&["anc", "--command", "rg"])); + assert_eq!(names(out), vec!["anc", "check", "--command", "rg"]); + } + + #[test] + fn output_flag_with_no_positional_injects_check() { + // `anc --output json --source` — only flags, but `--output` and + // `--source` are both subcommand-scoped, so inject `check`. + let out = inject_default_subcommand(args(&["anc", "--output", "json", "--source"])); + assert_eq!( + names(out), + vec!["anc", "check", "--output", "json", "--source"] + ); + } + + #[test] + fn equals_form_value_flag_is_recognized_as_subcommand_scoped() { + // `anc --output=json --source` — equals form. The scanner classifies + // `--output=json` as a single subcommand-scoped token (no separate + // value to skip) and still injects `check`. + let out = inject_default_subcommand(args(&["anc", "--output=json", "--source"])); + assert_eq!( + names(out), + vec!["anc", "check", "--output=json", "--source"] + ); + } + + #[test] + fn principle_value_flag_pairs_with_numeric_value() { + // `anc --principle 4` — `4` is the value, not a path candidate. + let out = inject_default_subcommand(args(&["anc", "--principle", "4"])); + assert_eq!(names(out), vec!["anc", "check", "--principle", "4"]); + } + + #[test] + fn double_dash_separator_injects_check_before_separator() { + // `anc -- .` — POSIX `--` ends option parsing. Inject before it so + // clap's `check` parser sees `-- .`. + let out = inject_default_subcommand(args(&["anc", "--", "."])); + assert_eq!(names(out), vec!["anc", "check", "--", "."]); + } + + #[test] + fn double_dash_alone_passes_through() { + // `anc --` with nothing after — let clap handle it natively. + let out = inject_default_subcommand(args(&["anc", "--"])); + assert_eq!(names(out), vec!["anc", "--"]); + } + + #[test] + fn directory_path_gets_check_injected() { + let out = inject_default_subcommand(args(&["anc", "/some/dir"])); + assert_eq!(names(out), vec!["anc", "check", "/some/dir"]); + } + + #[test] + fn trailing_flags_pass_through() { + let out = inject_default_subcommand(args(&["anc", ".", "--output", "json"])); + assert_eq!(names(out), vec!["anc", "check", ".", "--output", "json"]); + } +} diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..b616f15 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,20 @@ +use crate::project::Project; +use crate::types::{CheckGroup, CheckLayer, CheckResult}; + +/// Trait implemented by all checks (behavioral, source, project). +pub trait Check { + /// Unique identifier for this check (e.g., "code-unwrap", "p3-help"). + fn id(&self) -> &str; + + /// Which principle or category this check belongs to. + fn group(&self) -> CheckGroup; + + /// Which layer this check operates in. + fn layer(&self) -> CheckLayer; + + /// Whether this check is applicable to the given project. + fn applicable(&self, project: &Project) -> bool; + + /// Run the check against the project. + fn run(&self, project: &Project) -> anyhow::Result; +} diff --git a/src/checks/behavioral/bad_args.rs b/src/checks/behavioral/bad_args.rs new file mode 100644 index 0000000..25cfec8 --- /dev/null +++ b/src/checks/behavioral/bad_args.rs @@ -0,0 +1,83 @@ +use crate::check::Check; +use crate::project::Project; +use crate::runner::RunStatus; +use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus}; + +pub struct BadArgsCheck; + +impl Check for BadArgsCheck { + fn id(&self) -> &str { + "p4-bad-args" + } + + fn group(&self) -> CheckGroup { + CheckGroup::P4 + } + + fn layer(&self) -> CheckLayer { + CheckLayer::Behavioral + } + + fn applicable(&self, project: &Project) -> bool { + project.runner.is_some() + } + + fn run(&self, project: &Project) -> anyhow::Result { + let runner = project.runner_ref(); + let result = runner.run(&["--this-flag-does-not-exist-agentnative-probe"], &[]); + + let status = match result.status { + RunStatus::Ok => { + if result.exit_code.is_some_and(|c| c > 0) { + CheckStatus::Pass + } else { + CheckStatus::Fail("binary silently accepted invalid flag (exit 0)".into()) + } + } + RunStatus::Crash { signal } => { + CheckStatus::Fail(format!("binary crashed on bad args (signal {signal})")) + } + _ => CheckStatus::Fail(format!("unexpected status: {:?}", result.status)), + }; + + Ok(CheckResult { + id: self.id().to_string(), + label: "Rejects invalid arguments".into(), + group: CheckGroup::P4, + layer: CheckLayer::Behavioral, + status, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::behavioral::tests::test_project_with_sh_script; + use crate::types::CheckStatus; + + #[test] + fn bad_args_pass_when_rejected() { + // sh -c 'exit 1' always exits non-zero + let project = test_project_with_sh_script("exit 2"); + let result = BadArgsCheck.run(&project).expect("check should run"); + assert!(matches!(result.status, CheckStatus::Pass)); + } + + #[test] + fn bad_args_fail_when_accepted() { + // echo silently accepts any args with exit 0 + let project = crate::checks::behavioral::tests::test_project_with_runner("/bin/echo"); + let result = BadArgsCheck.run(&project).expect("check should run"); + assert!(matches!(result.status, CheckStatus::Fail(_))); + } + + #[test] + fn bad_args_handles_crash() { + let project = test_project_with_sh_script("kill -11 $$"); + let result = BadArgsCheck + .run(&project) + .expect("check should not panic on crash"); + assert!(matches!(result.status, CheckStatus::Fail(_))); + } +} diff --git a/src/checks/behavioral/help.rs b/src/checks/behavioral/help.rs new file mode 100644 index 0000000..37a3fc0 --- /dev/null +++ b/src/checks/behavioral/help.rs @@ -0,0 +1,114 @@ +use crate::check::Check; +use crate::project::Project; +use crate::runner::RunStatus; +use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus}; + +pub struct HelpCheck; + +impl Check for HelpCheck { + fn id(&self) -> &str { + "p3-help" + } + + fn group(&self) -> CheckGroup { + CheckGroup::P3 + } + + fn layer(&self) -> CheckLayer { + CheckLayer::Behavioral + } + + fn applicable(&self, project: &Project) -> bool { + project.runner.is_some() + } + + fn run(&self, project: &Project) -> anyhow::Result { + let runner = project.runner_ref(); + let result = runner.run(&["--help"], &[]); + + let status = match result.status { + RunStatus::Ok if result.exit_code == Some(0) => { + let output = format!("{}{}", result.stdout, result.stderr); + if output.trim().is_empty() { + CheckStatus::Fail("--help produced no output".into()) + } else if has_examples_section(&output) { + CheckStatus::Pass + } else { + CheckStatus::Warn( + "--help output exists but no examples section detected".into(), + ) + } + } + RunStatus::Ok => CheckStatus::Fail(format!( + "--help exited with code {}", + result + .exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".into()) + )), + _ => CheckStatus::Fail(format!("--help failed: {:?}", result.status)), + }; + + Ok(CheckResult { + id: self.id().to_string(), + label: "Help flag produces useful output".into(), + group: CheckGroup::P3, + layer: CheckLayer::Behavioral, + status, + }) + } +} + +fn has_examples_section(text: &str) -> bool { + let lower = text.to_lowercase(); + lower.contains("example") + || lower.contains("usage:") + || lower.contains("usage\n") + || lower.contains("examples:") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::behavioral::tests::test_project_with_runner; + + #[test] + fn help_check_applicable_with_runner() { + let project = test_project_with_runner("/bin/echo"); + assert!(HelpCheck.applicable(&project)); + } + + #[test] + fn help_check_not_applicable_without_runner() { + let project = test_project_with_runner("/bin/echo"); + let mut project = project; + project.runner = None; + assert!(!HelpCheck.applicable(&project)); + } + + #[test] + fn help_pass_with_examples() { + let runner = + crate::runner::BinaryRunner::new("/bin/sh".into(), std::time::Duration::from_secs(5)) + .expect("create test runner"); + let result = runner.run(&["-c", "echo 'Usage: foo\nExamples:\n foo bar'"], &[]); + assert!(has_examples_section(&result.stdout)); + } + + #[test] + fn help_detects_examples_section() { + assert!(has_examples_section("EXAMPLES\n run foo")); + assert!(has_examples_section("Usage: mycli [OPTIONS]")); + assert!(has_examples_section("Examples:\n mycli run")); + assert!(!has_examples_section("This is just a description")); + } + + #[test] + fn help_handles_crash() { + let project = crate::checks::behavioral::tests::test_project_with_sh_script("kill -11 $$"); + let result = HelpCheck + .run(&project) + .expect("check should not panic on crash"); + assert!(matches!(result.status, CheckStatus::Fail(_))); + } +} diff --git a/src/checks/behavioral/json_output.rs b/src/checks/behavioral/json_output.rs new file mode 100644 index 0000000..c324246 --- /dev/null +++ b/src/checks/behavioral/json_output.rs @@ -0,0 +1,376 @@ +use crate::check::Check; +use crate::project::Project; +use crate::runner::{BinaryRunner, RunStatus}; +use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus}; + +pub struct JsonOutputCheck; + +impl Check for JsonOutputCheck { + fn id(&self) -> &str { + "p2-json-output" + } + + fn group(&self) -> CheckGroup { + CheckGroup::P2 + } + + fn layer(&self) -> CheckLayer { + CheckLayer::Behavioral + } + + fn applicable(&self, project: &Project) -> bool { + project.runner.is_some() + } + + fn run(&self, project: &Project) -> anyhow::Result { + let runner = project.runner_ref(); + let help_result = runner.run(&["--help"], &[]); + + let status = match help_result.status { + RunStatus::Ok => { + let output = format!("{}{}", help_result.stdout, help_result.stderr); + let lower = output.to_lowercase(); + let has_output_flag = lower.contains("--output"); + let has_format_flag = lower.contains("--format"); + + if has_output_flag || has_format_flag { + // Flag found in top-level help, validate directly + validate_json_output(runner, &[], has_output_flag, has_format_flag) + } else { + // Flag not in top-level help. Probe subcommands, since most CLIs + // (gh, kubectl, cargo) put --output on subcommands, not top-level. + probe_subcommands(runner, &output) + } + } + _ => CheckStatus::Skip("could not run --help to detect output flags".into()), + }; + + Ok(CheckResult { + id: self.id().to_string(), + label: "Structured output support".into(), + group: CheckGroup::P2, + layer: CheckLayer::Behavioral, + status, + }) + } +} + +/// Parse subcommand names from --help output and check each for --output/--format. +/// +/// Most CLI frameworks (clap, cobra, argparse) list subcommands under a "Commands:" +/// or "Subcommands:" section. We parse those names and probe each one. +fn probe_subcommands(runner: &BinaryRunner, help_output: &str) -> CheckStatus { + let subcommands = parse_subcommand_names(help_output); + if subcommands.is_empty() { + return CheckStatus::Skip("no --output/--format flag detected".into()); + } + + for subcmd in &subcommands { + let sub_help = runner.run(&[subcmd, "--help"], &[]); + if sub_help.status != RunStatus::Ok { + continue; + } + + let sub_output = format!("{}{}", sub_help.stdout, sub_help.stderr); + let sub_lower = sub_output.to_lowercase(); + let has_output = sub_lower.contains("--output"); + let has_format = sub_lower.contains("--format"); + + if has_output || has_format { + return validate_json_output(runner, &[subcmd], has_output, has_format); + } + } + + CheckStatus::Skip("no --output/--format flag detected in any subcommand".into()) +} + +/// Extract subcommand names from CLI --help output. +/// +/// Looks for a "Commands:" or "Subcommands:" section and parses the first word +/// of each indented line. Stops at the next section header or blank line gap. +fn parse_subcommand_names(help_output: &str) -> Vec { + let mut names = Vec::new(); + let mut in_commands_section = false; + + for line in help_output.lines() { + let trimmed = line.trim(); + + // Detect the start of a commands section + if trimmed.eq_ignore_ascii_case("commands:") + || trimmed.eq_ignore_ascii_case("subcommands:") + || trimmed.starts_with("Commands:") + || trimmed.starts_with("Subcommands:") + { + in_commands_section = true; + continue; + } + + if in_commands_section { + // End of section: non-indented non-empty line (next section header) + if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') { + break; + } + // Skip empty lines within the section + if trimmed.is_empty() { + continue; + } + // Extract the first word as the subcommand name + if let Some(name) = trimmed.split_whitespace().next() { + // Skip "help" subcommand (meta, not a real command) + if name != "help" { + names.push(name.to_string()); + } + } + } + } + + names +} + +/// Try safe subcommands with the detected flag to validate actual JSON output. +/// +/// `prefix` contains any subcommand path (e.g., ["check"]) to prepend to the +/// probe commands. For top-level flags, prefix is empty. +/// +/// Strategy: try `[prefix...] --help --flag json` first (safe, --help always exits +/// without side effects). If that produces non-JSON (many CLIs ignore --output with +/// --help), fall back to `[prefix...] --version --flag json`. Never run the binary +/// bare with just `--flag json`, as that could execute destructive commands. +fn validate_json_output( + runner: &BinaryRunner, + prefix: &[&str], + has_output_flag: bool, + has_format_flag: bool, +) -> CheckStatus { + let flag_variants: Vec<&str> = { + let mut v = Vec::new(); + if has_output_flag { + v.push("--output"); + } + if has_format_flag { + v.push("--format"); + } + v + }; + + // Safe suffixes: always probe with --help or --version, never bare invocation. + // Bare subcommand probing (`&[]`) was removed because it is unsafe in the + // general case — subcommands may have side effects (kubectl apply, docker rm, + // terraform plan), and for agentnative itself it caused fork bombs. + let safe_suffixes: Vec<&[&str]> = vec![&["--help"], &["--version"]]; + + // Try space-separated: [prefix...] [suffix...] flag json + for flag in &flag_variants { + for suffix in &safe_suffixes { + let mut args: Vec<&str> = prefix.to_vec(); + args.extend_from_slice(suffix); + args.push(flag); + args.push("json"); + + if let Some(status) = try_json_probe(runner, &args) { + return status; + } + } + } + + // Try =json syntax: [prefix...] [suffix...] flag=json + for flag in &flag_variants { + let flag_eq = format!("{flag}=json"); + for suffix in &safe_suffixes { + let mut args: Vec<&str> = prefix.to_vec(); + args.extend_from_slice(suffix); + let args_with_eq: Vec = args.iter().map(|s| s.to_string()).collect(); + let mut final_args: Vec<&str> = args_with_eq.iter().map(|s| s.as_str()).collect(); + final_args.push(&flag_eq); + + if let Some(status) = try_json_probe(runner, &final_args) { + return status; + } + } + } + + CheckStatus::Warn("--output/--format flag detected but could not validate JSON via safe probes (--help/--version override output flags in most CLIs)".into()) +} + +/// Run a single JSON probe and return Some(status) if valid JSON found. +fn try_json_probe(runner: &BinaryRunner, args: &[&str]) -> Option { + let result = runner.run(args, &[]); + + match result.status { + RunStatus::Ok => { + let stdout = result.stdout.trim(); + if !stdout.is_empty() && serde_json::from_str::(stdout).is_ok() { + return Some(CheckStatus::Pass); + } + + let stderr = result.stderr.trim(); + if !stderr.is_empty() && serde_json::from_str::(stderr).is_ok() { + if result.exit_code != Some(0) { + return Some(CheckStatus::Warn( + "binary exits non-zero but produces valid JSON on stderr".into(), + )); + } + return Some(CheckStatus::Pass); + } + + None + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::checks::behavioral::tests::test_project_with_sh_script; + use crate::types::CheckStatus; + + #[test] + fn json_output_pass_with_valid_json() { + let script = r#" +case "$*" in + *--help*--output*json*|*--output*json*--help*) + echo '{"help":true,"format":"json"}';; + *--help*) + echo "Usage: test [--output FORMAT]";; + *--output\ json*|*--output=json*) + echo '{"version":"1.0"}';; + *) + echo "hello";; +esac +"#; + let project = test_project_with_sh_script(script); + let result = JsonOutputCheck.run(&project).expect("check should run"); + assert_eq!(result.status, CheckStatus::Pass, "got {:?}", result.status); + } + + #[test] + fn json_output_pass_with_format_flag() { + let script = r#" +case "$*" in + *--help*--format*json*|*--format*json*--help*) + echo '{"help":true}';; + *--help*) + echo "Usage: test [--format FORMAT]";; + *) + echo "hello";; +esac +"#; + let project = test_project_with_sh_script(script); + let result = JsonOutputCheck.run(&project).expect("check should run"); + assert_eq!(result.status, CheckStatus::Pass, "got {:?}", result.status); + } + + #[test] + fn json_output_fail_with_invalid_json() { + let script = r#" +case "$*" in + *--help*) + echo "Usage: test [--output FORMAT]";; + *--output*) + echo "this is not json";; + *) + echo "hello";; +esac +"#; + let project = test_project_with_sh_script(script); + let result = JsonOutputCheck.run(&project).expect("check should run"); + match &result.status { + CheckStatus::Warn(msg) => { + assert!(msg.contains("could not validate JSON"), "got: {msg}") + } + other => panic!("expected Warn, got {other:?}"), + } + } + + #[test] + fn json_output_skip_no_flag() { + let project = test_project_with_sh_script("echo 'just some help text'"); + let result = JsonOutputCheck.run(&project).expect("check should run"); + match &result.status { + CheckStatus::Skip(msg) => assert!(msg.contains("no --output")), + other => panic!("expected Skip, got {other:?}"), + } + } + + #[test] + fn json_output_fallback_to_version() { + let script = r#" +case "$*" in + *--version*--output*json*|*--output*json*--version*|*--version*--output=json*|*--output=json*--version*) + echo '{"version":"2.0"}';; + *--help*) + echo "Usage: test [--output FORMAT]";; + *--version*) + echo "test 2.0";; + *) + echo "hello";; +esac +"#; + let project = test_project_with_sh_script(script); + let result = JsonOutputCheck.run(&project).expect("check should run"); + assert_eq!(result.status, CheckStatus::Pass, "got {:?}", result.status); + } + + #[test] + fn json_output_handles_crash() { + let project = test_project_with_sh_script("kill -11 $$"); + let result = JsonOutputCheck + .run(&project) + .expect("check should not panic on crash"); + assert!(matches!(result.status, CheckStatus::Skip(_))); + } + + #[test] + fn json_output_probes_subcommands() { + // Simulates a CLI with subcommands where --output is on the subcommand. + // More specific patterns must come before *--help* catch-all. + let script = r#" +case "$*" in + *check*--output*json*|*check*--output=json*) + echo '{"checks":"passed"}';; + *check*--help*) + echo "Usage: test check [--output FORMAT]";; + *--help*) + echo "Usage: test [COMMAND] + +Commands: + check Run checks + list List items + help Print help";; + *) + echo "hello";; +esac +"#; + let project = test_project_with_sh_script(script); + let result = JsonOutputCheck.run(&project).expect("check should run"); + // The check should find --output in the "check" subcommand help + // and validate JSON output + assert!( + matches!(result.status, CheckStatus::Pass | CheckStatus::Fail(_)), + "expected Pass or Fail (not Skip), got {:?}", + result.status + ); + } + + #[test] + fn parse_subcommand_names_clap_format() { + let help = "Usage: mycli [COMMAND]\n\nCommands:\n check Run checks\n list List items\n help Print help\n\nOptions:\n -h, --help Print help\n"; + let names = parse_subcommand_names(help); + assert_eq!(names, vec!["check", "list"]); + } + + #[test] + fn parse_subcommand_names_empty() { + let help = "Usage: mycli [OPTIONS]\n\nOptions:\n -h, --help Print help\n"; + let names = parse_subcommand_names(help); + assert!(names.is_empty()); + } + + #[test] + fn parse_subcommand_names_subcommands_header() { + let help = "Subcommands:\n run Execute\n build Compile\n"; + let names = parse_subcommand_names(help); + assert_eq!(names, vec!["run", "build"]); + } +} diff --git a/src/checks/behavioral/mod.rs b/src/checks/behavioral/mod.rs new file mode 100644 index 0000000..ec77a66 --- /dev/null +++ b/src/checks/behavioral/mod.rs @@ -0,0 +1,108 @@ +mod bad_args; +mod help; +mod json_output; +mod no_color; +mod non_interactive; +mod quiet; +mod sigpipe; +mod version; + +use crate::check::Check; + +pub fn all_behavioral_checks() -> Vec> { + vec![ + Box::new(help::HelpCheck), + Box::new(version::VersionCheck), + Box::new(json_output::JsonOutputCheck), + Box::new(bad_args::BadArgsCheck), + Box::new(quiet::QuietCheck), + Box::new(sigpipe::SigpipeCheck), + Box::new(non_interactive::NonInteractiveCheck), + Box::new(no_color::NoColorBehavioralCheck), + ] +} + +#[cfg(test)] +pub(crate) mod tests { + use std::path::PathBuf; + use std::sync::OnceLock; + use std::time::Duration; + + use crate::project::Project; + use crate::runner::BinaryRunner; + + /// Create a test project backed by the given binary path. + pub fn test_project_with_runner(binary: &str) -> Project { + Project { + path: PathBuf::from("."), + language: None, + binary_paths: vec![PathBuf::from(binary)], + manifest_path: None, + runner: Some( + BinaryRunner::new(PathBuf::from(binary), Duration::from_secs(5)) + .expect("create test runner"), + ), + include_tests: false, + parsed_files: OnceLock::new(), + } + } + + /// Create a test project backed by `/bin/sh -c "