diff --git a/AGENTS.md b/AGENTS.md index d4d3e98..be75b54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,11 @@ +--- +name: agentnative +binary: anc +description: Agent-native CLI linter that checks whether a CLI follows the 8 agent-readiness principles. Bundle covers operator-facing usage, project structure, and the check catalog. +homepage: https://anc.dev +repository: https://github.com/brettdavies/agentnative-cli +--- + # AGENTS.md ## Running anc diff --git a/README.md b/README.md index f500f4e..ab92ce6 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Crates.io](https://img.shields.io/crates/v/agentnative.svg)](https://crates.io/crates/agentnative) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue.svg)](#license) -The agent-native CLI linter. Checks whether your CLI follows the 7 agent-readiness principles. +The agent-native CLI linter. Checks whether your CLI follows the 8 agent-readiness principles. -`anc` dogfoods the spec it enforces — the badge above is its own live score. +`anc` dogfoods the spec it enforces. The badge above is its own live score. ## Install @@ -47,8 +47,8 @@ anc skill install --dry-run claude_code # git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/you/.claude/skills/agent-native-cli ``` -JSON output (mode `dry-run` and `install`, success and error) is uniform — agents can rely on the same envelope shape -across every outcome: +JSON output (modes `dry-run` and `install`, success and error) shares one envelope shape. Agents parse the same fields +on every outcome: ```bash anc skill install --dry-run claude_code --output json @@ -84,19 +84,20 @@ anc . --principle 3 anc . -q ``` -## The 7 Principles +## The 8 Principles -agentnative checks your CLI against seven agent-readiness principles: +agentnative checks your CLI against eight 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 | +| P2 | Structured Output | `--output json` exists, produces valid JSON, and the shape is discoverable via `schema` subcommand or `--schema` flag | | 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 | +| P6 | Composable Structure | SIGPIPE handled, NO_COLOR respected, shell completions, `AGENTS.md`, SIGTERM cleanup | | P7 | Bounded Responses | `--quiet` flag, no unbounded list output, clamped pagination | +| P8 | Discoverable Skill Bundle | Top-level `AGENTS.md` / `SKILL.md` with YAML frontmatter, `tool skill install []` for agent runtimes | ## Example Output @@ -104,55 +105,55 @@ agentnative checks your CLI against seven agent-readiness principles: P1 — Non-Interactive by Default [PASS] Non-interactive by default (p1-non-interactive) [PASS] Flags advertise env-var bindings in --help (p1-env-hints) + [PASS] Secret-bearing flags expose stdin or *-file companion (p1-secret-non-leaky-path) [PASS] TTY detection for color output (p1-tty-detection-source) [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] Structured error types (p4-error-types) - [PASS] Exit codes use named constants (p4-exit-codes) - [PASS] No process::exit outside main (p4-process-exit) - [PASS] Dedicated error module exists (p4-error-module) +P2 — Structured Output + [PASS] Structured-output CLI exposes its schema at runtime (p2-schema-print) + [PASS] --json / --jsonl short aliases for --output (p2-json-aliases) + [PASS] Output schema exported to a stable file path (p2-schema-file) P6 — Composable Structure [PASS] Handles SIGPIPE gracefully (p6-sigpipe) - [PASS] Respects NO_COLOR (p6-no-color-behavioral) + [PASS] Subcommand verbs follow community-standard names (p6-standard-names) + [PASS] Long-running CLI handles SIGTERM (p6-sigterm) [PASS] Shell completions support (p6-completions) +P8 — Discoverable Skill Bundles + [PASS] Skill bundle has install path (`tool skill install []`) (p8-bundle-install) + [PASS] Top-level AGENTS.md / SKILL.md bundle present (p8-bundle-exists) + Code Quality [PASS] No .unwrap() in source (code-unwrap) -33 checks: 28 pass, 1 warn, 0 fail, 4 skip, 0 error +44 checks: 37 pass, 3 warn, 0 fail, 4 skip, 0 error -🏆 Score: 97% — your tool qualifies for the agent-native badge. +🏆 Score: 93% — your tool qualifies for the agent-native badge. Embed in your README: [![agent-native](https://anc.dev/badge/anc.svg)](https://anc.dev/score/anc) Convention: https://anc.dev/badge ``` The badge hint appears in `text` output when a tool scores at or above the 80% eligibility floor. Below the floor, `anc` -prints nothing badge-related — the convention is to surface the embed only when earned. +prints nothing badge-related. The convention is to surface the embed only when earned. ## 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. +- **Behavioral**: runs the compiled binary, checks `--help`, `--version`, `--output json`, SIGPIPE, NO_COLOR, SIGTERM, + exit codes. Language-agnostic. +- **Source**: ast-grep pattern matching on source code. Detects `.unwrap()`, missing error types, naked `println!`, + closed-set rejection, and more. Supports Rust and Python. +- **Project**: inspects files and manifests. Checks for `AGENTS.md` / `SKILL.md` bundle, recommended dependencies, + dedicated error/output modules, output-schema file at the repo root. ## 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. +and `anc --command ripgrep` all resolve to `anc check …`. Bare `anc` (no arguments) still prints help and exits 2: a +deliberate fork-bomb guard for when agentnative dogfoods itself. ```text Usage: anc check [OPTIONS] [PATH] @@ -164,7 +165,7 @@ 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) + --principle Filter checks by principle number (1-8) --output Output format [default: text] [possible values: text, json] -q, --quiet Suppress non-essential output [env: AGENTNATIVE_QUIET=] --include-tests Include test code in source analysis @@ -173,14 +174,15 @@ Options: -h, --help Print help ``` -`--command` and `[PATH]` are mutually exclusive — pick one. `--command` runs behavioral checks only; source and project +`--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. -`--audit-profile` suppresses checks that legitimately do not apply to a class of tool (e.g., `human-tui` for TUI apps -like `lazygit` whose contract IS the TTY, `posix-utility` for stdin-primary tools like `cat`/`sed`/`awk`, +`--audit-profile` suppresses checks that legitimately do not apply to a class of tool. Profiles: `human-tui` for TUI +apps like `lazygit` whose contract IS the TTY, `posix-utility` for stdin-primary tools like `cat`/`sed`/`awk`, `diagnostic-only` for read-only tools like `nvidia-smi`, `file-traversal` reserved for upcoming subcommand-structure -relaxations on `fd`/`find`-class tools). Suppressed checks emit `Skip` with structured evidence. The full per-category -mapping lives in `coverage/matrix.json` under `audit_profiles[]` — agents should read it rather than scrape `--help`. +relaxations on `fd`/`find`-class tools. Suppressed checks emit `Skip` with structured evidence. The full per-category +mapping lives in `coverage/matrix.json` under `audit_profiles[]`. Agents should read that file rather than scrape +`--help`. ### Exit Codes @@ -192,7 +194,7 @@ mapping lives in `coverage/matrix.json` under `audit_profiles[]` — agents shou 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. +`anc --help` first to confirm the invocation shape. ### Shell Completions @@ -221,9 +223,10 @@ Pre-generated scripts are also available in `completions/`. anc check . --output json ``` -Produces a self-describing scoring run record (`schema_version: "0.5"`) with results, summary, coverage against the 7 -principles, plus contextual metadata identifying which tool was scored, by which `anc` build, on which platform, and -how: +Produces a self-describing scoring run record (`schema_version: "0.5"`) with results, summary, coverage against the +eight principles, plus contextual metadata identifying which tool was scored, by which `anc` build, on which platform, +and how. Each scorecard conforms to the JSON Schema emitted by `anc schema` (also committed at +`schema/scorecard.schema.json`): ```json { @@ -248,15 +251,15 @@ how: "error": 0 }, "coverage_summary": { - "must": { "total": 23, "verified": 17 }, - "should": { "total": 16, "verified": 2 }, - "may": { "total": 7, "verified": 0 } + "must": { "total": 27, "verified": 21 }, + "should": { "total": 20, "verified": 6 }, + "may": { "total": 10, "verified": 3 } }, "audience": "agent-optimized", "audit_profile": null, - "spec_version": "0.3.0", - "tool": { "name": "ripgrep", "binary": "rg", "version": "ripgrep 15.1.0" }, - "anc": { "version": "0.3.0", "commit": "abc1234" }, + "spec_version": "0.4.0", + "tool": { "name": "ripgrep", "binary": "rg", "version": "ripgrep 15.1.0" }, + "anc": { "version": "0.4.0" }, "run": { "invocation": "anc check --command rg --output json", "started_at": "2026-04-29T16:00:00Z", @@ -275,53 +278,65 @@ how: } ``` -- `coverage_summary` — how many MUSTs/SHOULDs/MAYs the checks that ran actually verified, against the spec registry's +- `coverage_summary`: how many MUSTs/SHOULDs/MAYs the checks that ran actually verified, against the spec registry's totals. See `docs/coverage-matrix.md` for the per-requirement breakdown. Checks suppressed by `--audit-profile` do - **not** count toward `verified` — suppression means the requirement was not verified, even if the check is skipped - rather than run. -- `audience` — derived classification from 4 signal behavioral checks (`p1-non-interactive`, `p2-json-output`, + **not** count toward `verified`. Suppression means the requirement was not verified, even when the check shows as Skip + rather than running. +- `audience`: derived classification from 4 signal behavioral checks (`p1-non-interactive`, `p2-json-output`, `p7-quiet`, `p6-no-color-behavioral`). Emits `agent-optimized` (0-1 Warns), `mixed` (2 Warns), or `human-primary` (3-4 Warns). Returns `null` when any signal check failed to run (source-only mode, missing runner, or `--audit-profile` - suppression). Informational only — never gates totals or exit codes. Values serialize as kebab-case to match + suppression). Informational only; never gates totals or exit codes. Values serialize as kebab-case to match `audit_profile`'s format within the same JSON document. -- `audience_reason` — present only when `audience` is `null`. Values: `suppressed` (at least one signal check was masked +- `audience_reason`: present only when `audience` is `null`. Values: `suppressed` (at least one signal check was masked by `--audit-profile`) or `insufficient_signal` (signal check never produced, e.g. source-only run). Additive to schema `0.2`; older consumers feature-detect. -- `audit_profile` — echoes the applied `--audit-profile ` flag value (`human-tui`, `file-traversal`, +- `audit_profile`: echoes the applied `--audit-profile ` flag value (`human-tui`, `file-traversal`, `posix-utility`, or `diagnostic-only`). `null` when no profile is set. See `coverage/matrix.json` under `audit_profiles` for the committed per-category mapping of which check IDs each profile suppresses. -- `tool` — identifies what was scored. `name` is always present (deterministic from path or command). `binary` is the - executable basename when one is located; `null` for project-mode runs without a built artifact. `version` is - best-effort: project-mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`), command/binary mode probes - ` --version` then `-V`. `null` when probing failed or was declined by the self-spawn guard. The site's - `registry.yaml` `version_extract` snippets remain authoritative for tools whose self-report is unreliable. Schema - `0.4` addition. -- `anc` — identifies the `anc` build that produced the scorecard. `version` is the crate version at compile time. - `commit` is the short Git SHA at compile time, or `null` for builds outside a Git checkout (e.g., `cargo install` from - crates.io). Informational, not a signed provenance signal — pair with a Sigstore-signed release artifact if provenance - is required. Schema `0.4` addition. -- `run` — run-level facts. `invocation` is the user's argv joined with shell-safe quoting, captured **before** +- `tool`: identifies what was scored. `name` is always present and follows a four-tier fallback (command name, binary + basename, manifest package name, project directory basename) that matches the site registry's slug convention. + `binary` is the executable basename when one is located; `null` for project-mode runs without a built artifact. + `version` is best-effort: project-mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`), command/binary + mode probes ` --version` then `-V`. `null` when probing failed or was declined by the self-spawn guard. The + site's `registry.yaml` `version_extract` snippets remain authoritative for tools whose self-report is unreliable. + Schema `0.4` addition. +- `anc`: identifies the `anc` build that produced the scorecard. `version` is the crate version at compile time. + Informational, not a signed provenance signal. Pair with a Sigstore-signed release artifact when provenance is + required. Schema `0.4` addition. +- `run`: run-level facts. `invocation` is the user's argv joined with shell-safe quoting, captured **before** default-subcommand injection so it reflects what the user typed (`anc .`, not `anc check .`). `started_at` is RFC 3339 UTC. `duration_ms` is wall-clock milliseconds. `platform.os` / `platform.arch` come from `std::env::consts`. Schema `0.4` addition. -- `target` — what `anc` was pointed at. `kind` is `"project"` (directory), `"binary"` (executable file), or `"command"` +- `target`: what `anc` was pointed at. `kind` is `"project"` (directory), `"binary"` (executable file), or `"command"` (PATH-resolved name from `--command`). `path` is the **basename** of the resolved target (project directory name or - binary file name) — never the absolute path, so home-dir usernames and employer directory layouts don't leak into + binary file name), never the absolute path, so home-dir usernames and employer directory layouts do not leak into scorecards committed to repos or posted by agents. `command` carries the user-supplied name for command mode. The - unused field is always `null`, never missing — consumer code can access both unconditionally. Schema `0.4` addition. -- `badge` — agent-native badge derivation from the live run. `score_pct` is `pass / (pass + warn + fail)` rounded (Skips + unused field is always `null`, never missing. Consumer code can access both fields unconditionally. Schema `0.4` + addition. +- `badge`: agent-native badge derivation from the live run. `score_pct` is `pass / (pass + warn + fail)` rounded (Skips and Errors excluded from both sides of the ratio). `eligible` is true iff `score_pct >= 80` **and** a tool slug was - derivable. `embed_markdown` is `null` below the floor — the convention is "do not nag" until earned; `scorecard_url` + derivable. `embed_markdown` is `null` below the floor (the convention is "do not nag" until earned). `scorecard_url` and `badge_url` are populated whenever a slug exists, even below the floor, so the site renders an SVG for every scored tool (a regression below the floor shifts color rather than 404s). `convention_url` always points at `https://anc.dev/badge`. Schema `0.5` addition. > Publishing a scorecard? `run.invocation` may carry usernames or absolute paths from the machine that produced the > scorecard. `target.path` is intentionally the basename only and is safe to commit. Review `run.invocation` before -> publishing — `anc` does not silently redact, since that would surprise users debugging their own runs. +> publishing. `anc` does not silently redact, since that would surprise users debugging their own runs. ## Contributing +Three shapes of contribution, in order of cost: + +1. **Signal** (false-positive report, scoring bug, feature request, registry submission): file an issue with the + matching template at + [github.com/brettdavies/agentnative-cli/issues/new/choose](https://github.com/brettdavies/agentnative-cli/issues/new/choose). +2. **Proposal** (new language checker, scoring-engine rework, registry expansion): open a design issue first; the + maintainer signs off before code lands. +3. **Code**: PR against `dev` (per branch discipline). + +Local setup: + ```bash git clone https://github.com/brettdavies/agentnative-cli cd agentnative-cli @@ -330,25 +345,11 @@ cargo test cargo run -- check . ``` -### Reporting issues - -Open an issue at -[github.com/brettdavies/agentnative-cli/issues/new/choose](https://github.com/brettdavies/agentnative-cli/issues/new/choose). -The chooser surfaces three structured templates plus a blank fallback for everything else: - -| Template | Use it when | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Blank issue | Anything outside the structured templates below. | -| False positive | A check flagged your CLI but you believe your CLI is doing the right thing. | -| Scoring bug | Results don't match what the check should be doing (wrong status, miscategorized group/layer, evidence pointing at the wrong line). | -| Feature request | Missing capability, flag, or output format in the checker itself. | - -Spec questions, principle pressure-tests, and CLI grading live on the spec repo — -[brettdavies/agentnative](https://github.com/brettdavies/agentnative/issues/new/choose). The chooser config redirects -those automatically. Site bugs (rendering, performance) go to -[brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site/issues/new/choose). See -[CONTRIBUTING.md on the spec repo](https://github.com/brettdavies/agentnative/blob/main/CONTRIBUTING.md) for the full -cross-repo routing table. +The full tier breakdown, pre-push hook contents, and commit-message conventions live in +[`CONTRIBUTING.md`](./CONTRIBUTING.md). Cross-repo routing: principle-level discussion (MUST/SHOULD/MAY tier changes, +new principles, applicability clauses) goes to the +[spec repo](https://github.com/brettdavies/agentnative/issues/new/choose); site bugs (rendering, performance) to +[brettdavies/agentnative-site](https://github.com/brettdavies/agentnative-site/issues/new/choose). ## License diff --git a/completions/anc.bash b/completions/anc.bash index 5c69f67..593f73d 100644 --- a/completions/anc.bash +++ b/completions/anc.bash @@ -28,6 +28,9 @@ _anc() { anc,help) cmd="anc__help" ;; + anc,schema) + cmd="anc__schema" + ;; anc,skill) cmd="anc__skill" ;; @@ -55,6 +58,9 @@ _anc() { anc__help,help) cmd="anc__help__help" ;; + anc__help,schema) + cmd="anc__help__schema" + ;; anc__help,skill) cmd="anc__help__skill" ;; @@ -83,7 +89,7 @@ _anc() { case "${cmd}" in anc) - opts="-q -h -V --quiet --json --help --version check completions generate skill help" + opts="-q -h -V --quiet --json --help --version check completions generate skill schema help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -219,7 +225,7 @@ _anc() { return 0 ;; anc__help) - opts="check completions generate skill help" + opts="check completions generate skill schema help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -302,6 +308,20 @@ _anc() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + anc__help__schema) + 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__skill) opts="install" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -330,6 +350,20 @@ _anc() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + anc__schema) + opts="-q -h --quiet --json --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__skill) opts="-q -h --quiet --json --help install help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/anc.elvish b/completions/anc.elvish index d73abb7..4305654 100644 --- a/completions/anc.elvish +++ b/completions/anc.elvish @@ -29,11 +29,12 @@ set edit:completion:arg-completer[anc] = {|@words| cand completions 'Generate shell completions' cand generate 'Generate build artifacts (coverage matrix, etc.)' cand skill 'Install or manage the agentnative skill bundle' + cand schema 'Print the scorecard JSON Schema to stdout' 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 --principle 'Filter checks by principle number (1-8)' cand --output 'Output format' cand --audit-profile 'Exemption category for the target. Suppresses checks that do not apply to this class of tool — e.g., TUI apps legitimately intercept the TTY, so `--audit-profile human-tui` skips the interactive-prompt MUSTs. Suppressed checks emit `Skip` with structured evidence so readers see what was excluded' cand --binary 'Run only behavioral checks (skip source analysis)' @@ -105,11 +106,19 @@ set edit:completion:arg-completer[anc] = {|@words| } &'anc;skill;help;help'= { } + &'anc;schema'= { + cand -q 'Suppress non-essential output' + cand --quiet 'Suppress non-essential output' + cand --json 'Emit JSON output. Short alias for `--output json` on subcommands that support it. Per the agent-native convention (`p2-should-json-aliases`), the short form works alongside the canonical `--output` enum' + cand -h 'Print help (see more with ''--help'')' + cand --help 'Print help (see more with ''--help'')' + } &'anc;help'= { cand check 'Check a CLI project or binary for agent-readiness' cand completions 'Generate shell completions' cand generate 'Generate build artifacts (coverage matrix, etc.)' cand skill 'Install or manage the agentnative skill bundle' + cand schema 'Print the scorecard JSON Schema to stdout' cand help 'Print this message or the help of the given subcommand(s)' } &'anc;help;check'= { @@ -126,6 +135,8 @@ set edit:completion:arg-completer[anc] = {|@words| } &'anc;help;skill;install'= { } + &'anc;help;schema'= { + } &'anc;help;help'= { } ] diff --git a/completions/anc.fish b/completions/anc.fish index 32dcb8e..b7f7abd 100644 --- a/completions/anc.fish +++ b/completions/anc.fish @@ -32,9 +32,10 @@ complete -c anc -n "__fish_anc_needs_command" -f -a "check" -d 'Check a CLI proj 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 "generate" -d 'Generate build artifacts (coverage matrix, etc.)' complete -c anc -n "__fish_anc_needs_command" -f -a "skill" -d 'Install or manage the agentnative skill bundle' +complete -c anc -n "__fish_anc_needs_command" -f -a "schema" -d 'Print the scorecard JSON Schema to stdout' 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 principle -d 'Filter checks by principle number (1-8)' -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 audit-profile -d 'Exemption category for the target. Suppresses checks that do not apply to this class of tool — e.g., TUI apps legitimately intercept the TTY, so `--audit-profile human-tui` skips the interactive-prompt MUSTs. Suppressed checks emit `Skip` with structured evidence so readers see what was excluded' -r -f -a "human-tui\t'TUI-by-design tools (lazygit, k9s, btop). Suppresses interactive-prompt MUSTs and SIGPIPE — their contract is the TTY' @@ -76,10 +77,14 @@ complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcomman complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help (see more with \'--help\')' complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory' complete -c anc -n "__fish_anc_using_subcommand skill; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill 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 generate skill 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 generate skill help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "skill" -d 'Install or manage the agentnative skill bundle' -complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c anc -n "__fish_anc_using_subcommand schema" -s q -l quiet -d 'Suppress non-essential output' +complete -c anc -n "__fish_anc_using_subcommand schema" -l json -d 'Emit JSON output. Short alias for `--output json` on subcommands that support it. Per the agent-native convention (`p2-should-json-aliases`), the short form works alongside the canonical `--output` enum' +complete -c anc -n "__fish_anc_using_subcommand schema" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill schema 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 generate skill schema 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 generate skill schema help" -f -a "generate" -d 'Generate build artifacts (coverage matrix, etc.)' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill schema help" -f -a "skill" -d 'Install or manage the agentnative skill bundle' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill schema help" -f -a "schema" -d 'Print the scorecard JSON Schema to stdout' +complete -c anc -n "__fish_anc_using_subcommand help; and not __fish_seen_subcommand_from check completions generate skill schema help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "coverage-matrix" -d 'Render the spec coverage matrix (registry → checks → artifact)' complete -c anc -n "__fish_anc_using_subcommand help; and __fish_seen_subcommand_from skill" -f -a "install" -d 'Install the skill bundle into a host\'s canonical skills directory' diff --git a/completions/anc.powershell b/completions/anc.powershell index a84e745..70f24ab 100644 --- a/completions/anc.powershell +++ b/completions/anc.powershell @@ -32,12 +32,13 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate shell completions') [CompletionResult]::new('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)') [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle') + [CompletionResult]::new('schema', 'schema', [CompletionResultType]::ParameterValue, 'Print the scorecard JSON Schema to stdout') [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('--principle', '--principle', [CompletionResultType]::ParameterName, 'Filter checks by principle number (1-8)') [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output format') [CompletionResult]::new('--audit-profile', '--audit-profile', [CompletionResultType]::ParameterName, 'Exemption category for the target. Suppresses checks that do not apply to this class of tool — e.g., TUI apps legitimately intercept the TTY, so `--audit-profile human-tui` skips the interactive-prompt MUSTs. Suppressed checks emit `Skip` with structured evidence so readers see what was excluded') [CompletionResult]::new('--binary', '--binary', [CompletionResultType]::ParameterName, 'Run only behavioral checks (skip source analysis)') @@ -121,11 +122,20 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { 'anc;skill;help;help' { break } + 'anc;schema' { + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Suppress non-essential output') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit JSON output. Short alias for `--output json` on subcommands that support it. Per the agent-native convention (`p2-should-json-aliases`), the short form works alongside the canonical `--output` enum') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--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('generate', 'generate', [CompletionResultType]::ParameterValue, 'Generate build artifacts (coverage matrix, etc.)') [CompletionResult]::new('skill', 'skill', [CompletionResultType]::ParameterValue, 'Install or manage the agentnative skill bundle') + [CompletionResult]::new('schema', 'schema', [CompletionResultType]::ParameterValue, 'Print the scorecard JSON Schema to stdout') [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') break } @@ -149,6 +159,9 @@ Register-ArgumentCompleter -Native -CommandName 'anc' -ScriptBlock { 'anc;help;skill;install' { break } + 'anc;help;schema' { + break + } 'anc;help;help' { break } diff --git a/completions/anc.zsh b/completions/anc.zsh index bd30dd4..1ad8098 100644 --- a/completions/anc.zsh +++ b/completions/anc.zsh @@ -34,7 +34,7 @@ _anc() { (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' \ +'--principle=[Filter checks by principle number (1-8)]:PRINCIPLE:_default' \ '--output=[Output format]:OUTPUT:(text json)' \ '--audit-profile=[Exemption category for the target. Suppresses checks that do not apply to this class of tool — e.g., TUI apps legitimately intercept the TTY, so \`--audit-profile human-tui\` skips the interactive-prompt MUSTs. Suppressed checks emit \`Skip\` with structured evidence so readers see what was excluded]:CATEGORY:((human-tui\:"TUI-by-design tools (lazygit, k9s, btop). Suppresses interactive-prompt MUSTs and SIGPIPE — their contract is the TTY" file-traversal\:"File-traversal utilities (fd, find). Reserved for subcommand-structure relaxations as those checks land" @@ -175,6 +175,15 @@ esac ;; esac ;; +(schema) +_arguments "${_arguments_options[@]}" : \ +'-q[Suppress non-essential output]' \ +'--quiet[Suppress non-essential output]' \ +'--json[Emit JSON output. Short alias for \`--output json\` on subcommands that support it. Per the agent-native convention (\`p2-should-json-aliases\`), the short form works alongside the canonical \`--output\` enum]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_anc__help_commands" \ @@ -235,6 +244,10 @@ _arguments "${_arguments_options[@]}" : \ ;; esac ;; +(schema) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -255,6 +268,7 @@ _anc_commands() { 'completions:Generate shell completions' \ 'generate:Generate build artifacts (coverage matrix, etc.)' \ 'skill:Install or manage the agentnative skill bundle' \ +'schema:Print the scorecard JSON Schema to stdout' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'anc commands' commands "$@" @@ -307,6 +321,7 @@ _anc__help_commands() { 'completions:Generate shell completions' \ 'generate:Generate build artifacts (coverage matrix, etc.)' \ 'skill:Install or manage the agentnative skill bundle' \ +'schema:Print the scorecard JSON Schema to stdout' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'anc help commands' commands "$@" @@ -338,6 +353,11 @@ _anc__help__help_commands() { local commands; commands=() _describe -t commands 'anc help help commands' commands "$@" } +(( $+functions[_anc__help__schema_commands] )) || +_anc__help__schema_commands() { + local commands; commands=() + _describe -t commands 'anc help schema commands' commands "$@" +} (( $+functions[_anc__help__skill_commands] )) || _anc__help__skill_commands() { local commands; commands=( @@ -350,6 +370,11 @@ _anc__help__skill__install_commands() { local commands; commands=() _describe -t commands 'anc help skill install commands' commands "$@" } +(( $+functions[_anc__schema_commands] )) || +_anc__schema_commands() { + local commands; commands=() + _describe -t commands 'anc schema commands' commands "$@" +} (( $+functions[_anc__skill_commands] )) || _anc__skill_commands() { local commands; commands=( diff --git a/schema/scorecard.schema.json b/schema/scorecard.schema.json new file mode 100644 index 0000000..7a5341b --- /dev/null +++ b/schema/scorecard.schema.json @@ -0,0 +1,246 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://anc.dev/scorecard-v0.5.schema.json", + "title": "agentnative scorecard", + "description": "JSON Schema for `anc check --output json` scorecards (schema version 0.5). Hand-written for v0.4.0. A schemars-derived version generated from src/scorecard/mod.rs is planned for a follow-up release; see docs/plans/2026-04-30-002-feat-scorecard-json-schema-plan.md on the `dev` branch.", + "type": "object", + "required": [ + "schema_version", + "results", + "summary", + "coverage_summary", + "audience", + "audit_profile", + "spec_version", + "tool", + "anc", + "run", + "target", + "badge" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "description": "Scorecard schema version. Pre-launch additive — consumers feature-detect new fields rather than pin to an exact value.", + "examples": ["0.5"] + }, + "results": { + "type": "array", + "description": "Per-check outcomes. One entry per check the runner produced.", + "items": { "$ref": "#/$defs/CheckResultView" } + }, + "summary": { "$ref": "#/$defs/Summary" }, + "coverage_summary": { "$ref": "#/$defs/CoverageSummary" }, + "audience": { + "type": ["string", "null"], + "description": "Derived audience classification. `null` when the classifier could not produce a label (e.g., signal check suppressed by `--audit-profile`, source-only run). Read-only over results; never gates totals or exit codes.", + "enum": ["agent-optimized", "mixed", "human-primary", null] + }, + "audience_reason": { + "type": "string", + "description": "Reason the classifier declined a label, when `audience` is null. Omitted from JSON when `audience` has a value.", + "enum": ["suppressed", "insufficient_signal"] + }, + "audit_profile": { + "type": ["string", "null"], + "description": "The `--audit-profile` value applied to this run, or null when no profile was set. Drives suppression of checks listed in the registry's SUPPRESSION_TABLE for that category.", + "enum": ["human-tui", "file-traversal", "posix-utility", "diagnostic-only", null] + }, + "spec_version": { + "type": "string", + "description": "agentnative-spec version this `anc` build was compiled against. Sourced at build time from `src/principles/spec/VERSION`. Reads `unknown` when the vendored VERSION file was missing at build time.", + "examples": ["0.4.0"] + }, + "tool": { "$ref": "#/$defs/ToolInfo" }, + "anc": { "$ref": "#/$defs/AncInfo" }, + "run": { "$ref": "#/$defs/RunInfo" }, + "target": { "$ref": "#/$defs/TargetInfo" }, + "badge": { "$ref": "#/$defs/BadgeInfo" } + }, + "$defs": { + "CheckResultView": { + "type": "object", + "required": ["id", "label", "group", "layer", "status", "evidence", "confidence"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable check identifier (e.g., `p1-non-interactive`). The site's `/score/` page and external leaderboards pin on these strings." + }, + "label": { + "type": "string", + "description": "Human-readable summary of what the check verifies." + }, + "group": { + "type": "string", + "description": "Principle bucket the check belongs to. Cross-cutting groups (`CodeQuality`, `ProjectStructure`) appear regardless of `--principle` filter.", + "examples": ["P1", "P2", "P8", "CodeQuality", "ProjectStructure"] + }, + "layer": { + "type": "string", + "description": "Where the check ran. Serialized as snake_case.", + "enum": ["behavioral", "source", "project"] + }, + "status": { + "type": "string", + "description": "Outcome of the check. Skips and Errors are excluded from badge scoring.", + "enum": ["pass", "warn", "fail", "skip", "error"] + }, + "evidence": { + "type": ["string", "null"], + "description": "Structured evidence for non-Pass statuses: the suppression-table marker, the unrecognized-flag list, the missing-file path. `null` for clean Pass." + }, + "confidence": { + "type": "string", + "description": "How directly the check verifies its requirement. `high` for direct probes; `medium` for heuristics. Older consumers feature-detect.", + "enum": ["high", "medium"] + } + } + }, + "Summary": { + "type": "object", + "description": "Run-level outcome counts.", + "required": ["total", "pass", "warn", "fail", "skip", "error"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "pass": { "type": "integer", "minimum": 0 }, + "warn": { "type": "integer", "minimum": 0 }, + "fail": { "type": "integer", "minimum": 0 }, + "skip": { "type": "integer", "minimum": 0 }, + "error": { "type": "integer", "minimum": 0 } + } + }, + "LevelCounts": { + "type": "object", + "description": "Per-level verification counts. `verified` counts requirements that had at least one check declare `covers()` against them in this run, regardless of pass/fail.", + "required": ["total", "verified"], + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "verified": { "type": "integer", "minimum": 0 } + } + }, + "CoverageSummary": { + "type": "object", + "description": "Three-way coverage rollup over registry requirements: how many `must` / `should` / `may` requirements were verified by this run's checks.", + "required": ["must", "should", "may"], + "additionalProperties": false, + "properties": { + "must": { "$ref": "#/$defs/LevelCounts" }, + "should": { "$ref": "#/$defs/LevelCounts" }, + "may": { "$ref": "#/$defs/LevelCounts" } + } + }, + "ToolInfo": { + "type": "object", + "description": "Identity of the scored target. `binary` is `null` for project-mode runs without a built artifact; `version` is `null` when probing failed, produced no parseable output, or was declined by the self-spawn guard.", + "required": ["name", "binary", "version"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "binary": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] } + } + }, + "AncInfo": { + "type": "object", + "description": "Identity of the `anc` build that produced this scorecard.", + "required": ["version"], + "additionalProperties": false, + "properties": { + "version": { "type": "string", "examples": ["0.4.0"] } + } + }, + "PlatformInfo": { + "type": "object", + "description": "OS / architecture pair sourced from `std::env::consts::{OS, ARCH}`.", + "required": ["os", "arch"], + "additionalProperties": false, + "properties": { + "os": { "type": "string", "examples": ["linux", "macos", "windows"] }, + "arch": { "type": "string", "examples": ["x86_64", "aarch64"] } + } + }, + "RunInfo": { + "type": "object", + "description": "Run-level facts. `invocation` is argv joined with spaces, captured before `inject_default_subcommand` rewrites bare paths. `started_at` is RFC 3339 UTC. `duration_ms` is wall-clock.", + "required": ["invocation", "started_at", "duration_ms", "platform"], + "additionalProperties": false, + "properties": { + "invocation": { "type": "string", "examples": ["anc ."] }, + "started_at": { "type": "string", "format": "date-time" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "platform": { "$ref": "#/$defs/PlatformInfo" } + } + }, + "TargetInfo": { + "type": "object", + "description": "What `anc check` was pointed at. `path` is the basename (never an absolute path — protects against home-dir PII leaks). Always-present-null keys keep consumer code simple.", + "required": ["kind", "path", "command"], + "additionalProperties": false, + "properties": { + "kind": { "type": "string", "enum": ["project", "binary", "command"] }, + "path": { "type": ["string", "null"] }, + "command": { "type": ["string", "null"] } + } + }, + "BadgeInfo": { + "type": "object", + "description": "Agent-native badge metadata derived from this run. `score_pct` is `pass / (pass + warn + fail)` rounded to an integer percent — Skips and Errors are excluded from both sides of the ratio. `eligible` is `true` iff `score_pct >= 80` and a tool slug was derivable. `embed_markdown` is populated only when `eligible` (do-not-nag contract); `scorecard_url` / `badge_url` are populated whenever a slug exists so below-floor regressions shift color rather than 404.", + "required": ["eligible", "score_pct", "embed_markdown", "scorecard_url", "badge_url", "convention_url"], + "additionalProperties": false, + "properties": { + "eligible": { "type": "boolean" }, + "score_pct": { "type": "integer", "minimum": 0, "maximum": 100 }, + "embed_markdown": { "type": ["string", "null"] }, + "scorecard_url": { "type": ["string", "null"], "format": "uri" }, + "badge_url": { "type": ["string", "null"], "format": "uri" }, + "convention_url": { "type": "string", "format": "uri", "const": "https://anc.dev/badge" } + } + } + }, + "examples": [ + { + "schema_version": "0.5", + "results": [ + { + "id": "p1-non-interactive", + "label": "Non-interactive by default", + "group": "P1", + "layer": "behavioral", + "status": "pass", + "evidence": null, + "confidence": "high" + } + ], + "summary": { "total": 1, "pass": 1, "warn": 0, "fail": 0, "skip": 0, "error": 0 }, + "coverage_summary": { + "must": { "total": 23, "verified": 17 }, + "should": { "total": 14, "verified": 9 }, + "may": { "total": 7, "verified": 3 } + }, + "audience": "agent-optimized", + "audit_profile": null, + "spec_version": "0.4.0", + "tool": { "name": "ripgrep", "binary": "rg", "version": "14.1.0" }, + "anc": { "version": "0.4.0" }, + "run": { + "invocation": "anc --command ripgrep", + "started_at": "2026-05-21T17:03:00Z", + "duration_ms": 1240, + "platform": { "os": "linux", "arch": "x86_64" } + }, + "target": { "kind": "command", "path": null, "command": "ripgrep" }, + "badge": { + "eligible": true, + "score_pct": 100, + "embed_markdown": "[![agent-native](https://anc.dev/badge/ripgrep.svg)](https://anc.dev/score/ripgrep)", + "scorecard_url": "https://anc.dev/score/ripgrep", + "badge_url": "https://anc.dev/badge/ripgrep.svg", + "convention_url": "https://anc.dev/badge" + } + } + ] +} diff --git a/src/cli.rs b/src/cli.rs index 1d8a7b8..76cd72d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,7 +56,7 @@ pub enum Commands { #[arg(long)] source: bool, - /// Filter checks by principle number (1-7) + /// Filter checks by principle number (1-8) #[arg(long)] principle: Option, @@ -91,6 +91,14 @@ pub enum Commands { #[command(subcommand)] cmd: SkillCmd, }, + /// Print the scorecard JSON Schema to stdout + /// + /// Emits the JSON Schema (draft 2020-12) that describes the shape of + /// `anc check --output json`. Consumers (site renderer, leaderboards, + /// agent integrations) validate scorecards against this contract instead + /// of inferring the shape from sample output. The schema is the same + /// document committed at `schema/scorecard.schema.json` in this repo. + Schema, } #[derive(Subcommand)] diff --git a/src/main.rs b/src/main.rs index 0ac09b9..8bb5fa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ use scorecard::{ }; use types::{CheckGroup, CheckResult, CheckStatus, Confidence}; +const SCORECARD_SCHEMA_JSON: &str = include_str!("../schema/scorecard.schema.json"); + fn main() { // Fix SIGPIPE handling so piping to head/grep works correctly. #[cfg(unix)] @@ -103,6 +105,10 @@ fn run() -> Result { Some(Commands::Skill { cmd }) => { return run_skill(cmd, json_alias); } + Some(Commands::Schema) => { + output::emit(SCORECARD_SCHEMA_JSON); + return Ok(0); + } None => { let mut cmd = ::command(); eprintln!("{}", cmd.render_help()); @@ -314,22 +320,61 @@ fn basename_string(path: &std::path::Path) -> Option { path.file_name().map(|n| n.to_string_lossy().into_owned()) } -/// Cheap slug derivation: the same `name` `build_tool_info` would emit, but -/// without the manifest read or `--version` subprocess probe. Used by the -/// text-mode badge hint, where we need the slug to render the embed URL but -/// have no use for the version. Keeping this in lock-step with -/// `build_tool_info`'s `name` calculation guarantees the text-mode hint -/// references the same `` slug a `--output json` consumer would see. +/// Slug derivation used by both `build_tool_info` (JSON `tool.name`) and the +/// text-mode badge hint. The fallback chain mirrors how the site's +/// `registry.yaml` keys tools — the binary name is canonical, the directory +/// basename is the last resort. Reads the manifest synchronously (no +/// subprocess probe), so it remains cheap. +/// +/// Fallbacks, in order: +/// +/// 1. `command_name` when `--command ` was passed. +/// 2. Binary basename from `project.binary_paths[0]`. +/// 3. Manifest package name (`[package].name` in `Cargo.toml`, +/// `[project].name` in `pyproject.toml`). +/// 4. Project directory basename. +/// +/// The previous shape (directory-basename only) produced 404-bound badge URLs +/// for any tool whose registered slug differed from its directory name. See +/// `.context/compound-engineering/todos/023-pending-p1-derive-tool-name-uses-dir-basename-not-registry-slug.md` +/// on `dev` for the bug context this addresses. fn derive_tool_name(command_name: Option<&str>, project: &Project) -> String { - match command_name { - Some(cmd) => cmd.to_string(), - None => project - .path - .file_name() - .and_then(|n| n.to_str()) - .map(String::from) - .unwrap_or_default(), + derive_tool_name_inner( + command_name, + project + .binary_paths + .first() + .map(std::path::PathBuf::as_path), + project.manifest_path.as_deref(), + &project.path, + ) +} + +/// Pure core of `derive_tool_name`. Takes the four inputs verbatim so unit +/// tests can exercise the fallback chain without constructing a `Project`. +fn derive_tool_name_inner( + command_name: Option<&str>, + binary_path: Option<&std::path::Path>, + manifest_path: Option<&std::path::Path>, + project_path: &std::path::Path, +) -> String { + if let Some(cmd) = command_name { + return cmd.to_string(); } + if let Some(bin) = binary_path + .and_then(std::path::Path::file_name) + .and_then(|n| n.to_str()) + { + return bin.to_string(); + } + if let Some(name) = manifest_path.and_then(read_manifest_name) { + return name; + } + project_path + .file_name() + .and_then(|n| n.to_str()) + .map(String::from) + .unwrap_or_default() } /// Build the scorecard's `tool` block. `name` is always present (deterministic @@ -438,6 +483,27 @@ fn read_manifest_version(manifest: &std::path::Path) -> Option { None } +fn read_manifest_name(manifest: &std::path::Path) -> Option { + let content = std::fs::read_to_string(manifest).ok()?; + let parsed: toml::Value = content.parse().ok()?; + + if let Some(v) = parsed + .get("package") + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + { + return Some(v.to_string()); + } + if let Some(v) = parsed + .get("project") + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + { + return Some(v.to_string()); + } + None +} + /// Resolve a command name to an absolute path by shelling out to `which` /// (Unix) or `where` (Windows). Returns a clear, actionable error when the /// name cannot be found on PATH. Subsequent `Project::discover()` validates @@ -595,5 +661,89 @@ fn matches_principle(group: &CheckGroup, principle: u8) -> bool { | (CheckGroup::P5, 5) | (CheckGroup::P6, 6) | (CheckGroup::P7, 7) + | (CheckGroup::P8, 8) ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "anc-derive-tool-name-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after UNIX epoch") + .as_nanos(), + )); + std::fs::create_dir_all(&p).expect("create test dir"); + p + } + + #[test] + fn command_mode_takes_command_name() { + let dir = tmp("cmd"); + let got = + derive_tool_name_inner(Some("ripgrep"), Some(Path::new("/usr/bin/rg")), None, &dir); + assert_eq!(got, "ripgrep"); + } + + #[test] + fn project_mode_prefers_binary_basename_over_manifest_and_dir() { + let dir = tmp("project-with-binary"); + let manifest = dir.join("Cargo.toml"); + std::fs::write( + &manifest, + "[package]\nname = \"agentnative\"\nversion = \"0.0.0\"\n", + ) + .expect("write manifest"); + let got = derive_tool_name_inner( + None, + Some(Path::new("target/release/anc")), + Some(&manifest), + &dir, + ); + assert_eq!(got, "anc"); + } + + #[test] + fn falls_back_to_cargo_package_name_when_binary_missing() { + let dir = tmp("project-no-binary-cargo"); + let manifest = dir.join("Cargo.toml"); + std::fs::write( + &manifest, + "[package]\nname = \"my-tool\"\nversion = \"0.0.0\"\n", + ) + .expect("write manifest"); + let got = derive_tool_name_inner(None, None, Some(&manifest), &dir); + assert_eq!(got, "my-tool"); + } + + #[test] + fn falls_back_to_pyproject_project_name_when_binary_missing() { + let dir = tmp("project-no-binary-pyproject"); + let manifest = dir.join("pyproject.toml"); + std::fs::write( + &manifest, + "[project]\nname = \"py-tool\"\nversion = \"0.0.0\"\n", + ) + .expect("write manifest"); + let got = derive_tool_name_inner(None, None, Some(&manifest), &dir); + assert_eq!(got, "py-tool"); + } + + #[test] + fn last_resort_uses_directory_basename() { + let dir = tmp("last-resort"); + let got = derive_tool_name_inner(None, None, None, &dir); + let basename = dir + .file_name() + .and_then(|n| n.to_str()) + .expect("tmp dir has a utf-8 basename") + .to_string(); + assert_eq!(got, basename); + } +}