diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f10ea4f2..58ff2d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: branches: - "[0-9]*.[0-9]*.[0-9]*" - main + workflow_dispatch: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -20,6 +21,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: + # macos-latest deliberately omitted: ubuntu-latest covers the POSIX/Unix + # surface and windows-latest covers the non-POSIX surface; macOS is POSIX-like, + # so it is unlikely to surface issues ubuntu does not, and GitHub's macOS + # runners bill far more CI minutes. Revisit if a macOS-specific defect appears. os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -37,12 +42,38 @@ jobs: - name: Type check run: uv run poe type + - name: Rule lint + run: uv run poe test_lint + - name: Unit tests run: uv run poe test_unit - name: Integration tests run: uv run poe test_integration - # Smoke tests intentionally not gated in CI yet — a few test cases have - # environment-dependent assumptions that pass locally but fail on the GH - # runner. Re-enable once the smoke suite is hermetic. + # Smoke suite is hermetic: tests/smoke/conftest.py forces offline + # diagnostics (no network), HOME is isolated, and tests that can only + # be observed via the mapper model declare `requires_model` so they + # skip cleanly on the model-free runner. + - name: Smoke tests + run: uv run poe test_smoke + + # Live-path regression — hits the real platform host, so it stays out of the + # gated QA matrix (offline CI must remain green when the edge is flaky). + # Manual via the Actions tab; the assertion is the new-user activation hop. + live: + name: Live activation path + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Install dependencies + run: uv sync + + - name: Live network tests + run: uv run poe test_live diff --git a/.gitignore b/.gitignore index 07514990..fd3100eb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ __pycache__ # Internal coordination surface — private inner repo, never ships in the public CLI /hub/ +# doku session/worker state — hub coordination bookkeeping, never tracked in the public CLI +/.doku/ + # Claude specific - Dev internals (root only; fixtures under tests/ and framework/ are tracked) /.claude/ /.mcp.json diff --git a/README.md b/README.md index 1bb8f817..51413959 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Reporails CLI (v0.5.11) +# Reporails CLI (v0.5.12) > **AI Instruction Diagnostics for coding agents. Validates the entire agentic instruction system against 120+ rules across six rule packs (core + per-agent). Supports Claude, Codex, Copilot, Cursor, and Gemini.** > diff --git a/UNRELEASED.md b/UNRELEASED.md index e93f97cd..a06f4400 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,8 +2,51 @@ ### Added +- check: inline per-line finding suppression. Mark a single reviewed line with `` to silence just that one rule on just that line, while the rule keeps firing everywhere else. The directive is an invisible HTML comment, must name the rule (space/comma-separate several), and never changes how the rest of the line is analyzed. See `docs/configuration.md`. +- testing: added internal regression coverage to keep `ails check` output stable across refactors. +- testing: added an architecture check that keeps error handling at the network boundary consistent, so faults surface clearly instead of being silently swallowed. +- testing: opt-in live-network lane exercising the `ails auth login` activation path so first-contact auth regressions surface in CI rather than at a new user. +- testing: the suite now runs against an isolated home directory, so a contributor's machine-wide config (`~/.reporails/config.yml`, `~/.codex/`, `~/.claude/`) no longer leaks into agent-detection tests — they pass or fail the same way locally as in CI. Includes a regression test pinning that a global `~/.codex/config.toml` cannot hijack detection of an `AGENTS.md`-only project. +- testing: the end-to-end smoke suite now runs in CI on every push. It is hermetic — diagnostics run offline (no network), home is isolated, and the few assertions that can only be observed through the bundled mapper model skip cleanly on the model-free runner — so smoke regressions are caught in CI instead of shipping green. +- testing: the rule-library lint pass (`ails test --lint`) now runs as part of the QA gate, so a duplicate rule ID anywhere in the rule library or a malformed rule fails the gate instead of slipping through unnoticed. +- testing: new CI-gated coverage for three previously-untested behaviors — `ails check --heal --dry-run` leaving files unmodified, the friendly "Path not found" error on a bare keyword that is neither a path nor a capability, and multi-target `ails check` (several targets in one invocation) scoping the scan to exactly those targets. +- testing: hardened that new coverage — the multi-target test now asserts against a file the scan would otherwise discover, so it genuinely proves out-of-target scoping, and the `--heal --dry-run` no-mutation test documents that its full assertion runs only where the bundled model is present. + ### Changed +- rules: renamed the Google agent rule pack and registry entry from `gemini` to `antigravity`, following Google's 2026-06-18 retirement of the Gemini CLI in favor of the Antigravity CLI. The pack still validates legacy `GEMINI.md` / `~/.gemini/` files for backward-compat and now also recognizes Antigravity's `AGENTS.md` primary and `.agents/` skills layout. `ails check` reports the agent as `antigravity`; the five implemented agents are now claude, codex, copilot, cursor, antigravity. +- testing: normalized code formatting in two test modules (no behavior change). +- check: `ails check --heal` now requires you to name what to fix. A whole-project heal — a bare `--heal`, or `ails check . --heal` / `ails check ./ --heal` — is refused; pass an explicit target (`ails check CLAUDE.md --heal`), preview everything with `--dry-run`, or opt into a whole-project rewrite with the new `--cwd` flag. `--heal` also never writes through an in-tree symlink whose real file lies outside the named target, so a scoped heal cannot modify files outside its scope. Prevents accidental project-wide and out-of-scope rewrites. +- check: applying fixes now requires an account. The diagnosis stays free for everyone, but `--heal` (apply or `--dry-run` preview) needs sign-in — anonymous users get the full diagnosis plus a prompt to run `ails auth login`. This keeps the free experience honest: the diagnosis names what to fix; the fix is the account feature. +- explain: `ails explain ` now shows the rule's Pass / Fail examples, matching `ails rules -f md`. Both surfaces draw examples from the same fence-aware extractor, and both now name an absent example block ("no Pass / Fail examples") instead of silently omitting it. +- json: `-f json` and the `--format github` trailing JSON now emit canonical rule IDs (e.g. `CORE:S:0039`) for findings that previously carried a bare client-side token like `format` or `orphan`, matching the text output. The raw token is preserved under a new `label` key when it differs, so machine baselines keyed on it stay stable. As a result those findings now carry a populated `category` and join the per-surface category breakdown, and `top_rules` is keyed by canonical ID. (The `ambiguous_charge` classifier-confidence marker has no canonical rule and intentionally stays label-only.) +- rules: a rule's check can now declare `project_scope: true` in `checks.yml` to be skipped on a narrowed (single-target) run where a whole-project aggregate is meaningless — previously this was hard-coded in the engine, so adding such a rule required a code change. No change to default whole-project scans. +- help: the `npx @reporails/cli` help output now lists the `ails rules` command. +- check: the collapsed lower-priority findings row no longer claims those findings "won't move your score yet" — it now reads `+N lower-priority · -v to list`. The old wording implied the deferred findings would move the score once the higher-priority ones were fixed, which overpromised; the row simply marks the collapsed tail. + ### Fixed +- check: a neutral sentence is no longer flagged as ambiguous when the only instruction-like word sits inside a Markdown link label, a citation reference, or a `code span` — those are references, not instructions. Genuine inline instruction language in a neutral sentence still surfaces. Reduces false positives on documentation prose that links to or cites a rule by name. +- auth: clearer errors when the credentials or config file can't be read — a corrupt file now produces a visible warning and the session continues with anonymous access, instead of a silent tier downgrade. +- performance: `ails check` is substantially faster on large projects, with identical output. +- auth: `ails auth login` now gives a clear, actionable error when the auth endpoint is reachable but returns an unexpected response (a non-200 status or a non-JSON body) — "retry shortly or contact support" — instead of a generic HTTP error or a misleading "OAuth not configured" message. Both the client-id lookup and the token-exchange step surface it. +- check: a whole-project scan no longer lets your machine-wide agent config decide which agent a repository is. A global home-directory file such as `~/.codex/config.toml` could make `ails check` treat a project that only has an `AGENTS.md` as that specific agent's project, narrowing the findings to that agent's rules instead of the cross-agent core set. Discovery now ignores home-scope (`~/...`) paths during a repository scan; those surfaces remain reachable only when you target them explicitly (e.g. `ails check subagent_memory`). +- check: a whole-project scan again surfaces the project's own auto-memory (the Memory segment). A recent change that stopped a machine-wide config from deciding a repo's agent had also dropped the project's per-project memory, which is keyed to this project specifically and is not a cross-project surface — it now shows again, while another project's memory still stays out of scope. +- check: the bridging caption "the listed errors are still your worklist" no longer appears when there is no error list below it. Anonymous and free-tier runs that show a quality score but gate the per-finding list behind sign-in previously printed the caption pointing at a list that wasn't there; it now appears only when error findings are actually listed. +- check: per-surface health bars stay column-aligned when a surface name is long and its file count reaches two digits — the name column now sizes to the widest label in the set instead of a fixed width. +- check: targeting a directory (`ails check `) no longer drops instruction files that are symlinks pointing outside that directory; an in-tree symlinked file under the target is now scanned. +- update: rule-framework archive extraction is forward-compatible with Python 3.14's stricter tar handling (uses the safe `data` extraction filter). +- check: `--heal` now leaves a line you marked with `` untouched — a line you reviewed and silenced is no longer auto-rewritten. `--heal` also never writes through a symlinked file (including a capability target like a `.claude/rules/` symlink) whose real path lies outside the named scope, and skips a file whose `@import` directives would shift its line numbers rather than risk editing the wrong line. +- check: inline `ails-disable-line` suppression directives are now honored on the MCP `validate` tool too, matching `ails check` — an agent no longer re-flags a finding you already dismissed. +- check: `--exclude-files` (and `exclude_files` config) now also excludes a file reached only via an `@import` from a non-excluded file; previously such a file silently re-entered the scan. Naming an excluded file explicitly (`ails check VENDORED.md`) still scans it — an explicit target overrides the exclusion. +- check: `ails check --heal -f json` run anonymously now still emits the full diagnosis JSON (the auth notice goes to stderr) — a machine consumer that adds `--heal` no longer loses all diagnostic data. +- check: `--strict` now exits non-zero for a displayed error on a user-scope target (e.g. `ails check subagent_memory --strict`), matching what the run displayed. +- auth: a session no longer silently downgrades to the free tier when the server returns a tier alongside an otherwise-unparseable response body. +- testing: the heal scope-safety regression for `ails check . --heal` now runs in an isolated working directory, so it no longer depends on the checkout carrying a root `CLAUDE.md` — it passes identically in CI and locally. +- testing: the uppercase-agent smoke test now requests text output explicitly, so it asserts the human "No instruction files found" message reliably under CI (where the default output format is machine-readable) instead of only locally. +- testing: the project auto-memory regression tests skip on Windows, where the `~/.claude/projects//memory/` slug derivation is POSIX-path-shaped and does not form a valid Windows path component — the behavior they assert holds only on POSIX. + ### Removed + +- internal: removed two unused output-rendering helpers; no change to `ails check` output. +- internal: pruned several unused internal modules (a feature-summary helper, a display stub, a dead init path, a rules-path resolver, and an orphan-feature detector); no user-facing behavior change. diff --git a/docs/configuration.md b/docs/configuration.md index 1e4d034e..51d9f6ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,22 @@ disabled_rules: Browse the full rule reference at [reporails.com/rules](https://reporails.com/rules) to look up each rule's body and pass / fail examples before disabling — sometimes the rule's intent fits your project but the surface form doesn't, and a severity override (below) reads better than a disable. `ails explain CORE:C:0010` shows the rule body inline from the CLI when you already know the ID. +## Suppressing one finding on one line + +`disabled_rules` turns a rule off everywhere. When a single line is an intentional, reviewed exception but the rule is still worth running everywhere else, mark just that line with an inline directive instead: + +```markdown +## Always run the tests before committing +``` + +The directive is an HTML comment, so it stays invisible when the file renders. It suppresses **only** the named rule **only** on its own line — the same rule still fires on every other line, and other rules on the annotated line still fire. Name several rules with a space- or comma-separated list: + +```markdown +Some heading line +``` + +Use the rule ID shown next to the finding in `ails check` output (e.g. `CORE:C:0047`); the rule's slug works too. A directive that names no rule suppresses nothing — suppression is always targeted, so it stays auditable in review. The directive text never affects how the rest of the line is analyzed. + ## Excluding directories `exclude_dirs` is a list of directory *names* (not paths). Any directory matching one of these names is skipped no matter where it appears. The setting exists because the discovery walk scans every directory looking for instruction files — without an exclude list it would descend into vendored trees (`node_modules`, `vendor/`), build output (`dist/`, `target/`), and data dumps (`data/`), surfacing third-party `CLAUDE.md` / `AGENTS.md` files you didn't author and slowing the scan. diff --git a/framework/capabilities_matrix.yml b/framework/capabilities_matrix.yml index 58ceef50..1e04f0e6 100644 --- a/framework/capabilities_matrix.yml +++ b/framework/capabilities_matrix.yml @@ -7,7 +7,7 @@ version: "1.1.0" # Implemented scope — these agents have full config.yml and audited docs. # The rest are cataloged but not reconciled. -implemented: [claude, codex, copilot, cursor, gemini] +implemented: [antigravity, claude, codex, copilot, cursor] # Capability taxonomy — stable IDs mapped to human labels. taxonomy: @@ -35,6 +35,7 @@ agents: aider: [root, user_surfaces, config] amazonq: [root, scoped, hooks, mcp, subagents, enterprise, plugins, agents_md, cross_read, user_surfaces, config] amp: [root, scoped, skills, mcp, enterprise, agents_md, cross_read, user_surfaces, config] + antigravity: [root, scoped, skills, hooks, mcp, subagents, memory, enterprise, plugins, agents_md, cross_read, user_surfaces, config, templates, output] augment: [root, scoped, mcp, agents_md, cross_read, user_surfaces, config] bito: [root, enterprise, agents_md, cross_read, user_surfaces, config] blackbox: [skills, user_surfaces, config] @@ -48,7 +49,6 @@ agents: copilot: [root, scoped, skills, hooks, mcp, subagents, memory, enterprise, plugins, agents_md, cross_read, user_surfaces, config, templates] cursor: [root, scoped, skills, hooks, mcp, subagents, memory, enterprise, plugins, agents_md, cross_read, user_surfaces, config, templates, output, scheduled_tasks] devin: [root, scoped, mcp, memory, enterprise, agents_md, user_surfaces, config, templates] - gemini: [root, scoped, skills, hooks, mcp, subagents, memory, enterprise, plugins, agents_md, cross_read, user_surfaces, config, templates, output] goose: [root, scoped, mcp, memory, enterprise, plugins, agents_md, cross_read, user_surfaces, config, templates] jetbrains: [root, scoped, mcp, enterprise, user_surfaces, config] junie: [root, agents_md, user_surfaces, config] diff --git a/framework/rules/antigravity/config.yml b/framework/rules/antigravity/config.yml new file mode 100644 index 00000000..ee5b5124 --- /dev/null +++ b/framework/rules/antigravity/config.yml @@ -0,0 +1,318 @@ +# Google Antigravity Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 +# +# Synced from .claude/skills/audit-agent/assets/registry/antigravity/config.yml +# Last verified: 2026-06-21 +# +# Provenance. Google retired the open-source `gemini` CLI on 2026-06-18 and +# succeeded it with the Antigravity CLI (Google transition blog, 2026-06-18). +# Antigravity reads AGENTS.md + GEMINI.md project rules and the global +# ~/.gemini/GEMINI.md, keeps Agent Skills / Hooks / Subagents / Extensions +# (now "plugins"), and stores its own state under ~/.gemini/antigravity/. +# AGENTS.md is the go-forward primary instruction file; GEMINI.md is retained +# as the backward-compat instruction surface (Antigravity still reads it). +# +# Items intentionally NOT included here (not measurable on disk): +# - Cloud-managed enterprise policies (Google Cloud admin / Code Assist Enterprise) +# - Scheduled tasks (CLI itself doesn't have native scheduling — uses external cron) +# +# UNCONFIRMED: the exact Antigravity hook wire format. antigravity.google/docs is +# JS-rendered and not machine-fetchable; the blog confirms Hooks are kept but not +# the schema. The `hooks` file_type and the 3 ANTIGRAVITY:S:* hook rules inherit +# the Gemini-CLI `.gemini/settings.json` shape pending accessible Antigravity hook +# docs — re-verify when antigravity.google/docs/hooks becomes fetchable. + +agent: antigravity +version: "0.5.0" +prefix: ANTIGRAVITY +name: Google Antigravity + +file_types: + main: + # AGENTS.md (go-forward primary) + GEMINI.md (backward-compat) walked from + # project root through ancestors of cwd to .git or home. All discovered files + # concatenated. Global user rules live at ~/.gemini/GEMINI.md (shared with the + # legacy gemini surface; Antigravity hardcodes its global rules here too). + source: https://antigravity.google/docs/gcli-migration + required: true + format: freeform + scope: global + cardinality: chain + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/AGENTS.md", "**/GEMINI.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.gemini/GEMINI.md"] + precedence: user + vcs: external + maintainer: hybrid + lifecycle: mutable + + nested_context: + # Subdirectory AGENTS.md / GEMINI.md files — loaded on-demand when the agent + # accesses files in those directories. scope: nested captures the + # location-based subtree applicability. + source: https://antigravity.google/docs/gcli-migration + format: freeform + scope: nested + cardinality: hierarchical + lifecycle: static + loading: on_demand + scopes: + project: + patterns: ["**/AGENTS.md", "**/GEMINI.md"] + precedence: project + vcs: committed + maintainer: human + + cross_read: + # Additional cross-agent context file when context.fileName is configured. + # AGENTS.md is promoted to `main` (Antigravity's go-forward primary); CONTEXT.md + # remains an opt-in cross-read. + source: https://antigravity.google/docs/gcli-migration + format: freeform + scope: global + cardinality: chain + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/CONTEXT.md"] + precedence: project + vcs: committed + maintainer: human + + skills: + # `.agents/skills/` is the go-forward Antigravity skills location; a legacy + # `.gemini/skills/` folder must be relocated to `.agents/skills/` to be + # recognized. User skills also ship under ~/.gemini/antigravity-cli/plugins. + source: https://antigravity.google/docs/gcli-migration + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".agents/skills/**/SKILL.md", ".gemini/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: + - "~/.agents/skills/**/SKILL.md" + - "~/.gemini/antigravity-cli/skills/**/SKILL.md" + - "~/.gemini/skills/**/SKILL.md" + precedence: user + vcs: external + maintainer: human + + agents: + # Subagents — reusable agent definitions (e.g. code-reviewer.md, + # security-auditor.md, test-engineer.md). Project agents under .agents/agents/ + # (go-forward) or .gemini/agents/ (legacy). Custom agents invoked via @name. + source: https://antigravity.google/docs/gcli-migration + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".agents/agents/*.md", ".gemini/agents/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.gemini/agents/*.md"] + precedence: user + vcs: external + maintainer: human + + commands: + # TOML format with prompt + description, supports {{args}}, !{shell}, @{file} + source: https://antigravity.google/docs/gcli-migration + format: schema_validated + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".gemini/commands/*.toml"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.gemini/commands/*.toml"] + precedence: user + vcs: external + maintainer: human + + hooks: + # Hooks are kept by Antigravity (Google transition blog). The wire format and + # event set below are INHERITED from the Gemini-CLI `.gemini/settings.json` + # `hooks` key (11 events) pending accessible Antigravity hook docs — see the + # UNCONFIRMED note in the header and the 3 ANTIGRAVITY:S:* hook rules. + source: https://antigravity.google/docs/gcli-migration + format: schema_validated + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".gemini/settings.json"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.gemini/settings.json"] + precedence: user + vcs: external + maintainer: human + + config: + # Antigravity stores config under the .gemini/ directory (project) and + # ~/.gemini/antigravity/ (user state: mcp_config.json, conversations/, brain/, + # global_workflows/). JSON and TOML settings files are both honored. + source: https://antigravity.google/docs/gcli-migration + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".gemini/settings.json", ".gemini/settings.toml"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: + - "~/.gemini/settings.json" + - "~/.gemini/settings.toml" + - "~/.gemini/antigravity/**" + precedence: user + vcs: external + maintainer: human + system_defaults: + patterns: + - "/etc/gemini-cli/system-defaults.json" + - "/Library/Application Support/GeminiCli/system-defaults.json" + - "C:/ProgramData/gemini-cli/system-defaults.json" + precedence: managed + vcs: external + maintainer: system + system_overrides: + patterns: + - "/etc/gemini-cli/settings.json" + - "/Library/Application Support/GeminiCli/settings.json" + precedence: managed + vcs: external + maintainer: system + + mcp: + # Antigravity separates MCP servers into a dedicated lightweight JSON profile + # (mcp_config.json) rather than nesting them in primary settings. Legacy + # gemini settings.json `mcpServers` key still read for backward-compat. + source: https://antigravity.google/docs/gcli-migration + format: schema_validated + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".agents/mcp_config.json", ".gemini/settings.json", ".gemini/settings.toml"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: + - "~/.gemini/antigravity/mcp_config.json" + - "~/.gemini/settings.json" + - "~/.gemini/settings.toml" + precedence: user + vcs: external + maintainer: human + + extensions: + # Plugin system (Gemini "Extensions" are now Antigravity "plugins"): bundles + # MCP servers, commands, skills, hooks, themes. Installed plugins live under + # ~/.gemini/antigravity-cli/plugins/. + source: https://antigravity.google/docs/gcli-migration + format: schema_validated + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".gemini/extensions/**"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.gemini/antigravity-cli/plugins/**"] + precedence: user + vcs: external + maintainer: human + + geminiignore: + source: https://antigravity.google/docs/gcli-migration + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".geminiignore"] + precedence: project + vcs: committed + maintainer: human + + system_prompt: + # Replace default system prompt entirely + source: https://antigravity.google/docs/gcli-migration + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".gemini/system-prompt.md"] + precedence: project + vcs: committed + maintainer: human + + memory: + # Antigravity's private persistent state lives under ~/.gemini/antigravity/ + # (brain/, conversations/, global_workflows/) — direct-edit, recalled on + # demand. The legacy ~/.gemini/GEMINI.md "## Gemini Added Memories" section + # is retained as a backward-compat memory surface (`main`, scopes.user). + source: https://antigravity.google/docs/gcli-migration + format: freeform + scope: global + cardinality: collection + lifecycle: mutable + loading: session_start + scopes: + user_project: + patterns: + - "~/.gemini/antigravity/brain/**" + - "~/.gemini/antigravity/global_workflows/**" + precedence: user + vcs: external + maintainer: hybrid + +excludes: + - CLAUDE:* + - CODEX:* diff --git a/framework/rules/gemini/hook-command-has-field/checks.yml b/framework/rules/antigravity/hook-command-has-field/checks.yml similarity index 68% rename from framework/rules/gemini/hook-command-has-field/checks.yml rename to framework/rules/antigravity/hook-command-has-field/checks.yml index ea7e5178..300df22b 100644 --- a/framework/rules/gemini/hook-command-has-field/checks.yml +++ b/framework/rules/antigravity/hook-command-has-field/checks.yml @@ -1,8 +1,8 @@ checks: -- id: GEMINI.S.0003.config-file-exists +- id: ANTIGRAVITY.S.0003.config-file-exists type: mechanical check: file_exists -- id: GEMINI.S.0003.command-has-field +- id: ANTIGRAVITY.S.0003.command-has-field type: deterministic expect: present pattern-regex: '"command"\s*:\s*"[^"]+"' diff --git a/framework/rules/gemini/hook-command-has-field/rule.md b/framework/rules/antigravity/hook-command-has-field/rule.md similarity index 73% rename from framework/rules/gemini/hook-command-has-field/rule.md rename to framework/rules/antigravity/hook-command-has-field/rule.md index 72216493..cc6b9e4d 100644 --- a/framework/rules/gemini/hook-command-has-field/rule.md +++ b/framework/rules/antigravity/hook-command-has-field/rule.md @@ -1,5 +1,5 @@ --- -id: GEMINI:S:0003 +id: ANTIGRAVITY:S:0003 slug: hook-command-has-field title: Hook Command Has Field category: structure @@ -8,12 +8,12 @@ severity: high backed_by: [] match: {type: config} supersedes: CORE:S:0029 -source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/index.md +source: https://antigravity.google/docs/gcli-migration --- # Hook Command Has Field -Hook handlers with `"type": "command"` in `.gemini/settings.json` MUST include a `"command"` field containing the shell command to execute. Without it, Gemini CLI has no command to run and the hook fails silently. +Hook handlers with `"type": "command"` in `.gemini/settings.json` MUST include a `"command"` field containing the shell command to execute. Without it, the agent has no command to run and the hook fails silently. ## Antipatterns @@ -36,4 +36,4 @@ Hook handlers with `"type": "command"` in `.gemini/settings.json` MUST include a ## Limitations -Checks that at least one handler has a command field with a non-empty value. +Checks that at least one handler has a command field with a non-empty value. The handler contract is inherited from Gemini CLI pending accessible Antigravity hook documentation. diff --git a/framework/rules/gemini/hook-command-has-field/tests/fail/.gemini/settings.json b/framework/rules/antigravity/hook-command-has-field/tests/fail/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-command-has-field/tests/fail/.gemini/settings.json rename to framework/rules/antigravity/hook-command-has-field/tests/fail/.gemini/settings.json diff --git a/framework/rules/gemini/hook-command-has-field/tests/pass/.gemini/settings.json b/framework/rules/antigravity/hook-command-has-field/tests/pass/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-command-has-field/tests/pass/.gemini/settings.json rename to framework/rules/antigravity/hook-command-has-field/tests/pass/.gemini/settings.json diff --git a/framework/rules/antigravity/hook-handler-has-type/checks.yml b/framework/rules/antigravity/hook-handler-has-type/checks.yml new file mode 100644 index 00000000..ca4ce380 --- /dev/null +++ b/framework/rules/antigravity/hook-handler-has-type/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: ANTIGRAVITY.S.0002.config-file-exists + type: mechanical + check: file_exists +- id: ANTIGRAVITY.S.0002.handler-has-type + type: deterministic + expect: present + pattern-regex: '"type"\s*:\s*"command"' + message: No handler with a valid type field found — only "command" is supported diff --git a/framework/rules/gemini/hook-handler-has-type/rule.md b/framework/rules/antigravity/hook-handler-has-type/rule.md similarity index 58% rename from framework/rules/gemini/hook-handler-has-type/rule.md rename to framework/rules/antigravity/hook-handler-has-type/rule.md index 00c39a16..9bb78826 100644 --- a/framework/rules/gemini/hook-handler-has-type/rule.md +++ b/framework/rules/antigravity/hook-handler-has-type/rule.md @@ -1,5 +1,5 @@ --- -id: GEMINI:S:0002 +id: ANTIGRAVITY:S:0002 slug: hook-handler-has-type title: Hook Handler Has Type category: structure @@ -8,17 +8,17 @@ severity: high backed_by: [] match: {type: config} supersedes: CORE:S:0028 -source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md +source: https://antigravity.google/docs/gcli-migration --- # Hook Handler Has Type -Each hook handler object in `.gemini/settings.json` MUST contain a `"type"` field set to `command`. The Gemini CLI hooks reference states "Currently only `command` is supported." Without a type field, Gemini CLI cannot dispatch the handler and the hook silently does nothing. +Each hook handler object in `.gemini/settings.json` MUST contain a `"type"` field set to `command`. The inherited Gemini-CLI hooks contract supports only `command`. Without a type field, the agent cannot dispatch the handler and the hook silently does nothing. ## Antipatterns - **Missing type field.** Defining a handler with only `"command"` but no `"type"` key. -- **Invalid type value.** Setting `"type": "prompt"`, `"type": "shell"`, or `"type": "script"` — Gemini supports only `command`. (`prompt` is a Claude-specific hook type and is not recognized by Gemini.) +- **Invalid type value.** Setting `"type": "prompt"`, `"type": "shell"`, or `"type": "script"` — only `command` is supported. (`prompt` is a Claude-specific hook type and is not recognized here.) ## Pass / Fail @@ -48,4 +48,4 @@ Each hook handler object in `.gemini/settings.json` MUST contain a `"type"` fiel ## Limitations -Checks that at least one handler has a valid type field. Does not verify every handler individually. +Checks that at least one handler has a valid type field. Does not verify every handler individually. The handler contract is inherited from Gemini CLI pending accessible Antigravity hook documentation. diff --git a/framework/rules/gemini/hook-handler-has-type/tests/fail/.gemini/settings.json b/framework/rules/antigravity/hook-handler-has-type/tests/fail/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-handler-has-type/tests/fail/.gemini/settings.json rename to framework/rules/antigravity/hook-handler-has-type/tests/fail/.gemini/settings.json diff --git a/framework/rules/gemini/hook-handler-has-type/tests/pass/.gemini/settings.json b/framework/rules/antigravity/hook-handler-has-type/tests/pass/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-handler-has-type/tests/pass/.gemini/settings.json rename to framework/rules/antigravity/hook-handler-has-type/tests/pass/.gemini/settings.json diff --git a/framework/rules/gemini/hook-valid-event-types/checks.yml b/framework/rules/antigravity/hook-valid-event-types/checks.yml similarity index 60% rename from framework/rules/gemini/hook-valid-event-types/checks.yml rename to framework/rules/antigravity/hook-valid-event-types/checks.yml index 8e0b3237..37a536a7 100644 --- a/framework/rules/gemini/hook-valid-event-types/checks.yml +++ b/framework/rules/antigravity/hook-valid-event-types/checks.yml @@ -1,9 +1,9 @@ checks: -- id: GEMINI.S.0001.config-file-exists +- id: ANTIGRAVITY.S.0001.config-file-exists type: mechanical check: file_exists -- id: GEMINI.S.0001.valid-event-types +- id: ANTIGRAVITY.S.0001.valid-event-types type: deterministic expect: present pattern-regex: (?:SessionStart|SessionEnd|BeforeAgent|AfterAgent|BeforeModel|AfterModel|BeforeToolSelection|BeforeTool|AfterTool|PreCompress|Notification) - message: No recognized Gemini event type names found in hook configuration + message: No recognized hook event type names found in hook configuration diff --git a/framework/rules/antigravity/hook-valid-event-types/rule.md b/framework/rules/antigravity/hook-valid-event-types/rule.md new file mode 100644 index 00000000..f6e4cc1f --- /dev/null +++ b/framework/rules/antigravity/hook-valid-event-types/rule.md @@ -0,0 +1,48 @@ +--- +id: ANTIGRAVITY:S:0001 +slug: hook-valid-event-types +title: Hook Valid Event Types +category: structure +type: deterministic +severity: high +backed_by: [] +match: {type: config} +supersedes: CORE:S:0027 +source: https://antigravity.google/docs/gcli-migration +--- + +# Hook Valid Event Types + +Hook event keys in `.gemini/settings.json` MUST use recognized hook event type names (11 events). Unrecognized event names are silently ignored, so a typo means the hook never fires. Antigravity keeps the Hooks surface from Gemini CLI; the event-type set here is inherited pending published Antigravity hook docs. + +## Antipatterns + +- **Camel-case typos.** Writing event names with wrong capitalization. The agent silently ignores unrecognized keys. +- **Cross-agent event names.** Using event names from another agent (e.g., Claude's `PreToolUse` instead of the `.gemini/settings.json` convention). +- **Deprecated event names.** Using event names from older versions that have been renamed or removed. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "SessionStart": [{ "type": "command", "command": "echo hook" }] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "onToolUse": [{ "type": "command", "command": "echo hook" }] + } +} +``` + +## Limitations + +Checks that at least one recognized event type is present. Does not detect misspelled event names if a valid one also exists. The event-type set is inherited from Gemini CLI pending accessible Antigravity hook documentation. diff --git a/framework/rules/gemini/hook-valid-event-types/tests/fail/.gemini/settings.json b/framework/rules/antigravity/hook-valid-event-types/tests/fail/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-valid-event-types/tests/fail/.gemini/settings.json rename to framework/rules/antigravity/hook-valid-event-types/tests/fail/.gemini/settings.json diff --git a/framework/rules/gemini/hook-valid-event-types/tests/pass/.gemini/settings.json b/framework/rules/antigravity/hook-valid-event-types/tests/pass/.gemini/settings.json similarity index 100% rename from framework/rules/gemini/hook-valid-event-types/tests/pass/.gemini/settings.json rename to framework/rules/antigravity/hook-valid-event-types/tests/pass/.gemini/settings.json diff --git a/framework/rules/codex/agents-md-within-size-limit/checks.yml b/framework/rules/codex/agents-md-within-size-limit/checks.yml index 0052c019..5a9a6344 100644 --- a/framework/rules/codex/agents-md-within-size-limit/checks.yml +++ b/framework/rules/codex/agents-md-within-size-limit/checks.yml @@ -3,5 +3,6 @@ checks: type: mechanical check: aggregate_byte_size replaces: CORE.E.0001.check + project_scope: true args: max: 32768 diff --git a/framework/rules/core/hook-valid-event-types/rule.md b/framework/rules/core/hook-valid-event-types/rule.md index bbeeb9f5..748eefcc 100644 --- a/framework/rules/core/hook-valid-event-types/rule.md +++ b/framework/rules/core/hook-valid-event-types/rule.md @@ -11,7 +11,7 @@ match: {type: config} # Hook Valid Event Types -Config files must contain a `"hooks"` key. This base rule gates hook validation — agent-specific rules (Claude, Codex, Copilot, Cursor, Gemini) supersede with checks for recognized event names per agent. +Config files must contain a `"hooks"` key. This base rule gates hook validation — agent-specific rules (Claude, Codex, Copilot, Cursor, Antigravity) supersede with checks for recognized event names per agent. ## Antipatterns diff --git a/framework/rules/core/import-depth-within-limit/rule.md b/framework/rules/core/import-depth-within-limit/rule.md index 5ea4910a..4c9572e4 100644 --- a/framework/rules/core/import-depth-within-limit/rule.md +++ b/framework/rules/core/import-depth-within-limit/rule.md @@ -11,7 +11,7 @@ source: https://code.claude.com/docs/en/memory#import-additional-files # Import Depth Within Limit -Import chains in root instruction files should be bounded. Deep import hierarchies increase context loading time and create fragile dependency chains; a change to a deeply nested file can silently break import resolution of files several levels up. The CORE check enforces a permissive absolute ceiling (10 hops) — agents whose `@` syntax has a documented behavior should declare a per-agent supersede stub with the actual threshold. Of the agents currently in the registry: Claude defines a 5-hop hard limit (see `CLAUDE:S:0010`); Cursor's `@filename` is single-level only (see `CURSOR:S:0006`); Gemini supports chained `@file.md` imports without a documented max and inherits the CORE ceiling; Codex and Copilot declare `CORE:S:0033` in their `config.yml` `excludes:` because their instruction files do not honor any `@` inclusion syntax. +Import chains in root instruction files should be bounded. Deep import hierarchies increase context loading time and create fragile dependency chains; a change to a deeply nested file can silently break import resolution of files several levels up. The CORE check enforces a permissive absolute ceiling (10 hops) — agents whose `@` syntax has a documented behavior should declare a per-agent supersede stub with the actual threshold. Of the agents currently in the registry: Claude defines a 5-hop hard limit (see `CLAUDE:S:0010`); Cursor's `@filename` is single-level only (see `CURSOR:S:0006`); Antigravity supports chained `@file.md` imports without a documented max and inherits the CORE ceiling; Codex and Copilot declare `CORE:S:0033` in their `config.yml` `excludes:` because their instruction files do not honor any `@` inclusion syntax. ## Antipatterns diff --git a/framework/rules/core/modular-file-organization/checks.yml b/framework/rules/core/modular-file-organization/checks.yml index ff9d2137..2237667b 100644 --- a/framework/rules/core/modular-file-organization/checks.yml +++ b/framework/rules/core/modular-file-organization/checks.yml @@ -5,5 +5,6 @@ checks: - id: CORE.S.0010.modular type: mechanical check: file_count + project_scope: true args: min: 2 diff --git a/framework/rules/core/skill-description-length/rule.md b/framework/rules/core/skill-description-length/rule.md index d2aaac1d..236139dd 100644 --- a/framework/rules/core/skill-description-length/rule.md +++ b/framework/rules/core/skill-description-length/rule.md @@ -12,7 +12,7 @@ source: https://agentskills.io/specification # Skill Description Length -The `description` field in `SKILL.md` YAML frontmatter MUST be present and concise. The Agent Skills open standard at agentskills.io caps the field at **1024 characters**, and GitHub Copilot enforces the same 1024-character cap explicitly. Claude Code caps the combined `description` + `when_to_use` text at **1,536 characters** in the skill listing. Codex bounds the entire skill list (not the individual description) at roughly 2% of the model context window or 8000 characters. Cursor and Gemini do not document a hard cap but follow the open standard. A description that respects 1024 characters is portable across every agent; long descriptions waste context tokens and risk truncation in the agents that enforce the tighter cap. Front-load the key use case. +The `description` field in `SKILL.md` YAML frontmatter MUST be present and concise. The Agent Skills open standard at agentskills.io caps the field at **1024 characters**, and GitHub Copilot enforces the same 1024-character cap explicitly. Claude Code caps the combined `description` + `when_to_use` text at **1,536 characters** in the skill listing. Codex bounds the entire skill list (not the individual description) at roughly 2% of the model context window or 8000 characters. Cursor and Antigravity do not document a hard cap but follow the open standard. A description that respects 1024 characters is portable across every agent; long descriptions waste context tokens and risk truncation in the agents that enforce the tighter cap. Front-load the key use case. ## Antipatterns diff --git a/framework/rules/core/skill-entry-point-present/checks.yml b/framework/rules/core/skill-entry-point-present/checks.yml index d070c8ef..6f3b8b4b 100644 --- a/framework/rules/core/skill-entry-point-present/checks.yml +++ b/framework/rules/core/skill-entry-point-present/checks.yml @@ -5,5 +5,6 @@ checks: - id: CORE.S.0015.entrypoint type: mechanical check: skill_entrypoint_present + project_scope: true args: entry: SKILL.md diff --git a/framework/rules/core/total-instruction-size-limit/checks.yml b/framework/rules/core/total-instruction-size-limit/checks.yml index 11377996..591a9890 100644 --- a/framework/rules/core/total-instruction-size-limit/checks.yml +++ b/framework/rules/core/total-instruction-size-limit/checks.yml @@ -2,5 +2,6 @@ checks: - id: CORE.E.0001.check type: mechanical check: aggregate_byte_size + project_scope: true args: max: 102400 diff --git a/framework/rules/gemini/config.yml b/framework/rules/gemini/config.yml deleted file mode 100644 index 6588a920..00000000 --- a/framework/rules/gemini/config.yml +++ /dev/null @@ -1,296 +0,0 @@ -# Google Gemini Agent Configuration -# Schema: schemas/agent.schema.yml v0.5.0 -# -# Synced from .claude/skills/audit-agent/assets/registry/gemini/config.yml -# Last verified: 2026-06-15 -# -# Items intentionally NOT included here (not measurable on disk): -# - Cloud-managed enterprise policies (Google Cloud admin / Code Assist Enterprise) -# - Scheduled tasks (CLI itself doesn't have native scheduling — uses external cron) - -agent: gemini -version: "0.5.0" -prefix: GEMINI -name: Google Gemini - -file_types: - main: - # GEMINI.md walked from project root through ancestors of cwd to .git or home. - # All discovered files concatenated. Subdirectory GEMINI.md is "Just-In-Time" - # loaded on-demand (`nested_context`). User-scope file at ~/.gemini/GEMINI.md - # also doubles as the memory surface (agent appends to ## Gemini Added Memories). - source: https://geminicli.com/docs/cli/gemini-md/ - required: true - format: freeform - scope: global - cardinality: chain - lifecycle: static - loading: session_start - scopes: - project: - patterns: ["**/GEMINI.md"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/GEMINI.md"] - precedence: user - vcs: external - maintainer: hybrid - lifecycle: mutable - - nested_context: - # Subdirectory GEMINI.md files — Just-In-Time loaded when Gemini accesses - # files in those directories. scope: nested captures the location-based - # subtree applicability without overloading other scope values. - source: https://geminicli.com/docs/cli/gemini-md/ - format: freeform - scope: nested - cardinality: hierarchical - lifecycle: static - loading: on_demand - scopes: - project: - patterns: ["**/GEMINI.md"] - precedence: project - vcs: committed - maintainer: human - - cross_read: - # Cross-agent files (AGENTS.md, CONTEXT.md) when context.fileName is configured. - source: https://geminicli.com/docs/reference/configuration/ - format: freeform - scope: global - cardinality: chain - lifecycle: static - loading: session_start - scopes: - project: - patterns: ["**/AGENTS.md", "**/CONTEXT.md"] - precedence: project - vcs: committed - maintainer: human - - skills: - # `.agents/skills/` is the cross-agent alias supported by Gemini. - source: https://geminicli.com/docs/cli/skills/ - format: [frontmatter, freeform] - scope: task_scoped - cardinality: hierarchical - lifecycle: static - loading: on_invocation - scopes: - project: - patterns: [".gemini/skills/**/SKILL.md", ".agents/skills/**/SKILL.md"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/skills/**/SKILL.md", "~/.agents/skills/**/SKILL.md"] - precedence: user - vcs: external - maintainer: human - - agents: - # Subagents — built-ins: codebase_investigator, cli_help, generalist_agent, - # browser_agent (experimental). Custom agents invoked via @name syntax. - source: https://geminicli.com/docs/core/subagents/ - format: [frontmatter, freeform] - scope: task_scoped - cardinality: collection - lifecycle: static - loading: on_invocation - scopes: - project: - patterns: [".gemini/agents/*.md"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/agents/*.md"] - precedence: user - vcs: external - maintainer: human - - commands: - # TOML format with prompt + description, supports {{args}}, !{shell}, @{file} - source: https://geminicli.com/docs/cli/custom-commands/ - format: schema_validated - scope: task_scoped - cardinality: collection - lifecycle: static - loading: on_invocation - scopes: - project: - patterns: [".gemini/commands/*.toml"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/commands/*.toml"] - precedence: user - vcs: external - maintainer: human - - hooks: - # 11 events configured under hooks key in settings.json. - source: https://geminicli.com/docs/hooks/ - format: schema_validated - scope: global - cardinality: collection - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".gemini/settings.json"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/settings.json"] - precedence: user - vcs: external - maintainer: human - - config: - # Gemini supports both JSON and TOML settings files. - source: https://geminicli.com/docs/reference/configuration/ - format: schema_validated - scope: global - cardinality: singleton - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".gemini/settings.json", ".gemini/settings.toml"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/settings.json", "~/.gemini/settings.toml"] - precedence: user - vcs: external - maintainer: human - system_defaults: - patterns: - - "/etc/gemini-cli/system-defaults.json" - - "/Library/Application Support/GeminiCli/system-defaults.json" - - "C:/ProgramData/gemini-cli/system-defaults.json" - precedence: managed - vcs: external - maintainer: system - system_overrides: - patterns: - - "/etc/gemini-cli/settings.json" - - "/Library/Application Support/GeminiCli/settings.json" - precedence: managed - vcs: external - maintainer: system - - mcp: - # MCP servers under mcpServers key in settings.json/settings.toml. - source: https://geminicli.com/docs/reference/configuration/ - format: schema_validated - scope: global - cardinality: collection - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".gemini/settings.json", ".gemini/settings.toml"] - precedence: project - vcs: committed - maintainer: human - user: - patterns: ["~/.gemini/settings.json", "~/.gemini/settings.toml"] - precedence: user - vcs: external - maintainer: human - - extensions: - # Plugin system: bundles MCP servers, commands, skills, hooks, themes - source: https://geminicli.com/docs/extensions/writing-extensions/ - format: schema_validated - scope: global - cardinality: collection - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".gemini/extensions/**"] - precedence: project - vcs: committed - maintainer: human - - geminiignore: - source: https://geminicli.com/docs/cli/gemini-ignore/ - format: freeform - scope: global - cardinality: singleton - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".geminiignore"] - precedence: project - vcs: committed - maintainer: human - - system_prompt: - # Replace default system prompt entirely - source: https://geminicli.com/docs/cli/system-prompt/ - format: freeform - scope: global - cardinality: singleton - lifecycle: static - loading: session_start - scopes: - project: - patterns: [".gemini/system-prompt.md"] - precedence: project - vcs: committed - maintainer: human - - memory: - # Gemini's stable memory model is direct-edit markdown across four tiers - # (source-verified vs gemini-cli main @ 83d7567, 2026-06-15): - # - # 1. Project Instructions — ./GEMINI.md (`main`, scopes.project) - # 2. Subdir Instructions — ./**/GEMINI.md (`nested_context`) - # 3. Private Project Memory — ~/.gemini/tmp//memory/MEMORY.md - # + sibling *.md notes (THIS block) - # 4. Global Personal Memory — ~/.gemini/GEMINI.md (`main`, scopes.user) - # - # Only tier 3 — the private project memory directory — lives under - # `memory`; the other tiers are GEMINI.md context files (`main`, - # `nested_context`). The old `save_memory` tool + "Gemini Added Memories" - # section is REMOVED in source (0 git-grep hits; prompt snippets.ts:849 - # "There is no save_memory tool") — the agent now writes memory by direct - # file edits. - # - # Tier 3 is the STABLE surviving private surface and mirrors Claude's - # shape: MEMORY.md is a lean EAGER index, sibling *.md notes carry the - # detail and are RECALLED ON-DEMAND (snippets.ts:838,857). `` - # is a slug short-id (migrated from a sha256 hash; ProjectRegistry - # .getShortId + performMigration). `memory_locator` enumerates the dir - # via the standard directory-glob dispatch. - # - # "Auto Memory" (experimentalAutoMemory, off by default — config.ts:717) - # is optional background extraction that drafts review-inbox candidates - # INTO this same directory; it is not a separate storage model. - source: https://geminicli.com/docs/cli/tutorials/memory-management/ - format: freeform - scope: global - cardinality: collection - lifecycle: mutable - loading: session_start - scopes: - user_project: - patterns: ["~/.gemini/tmp/*/memory/"] - precedence: user - vcs: external - maintainer: hybrid - -excludes: - - CLAUDE:* - - CODEX:* diff --git a/framework/rules/gemini/hook-handler-has-type/checks.yml b/framework/rules/gemini/hook-handler-has-type/checks.yml deleted file mode 100644 index ba8f0528..00000000 --- a/framework/rules/gemini/hook-handler-has-type/checks.yml +++ /dev/null @@ -1,9 +0,0 @@ -checks: -- id: GEMINI.S.0002.config-file-exists - type: mechanical - check: file_exists -- id: GEMINI.S.0002.handler-has-type - type: deterministic - expect: present - pattern-regex: '"type"\s*:\s*"command"' - message: No handler with a valid type field found — Gemini supports only "command" diff --git a/framework/rules/gemini/hook-valid-event-types/rule.md b/framework/rules/gemini/hook-valid-event-types/rule.md deleted file mode 100644 index 81a94969..00000000 --- a/framework/rules/gemini/hook-valid-event-types/rule.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: GEMINI:S:0001 -slug: hook-valid-event-types -title: Hook Valid Event Types -category: structure -type: deterministic -severity: high -backed_by: [] -match: {type: config} -supersedes: CORE:S:0027 -source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/index.md ---- - -# Hook Valid Event Types - -Hook event keys in `.gemini/settings.json` MUST use recognized Gemini CLI event type names (11 events). Unrecognized event names are silently ignored, so a typo means the hook never fires. - -## Antipatterns - -- **Camel-case typos.** Writing event names with wrong capitalization. Gemini CLI silently ignores unrecognized keys. -- **Cross-agent event names.** Using event names from another agent (e.g., Claude's `PreToolUse` instead of Gemini CLI's convention). -- **Deprecated event names.** Using event names from older versions that have been renamed or removed. - -## Pass / Fail - -### Pass - -```json -{ - "hooks": { - "SessionStart": [{ "type": "command", "command": "echo hook" }] - } -} -``` - -### Fail - -```json -{ - "hooks": { - "onToolUse": [{ "type": "command", "command": "echo hook" }] - } -} -``` - -## Limitations - -Checks that at least one recognized Gemini CLI event type is present. Does not detect misspelled event names if a valid one also exists. diff --git a/framework/schemas/rule.schema.yml b/framework/schemas/rule.schema.yml index af01cf3a..fdf2d92c 100644 --- a/framework/schemas/rule.schema.yml +++ b/framework/schemas/rule.schema.yml @@ -29,7 +29,7 @@ reserved_agent_prefixes: - AIDER - CONTINUE - ROO - - GEMINI + - ANTIGRAVITY reserved_package_prefixes: - RRAILS @@ -228,6 +228,11 @@ fields: type: boolean default: false description: "Whether pattern operates across the full target set (not per-file)" + project_scope: + required: false + type: boolean + default: false + description: "Project-aggregate check — skipped under a scoped/narrowed run where the aggregate is meaningless. Data-driven scoped-skip (no engine edit)." # shared optional fields replaces: required: false diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index dbf9df24..ebb6d0fd 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -13,6 +13,7 @@ ails — Validate and score AI instruction files Usage: ails check [PATH] [OPTIONS] Validate instruction files ails explain RULE_ID Show rule details + ails rules [list|capabilities] Browse the framework rule registry ails install [PATH] Install MCP server for detected agents ails update Update rules framework ails update --cli Upgrade CLI package itself diff --git a/packages/npm/package.json b/packages/npm/package.json index 712ccfa5..c4185edf 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,6 +1,6 @@ { "name": "@reporails/cli", - "version": "0.5.11", + "version": "0.5.12", "description": "AI instruction diagnostics for coding agents", "type": "module", "bin": { diff --git a/pyproject.toml b/pyproject.toml index b238a878..8fad9395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reporails-cli" -version = "0.5.11" +version = "0.5.12" description = "AI instruction diagnostics for coding agents" readme = "README.md" license = "BUSL-1.1" @@ -110,8 +110,10 @@ type = "mypy src/" test_unit = "pytest tests/unit/ -v" test_integration = "pytest tests/integration/ -v" test_smoke = "pytest tests/smoke/ -v -m e2e" -qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_markers", "test_unit"] -qa = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_unit", "test_integration", "test_smoke"] +test_live = "pytest -v -m requires_network" +test_lint = "ails test --lint --rules-root framework/rules" +qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_markers", "test_lint", "test_unit"] +qa = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_lint", "test_unit", "test_integration", "test_smoke"] mcp_dev = "python -m reporails_cli.interfaces.mcp.server" fetch_bundled_model = "python scripts/fetch_bundled_model.py" test_markers = "python scripts/check_test_markers.py" diff --git a/scripts/check-config-sync.sh b/scripts/check-config-sync.sh index 7b404c89..0fd51ced 100755 --- a/scripts/check-config-sync.sh +++ b/scripts/check-config-sync.sh @@ -61,8 +61,9 @@ if mismatches: sys.exit(1) # Verify the root README carries the current version in its first heading. -# packages/npm/README.md is a symlink to this file (committed under our control), -# so checking it separately would be redundant. +# packages/npm/README.md is a publish-time copy of this file (not a committed +# symlink — a symlinked README breaks npmjs.com per-version display), so checking +# the root README here is sufficient. expected_ver = py.get("version", "") readme_root = Path(pyproject_path).parent / "README.md" first_heading = "" diff --git a/src/reporails_cli/core/classify/__init__.py b/src/reporails_cli/core/classify/__init__.py index aeeb2969..c3e65918 100644 --- a/src/reporails_cli/core/classify/__init__.py +++ b/src/reporails_cli/core/classify/__init__.py @@ -120,7 +120,7 @@ def _apply_project_overrides( # Memory surfaces share an index-and-recall shape across agents (claude + -# gemini, source-verified): MEMORY.md is the eager index, sibling entries are +# antigravity, source-verified): MEMORY.md is the eager index, sibling entries are # recalled on-demand. Stamped per-entry at classification so size/coherence # rules can tell the always-injected index from the recalled siblings. _MEMORY_SURFACES = frozenset({"memory", "subagent_memory"}) diff --git a/src/reporails_cli/core/classify/generic_type.py b/src/reporails_cli/core/classify/generic_type.py index b66c4850..b8280cfc 100644 --- a/src/reporails_cli/core/classify/generic_type.py +++ b/src/reporails_cli/core/classify/generic_type.py @@ -2,7 +2,7 @@ The two classes describe different harness loading models: -- `generic` — reached via `@` import in a Claude/Gemini surface. The +- `generic` — reached via `@` import in a Claude/Antigravity surface. The harness auto-loads the imported content alongside its parent, so the file is genuinely present in the agent's context budget. - `referenced` — reached only via `[text](path)` / `[ref]: path` markdown diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index 18577f9b..4a1b0faa 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -30,6 +30,13 @@ # Filtered via `_is_external_pattern` (defined below). _USER_SCOPE_OPT_IN_CAPABILITIES = frozenset({"subagent_memory"}) +# Capabilities whose user-scope patterns are project-specific despite the `~` +# prefix — the `~/.claude/projects/*/memory/` glob is slug-keyed to the current +# project in `_glob_directory_entries`, so it surfaces only THIS project's +# auto-memory and survives a repo-scoped scan (it does not drive agent detection, +# so it cannot reintroduce the home-config hijack the external-drop guards against). +_PROJECT_SCOPED_HOME_CAPABILITIES = frozenset({"memory"}) + def ci_glob(target: Path, pattern: str) -> list[Path]: """Case-sensitive glob — agent specs treat filename casing as authoritative. @@ -462,12 +469,31 @@ def _surface_exclude_patterns(agent_id: str, file_type_name: str, project_config return [str(p) for p in exclude] +def _repo_scoped_patterns(patterns: list[str], ft_name: str, repo_scoped: bool) -> list[str]: + """Drop cross-project user-scope (~/..., absolute) patterns from a repo-scoped scan. + + Resolving them against the developer's HOME falsely marks an agent present — a + global ~/.codex/config.toml makes codex look distinctive, collapsing a shared + AGENTS.md to that agent and dropping the generic core findings. Home-scope + surfaces stay reachable via an explicit capability target, which resolves them + through classify.capability_paths / memory_locator, not this path. The project-scoped + auto-memory (`memory`) is exempt — its `~/.claude/projects/*/memory/` glob slug-keys to the + current project, so it is project-specific, not a cross-project hijack surface. + """ + if ft_name in _PROJECT_SCOPED_HOME_CAPABILITIES: + return patterns + if repo_scoped or ft_name in _USER_SCOPE_OPT_IN_CAPABILITIES: + return [p for p in patterns if not _is_external_pattern(p)] + return patterns + + def discover_from_config( target: Path, agent_id: str, rules_paths: list[Path] | None = None, extra_exclude_dirs: frozenset[str] = frozenset(), project_config: ProjectConfig | None = None, + repo_scoped: bool = False, ) -> tuple[list[Path], list[Path], list[Path]] | None: """Discover files using config.yml file_types. @@ -501,14 +527,11 @@ def discover_from_config( for ft_name, spec in file_types.items(): if not isinstance(spec, dict): continue - patterns = list(_extract_patterns(spec)) + patterns = _repo_scoped_patterns(list(_extract_patterns(spec)), ft_name, repo_scoped) + if not patterns: + continue properties = _extract_properties(spec) - # Drop cross-project user-scope patterns from repo-scoped discovery — - # reachable only via an explicit capability target. - if ft_name in _USER_SCOPE_OPT_IN_CAPABILITIES: - patterns = [p for p in patterns if not _is_external_pattern(p)] - bucket = categorize_file_type(patterns, properties) if bucket == "skip": continue diff --git a/src/reporails_cli/core/discovery/agents.py b/src/reporails_cli/core/discovery/agents.py index 605516a0..8a5556f6 100644 --- a/src/reporails_cli/core/discovery/agents.py +++ b/src/reporails_cli/core/discovery/agents.py @@ -332,7 +332,7 @@ def detect_agents( # pylint: disable=too-many-locals # Config-driven discovery from bundled config.yml config_result = _discover_from_config( - target, agent_id, rules_paths, project_excludes, project_config=project_config + target, agent_id, rules_paths, project_excludes, project_config=project_config, repo_scoped=True ) if config_result is None: continue @@ -375,7 +375,7 @@ def detect_single_agent( if not agent_type: return None - config_result = _discover_from_config(target, agent_id, rules_paths) + config_result = _discover_from_config(target, agent_id, rules_paths, repo_scoped=True) if config_result is None: return None instruction_files, rule_files, config_files = config_result @@ -429,7 +429,7 @@ def _disambiguate_shared_files(detected: list[DetectedAgent]) -> list[DetectedAg """Drop agents whose instruction files are entirely shared with other agents. AGENTS.md is a cross-agent standard — any project with it triggers detection - for cursor, copilot, codex, gemini, and generic. This function removes agents + for cursor, copilot, codex, antigravity, and generic. This function removes agents that found ONLY shared files (files claimed by 2+ agents), keeping agents that have at least one distinctive file. Generic is exempt (catch-all for AGENTS.md). """ diff --git a/src/reporails_cli/core/discovery/memory_locator.py b/src/reporails_cli/core/discovery/memory_locator.py index ab2bf01f..cf9e6574 100644 --- a/src/reporails_cli/core/discovery/memory_locator.py +++ b/src/reporails_cli/core/discovery/memory_locator.py @@ -59,7 +59,7 @@ def _entries_from_spec( spec: dict[str, Any], project_root: Path, ) -> list[MemoryEntry]: - """Dispatch on locator type — `file_section` (gemini) vs scopes (claude).""" + """Dispatch on locator type — `file_section` (a heading in one file) vs scopes (directory globs).""" locator = spec.get("locator") if isinstance(locator, dict) and locator.get("type") == "file_section": return _entries_from_file_section(agent, locator) @@ -67,10 +67,10 @@ def _entries_from_spec( def _entries_from_file_section(agent: str, locator: dict[str, Any]) -> list[MemoryEntry]: - """Extract section content from a single file (gemini shape). + """Extract section content from a single file (file_section shape). `file` is the path to read (supports `~/` expansion). `section` is - the literal section heading (e.g. `"## Gemini Added Memories"`). + the literal section heading (e.g. a `"## Added Memories"` heading). Returns a single MemoryEntry with the section body when the section exists and is non-empty; otherwise an empty list. """ diff --git a/src/reporails_cli/core/funnel/__init__.py b/src/reporails_cli/core/funnel/__init__.py index 19f06399..94d06c21 100644 --- a/src/reporails_cli/core/funnel/__init__.py +++ b/src/reporails_cli/core/funnel/__init__.py @@ -18,8 +18,11 @@ WIRE_MAX_FILES = 500 WIRE_MAX_CLUSTERS = 2000 -# Per-tier body byte caps. Mirrored locally so preflight_byte_size returns -# a FunnelError before transmission instead of a server 4xx. +# Per-tier body byte caps. These mirror the server's authoritative per-tier +# `payload_bytes` caps so preflight_byte_size returns a FunnelError before +# transmission instead of a server 4xx. Keep these two values in sync with the +# server-side per-tier caps; a divergence sends an oversized body that the +# server then rejects. WIRE_MAX_BYTES_BY_TIER = { "anonymous": 2 * 1024 * 1024, "pro": 20 * 1024 * 1024, diff --git a/src/reporails_cli/core/heal/mechanical_fixers.py b/src/reporails_cli/core/heal/mechanical_fixers.py index 54223405..375657e1 100644 --- a/src/reporails_cli/core/heal/mechanical_fixers.py +++ b/src/reporails_cli/core/heal/mechanical_fixers.py @@ -11,12 +11,15 @@ from __future__ import annotations +import logging import re from dataclasses import dataclass from pathlib import Path from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class MechanicalFix: @@ -51,12 +54,9 @@ def _wrap_token_outside_links(text: str, token: str) -> str: for m in pattern.finditer(text): if all(m.end() <= s or m.start() >= e for s, e in spans): return text[: m.start()] + f"`{token}`" + text[m.end() :] - # Fallback: first plain occurrence outside link spans - pos = text.find(token) - while pos != -1: - if all(pos + len(token) <= s or pos >= e for s, e in spans): - return text[:pos] + f"`{token}`" + text[pos + len(token) :] - pos = text.find(token, pos + 1) + # No word-boundaried occurrence outside a link: nothing safe to wrap. A plain + # substring fallback would wrap the token mid-word (e.g. `npm` inside `npmrc`), + # corrupting the line — so leave the text unchanged. return text @@ -257,11 +257,18 @@ def apply_mechanical_fixes( *, dry_run: bool = False, fix_types: set[str] | None = None, + allowed_files: set[Path] | None = None, + suppressed: dict[Path, set[int]] | None = None, ) -> list[MechanicalFix]: """Apply all mechanical fixes to files in the ruleset map. Returns the list of fixes applied. When dry_run is True, computes - fixes but does not write files. + fixes but does not write files. When `allowed_files` is given (resolved + paths), a mapped file outside it is skipped — the write set is bounded to + the scoped heal files, so a file whose real path escapes the heal target + is never rewritten. When `suppressed` (resolved path -> line numbers) is + given, atoms on those lines are skipped — a line the author annotated with + an `ails-disable-line` directive is reviewed, so heal leaves it unmodified. """ all_fixes: list[MechanicalFix] = [] @@ -276,24 +283,42 @@ def apply_mechanical_fixes( path = Path(file_path) if not path.is_file(): continue + resolved = path.resolve() + if allowed_files is not None and resolved not in allowed_files: + continue + sup_lines = suppressed.get(resolved, set()) if suppressed else set() + active = [a for a in atoms if a.line not in sup_lines] if sup_lines else atoms + all_fixes.extend(_fix_one_file(path, active, allowed, dry_run)) - content = path.read_text(encoding="utf-8") - lines = content.splitlines(keepends=True) - file_fixes: list[MechanicalFix] = [] - - # Apply in order: format → bold → italic_constraint → ordering - if "format" in allowed: - file_fixes.extend(fix_unformatted_code(atoms, lines)) - if "bold" in allowed: - file_fixes.extend(fix_bold_on_constraints(atoms, lines)) - if "italic_constraint" in allowed: - file_fixes.extend(fix_italic_constraints(atoms, lines)) - if "ordering" in allowed: - file_fixes.extend(fix_ordering(atoms, lines)) - - if file_fixes and not dry_run: - path.write_text("".join(lines), encoding="utf-8") + return all_fixes - all_fixes.extend(file_fixes) - return all_fixes +def _fix_one_file(path: Path, atoms: list[Atom], allowed: set[str], dry_run: bool) -> list[MechanicalFix]: + """Apply the enabled fixers to one file's atoms; write unless dry_run. Returns its fixes.""" + content = path.read_text(encoding="utf-8") + # Skip files whose @imports expand: atom.line is import-expanded, the fixer edits raw + # lines, so a write would mis-target. + from reporails_cli.core.mapper.imports import expand_imports + + try: + if expand_imports(content, path) != content: + return [] + except Exception as exc: # any import-resolution error: skip this file, never abort the heal pass + logger.warning("Skipping mechanical fix for %s: import-expansion failed: %s", path, exc) + return [] + + lines = content.splitlines(keepends=True) + fixers = ( + ("format", fix_unformatted_code), + ("bold", fix_bold_on_constraints), + ("italic_constraint", fix_italic_constraints), + ("ordering", fix_ordering), + ) + file_fixes: list[MechanicalFix] = [] + for name, fixer in fixers: + if name in allowed: + file_fixes.extend(fixer(atoms, lines)) + + if file_fixes and not dry_run: + path.write_text("".join(lines), encoding="utf-8") + return file_fixes diff --git a/src/reporails_cli/core/install/download.py b/src/reporails_cli/core/install/download.py index f8de596b..86ee508f 100644 --- a/src/reporails_cli/core/install/download.py +++ b/src/reporails_cli/core/install/download.py @@ -36,7 +36,9 @@ def _safe_extractall(tar: tarfile.TarFile, dest: Path) -> None: if target.startswith("/") or ".." in target.split("/"): msg = f"Unsafe symlink in archive: {member.name} -> {target}" raise RuntimeError(msg) - tar.extractall(path=dest) + # `filter="data"` is the safe extraction filter (Python 3.12+); it also silences + # the 3.14 deprecation that defaults the filter and would otherwise warn/break. + tar.extractall(path=dest, filter="data") def _validate_rules_structure(rules_path: Path) -> None: diff --git a/src/reporails_cli/core/install/init.py b/src/reporails_cli/core/install/init.py deleted file mode 100644 index f80b206b..00000000 --- a/src/reporails_cli/core/install/init.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Init command — set up rules at `~/.reporails/rules/`.""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.install.download import ( - RULES_VERSION, - download_rules, - write_version_file, -) - - -def run_init() -> dict[str, str | int | Path]: - """Run global initialization. - - Setup rules at ~/.reporails/rules/ (from local framework or GitHub). - - Returns dict with status info. - """ - results: dict[str, str | int | Path] = {} - - # Setup rules (check local framework_path first, then GitHub) - rules_path, rule_count = download_rules() - results["rules_path"] = rules_path - results["rule_count"] = rule_count - - # Write version file - write_version_file(RULES_VERSION) - results["rules_version"] = RULES_VERSION - - return results diff --git a/src/reporails_cli/core/lint/mechanical/checks.py b/src/reporails_cli/core/lint/mechanical/checks.py index eb734e11..e4c3d846 100644 --- a/src/reporails_cli/core/lint/mechanical/checks.py +++ b/src/reporails_cli/core/lint/mechanical/checks.py @@ -364,6 +364,3 @@ def byte_size( "memory_dir_exists": directory_exists, "total_size_check": aggregate_byte_size, } - -# Checks that aggregate across the whole project and are meaningless on a narrowed subset. -_PROJECT_SCOPE_CHECKS = frozenset({"file_count", "aggregate_byte_size", "skill_entrypoint_present"}) diff --git a/src/reporails_cli/core/lint/mechanical/checks_advanced.py b/src/reporails_cli/core/lint/mechanical/checks_advanced.py index 7504b340..1dda41de 100644 --- a/src/reporails_cli/core/lint/mechanical/checks_advanced.py +++ b/src/reporails_cli/core/lint/mechanical/checks_advanced.py @@ -281,7 +281,7 @@ def skill_entrypoint_present( Skills-root directories are located by globbing for existing entry files (agent-agnostic); every immediate subdirectory of a skills root must then contain the entry file. Project-aggregate: enumerates whole - skills roots, so it is skipped under scoped runs (see `_PROJECT_SCOPE_CHECKS`). + skills roots, so its check entry is flagged `project_scope: true` to skip under scoped runs. """ entry = str(args.get("entry", "SKILL.md")) roots = {f.parent.parent for f in _resolve_glob_targets(f"**/{entry}", root) if f.is_file()} diff --git a/src/reporails_cli/core/lint/mechanical/runner.py b/src/reporails_cli/core/lint/mechanical/runner.py index faab2a77..0cc585f6 100644 --- a/src/reporails_cli/core/lint/mechanical/runner.py +++ b/src/reporails_cli/core/lint/mechanical/runner.py @@ -98,13 +98,12 @@ def run_mechanical_checks( target: Project root directory classified_files: Classified files for file targeting scoped: When True, skip project-aggregate checks that are - meaningless on a narrowed subset (see `_PROJECT_SCOPE_CHECKS`). + meaningless on a narrowed subset (checks flagged `project_scope: true`). Returns: List of Violation objects for failed checks """ from reporails_cli.core.classify import match_files - from reporails_cli.core.lint.mechanical.checks import _PROJECT_SCOPE_CHECKS violations: list[Violation] = [] @@ -126,7 +125,7 @@ def run_mechanical_checks( for check in rule.checks: if check.type != "mechanical": continue - if scoped and check.check in _PROJECT_SCOPE_CHECKS: + if scoped and check.project_scope: continue violation, result = dispatch_single_check( check, rule, target, matched, location, extra_args=accumulated_args diff --git a/src/reporails_cli/core/lint/rule_runner.py b/src/reporails_cli/core/lint/rule_runner.py index d5e9a406..7ddab522 100644 --- a/src/reporails_cli/core/lint/rule_runner.py +++ b/src/reporails_cli/core/lint/rule_runner.py @@ -151,6 +151,29 @@ def _extend_with_generic( return effective +def _drop_excluded( + classified: list[Any], + exclude_files: list[str] | None, + project_dir: Path, + keep: list[Path] | None = None, +) -> list[Any]: + """Drop link-walked classified files matching `exclude_files`. The generic-scan link + walk re-discovers @import-reached files agent discovery already excluded, so the + exclusion is re-applied here. An EXPLICITLY-targeted file in `keep` is never dropped — + naming a file (even one matching an exclude glob) overrides the project exclusion, so + the file still gets scored instead of returning a falsely-clean empty result.""" + if not exclude_files: + return classified + from reporails_cli.core.platform.utils.utils import matches_any_glob + + keep_resolved = {p.resolve() for p in keep} if keep else set() + return [ + cf + for cf in classified + if cf.path.resolve() in keep_resolved or not matches_any_glob(cf.path, exclude_files, project_dir) + ] + + def run_m_probes( project_dir: Path, instruction_files: list[Path], @@ -171,10 +194,14 @@ def run_m_probes( rules = load_rules(project_root=project_dir, scan_root=scan_dir, agent=agent) file_types = load_file_types(agent or "generic") try: - generic_scanning = get_project_config(project_dir).generic_scanning + _config = get_project_config(project_dir) + generic_scanning = _config.generic_scanning + exclude_files = _config.exclude_files except (OSError, ValueError): generic_scanning = False + exclude_files = None classified = classify_files(project_dir, instruction_files, file_types, generic_scanning=generic_scanning) + classified = _drop_excluded(classified, exclude_files, project_dir, keep=instruction_files) effective_files = _extend_with_generic(instruction_files, classified, generic_scanning) findings: list[LocalFinding] = [] @@ -213,9 +240,13 @@ def run_content_quality_checks( if instruction_files: file_types = load_file_types(agent or "generic") try: - generic_scanning = get_project_config(project_dir).generic_scanning + _config = get_project_config(project_dir) + generic_scanning = _config.generic_scanning + exclude_files = _config.exclude_files except (OSError, ValueError): generic_scanning = False + exclude_files = None classified = classify_files(project_dir, instruction_files, file_types, generic_scanning=generic_scanning) + classified = _drop_excluded(classified, exclude_files, project_dir, keep=instruction_files) return run_content_checks(ruleset_map, rules, classified) diff --git a/src/reporails_cli/core/lint/suppression.py b/src/reporails_cli/core/lint/suppression.py new file mode 100644 index 00000000..6e2dfbcb --- /dev/null +++ b/src/reporails_cli/core/lint/suppression.py @@ -0,0 +1,153 @@ +"""Inline per-line finding suppression. + +An author marks a single reviewed finding as intentional with a rule-named +inline directive on the offending line; the rule stays armed on every other +line. Filtering runs after merge, before display and before strict gating. + +Directive form (inline HTML comment, invisible when the file renders): + + Some instruction here. + +The directive must name at least one rule (space- or comma-separated for +several); a bare directive with no rule names nothing. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable +from dataclasses import replace +from pathlib import Path +from typing import Any + +# Canonical form is an inline HTML comment, invisible when the file renders. +_DIRECTIVE_RE = re.compile(r"") +_RULE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$") + +# (file, line) -> set of rule names the author chose to silence on that line. +SuppressionIndex = dict[tuple[str, int], set[str]] + + +def _rule_tokens(captured: str) -> set[str]: + """Pull valid rule names out of a directive's body, dropping separators.""" + return {tok for tok in re.split(r"[,\s]+", captured.strip()) if _RULE_TOKEN_RE.match(tok)} + + +def parse_directives(text: str) -> dict[int, set[str]]: + """Map 1-based line number to the set of rule names suppressed on that line.""" + out: dict[int, set[str]] = {} + for lineno, line in enumerate(text.splitlines(), start=1): + for match in _DIRECTIVE_RE.finditer(line): + rules = _rule_tokens(match.group("rules")) + if rules: + out.setdefault(lineno, set()).update(rules) + return out + + +def strip_directives(content: str) -> str: + """Remove suppression-directive comments so they never reach classification. + + Replaces each directive comment with an equal run of spaces, preserving + every newline and column offset so atom line numbers stay exact. + """ + return _DIRECTIVE_RE.sub(lambda m: " " * (m.end() - m.start()), content) + + +def build_index( + finding_files: Iterable[str], + project_root: Path | None, +) -> SuppressionIndex: + """Scan each finding-bearing file for directives and key them by (file, line). + + Directives are parsed from the import-EXPANDED content, the same coordinate + space the classifier assigns finding line numbers in (`strip_directives( + expand_imports(...))`). Parsing the raw file instead would diverge whenever an + `@import` precedes a directive, so the suppression would silently miss. + """ + from reporails_cli.core.mapper.imports import expand_imports + + index: SuppressionIndex = {} + for rel in set(finding_files): + path = _resolve(rel, project_root) + if path is None: + continue + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + try: + text = expand_imports(raw, path) + except (OSError, UnicodeDecodeError, RecursionError): + text = raw + for lineno, rules in parse_directives(text).items(): + index[(rel, lineno)] = rules + return index + + +def suppressed_lines( + finding_files: Iterable[str], + project_root: Path | None, +) -> dict[str, set[int]]: + """Per-file set of lines carrying any suppression directive (for the heal write path). + + Heal must not mechanically rewrite a line the author explicitly annotated as + reviewed with an `ails-disable-line` directive; this surfaces those lines keyed + by the same expanded coordinate space the atoms use. + """ + out: dict[str, set[int]] = {} + for rel, lineno in build_index(finding_files, project_root): + out.setdefault(rel, set()).add(lineno) + return out + + +def is_suppressed( + finding: Any, + index: SuppressionIndex, + alias_fn: Callable[[str], set[str]] | None = None, +) -> bool: + """True when a directive on the finding's line names the finding's rule.""" + named = index.get((finding.file, finding.line)) + if not named: + return False + aliases = alias_fn(finding.rule) if alias_fn else {finding.rule} + return bool(named & aliases) + + +def apply_suppressions( + result: Any, + project_root: Path | None = None, + alias_fn: Callable[[str], set[str]] | None = None, +) -> Any: + """Return `result` with directive-suppressed findings removed and stats rebuilt.""" + if not result.findings: + return result + index = build_index((f.file for f in result.findings), project_root) + if not index: + return result + kept = tuple(f for f in result.findings if not is_suppressed(f, index, alias_fn)) + if len(kept) == len(result.findings): + return result + return replace(result, findings=kept, stats=_rebuild_stats(result.stats, kept)) + + +def _rebuild_stats(stats: Any, findings: tuple[Any, ...]) -> Any: + """Recompute the severity counters over the kept findings, source counts preserved.""" + return replace( + stats, + total_findings=len(findings), + errors=sum(1 for f in findings if f.severity == "error"), + warnings=sum(1 for f in findings if f.severity == "warning"), + infos=sum(1 for f in findings if f.severity == "info"), + ) + + +def _resolve(rel: str, project_root: Path | None) -> Path | None: + """Reconstruct an absolute path from a normalized finding path.""" + if rel.startswith("~/"): + return Path.home() / rel[2:] + p = Path(rel) + if p.is_absolute(): + return p + if project_root is None: + return p + return project_root / p diff --git a/src/reporails_cli/core/mapper/imports.py b/src/reporails_cli/core/mapper/imports.py index c655a184..1ff98977 100644 --- a/src/reporails_cli/core/mapper/imports.py +++ b/src/reporails_cli/core/mapper/imports.py @@ -1,6 +1,6 @@ """Stage 0 — `@path` inline import expansion. -Claude Code and Gemini CLI splice imported file content at the reference +Claude Code and Antigravity CLI splice imported file content at the reference position before the model sees it. The mapper must see the same expanded content to produce accurate atom counts and downstream classification. @@ -14,7 +14,7 @@ # Match @path references in instruction files. # Claude Code: @README, @docs/guide.md, @~/path, @./relative -# Gemini CLI: @./path.md, @../path.md, @/absolute/path.md +# Antigravity CLI: @./path.md, @../path.md, @/absolute/path.md # Must NOT match: email@addr, @mentions in code blocks, inline `@code` IMPORT_REF_RE = re.compile( r"(? str: """Expand @path inline imports in instruction file content. - Claude Code and Gemini CLI use @path syntax for inline expansion — + Claude Code and Antigravity CLI use @path syntax for inline expansion — the file content is spliced in at the reference position before the model sees it. The mapper must see the same expanded content. diff --git a/src/reporails_cli/core/mapper/parse.py b/src/reporails_cli/core/mapper/parse.py index 05e511d5..061bb846 100644 --- a/src/reporails_cli/core/mapper/parse.py +++ b/src/reporails_cli/core/mapper/parse.py @@ -743,6 +743,21 @@ def _rule_confidence(rule: str, charge: str) -> float: re.IGNORECASE, ) +# Markdown link/citation + code-span syntax. A charge word inside a link +# label, a citation reference, or a code span is referential, not +# instructional, so it must not promote a neutral atom to AMBIGUOUS. +# Mirrors `link_walker._INLINE_LINK_RE` / `_REF_DEFINITION_RE`. +_INLINE_LINK_RE = re.compile(r"\[(?:[^\]]+)\]\([^)]+\)") +_REF_DEFINITION_RE = re.compile(r"^\s*\[(?:[^\]]+)\]:\s*\S+", re.MULTILINE) +_CODE_SPAN_RE = re.compile(r"`[^`]*`") + + +def _strip_refs_for_marker_scan(text: str) -> str: + """Drop link labels, citation refs, and code spans before marker detection.""" + t = _CODE_SPAN_RE.sub(" ", text) + t = _INLINE_LINK_RE.sub(" ", t) + return _REF_DEFINITION_RE.sub(" ", t) + def _scan_neutral_for_embedded_markers(atoms: list[Atom]) -> None: """Scan neutral atoms for embedded charge markers. @@ -772,7 +787,7 @@ def _scan_neutral_for_embedded_markers(atoms: list[Atom]) -> None: continue if atom.rule in _TRUSTED_NEUTRAL_RULES: continue - text = atom.text + text = _strip_refs_for_marker_scan(atom.text) markers: list[str] = [] for m in _EMBEDDED_CONSTRAINT_RE.finditer(text): markers.append(f"constraint:{m.group().strip()}") @@ -800,7 +815,7 @@ def _scan_charged_for_compound_markers(atoms: list[Atom]) -> None: for atom in atoms: if atom.charge_value == 0 or atom.kind == "heading": continue - text = atom.text + text = _strip_refs_for_marker_scan(atom.text) opposite: list[str] = [] if atom.charge_value == 1: for m in _EMBEDDED_CONSTRAINT_RE.finditer(text): diff --git a/src/reporails_cli/core/mapper/pipeline.py b/src/reporails_cli/core/mapper/pipeline.py index ad07b94c..396602b4 100644 --- a/src/reporails_cli/core/mapper/pipeline.py +++ b/src/reporails_cli/core/mapper/pipeline.py @@ -47,7 +47,9 @@ def map_file(path: Path) -> tuple[list[Atom], str]: Returns: (atoms, content_hash) """ - content = path.read_text(encoding="utf-8", errors="replace") + from reporails_cli.core.lint.suppression import strip_directives + + content = strip_directives(path.read_text(encoding="utf-8", errors="replace")) atoms = tokenize(content) for a in atoms: a.file_path = str(path) @@ -66,9 +68,10 @@ def _classify_file( atoms_to_dicts, dicts_to_atoms, ) + from reporails_cli.core.lint.suppression import strip_directives raw_content = path.read_text(encoding="utf-8", errors="replace") - content = expand_imports(raw_content, path) + content = strip_directives(expand_imports(raw_content, path)) chash = content_hash(content) cached = map_cache.get(chash) if map_cache else None @@ -170,6 +173,9 @@ def map_ruleset( files are re-tokenized and re-embedded. Clustering always re-runs. """ from reporails_cli.core.cache.map_cache import MapCache + from reporails_cli.core.platform.observability.stage_timer import get_stage_timer + + timer = get_stage_timer() if models is None: models = get_models() @@ -187,9 +193,14 @@ def map_ruleset( map_cache, _load_registry(), ) + timer.mark("classify") - # Embed uncached atoms + # Embed uncached atoms. Force the lazy ONNX load before the first encode so + # the model-load cost times apart from embed-inference — on a warm daemon + # the model is already resident and this load mark reads ~0. if atoms_needing_embed: + _ = models.st + timer.mark("load") _embed_atoms_deduped(atoms_needing_embed, models.st) if map_cache is not None: _update_cache_after_embedding(map_cache, all_atoms, atoms_needing_embed, file_records) @@ -205,8 +216,12 @@ def map_ruleset( _embed_atoms_deduped(unembedded, models.st) _embed_file_descriptions(file_records, models.st) + timer.mark("embed") + + clusters = cluster_topics(all_atoms) + timer.mark("cluster") - ruleset = build_ruleset_map(file_records, all_atoms, cluster_topics(all_atoms)) + ruleset = build_ruleset_map(file_records, all_atoms, clusters) _validate_and_log(ruleset) return ruleset diff --git a/src/reporails_cli/core/platform/adapters/api_client.py b/src/reporails_cli/core/platform/adapters/api_client.py index 67a2806f..a79de857 100644 --- a/src/reporails_cli/core/platform/adapters/api_client.py +++ b/src/reporails_cli/core/platform/adapters/api_client.py @@ -12,6 +12,7 @@ import json import logging import os +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -20,6 +21,11 @@ parse_error_body, preflight_oversized, ) +from reporails_cli.core.platform.contract.errors import ( + ConfigUnreadableError, + CredentialsUnreadableError, + PlatformError, +) from reporails_cli.core.platform.dto.ruleset import RulesetMap logger = logging.getLogger(__name__) @@ -183,37 +189,71 @@ class LintResult: def _tier_from_config() -> str: - """Read tier from global config (~/.reporails/config.yml).""" + """Read tier from global config (~/.reporails/config.yml). + + Returns "" only for genuine absence (no config / no tier). Raises + ConfigUnreadableError when the config exists but cannot be read. + """ try: from reporails_cli.core.platform.config.config import get_global_config - - cfg = get_global_config() - return cfg.tier - except (ImportError, AttributeError, OSError) as exc: - logger.debug("Could not read tier from config: %s", exc) + except ImportError: + logger.debug("Config module unavailable — defaulting tier") return "" + try: + return get_global_config().tier + except (OSError, AttributeError) as exc: + raise ConfigUnreadableError(f"Could not read tier from config: {exc}") from exc def _api_key_from_credentials() -> str: - """Read API key from ~/.reporails/credentials.yml (set by `ails auth login`).""" + """Read API key from ~/.reporails/credentials.yml (set by `ails auth login`). + + Returns "" only for genuine absence (no file / no key). Raises + CredentialsUnreadableError when the file exists but cannot be read or parsed. + """ from pathlib import Path try: import yaml - - path = Path.home() / ".reporails" / "credentials.yml" - if not path.exists(): - return "" - data = yaml.safe_load(path.read_text(encoding="utf-8")) - return data.get("api_key", "") if isinstance(data, dict) else "" except ImportError: logger.debug("PyYAML not installed — cannot read credentials") return "" + + path = Path.home() / ".reporails" / "credentials.yml" + if not path.exists(): + return "" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: - logger.debug("Could not read credentials file: %s", exc) + raise CredentialsUnreadableError(f"Could not read credentials file: {exc}") from exc + return data.get("api_key", "") if isinstance(data, dict) else "" + + +def _degrade_on_fault(reader: Callable[[], str], unit: str) -> str: + """Run a credentials/config read; on PlatformError drop the unit with a visible WARNING. + + The legitimate crash-firewall: a corrupt file surfaces as a WARNING and the + session continues anonymous, rather than crashing or being debug-buried. + """ + try: + return reader() + except PlatformError as exc: + logger.warning("Dropping %s and continuing anonymous: %s", unit, exc) return "" +def has_api_key() -> bool: + """True when an API key is available (env override or stored credentials). + + Client-side affordance gate: distinguishes an authenticated user from an + anonymous one without consulting the server. The server remains the tier + authority; this only gates which local affordances are offered. + """ + if os.environ.get("AILS_API_KEY"): + return True + return bool(_degrade_on_fault(_api_key_from_credentials, "API key")) + + class AilsClient: """Diagnostic API client — HTTP to diagnostic API, local fallback. @@ -230,8 +270,10 @@ def __init__( timeout: float = 30.0, ) -> None: self.base_url = base_url or os.environ.get("AILS_SERVER_URL", "https://api.reporails.com") - self.api_key = api_key or os.environ.get("AILS_API_KEY") or _api_key_from_credentials() - self.tier = tier or os.environ.get("AILS_TIER") or _tier_from_config() or "free" + self.api_key = ( + api_key or os.environ.get("AILS_API_KEY") or _degrade_on_fault(_api_key_from_credentials, "API key") + ) + self.tier = tier or os.environ.get("AILS_TIER") or _degrade_on_fault(_tier_from_config, "tier") or "free" self.timeout = timeout def lint( @@ -622,7 +664,9 @@ def _deserialize_lint_result(data: dict[str, Any]) -> LintResult: report_data = data.get("report") if not isinstance(report_data, dict): logger.warning("API response missing 'report' key or not a dict") - return LintResult(report=RulesetReport()) + # Forward the server tier even on a malformed report — dropping it here silently + # relabels a pro/anonymous session as the default 'free' downstream. + return LintResult(report=RulesetReport(), tier=data.get("tier", "free")) report = RulesetReport( per_file=_deserialize_per_file(report_data), diff --git a/src/reporails_cli/core/platform/adapters/rule_builder.py b/src/reporails_cli/core/platform/adapters/rule_builder.py index 2a1c8aea..dfb88995 100644 --- a/src/reporails_cli/core/platform/adapters/rule_builder.py +++ b/src/reporails_cli/core/platform/adapters/rule_builder.py @@ -98,6 +98,7 @@ def _load_checks(frontmatter: dict[str, Any]) -> list[Check]: replaces=item.get("replaces", ""), severity=item.get("severity", ""), message=item.get("message", ""), + project_scope=item.get("project_scope", False), ) for item in frontmatter.get("checks", []) ] diff --git a/src/reporails_cli/core/platform/contract/errors.py b/src/reporails_cli/core/platform/contract/errors.py new file mode 100644 index 00000000..88ae45c3 --- /dev/null +++ b/src/reporails_cli/core/platform/contract/errors.py @@ -0,0 +1,24 @@ +"""Typed platform faults raised at adapter boundaries. + +Pure layer — imports nothing in-project. Adapters raise a `PlatformError` +subtype when a caught fault cannot be honestly collapsed to a guard-clause +sentinel; callers decide whether to degrade (log + continue) or surface it. +""" + +from __future__ import annotations + + +class PlatformError(Exception): + """Base for a fault caught at a platform adapter boundary.""" + + +class PlatformUnavailableError(PlatformError): + """An external platform surface was unreachable or returned an unexpected response.""" + + +class CredentialsUnreadableError(PlatformError): + """The credentials file exists but could not be read or parsed.""" + + +class ConfigUnreadableError(PlatformError): + """The global config exists but could not be read or parsed.""" diff --git a/src/reporails_cli/core/platform/dto/models.py b/src/reporails_cli/core/platform/dto/models.py index 85b68571..563dfcee 100644 --- a/src/reporails_cli/core/platform/dto/models.py +++ b/src/reporails_cli/core/platform/dto/models.py @@ -164,6 +164,7 @@ class Check(BaseModel): replaces: str = "" # Check ID from superseded rule to replace (inheritance) severity: str = "" # Check-level severity override (empty = use rule severity) message: str = "" # Check-level message (empty = use check result message) + project_scope: bool = False # Skip under a scoped/narrowed run (project-aggregate check) class Rule(BaseModel): diff --git a/src/reporails_cli/core/platform/observability/stage_timer.py b/src/reporails_cli/core/platform/observability/stage_timer.py new file mode 100644 index 00000000..721c1ac1 --- /dev/null +++ b/src/reporails_cli/core/platform/observability/stage_timer.py @@ -0,0 +1,66 @@ +"""Per-stage wall-clock timer for `ails check` — dev-only, zero-overhead when off. + +Records sequential per-stage durations so a perf fix can be ordered by real +seconds rather than cProfile-inflated ones. Disabled by default: every `mark()` +is a no-op until enabled, so normal runs pay nothing, and it is gated behind a +developer env var (`AILS_STAGE_TIMING`) — the breakdown never reaches the +default text / JSON output. + +The timer is a process-global singleton. The CLI entry point marks the coarse +stages; the in-process pipeline marks its finer stages into the same timer on a +cold run. A warm background-process run leaves the finer marks in that process, +so the parent sees only the coarse total. +""" + +from __future__ import annotations + +import time +from typing import Any + + +class StageTimer: + """Sequential stage timer. `mark(label)` records the gap since the last mark.""" + + def __init__(self) -> None: + self.records: list[tuple[str, float]] = [] + self._enabled = False + self._last = 0.0 + + @property + def enabled(self) -> bool: + return self._enabled + + def configure(self, enabled: bool) -> None: + """Set enabled state and reset the timeline. + + Called once per `ails check` so the process-global singleton never leaks + an `enabled` flag (or stale records) from a prior in-process invocation + (test runner, MCP server) into the next run. + """ + self._enabled = enabled + self.records = [] + self._last = time.perf_counter() + + def mark(self, label: str) -> None: + """Record elapsed since the previous mark (or `enable()`) under `label`.""" + if not self._enabled: + return + now = time.perf_counter() + self.records.append((label, (now - self._last) * 1000.0)) + self._last = now + + def as_dict(self) -> list[dict[str, Any]]: + """Stage records as `[{stage, ms}, ...]` for JSON emission.""" + return [{"stage": label, "ms": round(ms, 1)} for label, ms in self.records] + + def render_lines(self) -> list[str]: + """Aligned `label N.N ms` lines for verbose text emission.""" + return [f"{label:<12s} {ms:8.1f} ms" for label, ms in self.records] + + +_TIMER = StageTimer() + + +def get_stage_timer() -> StageTimer: + """Return the process-wide stage timer singleton.""" + return _TIMER diff --git a/src/reporails_cli/core/platform/policy/capability.py b/src/reporails_cli/core/platform/policy/capability.py deleted file mode 100644 index 84bbce63..00000000 --- a/src/reporails_cli/core/platform/policy/capability.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Feature summary generation for display purposes.""" - -from __future__ import annotations - -from reporails_cli.core.platform.dto.results import DetectedFeatures - - -def get_feature_summary(features: DetectedFeatures) -> str: - """Generate human-readable summary of detected features. - - Args: - features: Detected project features - - Returns: - Summary string for display - """ - parts = [] - - file_count = features.instruction_file_count - if file_count == 0: - parts.append("No instruction files") - elif file_count == 1: - parts.append("1 instruction file") - else: - parts.append(f"{file_count} instruction files") - - feature_list = [] - if features.is_abstracted: - feature_list.append("abstracted") - if features.has_backbone: - feature_list.append("backbone.yml") - if features.has_shared_files: - feature_list.append("shared files") - if features.has_hierarchical_structure: - feature_list.append("hierarchical") - - if feature_list: - parts.append(" + ".join(feature_list)) - - return ", ".join(parts) if parts else "No features detected" diff --git a/src/reporails_cli/core/platform/policy/levels.py b/src/reporails_cli/core/platform/policy/levels.py index 0edc8fce..98b4a616 100644 --- a/src/reporails_cli/core/platform/policy/levels.py +++ b/src/reporails_cli/core/platform/policy/levels.py @@ -206,23 +206,3 @@ def _detect_capability(features: DetectedFeatures, capability_id: str) -> bool: if detector is None: return False return detector(features) - - -def detect_orphan_features(features: DetectedFeatures, base_level: Level) -> bool: - """Check if project has capabilities detected above base level. - - Example: L2 project with skills directory (L6 feature) → has_orphan = True - Display as "L2+" to indicate advanced features present. - """ - if base_level == Level.L6: - return False - - base_index = _LEVEL_ORDER.index(base_level) if base_level in _LEVEL_ORDER else -1 - - for level in _LEVEL_ORDER[base_index + 1 :]: - capabilities = LEVEL_CAPS.get(level.value, []) - for cap_id in capabilities: - if _detect_capability(features, cap_id): - return True - - return False diff --git a/src/reporails_cli/formatters/github.py b/src/reporails_cli/formatters/github.py index 43238ef3..07249726 100644 --- a/src/reporails_cli/formatters/github.py +++ b/src/reporails_cli/formatters/github.py @@ -105,10 +105,13 @@ def format_combined_annotations(result: Any) -> str: if not isinstance(result, CombinedResult): return "" + from reporails_cli.formatters.text.display_constants import display_rule_id + lines: list[str] = [] for f in result.findings: command = "error" if f.severity == "error" else "warning" - title = _escape_workflow_property(f"[{f.rule}]") + # Canonical rule id, matching the trailing JSON summary's `rule` field. + title = _escape_workflow_property(f"[{display_rule_id(f.rule)}]") file_val = _escape_workflow_property(f.file) message = _escape_workflow_data(f.message) lines.append(f"::{command} file={file_val},line={f.line},title={title}::{message}") diff --git a/src/reporails_cli/formatters/json.py b/src/reporails_cli/formatters/json.py index 5759e90f..f6cf4d6f 100644 --- a/src/reporails_cli/formatters/json.py +++ b/src/reporails_cli/formatters/json.py @@ -287,20 +287,26 @@ def format_combined_result( return {"error": "Invalid result type"} from reporails_cli.core.platform.policy.leverage import resolve_leverage + from reporails_cli.formatters.text.display_constants import display_rule_id # Group findings by file for agent consumption — agents work file-by-file. # `leverage` is additive (raw `severity` is unchanged — the machine baseline). # It reflects the server's live per-finding tier when present, else the table. + # `rule` is the canonical `::` id (same as text output); the raw + # client-check token, when it differs, is preserved under `label` for baseline stability. by_file: dict[str, list[dict[str, Any]]] = {} for f in result.findings: + canonical = display_rule_id(f.rule) entry: dict[str, Any] = { "line": f.line, "severity": f.severity, - "rule": f.rule, - "category": _category_for_rule(f.rule), + "rule": canonical, + "category": _category_for_rule(canonical), "leverage": resolve_leverage(f).value, "message": f.message, } + if canonical != f.rule: + entry["label"] = f.rule if f.fix: entry["fix"] = f.fix by_file.setdefault(f.file, []).append(entry) @@ -368,10 +374,12 @@ def _aggregate_top_rules(findings: Any, limit: int = 10) -> list[dict[str, Any]] Used by both the JSON envelope and the text scorecard so the aggregation has one source of truth. """ + from reporails_cli.formatters.text.display_constants import display_rule_id + severity_rank = {"error": 0, "warning": 1, "info": 2} buckets: dict[str, dict[str, Any]] = {} for f in findings: - rule = f.rule + rule = display_rule_id(f.rule) bucket = buckets.setdefault(rule, {"count": 0, "severity": f.severity}) bucket["count"] += 1 if severity_rank.get(f.severity, 3) < severity_rank.get(bucket["severity"], 3): diff --git a/src/reporails_cli/formatters/mcp.py b/src/reporails_cli/formatters/mcp.py index 0fc87c36..17149c81 100644 --- a/src/reporails_cli/formatters/mcp.py +++ b/src/reporails_cli/formatters/mcp.py @@ -223,6 +223,22 @@ def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str: parts.append(body) parts.append("") + if "examples" in rule_data: + examples = rule_data["examples"] or {} + pass_ex, fail_ex = examples.get("pass"), examples.get("fail") + if not pass_ex and not fail_ex: + parts.append("Examples: none — this rule has no Pass / Fail examples.") + parts.append("") + else: + parts.append("Examples:") + if pass_ex: + parts.append("Pass:") + parts.append(pass_ex) + if fail_ex: + parts.append("Fail:") + parts.append(fail_ex) + parts.append("") + checks = rule_data.get("checks", []) if checks: parts.append("Checks:") diff --git a/src/reporails_cli/formatters/text/display.py b/src/reporails_cli/formatters/text/display.py index 1668246f..1066c2b8 100644 --- a/src/reporails_cli/formatters/text/display.py +++ b/src/reporails_cli/formatters/text/display.py @@ -27,8 +27,6 @@ from reporails_cli.formatters.text.scorecard import ( ScopeInfo, compute_score, - print_category_bars, - print_score_line, print_scorecard, ) from reporails_cli.formatters.text.triage_view import print_file_card @@ -38,8 +36,6 @@ # Re-export for backward compat (tests, other modules importing from display) __all__ = [ "compute_score", - "print_category_bars", - "print_score_line", "print_scorecard", "print_text_result", ] @@ -63,10 +59,14 @@ def _render_group_header( - gkey: str, group_files: list[tuple[str, list[Any]]], ruleset_map: Any, project_root: Path + gkey: str, + group_files: list[tuple[str, list[Any]]], + ruleset_map: Any, + project_root: Path, + atoms_by_path: dict[str, list[Any]] | None = None, ) -> None: """Print group header with optional atom stats.""" - group_atoms = get_group_atoms(gkey, group_files, ruleset_map, project_root) + group_atoms = get_group_atoms(gkey, group_files, ruleset_map, project_root, atoms_by_path) stats = f" [dim]{group_stats_line(group_atoms)}[/dim]" if group_atoms else "" label = _GROUP_LABELS.get(gkey, gkey.title()) console.print(f" [dim]\u250c\u2500[/dim] [bold]{label}[/bold] [dim]({len(group_files)})[/dim]{stats}") @@ -83,13 +83,14 @@ class _CardContext: hints_by_file: dict[str, list[Any]] = field(default_factory=dict) aliases_by_file: dict[str, list[str]] = field(default_factory=dict) regime_by_file: dict[str, Any] = field(default_factory=dict) + atoms_by_path: dict[str, list[Any]] = field(default_factory=dict) def _render_one_group(gkey: str, group_files: list[tuple[str, list[Any]]], ctx: _CardContext) -> None: """Render a single file group: header, file cards, footer.""" from reporails_cli.core.platform.runtime.merger import normalize_finding_path - _render_group_header(gkey, group_files, ctx.ruleset_map, ctx.project_root) + _render_group_header(gkey, group_files, ctx.ruleset_map, ctx.project_root, ctx.atoms_by_path) max_cards = 3 if not ctx.verbose else 999 for i, (filepath, findings) in enumerate(group_files): @@ -107,6 +108,7 @@ def _render_one_group(gkey: str, group_files: list[tuple[str, list[Any]]], ctx: file_hints=ctx.hints_by_file.get(filepath), aliases_by_file=ctx.aliases_by_file, project_root=ctx.project_root, + atoms_by_path=ctx.atoms_by_path, ) console.print(f" [dim]\u2514\u2500 {sum(len(fs) for _, fs in group_files)} findings[/dim]\n") @@ -151,6 +153,10 @@ def _collect_files_and_scope( all_files: set[str] = set() if result.findings: + # `FindingItem.file` is normalized at merge, but re-normalize defensively — + # the idempotent call is cheap (~#findings) and avoids betting the display on + # an unenforced single-producer invariant. The O(atoms x files) render hot loop + # is fixed in the atoms-side index (see display_constants), not here. all_files.update(normalize_finding_path(f.file, project_root) for f in result.findings) scope = ScopeInfo() @@ -384,11 +390,15 @@ def _render_findings_and_scorecard( `ails check skills` lists each skill with its own score); single-file runs show neither — the top `Score:` covers it. """ + from reporails_cli.formatters.text.display_constants import index_atoms_by_norm_path from reporails_cli.formatters.text.item_scorecard import compute_item_scores from reporails_cli.formatters.text.scorecard import compute_surface_scores has_quality = result.quality is not None and bool(result.quality.compliance_band) sev_icons = get_sev_icons(ascii_mode) + atoms_by_path = ( + index_atoms_by_norm_path(ruleset_map.atoms, project_root) if getattr(ruleset_map, "atoms", None) else {} + ) ctx = _CardContext( sev_icons=sev_icons, verbose=verbose, @@ -397,6 +407,7 @@ def _render_findings_and_scorecard( hints_by_file=_build_hints_by_file(result.hints, project_root), aliases_by_file=_build_aliases_by_file(project_root, result), regime_by_file=_build_regime_by_file(result, project_root), + atoms_by_path=atoms_by_path, ) _render_file_groups(_build_file_groups(result, file_type_by_path, project_root), ctx) _render_cross_file_coordinates(result, sev_icons) diff --git a/src/reporails_cli/formatters/text/display_constants.py b/src/reporails_cli/formatters/text/display_constants.py index f553f416..e34e9547 100644 --- a/src/reporails_cli/formatters/text/display_constants.py +++ b/src/reporails_cli/formatters/text/display_constants.py @@ -104,25 +104,6 @@ HINT_SEV_ORDER = {"error": 0, "warning": 1, "info": 2} -# Category extraction from rule IDs and client check labels -RULE_CATEGORY_MAP = { - "S": "Structure", - "C": "Content", - "E": "Efficiency", - "G": "Governance", - "D": "Maintenance", -} - -CLIENT_CHECK_CATEGORY = { - "format": "S", - "bold": "E", - "orphan": "C", - "heading_instruction": "S", - "ordering": "C", - "scope": "S", - "ambiguous_charge": "C", -} - # Client-check labels map to their canonical rule ID so local findings display the ID like # server findings. Unmapped tokens (server IDs, ambiguous_charge) pass through unchanged. CLIENT_CHECK_RULE_ID = { @@ -173,6 +154,16 @@ def linked_rule_id(rule: str) -> str: return f"[link={url}]{rule_id}[/link]" if url else rule_id +def rule_aliases(rule: str) -> set[str]: + """Every name a suppression directive may use for a finding's rule: raw token, canonical ID, slug.""" + canon = display_rule_id(rule) + names = {rule, canon} + slug = _rule_slug_map().get(canon) + if slug: + names.add(slug) + return names + + # ── File classification lookup tables ───────────────────────────────── _CONFIG_NAMES = frozenset(("settings.json", ".mcp.json", "config.yml", "settings.local.json")) @@ -314,15 +305,49 @@ def file_type_summary(filepaths: set[str]) -> str: return ", ".join(parts) -def per_file_stats(filepath: str, ruleset_map: Any, project_root: Path) -> str: - """Compute per-file stats from RulesetMap atoms. Returns compact stat string.""" +def index_atoms_by_norm_path(atoms: Any, project_root: Path) -> dict[str, list[Any]]: + """Group atoms by normalized file path, normalizing each distinct path once. + + Collapses the per-card / per-group re-normalization of every atom (an + O(atoms x files) render hot loop) into one `normalize_finding_path` call per + distinct `file_path`. Built once per render and shared via `_CardContext`. + """ + from reporails_cli.core.platform.runtime.merger import normalize_finding_path + + memo: dict[str, str] = {} + out: dict[str, list[Any]] = {} + for a in atoms: + fp = a.file_path + norm = memo.get(fp) + if norm is None: + norm = normalize_finding_path(fp, project_root) + memo[fp] = norm + out.setdefault(norm, []).append(a) + return out + + +def per_file_stats( + filepath: str, + ruleset_map: Any, + project_root: Path, + atoms_by_path: dict[str, list[Any]] | None = None, +) -> str: + """Compute per-file stats from RulesetMap atoms. Returns compact stat string. + + `atoms_by_path` is the prebuilt normalized-path index from + `index_atoms_by_norm_path`; when absent (e.g. ad-hoc callers/tests) it falls + back to the per-atom normalize scan. + """ if ruleset_map is None or len(filepath) < 3: return "" try: from reporails_cli.core.platform.runtime.merger import normalize_finding_path norm_target = normalize_finding_path(filepath, project_root) - atoms = [a for a in ruleset_map.atoms if normalize_finding_path(a.file_path, project_root) == norm_target] + if atoms_by_path is not None: + atoms = atoms_by_path.get(norm_target, []) + else: + atoms = [a for a in ruleset_map.atoms if normalize_finding_path(a.file_path, project_root) == norm_target] except (AttributeError, TypeError): return "" if not atoms: @@ -354,14 +379,24 @@ def get_group_atoms( group_files: list[tuple[str, list[Any]]], ruleset_map: Any, project_root: Path, + atoms_by_path: dict[str, list[Any]] | None = None, ) -> list[Any]: - """Get all atoms belonging to files in this group.""" + """Get all atoms belonging to files in this group. + + Uses the prebuilt `atoms_by_path` index when supplied; otherwise falls back + to the per-atom normalize scan. + """ if ruleset_map is None: return [] try: from reporails_cli.core.platform.runtime.merger import normalize_finding_path norm_fps = {normalize_finding_path(fp, project_root) for fp, _ in group_files} + if atoms_by_path is not None: + atoms: list[Any] = [] + for fp in norm_fps: + atoms.extend(atoms_by_path.get(fp, [])) + return atoms return [a for a in ruleset_map.atoms if normalize_finding_path(a.file_path, project_root) in norm_fps] except (AttributeError, TypeError): return [] @@ -380,11 +415,3 @@ def group_stats_line(atoms: list[Any]) -> str: instr_parts.append(f"{n_con} constraint") instr_str = " / ".join(instr_parts) if instr_parts else "0 instructions" return f"{instr_str} \u00b7 {prose_pct}% prose" - - -def finding_category(rule: str) -> str: - """Extract category letter from rule ID or client check label.""" - parts = rule.split(":") - if len(parts) >= 2 and len(parts[1]) == 1 and parts[1].isalpha(): - return parts[1] - return CLIENT_CHECK_CATEGORY.get(rule, "C") diff --git a/src/reporails_cli/formatters/text/rules.py b/src/reporails_cli/formatters/text/rules.py index 93a7f924..d7dce19b 100644 --- a/src/reporails_cli/formatters/text/rules.py +++ b/src/reporails_cli/formatters/text/rules.py @@ -8,6 +8,24 @@ from typing import Any +def _append_examples(lines: list[str], examples: dict[str, Any]) -> None: + """Render Pass / Fail examples, naming their absence rather than omitting silently.""" + pass_ex, fail_ex = examples.get("pass"), examples.get("fail") + if not pass_ex and not fail_ex: + lines.append("Examples: none — this rule has no Pass / Fail examples.") + lines.append("") + return + lines.append("Examples:") + if pass_ex: + lines.append(" Pass:") + lines.append(pass_ex) + lines.append("") + if fail_ex: + lines.append(" Fail:") + lines.append(fail_ex) + lines.append("") + + def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str: """Format rule explanation for terminal.""" lines = [] @@ -29,6 +47,9 @@ def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str: lines.append(f" {rule_data['description']}") lines.append("") + if "examples" in rule_data: + _append_examples(lines, rule_data["examples"] or {}) + # Support both checks and legacy antipatterns checks = rule_data.get("checks", rule_data.get("antipatterns", [])) if checks: diff --git a/src/reporails_cli/formatters/text/scorecard.py b/src/reporails_cli/formatters/text/scorecard.py index 0fa8e254..18929992 100644 --- a/src/reporails_cli/formatters/text/scorecard.py +++ b/src/reporails_cli/formatters/text/scorecard.py @@ -13,10 +13,8 @@ from reporails_cli.formatters.text.display_constants import ( HRULE, - RULE_CATEGORY_MAP, classify_file, display_rule_id, - finding_category, get_term_width, rule_docs_url, ) @@ -52,15 +50,6 @@ def compute_score(result: Any, has_quality: bool, n_atoms: int = 0) -> float: # return 0.0 -def print_score_line(score: float, tw: int) -> None: - """Print score with progress bar.""" - bar_width = min(40, tw - 26) - filled = round(bar_width * score / 10) - bar = "\u2593" * filled + "\u2591" * (bar_width - filled) - color = score_color(score) - console.print(f" Score: [{color} bold]{score:.1f}[/{color} bold] / 10 [dim]{bar}[/dim]") - - # ── Surface health ──────────────────────────────────────────────────── _SURFACE_NAMES = { @@ -148,7 +137,8 @@ def compute_surface_scores( except (AttributeError, TypeError): pass - # Group findings by surface + # Group findings by surface. Re-normalize defensively (cheap, ~#findings) rather + # than trust the unenforced "FindingItem.file already normalized" invariant. surface_findings: dict[str, list[Any]] = {} for f in result.findings: key = _surface_key(normalize_finding_path(f.file, root), ft_by_path) @@ -223,16 +213,16 @@ def _mean_display_score(analyses: list[Any]) -> float | None: def _count_categories(findings: list[Any]) -> dict[str, int]: """Group findings by rule-id-derived Category value. - Reads each finding's rule id (e.g. `CORE:C:0042`), pulls the second - segment (`C`), and maps it via `CATEGORY_CODES` to a category label. - Rules whose id shape doesn't yield a known code are skipped — the - counts only cover categorized findings. + Canonicalizes each finding's rule id first (a bare client-check token like + `format` maps to `CORE:E:0003`), pulls the second segment, and maps it via + `CATEGORY_CODES`. Only ids that still lack a known code after canonicalization + are skipped, so the breakdown stays consistent with the per-finding categories. """ from reporails_cli.core.platform.dto.models import CATEGORY_CODES counts: Counter[str] = Counter() for f in findings: - parts = (f.rule or "").split(":") + parts = display_rule_id(f.rule or "").split(":") if len(parts) < 2: continue category = CATEGORY_CODES.get(parts[1]) @@ -242,22 +232,24 @@ def _count_categories(findings: list[Any]) -> dict[str, int]: return dict(counts) -def _surface_cell(s: SurfaceHealth, bar_width: int = 15) -> str: +def _surface_cell(s: SurfaceHealth, bar_width: int = 15, label_width: int = 13) -> str: """Format one surface as a Rich-markup cell: 'Name (N): ▓▓▓▓▓▓▓▓▓▓▓░░░░ 7.2'. bar_width=15 is the smallest width that visually distinguishes 6.9 from 7.2 under integer rounding — at width 10, scores 6.5-7.4 all map to 7 filled cells. + label_width pads the name column to the widest label in the set so the two + columns stay aligned when a long name carries a 2-digit count. """ label = f"{s.name} ({s.file_count}):" err = f" [red]{s.errors} err[/red]" if s.errors else "" if s.score is None: empty = "░" * bar_width - return f"{label:13s} [dim]{empty}[/dim] [dim]not scored[/dim]{err}" + return f"{label:<{label_width}s} [dim]{empty}[/dim] [dim]not scored[/dim]{err}" color = score_color(s.score) bar = _score_bar(s.score, bar_width, color) # A surface can score well while errors remain, so the error count is what # routes attention — surface the per-surface error tally next to the bar. - return f"{label:13s} {bar} [{color} bold]{s.score:>4.1f}[/{color} bold]{err}" + return f"{label:<{label_width}s} {bar} [{color} bold]{s.score:>4.1f}[/{color} bold]{err}" def _score_bar(score: float, bar_width: int, color: str) -> str: @@ -282,51 +274,15 @@ def _render_surface_health(surfaces: list[SurfaceHealth]) -> None: """ if len(surfaces) <= 1: return + label_width = max(len(f"{s.name} ({s.file_count}):") for s in surfaces) console.print() for i in range(0, len(surfaces), 2): - left = _surface_cell(surfaces[i]) - right = _surface_cell(surfaces[i + 1]) if i + 1 < len(surfaces) else "" + left = _surface_cell(surfaces[i], label_width=label_width) + right = _surface_cell(surfaces[i + 1], label_width=label_width) if i + 1 < len(surfaces) else "" sep = " " if right else "" console.print(f" {left}{sep}{right}") -# ── Category bars ───────────────────────────────────────────────────── - - -def _render_category_bar(cat_key: str, count: int, has_errors: bool, bar_max: int, max_count: int) -> None: - """Render a single category bar line.""" - name = RULE_CATEGORY_MAP.get(cat_key, cat_key) - bar_len = max(1, round(bar_max * count / max_count)) - bar_color = "yellow" if has_errors else "green" - bar = "\u2588" * bar_len - pad = " " * (bar_max - bar_len + 1) - sev_icon = "[red]\u2717[/red]" if has_errors else "[dim]\u25cb[/dim]" - console.print(f" {name:<14s}[{bar_color}]{bar}[/{bar_color}]{pad}[dim]{count:>4d}[/dim] {sev_icon}") - - -def print_category_bars(findings: tuple[Any, ...], tw: int) -> None: - """Print per-category finding breakdown with colored bars.""" - cat_counts: Counter[str] = Counter() - cat_errors: Counter[str] = Counter() - for f in findings: - cat = finding_category(f.rule) - cat_counts[cat] += 1 - if f.severity == "error": - cat_errors[cat] += 1 - - if not cat_counts: - return - - max_count = max(cat_counts.values()) - bar_max = min(20, tw - 30) - console.print() - for cat_key in ["S", "C", "E", "G", "D"]: - count = cat_counts.get(cat_key, 0) - if count: - _render_category_bar(cat_key, count, cat_errors.get(cat_key, 0) > 0, bar_max, max_count) - console.print() - - # ── Scorecard sub-renderers ─────────────────────────────────────────── @@ -363,8 +319,12 @@ def _render_verdict_block( _render_findings_axis(result) # Bridging caption when the score is high but errors remain, so a green headline - # above red errors does not read as "nothing to do". - if score is not None and score >= SCORE_GREEN_CUTOFF and result.stats.errors: + # above red errors does not read as "nothing to do". Gate on visible error + # findings (the worklist actually rendered below), not the stats count — anon / + # free tier gates its errors into Pro hints, leaving no list for the caption to + # point at. + visible_errors = sum(1 for f in (result.findings or []) if f.severity == "error") + if score is not None and score >= SCORE_GREEN_CUTOFF and visible_errors: console.print(" [dim]Quality folds in the findings below; the listed errors are still your worklist.[/dim]") diff --git a/src/reporails_cli/formatters/text/triage_view.py b/src/reporails_cli/formatters/text/triage_view.py index f90ea083..e5f88536 100644 --- a/src/reporails_cli/formatters/text/triage_view.py +++ b/src/reporails_cli/formatters/text/triage_view.py @@ -174,7 +174,7 @@ def _render_triaged( _print_action(fix, border, msg_width) if result.collapsed: n = len(result.collapsed) - console.print(f" [dim]{border} ◦ +{n} lower-priority (won't move your score yet) · -v to list[/dim]") + console.print(f" [dim]{border} ◦ +{n} lower-priority · -v to list[/dim]") def _render_card_body( @@ -236,12 +236,13 @@ def print_file_card( file_hints: list[Any] | None = None, aliases_by_file: dict[str, list[str]] | None = None, project_root: Path | None = None, + atoms_by_path: dict[str, list[Any]] | None = None, ) -> None: """Print one file's card: name, stats, triaged findings (or neutral fallback).""" name = friendly_name(filepath, classify_file(filepath)) alias_list = (aliases_by_file or {}).get(filepath, []) name = f"{name}{_format_alias_suffix(filepath, alias_list)}" - stats = per_file_stats(filepath, ruleset_map, project_root or Path.cwd()) + stats = per_file_stats(filepath, ruleset_map, project_root or Path.cwd(), atoms_by_path) border = "│" msg_width = get_term_width() - 35 diff --git a/src/reporails_cli/interfaces/cli/auth_command.py b/src/reporails_cli/interfaces/cli/auth_command.py index d5c74327..0fd5033e 100644 --- a/src/reporails_cli/interfaces/cli/auth_command.py +++ b/src/reporails_cli/interfaces/cli/auth_command.py @@ -15,6 +15,8 @@ import yaml from rich.console import Console +from reporails_cli.core.platform.contract.errors import PlatformUnavailableError + logger = logging.getLogger(__name__) console = Console(emoji=False, highlight=False) @@ -34,8 +36,9 @@ DEFAULT_PLATFORM_URL = "https://reporails.com" -class PlatformUnavailableError(Exception): - """The Reporails platform's auth surface couldn't be reached or returned an unexpected response.""" +# PlatformUnavailableError moved to core.platform.contract.errors; re-exported above +# so the existing `auth_command.PlatformUnavailableError` import path keeps working. +__all__ = ["PlatformUnavailableError"] def _user_agent() -> str: @@ -103,7 +106,7 @@ def _resolve_client_id(base_url: str) -> str: """Resolve the GitHub OAuth client ID, trying embedded constant then platform. Raises PlatformUnavailableError when the platform endpoint is reachable but - returns a non-JSON body (typical of an edge challenge page) — the original + returns a non-JSON body (a transient network/proxy state) — the original code silently swallowed this into an empty client_id and surfaced it as a misleading "OAuth not configured" message. """ @@ -124,7 +127,8 @@ def _resolve_client_id(base_url: str) -> str: if resp.status_code != 200: logger.warning("Platform returned HTTP %s for client-id", resp.status_code) raise PlatformUnavailableError( - f"Reporails platform returned HTTP {resp.status_code} for client-id endpoint.", + f"Reporails platform returned HTTP {resp.status_code} for the client-id endpoint — " + "likely a transient network or edge issue. Retry shortly or contact support@reporails.com.", ) try: @@ -132,9 +136,8 @@ def _resolve_client_id(base_url: str) -> str: except ValueError as exc: logger.warning("Platform returned non-JSON for client-id: %s", resp.text[:200]) raise PlatformUnavailableError( - "Reporails platform returned a non-JSON body for the client-id endpoint — " - "likely a Cloudflare challenge or proxy error page. Check your network or " - "contact support@reporails.com.", + "Reporails platform returned an unexpected (non-JSON) response for the client-id endpoint — " + "likely a transient network or edge issue. Retry shortly or contact support@reporails.com.", ) from exc @@ -287,8 +290,8 @@ def login( exchange.text[:200], ) console.print( - " [red]Platform returned a non-JSON response[/] (likely a Cloudflare " - "challenge page). Contact support@reporails.com.", + " [red]Platform returned an unexpected (non-JSON) response[/] — likely a transient " + "network or edge issue. Retry shortly or contact support@reporails.com.", ) raise typer.Exit(1) from exc except (httpx.HTTPError, OSError) as exc: diff --git a/src/reporails_cli/interfaces/cli/checks_command.py b/src/reporails_cli/interfaces/cli/checks_command.py index 058897b2..019a5e38 100644 --- a/src/reporails_cli/interfaces/cli/checks_command.py +++ b/src/reporails_cli/interfaces/cli/checks_command.py @@ -118,6 +118,9 @@ def _print_md_section(rule: Rule, *, with_examples: bool) -> None: if not with_examples: return examples = load_rule_examples(rule) + if not examples.get("pass") and not examples.get("fail"): + print("_No Pass / Fail examples in this rule._") + return if examples.get("pass"): print("**Pass**:") print() diff --git a/src/reporails_cli/interfaces/cli/display.py b/src/reporails_cli/interfaces/cli/display.py deleted file mode 100644 index 2e8742d2..00000000 --- a/src/reporails_cli/interfaces/cli/display.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Check output display — format dispatch stub (pending 0.5.0 pipeline rewrite).""" - -from __future__ import annotations diff --git a/src/reporails_cli/interfaces/cli/heal.py b/src/reporails_cli/interfaces/cli/heal.py index cf88fde5..024cf9b1 100644 --- a/src/reporails_cli/interfaces/cli/heal.py +++ b/src/reporails_cli/interfaces/cli/heal.py @@ -20,15 +20,26 @@ def _apply_mechanical_fixes( dry_run: bool, show_progress: bool, console: Any, + allowed_files: list[Path] | None = None, + suppressed: dict[Path, set[int]] | None = None, ) -> list[dict[str, Any]]: - """Apply atom-level mechanical fixes. Returns list of fix dicts.""" + """Apply atom-level mechanical fixes. Returns list of fix dicts. + + `allowed_files` bounds the write set to the scoped heal files; a mapped file + outside it (e.g. an in-tree symlink whose real path escapes the target) is skipped. + `suppressed` maps a resolved file path to the line numbers the author annotated + with an `ails-disable-line` directive; heal leaves those lines unmodified. + """ if ruleset_map is None: return [] if show_progress: console.print("[bold]Applying mechanical fixes...[/bold]") from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes - mech_fixes = apply_mechanical_fixes(ruleset_map, target, dry_run=dry_run) + allowed = {p.resolve() for p in allowed_files} if allowed_files is not None else None + mech_fixes = apply_mechanical_fixes( + ruleset_map, target, dry_run=dry_run, allowed_files=allowed, suppressed=suppressed + ) return [ {"rule_id": mf.fix_type, "file_path": mf.file_path, "line": mf.line, "description": mf.description} for mf in mech_fixes diff --git a/src/reporails_cli/interfaces/cli/helpers.py b/src/reporails_cli/interfaces/cli/helpers.py index 88e5324b..38a08b85 100644 --- a/src/reporails_cli/interfaces/cli/helpers.py +++ b/src/reporails_cli/interfaces/cli/helpers.py @@ -156,15 +156,3 @@ def _handle_no_instruction_files(effective_agent: str, output_format: str, con: at = get_known_agents().get(effective_agent) hint = at.instruction_patterns[0] if at else "AGENTS.md" con.print(f"No instruction files found.\nLevel: L0 (Absent)\n\n[dim]Create a {hint} to get started.[/dim]") - - -def _resolve_rules_paths(rules: list[str] | None, con: Console) -> list[Path] | None: - """Validate and resolve --rules CLI option paths. Exits if any path missing.""" - if not rules: - return None - resolved = [Path(r).resolve() for r in rules] - for rp in resolved: - if not rp.is_dir(): - con.print(f"[red]Error:[/red] Rules directory not found: {rp}") - raise typer.Exit(2) - return resolved diff --git a/src/reporails_cli/interfaces/cli/main.py b/src/reporails_cli/interfaces/cli/main.py index f3deec5e..841b7459 100644 --- a/src/reporails_cli/interfaces/cli/main.py +++ b/src/reporails_cli/interfaces/cli/main.py @@ -240,6 +240,7 @@ def check( # noqa: C901 # pylint: disable=too-many-locals verbose: bool = typer.Option(False, "--verbose", "-v", help="Show details"), heal: bool = typer.Option(False, "--heal", "--fix", help="Apply auto-fixes after validation."), dry_run: bool = typer.Option(False, "--dry-run", help="With --heal: preview fixes without writing."), + cwd: bool = typer.Option(False, "--cwd", help="With --heal: opt into rewriting the whole project."), ) -> None: """Validate and score your instruction files. @@ -294,6 +295,21 @@ def check( # noqa: C901 # pylint: disable=too-many-locals output_format = format or _default_format() + # Scope-safety: --heal writes files. Refuse an implicit whole-project rewrite — + # require an explicit narrowing target (a file, a subdir, or a capability), a + # --dry-run preview, or the --cwd opt-in. A bare `--heal`, `ails check . --heal`, + # and `ails check ./ --heal` all resolve to the whole project root with no + # narrowing, so they are all refused. Targeting the project root explicitly (`.`) + # stays whole-project even when a second, narrower token is also passed — the root + # token keeps every file in scope, so the narrower token cannot rescue it. Emit the + # refusal in the active format so a machine consumer still gets parseable output. + root_targeted = any(t == project_root for t in path_targets) + no_narrowing = not path_targets and not capability_specs + whole_project_heal = target == project_root and (no_narrowing or root_targeted) + if heal and not dry_run and not cwd and whole_project_heal: + _emit_heal_scope_refusal(output_format) + raise typer.Exit(2) + # 1. Detect agents and resolve which one to use detected = detect_agents(target) config = get_project_config(target) @@ -317,11 +333,7 @@ def check( # noqa: C901 # pylint: disable=too-many-locals if single_file is not None: instruction_files = [single_file.resolve()] elif target != project_root: - instruction_files = [ - f - for f in instruction_files - if f == target or (target.is_dir() and f.resolve().is_relative_to(target.resolve())) - ] + instruction_files = [f for f in instruction_files if _file_under_target(f, target)] if not instruction_files: _handle_no_instruction_files(effective_agent, output_format, console) return @@ -368,6 +380,13 @@ def check( # noqa: C901 # pylint: disable=too-many-locals import_extra, file_type_by_path = _generic_scan_file_types( target, instruction_files, effective_agent, bool(config.generic_scanning) ) + if import_extra and excl_files: + # The @-import link-walk re-discovers files agent discovery already excluded; + # re-apply exclude_files so an excluded file does not silently re-enter the + # mapped + scored set via an import from a non-excluded file. + from reporails_cli.core.platform.utils.utils import matches_any_glob + + import_extra = [f for f in import_extra if not matches_any_glob(f, excl_files, target)] if import_extra: instruction_files = list(instruction_files) + import_extra @@ -388,6 +407,16 @@ def check( # noqa: C901 # pylint: disable=too-many-locals start_time = time.perf_counter() + import os as _os + + from reporails_cli.core.platform.observability.stage_timer import get_stage_timer + + # Developer-only diagnostic: the per-stage breakdown names internal pipeline + # stages, so it is gated behind an env var rather than the public -v / JSON + # surface. Unset by default; no internal stage names reach end users. + stage_timer = get_stage_timer() + stage_timer.configure(enabled=bool(_os.environ.get("AILS_STAGE_TIMING"))) + with spinner: from reporails_cli.interfaces.cli.check_mapper import build_ruleset_map, resolve_daemon_status @@ -414,11 +443,14 @@ def check( # noqa: C901 # pylint: disable=too-many-locals if verbose: console.print(f"[dim]Mapper unavailable: {exc}. Content checks skipped.[/dim]") + stage_timer.mark("map") + # 3. Run M probes (mechanical + structural deterministic) if show_progress: spinner.update("[bold]Running M probes...[/bold]") # type: ignore[union-attr] _progress("Running M probes...") m_findings = run_m_probes(target, instruction_files, agent=effective_agent, scoped=is_targeted) + stage_timer.mark("m_probe") # 4. Run content-quality checks + client checks on map content_findings: list[LocalFinding] = [] @@ -429,6 +461,7 @@ def check( # noqa: C901 # pylint: disable=too-many-locals _progress("Running content checks...") content_findings = run_content_quality_checks(ruleset_map, target, instruction_files, agent=effective_agent) client_findings = run_client_checks(ruleset_map) + stage_timer.mark("content") # 5. Server call (stub — returns None offline) if show_progress: @@ -444,6 +477,7 @@ def check( # noqa: C901 # pylint: disable=too-many-locals server_report = lint_result.report if lint_result else None hints = lint_result.hints if lint_result else () cross_file_coordinates = lint_result.cross_file_coordinates if lint_result else () + stage_timer.mark("server") # 5b. Memory index validation (client-side, reads local filesystem) memory_findings: list[LocalFinding] = [] @@ -472,6 +506,13 @@ def check( # noqa: C901 # pylint: disable=too-many-locals level=project_level, tier=lint_result.tier if lint_result else "", ) + + # 7a. Drop findings an author silenced with an inline rule-named directive, + # before display and before strict gating. The rule stays armed elsewhere. + from reporails_cli.core.lint.suppression import apply_suppressions + from reporails_cli.formatters.text.display_constants import rule_aliases + + result = apply_suppressions(result, project_root=target, alias_fn=rule_aliases) elapsed_ms = (time.perf_counter() - start_time) * 1000 # 7. Path filter for the display: reuse the upfront-narrow set so @@ -496,7 +537,20 @@ def check( # noqa: C901 # pylint: disable=too-many-locals display_result = result display_map = ruleset_map - if not (heal and output_format == "json"): + # Fixes are an authenticated affordance — anonymous users get the (free) diagnosis + # but not the fix (preview or apply). Resolve auth up front so the diagnosis dispatch + # can tell "authed heal in JSON" (which replaces stdout with the heal-result JSON) + # apart from "anonymous heal" (which still gets the diagnosis JSON). + heal_authed = False + if heal: + from reporails_cli.core.platform.adapters.api_client import has_api_key + + heal_authed = has_api_key() + + # Skip the diagnosis dispatch only when an AUTHED heal in JSON mode will emit the + # heal-result JSON in its place. An anonymous `--heal -f json` still gets the full + # diagnosis on stdout — the fix is gated, the diagnosis is not. + if not (heal_authed and output_format == "json"): _dispatch_output( output_format, display_result, @@ -509,12 +563,27 @@ def check( # noqa: C901 # pylint: disable=too-many-locals funnel_error, file_type_by_path, ) + stage_timer.mark("render") + _emit_stage_timing(stage_timer, output_format) _show_agent_auto_detect_hint(effective_agent, output_format, assumed, mixed, detected) if heal: - heal_files = [f for f in instruction_files if f in capability_paths] if capability_paths else instruction_files - _run_heal_pass(target, heal_files, ruleset_map, effective_agent, dry_run, output_format) + # Closes the trust risk of an anonymous user reading a low-leverage auto-fix as + # "fixed". The server stays the tier authority; this is the client-side gate. + if not heal_authed: + _emit_heal_auth_required(output_format) + else: + # Bound the write set, then drop files whose real path escapes the heal scope — + # an in-tree symlink (incl. a capability target) must not be written through. + # Applies to apply AND --dry-run preview, so the preview matches the write scope. + heal_scope = single_path if single_path is not None else target + candidate = ( + [f for f in instruction_files if f in capability_paths] if capability_paths else instruction_files + ) + heal_files = [f for f in candidate if _resolved_within_target(f, heal_scope)] + _notify_heal_scope_skips(len(candidate) - len(heal_files), output_format) + _run_heal_pass(target, heal_files, ruleset_map, effective_agent, dry_run, output_format) if _should_exit_strict(strict, capability_paths, target, result): raise typer.Exit(1) @@ -620,19 +689,97 @@ def _classify_target_token(token: str, sniff_agent: str, project_root: Path) -> return ("path", Path(token).resolve()) +def _resolved_within_target(f: Path, target: Path) -> bool: + """True when the file's real (symlink-resolved) location is within the heal target. + + Heal writes through to the real file, so an in-tree symlink whose resolved path + escapes the target must not be written — never mutate a file outside the named scope. + """ + fr = f.resolve() + tr = target.resolve() + return fr == tr or fr.is_relative_to(tr) + + +def _notify_heal_scope_skips(n_skipped: int, output_format: str) -> None: + """Tell the user heal skipped N files whose real path escapes the named scope. + + Without this a capability/dir heal over symlinks-into-a-shared-tree reports `0 fixes` + and reads as "nothing to fix" when it actually refused to touch out-of-scope files. + """ + if n_skipped <= 0 or output_format in ("json", "github"): + return + console.print( + f"[dim]{n_skipped} file(s) skipped — they resolve outside the heal scope " + f"(e.g. an in-tree symlink to a shared tree); heal will not write through them.[/dim]" + ) + + +def _emit_heal_auth_required(output_format: str) -> None: + """Emit the --heal auth-required notice in the active output format (anon gets diagnosis, not fix). + + Under json/github the diagnosis already occupies stdout (anon still gets the free + diagnosis), so the auth notice goes to stderr — stdout stays a single valid JSON + object the consumer can parse, instead of two concatenated objects. + """ + if output_format in ("json", "github"): + print( + json.dumps( + { + "error": "heal_requires_auth", + "message": "Applying fixes (--heal) requires an account. Run `ails auth login`.", + } + ), + file=sys.stderr, + ) + return + console.print( + "[yellow]Fixes need an account.[/yellow] The diagnosis above is free; applying fixes is not.\n" + " Run [bold]ails auth login[/bold] to enable [bold]--heal[/bold]." + ) + + +def _emit_heal_scope_refusal(output_format: str) -> None: + """Emit the --heal scope-safety refusal in the active output format.""" + if output_format in ("json", "github"): + print( + json.dumps( + { + "error": "heal_requires_target", + "message": "--heal writes files and needs an explicit target, --dry-run, or --cwd.", + } + ) + ) + return + console.print( + "[red]✗ --heal writes to files and needs an explicit target.[/red]\n" + " Name what to fix (e.g. [bold]ails check CLAUDE.md --heal[/bold] or " + "[bold]ails check skills --heal[/bold]),\n" + " preview the whole project with [bold]--dry-run[/bold], or opt into a " + "whole-project rewrite with [bold]--cwd[/bold]." + ) + + def _narrow_to_path_targets(instruction_files: list[Path], path_targets: set[Path]) -> list[Path]: """Keep only instruction files that are equal to or beneath one of `path_targets`.""" - narrowed: list[Path] = [] - for f in instruction_files: - f_res = f.resolve() - for tgt in path_targets: - if tgt.is_file() and f_res == tgt: - narrowed.append(f) - break - if tgt.is_dir() and (f_res == tgt or f_res.is_relative_to(tgt)): - narrowed.append(f) - break - return narrowed + return [f for f in instruction_files if any(_file_under_target(f, tgt) for tgt in path_targets)] + + +def _file_under_target(f: Path, tgt: Path) -> bool: + """True when instruction file `f` is the target or sits beneath it. + + Tests the file's logical absolute path alongside its symlink-resolved path so a + directory target keeps in-tree symlinked files (e.g. hub-symlinked rules) instead + of dropping them when resolution escapes the target. The single shared check used + by both the single-directory narrowing and the multi-path `--target` narrowing. + """ + import os + + candidates = (Path(os.path.abspath(f)), f.resolve()) + if tgt.is_file(): + return any(p == tgt for p in candidates) + if tgt.is_dir(): + return any(p == tgt or p.is_relative_to(tgt) for p in candidates) + return False def _sniff_agent(agent: str, project_root: Path) -> str: @@ -694,10 +841,15 @@ def _dispatch_output( from reporails_cli.formatters import json as json_formatter if output_format == "json": + from reporails_cli.core.platform.observability.stage_timer import get_stage_timer + data = json_formatter.format_combined_result( display_result, ruleset_map=ruleset_map, project_root=project_root, file_type_by_path=file_type_by_path ) data["elapsed_ms"] = round(elapsed_ms, 1) + timer = get_stage_timer() + if timer.enabled: + data["timing"] = timer.as_dict() if capability_paths: data["capability_paths"] = sorted(_relativize_paths(capability_paths, project_root)) print(json.dumps(data, indent=2)) @@ -719,6 +871,19 @@ def _dispatch_output( ) +def _emit_stage_timing(stage_timer: Any, output_format: str) -> None: + """Print the per-stage timing table (dev-only, gated by `AILS_STAGE_TIMING`). + + Only renders when the timer was enabled via the env var; the internal stage + names never reach the default text / JSON output. + """ + if output_format == "json" or not stage_timer.enabled or not stage_timer.records: + return + console.print("\n [dim]── Stage timing (wall-clock) ──[/dim]") + for line in stage_timer.render_lines(): + console.print(f" [dim]{line}[/dim]") + + def _run_heal_pass( target: Path, instruction_files: list[Path], @@ -728,6 +893,7 @@ def _run_heal_pass( output_format: str, ) -> None: """Apply mechanical + additive fixers using the already-built map.""" + from reporails_cli.core.lint.suppression import suppressed_lines from reporails_cli.interfaces.cli.heal import ( _apply_additive_fixes, _apply_mechanical_fixes, @@ -736,7 +902,14 @@ def _run_heal_pass( show = sys.stdout.isatty() and output_format != "json" heal_start = time.perf_counter() - mech = _apply_mechanical_fixes(ruleset_map, target, dry_run, show, console) + # A line the author annotated with an `ails-disable-line` directive is reviewed — + # heal must not mechanically rewrite it. Key the suppressed lines by resolved path + # so they match the atom files regardless of path form. + supp_raw = suppressed_lines([str(f) for f in instruction_files], target) + suppressed = {Path(k).resolve(): v for k, v in supp_raw.items()} + # Both fixer families write only within the scoped `instruction_files` set — the + # mechanical pass is bounded by it so it cannot rewrite a mapped file outside scope. + mech = _apply_mechanical_fixes(ruleset_map, target, dry_run, show, console, instruction_files, suppressed) additive = _apply_additive_fixes(target, instruction_files, effective_agent, dry_run, show, console) heal_ms = round((time.perf_counter() - heal_start) * 1000, 1) _output_heal_results(mech + additive, mech, additive, dry_run, heal_ms, output_format, console) @@ -751,7 +924,13 @@ def _should_exit_strict( if not strict: return False if capability_paths: - rel_keys = _relativize_paths(capability_paths, project_root) + # Key the scope set with the SAME normalization the display filter uses + # (`normalize_finding_path`), not `_relativize_paths`. The two diverge for + # user-scope paths (`~/.claude/...`), so a strict run on such a target could + # exit 0 while the display showed errors for it. + from reporails_cli.core.platform.runtime.merger import normalize_finding_path + + rel_keys = {normalize_finding_path(str(p), project_root) for p in capability_paths} return any(f.file in rel_keys for f in result.findings) return bool(result.findings) @@ -820,7 +999,7 @@ def explain( raise typer.Exit(2) rule = loaded_rules[rule_id_upper] - rule_data = { + rule_data: dict[str, Any] = { "title": rule.title, "category": rule.category.value, "type": rule.type.value, @@ -838,8 +1017,16 @@ def explain( if len(parts) >= 3: rule_data["description"] = parts[2].strip()[:500] + # Pass / Fail examples — same fence-aware extraction `ails rules -f md` uses, + # so the two surfaces agree on example presence (named as absent when missing). + from reporails_cli.core.platform.adapters.rules_query import load_rule_examples + + rule_data["examples"] = load_rule_examples(rule) + output = text_formatter.format_rule(rule_id_upper, rule_data) - console.print(output) + # markup=False: the rule body / Pass-Fail examples contain literal `[...]` (markdown + # links, regex classes, the `[type]` check annotation) that Rich would eat as tags. + console.print(output, markup=False) def main() -> None: diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index b8f39183..30794bba 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -140,7 +140,7 @@ async def list_tools() -> list[Tool]: }, "agent": { "type": "string", - "description": "Optional agent filter (claude, codex, gemini, ...); empty = all agents", + "description": "Optional agent filter (claude, codex, antigravity, ...); empty = all agents", "default": "", }, }, diff --git a/src/reporails_cli/interfaces/mcp/tools.py b/src/reporails_cli/interfaces/mcp/tools.py index 1f10a923..5c40036a 100644 --- a/src/reporails_cli/interfaces/mcp/tools.py +++ b/src/reporails_cli/interfaces/mcp/tools.py @@ -140,6 +140,13 @@ def _lint_discovered(scan_root: Path, effective_agent: str, instruction_files: l result = _merge_with_server( m_findings, content_findings + client_findings, ruleset_map, scan_root, agent=effective_agent ) + # Honor inline `ails-disable-line` directives on this surface too — the CLI `check` + # path drops them before display, so the agent-facing MCP surface must match or it + # re-flags a finding the author already dismissed. + from reporails_cli.core.lint.suppression import apply_suppressions + from reporails_cli.formatters.text.display_constants import rule_aliases + + result = apply_suppressions(result, project_root=scan_root, alias_fn=rule_aliases) return json_formatter.format_combined_result(result, ruleset_map=ruleset_map, project_root=scan_root) @@ -237,7 +244,7 @@ def explain_tool(rule_id: str, rules_paths: list[Path] | None = None) -> str | d } rule = rules[rule_id_upper] - rule_data = { + rule_data: dict[str, Any] = { "title": rule.title, "category": rule.category.value, "type": rule.type.value, @@ -257,4 +264,10 @@ def explain_tool(rule_id: str, rules_paths: list[Path] | None = None) -> str | d except (OSError, ValueError): pass + # Pass / Fail examples — same extractor `ails explain` / `ails rules -f md` use, + # so the MCP explain surface agrees with the CLI on example presence. + from reporails_cli.core.platform.adapters.rules_query import load_rule_examples + + rule_data["examples"] = load_rule_examples(rule) + return mcp_formatter.format_rule(rule_id_upper, rule_data) diff --git a/tests/conftest.py b/tests/conftest.py index b4fa48fb..fee7fd48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,25 @@ import pytest +@pytest.fixture(autouse=True) +def _isolate_home(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate HOME so a developer's global config can't leak into discovery. + + A real `~/.reporails/config.yml` (`default_agent`), `~/.codex/`, or + `~/.claude/` on the contributor's machine otherwise alters agent detection + and config merge, making detection tests pass in clean CI but fail locally. + Points HOME and the frozen `REPORAILS_HOME` constant at a fresh temp dir so + every test sees the clean-HOME baseline CI runs under. + """ + from reporails_cli.core.platform.config import bootstrap + + home = tmp_path_factory.mktemp("home") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(bootstrap, "REPORAILS_HOME", home / ".reporails") + + def pytest_addoption(parser: pytest.Parser) -> None: """Register custom CLI options.""" parser.addoption( diff --git a/tests/integration/test_auth_live.py b/tests/integration/test_auth_live.py new file mode 100644 index 00000000..0d95104e --- /dev/null +++ b/tests/integration/test_auth_live.py @@ -0,0 +1,45 @@ +"""Live-path regression for the new-user activation first hop. + +Exercises `_resolve_client_id` against the real platform host. This is the +exact network hop that a transient network/edge issue on `/api/auth/client-id` +would break — if the endpoint stops serving 200 JSON with a non-empty +client_id, terminal-based `ails auth login` cannot start the device flow. + +Network-only and excluded from the offline CI lanes; runs in the dedicated +`test_live` lane (`pytest -m requires_network`). +""" + +from __future__ import annotations + +import httpx +import pytest + +from reporails_cli.interfaces.cli.auth_command import ( + DEFAULT_PLATFORM_URL, + _resolve_client_id, +) + + +@pytest.mark.integration +@pytest.mark.requires_network +@pytest.mark.slow +@pytest.mark.subsys_api +def test_resolve_client_id_returns_nonempty_against_live_platform() -> None: + """The live platform serves a non-empty GitHub OAuth client id.""" + client_id = _resolve_client_id(DEFAULT_PLATFORM_URL) + assert client_id, "live platform returned an empty client_id — activation path broken" + + +@pytest.mark.integration +@pytest.mark.requires_network +@pytest.mark.slow +@pytest.mark.subsys_api +def test_client_id_endpoint_serves_200_json() -> None: + """The client-id endpoint serves 200 with a JSON body (no interstitial).""" + resp = httpx.get( + f"{DEFAULT_PLATFORM_URL}/api/auth/client-id", + timeout=10.0, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, f"expected 200, got {resp.status_code}" + assert resp.json().get("client_id"), "200 body missing a non-empty client_id" diff --git a/tests/integration/test_behavioral.py b/tests/integration/test_behavioral.py index ee53d712..4ada6fb2 100644 --- a/tests/integration/test_behavioral.py +++ b/tests/integration/test_behavioral.py @@ -385,7 +385,7 @@ class TestAgentCrossValidation: def test_registry_populated_from_configs(self) -> None: """Registry should contain at least the big 5 agents.""" agents = get_known_agents() - for agent_id in ("claude", "cursor", "copilot", "codex", "gemini"): + for agent_id in ("claude", "cursor", "copilot", "codex", "antigravity"): assert agent_id in agents, f"Agent {agent_id} missing from registry" @@ -537,8 +537,31 @@ def test_disabled_rules_excluded(self, tmp_path: Path) -> None: class TestHealCommand: """ails check --heal must auto-fix and report remaining violations.""" + @pytest.fixture(autouse=True) + def _authed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--heal is an authenticated affordance; these tests exercise the fix path, so authenticate.""" + monkeypatch.setenv("AILS_API_KEY", "test-key-heal") + # test_heal_missing_path covered by smoke tests + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic + @requires_model + @requires_rules + def test_heal_anonymous_is_refused(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Anonymous (no key) gets the diagnosis but not the fix — `--heal` returns an auth notice.""" + monkeypatch.delenv("AILS_API_KEY", raising=False) + p = tmp_path / "proj" + p.mkdir() + (p / "CLAUDE.md").write_text("# My Project\n\nA project.\n") + result = runner.invoke(app, ["check", str(p), "--heal", "-f", "json"]) + assert "heal_requires_auth" in result.output + # Regression (finding 10): the free diagnosis JSON must still be emitted for an + # anonymous `--heal -f json` — a machine consumer that adds --heal previously + # lost all diagnostic data and got only the auth error. + assert '"offline"' in result.output and '"files"' in result.output + @pytest.mark.integration @pytest.mark.subsys_lint @pytest.mark.subsys_diagnostic diff --git a/tests/integration/test_checks_command.py b/tests/integration/test_checks_command.py index 66251ba5..40081f19 100644 --- a/tests/integration/test_checks_command.py +++ b/tests/integration/test_checks_command.py @@ -34,9 +34,7 @@ def _run(*args: str) -> tuple[int, str, str]: # decodes with the OS locale (cp1252 on Windows), which cannot decode the # rich help panel's box-drawing glyphs — the reader thread dies and # proc.stdout comes back None. - proc = subprocess.run( - ["ails", *args], capture_output=True, text=True, encoding="utf-8", check=False, env=env - ) + proc = subprocess.run(["ails", *args], capture_output=True, text=True, encoding="utf-8", check=False, env=env) return proc.returncode, _ANSI_RE.sub("", proc.stdout or ""), _ANSI_RE.sub("", proc.stderr or "") diff --git a/tests/smoke/conftest.py b/tests/smoke/conftest.py new file mode 100644 index 00000000..cbf81995 --- /dev/null +++ b/tests/smoke/conftest.py @@ -0,0 +1,21 @@ +"""Hermetic isolation for the e2e smoke suite. + +The smoke tests invoke `ails check` end-to-end. By default the client posts the +ruleset map to the production endpoint (`https://api.reporails.com`), which makes +the suite depend on machine-local network reachability and the live edge — the +reason it was kept out of the gated CI matrix. Forcing an empty `AILS_SERVER_URL` +routes every invocation through the offline branch (`AilsClient.lint` returns an +empty `LintResponse` when `base_url` is falsy), so the suite runs deterministically +and asserts only on client-side findings, exit codes, and rendering. `HOME` +isolation is handled by the repo-root `conftest._isolate_home` autouse fixture. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _offline_server(monkeypatch: pytest.MonkeyPatch) -> None: + """Force offline diagnostics so the smoke suite never touches the network.""" + monkeypatch.setenv("AILS_SERVER_URL", "") diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index 6d0dfe14..ce0d1072 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -40,6 +40,22 @@ def _rules_installed() -> bool: ) +def _model_installed() -> bool: + from reporails_cli.bundled import get_models_path + + return (get_models_path() / "minilm-l6-v2" / "onnx" / "model.onnx").is_file() + + +# The bundled ONNX model is gitignored and absent in CI, so content/client +# checks (which need the mapper) don't run there. Tests whose assertion can +# only be observed via a content/client finding must declare this marker so +# they skip cleanly without the model rather than failing on the CI runner. +requires_model = pytest.mark.skipif( + not _model_installed(), + reason="Bundled ONNX model not installed (content checks unavailable)", +) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -296,7 +312,9 @@ def test_claude_no_other_agent_rules(self, claude_only: Path) -> None: data = _check_json(claude_only, agent="claude") findings = _all_findings(data) assert len(findings) > 0, "Claude fixture must produce findings" - foreign = {f["rule"] for f in findings if f["rule"].split(":")[0] in ("CODEX", "COPILOT", "CURSOR", "GEMINI")} + foreign = { + f["rule"] for f in findings if f["rule"].split(":")[0] in ("CODEX", "COPILOT", "CURSOR", "ANTIGRAVITY") + } assert not foreign, f"Foreign agent rules fired under --agent claude: {foreign}" @pytest.mark.e2e @@ -358,8 +376,15 @@ class TestMultiAgentProject: @pytest.mark.e2e @pytest.mark.subsys_cli_ux @requires_rules + @requires_model def test_no_agent_scans_all_on_mixed_signals(self, multi_agent: Path) -> None: - """Without --agent on multi-agent project, mixed signals → scan all instruction files.""" + """Without --agent on multi-agent project, mixed signals → scan all instruction files. + + CLAUDE.md earns findings here only from content/client checks, which need + the bundled mapper model — hence `requires_model`. The project-level + M-probe (`CORE:G:0001`) attaches to a single representative file, so the + claude-file inclusion is not observable model-free in mixed-signal mode. + """ data = _check_json(multi_agent) files = _finding_files(data) assert len(files) > 0, "Multi-agent project should produce findings" @@ -692,6 +717,9 @@ def test_unknown_agent_shows_known_list(self, claude_only: Path) -> None: @pytest.mark.subsys_cli_ux def test_uppercase_agent_no_match(self, claude_only: Path) -> None: """--agent CLAUDE (uppercase) finds no files — agents are case-sensitive.""" + # Force text format: under CI (GITHUB_ACTIONS set) the default format is + # machine-readable, so the human "No instruction files found" line only + # appears when text output is requested explicitly. result = runner.invoke( app, [ @@ -699,6 +727,8 @@ def test_uppercase_agent_no_match(self, claude_only: Path) -> None: str(claude_only), "--agent", "CLAUDE", + "-f", + "text", ], ) assert result.exit_code == 0 @@ -768,8 +798,13 @@ def test_cli_flag_overrides_config(self, multi_agent_with_config: Path) -> None: @pytest.mark.e2e @pytest.mark.subsys_cli_ux @requires_rules + @requires_model def test_no_config_mixed_signals_scans_all(self, multi_agent: Path) -> None: - """Without config on multi-agent project, mixed signals → scan all instruction files.""" + """Without config on multi-agent project, mixed signals → scan all instruction files. + + Claude-file inclusion is observable only via content/client findings, + which need the bundled mapper model — hence `requires_model`. + """ data = _check_json(multi_agent) files = _finding_files(data) # Mixed signals: claude + copilot → all instruction files scanned with core rules @@ -1123,6 +1158,11 @@ def test_unknown_rule_shows_error(self) -> None: class TestHealCommand: """ails check --heal applies auto-fixes and reports results.""" + @pytest.fixture(autouse=True) + def _authed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--heal is an authenticated affordance; authenticate so the fix path runs.""" + monkeypatch.setenv("AILS_API_KEY", "test-key-heal") + @pytest.mark.e2e @pytest.mark.subsys_cli_ux def test_missing_path_errors(self) -> None: @@ -1440,3 +1480,196 @@ def test_oversized_file_does_not_crash(self, tmp_path: Path) -> None: # Should complete without crash — findings may be many due to content issues assert "files" in data + + +# =========================================================================== +# Heal Dry-Run +# +# `ails check --heal --dry-run` must PREVIEW deterministic fixes without +# writing them. Pins the no-mutation contract: the same fixture that a real +# heal rewrites must be left byte-identical under --dry-run. +# =========================================================================== + + +# Content the mechanical fixers rewrite WHEN the bundled mapper model is +# present: a bare code token (→ backtick wrap) and a bolded constraint +# (→ full-sentence italic). Model-free (the CI runner) no fixer fires on it — +# mechanical fixes need the ruleset map and the additive C:0003/C:0010 probes +# are not in the model-free set — so the real fix/no-fix contrast lives in the +# @requires_model tests below. +_HEALABLE = ( + "# My Project\n\n" + "## Setup\n\n" + "You must run npm install before starting the app.\n\n" + "## Constraints\n\n" + "- **Never** commit secrets to the repository.\n" +) + + +@pytest.mark.e2e +class TestHealDryRun: + """--heal --dry-run previews fixes without mutating files.""" + + @pytest.fixture(autouse=True) + def _authed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--heal preview is an authenticated affordance; authenticate so the preview path runs.""" + monkeypatch.setenv("AILS_API_KEY", "test-key-heal") + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @requires_rules + def test_dry_run_does_not_mutate(self, tmp_path: Path) -> None: + """--heal --dry-run exits cleanly and leaves the file byte-identical. + + Runs model-free on CI, where no fixer fires on _HEALABLE, so this pins + the weaker no-crash / no-spurious-write contract on the no-fix path. + The load-bearing "fixes exist but --dry-run withholds them" contrast is + test_dry_run_matches_real_fix_set (@requires_model). + """ + project = tmp_path / "project" + project.mkdir() + target = project / "CLAUDE.md" + target.write_text(_HEALABLE) + before = target.read_text() + + result = runner.invoke(app, ["check", str(project), "--heal", "--dry-run", "--agent", "claude"]) + assert result.exit_code in (0, None), f"dry-run heal failed:\n{result.output}" + assert target.read_text() == before, "--dry-run must not modify the file" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @requires_rules + @requires_model + def test_dry_run_previews_fixes(self, tmp_path: Path) -> None: + """--dry-run reports the fixes it WOULD apply (preview, not silence). + + Mechanical fixers read atoms off the ruleset map, which needs the + bundled mapper model — hence `requires_model`. The model-free safety + contract (no mutation) is pinned by `test_dry_run_does_not_mutate`. + """ + project = tmp_path / "project" + project.mkdir() + (project / "CLAUDE.md").write_text(_HEALABLE) + + result = runner.invoke(app, ["check", str(project), "--heal", "--dry-run", "-f", "json", "--agent", "claude"]) + assert result.exit_code in (0, None), f"dry-run heal json failed:\n{result.output}" + data = json.loads(result.output) + assert len(data.get("auto_fixed", [])) > 0, "dry-run should still list the fixes it would make" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @requires_rules + @requires_model + def test_dry_run_matches_real_fix_set(self, tmp_path: Path) -> None: + """Real heal mutates and applies the same fix count dry-run only previews. + + Both arms need real fixes (mechanical, atom-level) — `requires_model`. + """ + # Dry-run project — must stay unchanged. + dry = tmp_path / "dry" + dry.mkdir() + (dry / "CLAUDE.md").write_text(_HEALABLE) + dry_result = runner.invoke(app, ["check", str(dry), "--heal", "--dry-run", "-f", "json", "--agent", "claude"]) + dry_data = json.loads(dry_result.output) + assert (dry / "CLAUDE.md").read_text() == _HEALABLE, "dry-run mutated the file" + + # Real project — must change and apply the same fixes. + real = tmp_path / "real" + real.mkdir() + (real / "CLAUDE.md").write_text(_HEALABLE) + real_result = runner.invoke(app, ["check", str(real), "--heal", "-f", "json", "--agent", "claude"]) + real_data = json.loads(real_result.output) + assert (real / "CLAUDE.md").read_text() != _HEALABLE, "real heal should mutate the file" + assert len(dry_data.get("auto_fixed", [])) == len(real_data.get("auto_fixed", [])), ( + "dry-run preview count must match the real applied count" + ) + + +# =========================================================================== +# Friendly Bare-Keyword Error +# +# The variadic-targets redesign routes a positional token to either a +# capability or a filesystem path. A bare word that is neither an existing +# path nor a recognized capability must produce a clear "Path not found" +# error with exit 2 — not a silent zero-finding pass or a crash. +# =========================================================================== + + +@pytest.mark.e2e +class TestBareKeywordError: + """A bare non-path, non-capability token errors clearly.""" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + def test_bare_keyword_exits_2(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """`ails check ` exits 2 (clear error, not silent success).""" + project = tmp_path / "project" + project.mkdir() + (project / "CLAUDE.md").write_text("# Proj\n\nminimal\n") + monkeypatch.chdir(project) + + result = runner.invoke(app, ["check", "nonexistentkeywordxyz", "--agent", "claude"]) + assert result.exit_code == 2, f"Expected exit 2, got {result.exit_code}:\n{result.output}" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + def test_bare_keyword_names_the_token(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The error message names what was not found.""" + project = tmp_path / "project" + project.mkdir() + (project / "CLAUDE.md").write_text("# Proj\n\nminimal\n") + monkeypatch.chdir(project) + + result = runner.invoke(app, ["check", "nonexistentkeywordxyz", "--agent", "claude"]) + assert "Path not found" in result.output, f"Missing friendly error:\n{result.output}" + + +# =========================================================================== +# Variadic Multi-Target Check +# +# `ails check ` accepts multiple targets in one invocation and scopes +# the scan to exactly those targets. Pins that both targets are scanned (not +# just the first) and that out-of-target files stay out. +# =========================================================================== + + +@pytest.mark.e2e +class TestVariadicTargets: + """Multiple targets in one ails check invocation all get scanned.""" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @requires_rules + def test_two_targets_both_scanned(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Two explicit file targets must both appear in the findings file set.""" + project = tmp_path / "project" + (project / "docs").mkdir(parents=True) + (project / "CLAUDE.md").write_text("# Proj\n\nshort\n") + (project / "docs" / "CLAUDE.md").write_text("# Docs\n\nshort nested\n") + monkeypatch.chdir(project) + + result = runner.invoke(app, ["check", "CLAUDE.md", "docs/CLAUDE.md", "-f", "json", "--agent", "claude"]) + assert result.exit_code == 0, f"variadic check failed:\n{result.output}" + files = _finding_files(json.loads(result.output)) + assert "CLAUDE.md" in files, f"first target not scanned: {files}" + assert "docs/CLAUDE.md" in files, f"second target not scanned: {files}" + + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @requires_rules + def test_multi_target_excludes_untargeted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A third, discoverable-but-untargeted file must not appear when two targets are named.""" + project = tmp_path / "project" + (project / "docs").mkdir(parents=True) + (project / "extra").mkdir(parents=True) + (project / "CLAUDE.md").write_text("# Proj\n\nshort\n") + (project / "docs" / "CLAUDE.md").write_text("# Docs\n\nshort nested\n") + # A third instruction file a whole-project scan WOULD discover — naming + # two targets must scope it out, so its absence is load-bearing. + (project / "extra" / "CLAUDE.md").write_text("# Extra\n\nshort extra\n") + monkeypatch.chdir(project) + + result = runner.invoke(app, ["check", "CLAUDE.md", "docs/CLAUDE.md", "-f", "json", "--agent", "claude"]) + assert result.exit_code == 0, f"variadic check failed:\n{result.output}" + files = _finding_files(json.loads(result.output)) + assert not any("extra/CLAUDE.md" in f for f in files), f"untargeted file leaked in: {files}" diff --git a/tests/unit/architecture/test_adapter_boundary.py b/tests/unit/architecture/test_adapter_boundary.py index 5f257f8c..ecd337d5 100644 --- a/tests/unit/architecture/test_adapter_boundary.py +++ b/tests/unit/architecture/test_adapter_boundary.py @@ -60,8 +60,7 @@ def _iter_imports(file_path: Path) -> list[str]: @pytest.mark.architecture def test_adapters_do_not_import_subsystems_or_outer_layers() -> None: """Adapters must not depend on subsystems, interfaces, or formatters.""" - if not ADAPTERS.is_dir(): - pytest.skip(f"{ADAPTERS} does not exist yet") + assert ADAPTERS.is_dir(), f"{ADAPTERS} must exist" forbidden = _FORBIDDEN_PREFIXES + _FORBIDDEN_SUBSYSTEM_PREFIXES violations: list[tuple[Path, str]] = [ (py, imp) diff --git a/tests/unit/architecture/test_fault_translation.py b/tests/unit/architecture/test_fault_translation.py new file mode 100644 index 00000000..d28f460e --- /dev/null +++ b/tests/unit/architecture/test_fault_translation.py @@ -0,0 +1,134 @@ +"""Fault-translation boundary — adapters must not collapse a caught fault into a sentinel. + +The import-direction boundary is enforced by `test_adapter_boundary.py`. This is the +error-translation contract on the same boundary: a fault caught in an `except` handler +must not silently become a sentinel (`""` / `None` / `0` / empty collection / a no-arg +constructor like `LintResponse()`) or `pass` that flows downstream as authoritative truth. +Adapters raise a typed `PlatformError` on fault and return a sentinel only for guard-clause +absence — see `core/platform/contract/errors.py`. + +Each flagged site is keyed by (path, enclosing-function, handler-exception-type) so the +allowlist survives line shifts and a NEW sentinel-collapse in a seeded function is still +caught. `_KNOWN_EXCEPTIONS` carries two classes: legitimate logged-isolation / dependency +degrades, and grandfathered debt tagged with a `# TODO`. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent.parent.parent +ADAPTERS = ROOT / "src" / "reporails_cli" / "core" / "platform" / "adapters" + +_FAIL_ON_VIOLATION = True + +# (relative_path, enclosing_function, handler_exception_type) tuples whose sentinel-collapse +# is reviewed-and-allowed. Regenerate by running this module's AST walk against the adapters. +_KNOWN_EXCEPTIONS: set[tuple[str, str, str]] = { + # Legitimate dependency-missing degrades — optional package absent → genuine anonymous. + ("src/reporails_cli/core/platform/adapters/api_client.py", "_tier_from_config", "ImportError"), + ("src/reporails_cli/core/platform/adapters/api_client.py", "_api_key_from_credentials", "ImportError"), + ("src/reporails_cli/core/platform/adapters/api_client.py", "_lint_remote", "ImportError"), + # Legitimate crash-firewall — PlatformError surfaced as a WARNING, session continues anonymous. + ("src/reporails_cli/core/platform/adapters/api_client.py", "_degrade_on_fault", "PlatformError"), + # Legitimate logged-isolation — network / parse faults logged at WARNING, server call degrades. + ("src/reporails_cli/core/platform/adapters/api_client.py", "_post_payload", "httpx.TimeoutException"), + ("src/reporails_cli/core/platform/adapters/api_client.py", "_post_payload", "httpx.HTTPStatusError"), + ("src/reporails_cli/core/platform/adapters/api_client.py", "_post_payload", "httpx.HTTPError"), + ( + "src/reporails_cli/core/platform/adapters/api_client.py", + "_post_payload", + "json.JSONDecodeError,KeyError,ValueError,TypeError", + ), + # Grandfathered debt — collapses a load fault to an empty result. TODO: raise a typed fault. + ( + "src/reporails_cli/core/platform/adapters/registry.py", + "structural_rule_ids", + "OSError,ValueError,KeyError", + ), # TODO: raise a typed fault + ( + "src/reporails_cli/core/platform/adapters/registry.py", + "_load_from_path", + "Exception", + ), # TODO: raise a typed fault + ( + "src/reporails_cli/core/platform/adapters/registry.py", + "_apply_agent_overrides", + "ValueError", + ), # TODO: raise a typed fault +} + + +def _handler_type(handler: ast.ExceptHandler) -> str: + """Stable string for the caught exception type(s).""" + if handler.type is None: + return "BARE" + if isinstance(handler.type, ast.Tuple): + return ",".join(ast.unparse(e) for e in handler.type.elts) + return ast.unparse(handler.type) + + +def _sentinel_kind(node: ast.stmt) -> str | None: + """Return a label when `node` is a sentinel collapse (`pass` or a bare-sentinel return).""" + if isinstance(node, ast.Pass): + return "pass" + if isinstance(node, ast.Return): + value = node.value + if value is None: + return "return None" + if isinstance(value, ast.Constant) and value.value in ("", None, 0, False): + return f"return {value.value!r}" + if isinstance(value, (ast.List, ast.Dict, ast.Set, ast.Tuple)): + elts = getattr(value, "elts", None) + keys = getattr(value, "keys", None) + if not elts and not keys: + return "return empty-collection" + if isinstance(value, ast.Call) and not value.args and not value.keywords: + return f"return {ast.unparse(value)}" + return None + + +def _collapsing_handlers(file_path: Path) -> list[tuple[str, str, str]]: + """Find except handlers whose last statement collapses the fault into a sentinel.""" + try: + tree = ast.parse(file_path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + return [] + rel = file_path.relative_to(ROOT).as_posix() + found: list[tuple[str, str, str]] = [] + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for handler in ast.walk(fn): + if not isinstance(handler, ast.ExceptHandler) or not handler.body: + continue + if _sentinel_kind(handler.body[-1]) is not None: + found.append((rel, fn.name, _handler_type(handler))) + return found + + +@pytest.mark.architecture +def test_adapters_do_not_collapse_faults_into_sentinels() -> None: + """Adapter except handlers must raise a typed fault, not silently return a sentinel.""" + if not ADAPTERS.is_dir(): + pytest.skip(f"{ADAPTERS} does not exist yet") + violations = [ + site + for py in sorted(ADAPTERS.rglob("*.py")) + if "__pycache__" not in py.parts + for site in _collapsing_handlers(py) + if site not in _KNOWN_EXCEPTIONS + ] + if not violations: + return + lines = [f"adapters layer has {len(violations)} unreviewed fault-collapse(s):"] + lines.extend(f" {v[0]}::{v[1]} catching {v[2]} returns a sentinel" for v in violations) + lines.append("Raise a typed PlatformError, or allowlist the site in _KNOWN_EXCEPTIONS with a rationale.") + msg = "\n".join(lines) + if _FAIL_ON_VIOLATION: + pytest.fail(msg) + else: + print(f"\n[report-only]\n{msg}\n") diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py index 4b4009ee..fdbd4492 100644 --- a/tests/unit/test_api_client.py +++ b/tests/unit/test_api_client.py @@ -11,14 +11,21 @@ LintResponse, preflight_oversized, ) +from reporails_cli.core.platform.adapters import api_client as api_client_mod from reporails_cli.core.platform.adapters.api_client import ( AilsClient, LintResult, + _api_key_from_credentials, _deserialize_cross_file_coordinates, _deserialize_hints, _deserialize_lint_result, _deserialize_per_file, _strip_and_serialize, + _tier_from_config, +) +from reporails_cli.core.platform.contract.errors import ( + ConfigUnreadableError, + CredentialsUnreadableError, ) from reporails_cli.core.platform.dto.ruleset import Atom, FileRecord, RulesetMap, RulesetSummary @@ -60,6 +67,83 @@ def test_custom_base_url(self) -> None: assert client.base_url == "https://custom.example.com" +class TestFaultDistinction: + """A caught fault becomes a distinct typed cause; genuine absence stays "". + + Models `test_auth_error_messaging.py`: monkeypatch the file/config reads so each + fault class is exercised without a real corrupt file on the host. + """ + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_missing_credentials_file_returns_empty(self, monkeypatch: pytest.MonkeyPatch, tmp_path: object) -> None: + """Genuine absence (no file) → "" with no raise.""" + from pathlib import Path + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert _api_key_from_credentials() == "" + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_corrupt_credentials_raises_typed_cause(self, monkeypatch: pytest.MonkeyPatch, tmp_path: object) -> None: + """A corrupt credentials file is a fault, not absence → CredentialsUnreadableError.""" + from pathlib import Path + + creds = tmp_path / ".reporails" / "credentials.yml" # type: ignore[operator] + creds.parent.mkdir(parents=True) + creds.write_text("api_key: [unterminated", encoding="utf-8") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + with pytest.raises(CredentialsUnreadableError): + _api_key_from_credentials() + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_absent_tier_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Genuine absence (config present, tier unset) → "" with no raise.""" + + class _Cfg: + tier = "" + + monkeypatch.setattr("reporails_cli.core.platform.config.config.get_global_config", lambda: _Cfg()) + assert _tier_from_config() == "" + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_unreadable_config_raises_typed_cause(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A config-read OSError is a fault, not absence → ConfigUnreadableError.""" + + def _boom() -> object: + raise OSError("disk gone") + + monkeypatch.setattr("reporails_cli.core.platform.config.config.get_global_config", _boom) + with pytest.raises(ConfigUnreadableError): + _tier_from_config() + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_client_degrades_to_anonymous_on_corrupt_credentials( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """AilsClient() with a fault on read → no raise, anonymous tier, visible WARNING.""" + import logging + + monkeypatch.delenv("AILS_API_KEY", raising=False) + monkeypatch.delenv("AILS_TIER", raising=False) + + def _raise_creds() -> str: + raise CredentialsUnreadableError("corrupt") + + monkeypatch.setattr(api_client_mod, "_api_key_from_credentials", _raise_creds) + monkeypatch.setattr(api_client_mod, "_tier_from_config", lambda: "") + + with caplog.at_level(logging.WARNING, logger="reporails_cli.core.platform.adapters.api_client"): + client = AilsClient() + + assert client.api_key == "" + assert client.tier == "free" + assert any(rec.levelno == logging.WARNING for rec in caplog.records) + + class TestV2WireFormat: """Verify _strip_and_serialize emits v2 obfuscated wire format.""" @@ -317,9 +401,9 @@ def test_lint_skips_http_when_no_files(self) -> None: class TestOutgoingHeaders: """Asserts on the outgoing HTTP request shape. - The default `httpx` User-Agent (`python-httpx/*`) trips Cloudflare's - bot-fight rules on anonymous-tier requests, producing a 403 with a - "Just a moment..." HTML challenge. Cost-free unit assertion that + The default `httpx` User-Agent (`python-httpx/*`) trips the upstream + edge bot-mitigation rules on anonymous-tier requests, producing a 403 + with a "Just a moment..." HTML challenge. Cost-free unit assertion that every outgoing diagnostic request carries a custom UA prevents the next regression of the same shape. """ @@ -368,7 +452,7 @@ def _fake_post(url: str, **kwargs: object) -> _FakeResponse: ua = captured["headers"].get("User-Agent", "") assert ua.startswith("reporails-cli/"), ( - f"outgoing request UA must start with 'reporails-cli/' to avoid Cloudflare bot-fight 403; got {ua!r}" + f"outgoing request UA must start with 'reporails-cli/' to avoid an edge bot-mitigation 403; got {ua!r}" ) @pytest.mark.unit @@ -542,3 +626,19 @@ def test_impact_tier_defaults_empty_when_absent(self) -> None: } (fa,) = _deserialize_per_file(data) assert fa.diagnostics[0].impact_tier == "" + + +class TestTierForwardedOnMalformedReport: + @pytest.mark.unit + @pytest.mark.subsys_api + def test_tier_forwarded_when_report_missing(self) -> None: + """Regression: a 2xx response with a missing/garbled `report` but a real `tier` + was deserialized as tier 'free', silently relabeling a pro/anonymous session.""" + result = _deserialize_lint_result({"tier": "pro"}) + assert result.tier == "pro" + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_tier_defaults_free_when_both_absent(self) -> None: + result = _deserialize_lint_result({}) + assert result.tier == "free" diff --git a/tests/unit/test_auth_error_messaging.py b/tests/unit/test_auth_error_messaging.py new file mode 100644 index 00000000..068ae89e --- /dev/null +++ b/tests/unit/test_auth_error_messaging.py @@ -0,0 +1,55 @@ +"""Offline coverage for auth-failure messaging. + +`_resolve_client_id` must surface a reachable-but-unexpected platform response as a +clear, actionable error (transient network/edge issue) instead of swallowing it into +an empty client_id and a misleading "OAuth not configured" message. The CLI +gives a generic helpful message — it does not fingerprint the proxy's response. +""" + +from __future__ import annotations + +import httpx +import pytest + +from reporails_cli.interfaces.cli.auth_command import ( + PlatformUnavailableError, + _resolve_client_id, +) + + +class _FakeResponse: + def __init__(self, status_code: int, text: str) -> None: + self.status_code = status_code + self.text = text + + def json(self) -> dict[str, str]: + if self.text.strip().startswith("{"): + return {"client_id": "abc123"} + raise ValueError("not json") + + +@pytest.mark.unit +@pytest.mark.subsys_api +def test_resolve_client_id_non_200_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + """A non-200 raises PlatformUnavailableError with a retry/contact-support message.""" + monkeypatch.setattr(httpx, "get", lambda *a, **k: _FakeResponse(403, "blocked")) + with pytest.raises(PlatformUnavailableError, match="HTTP 403"): + _resolve_client_id("https://reporails.com") + + +@pytest.mark.unit +@pytest.mark.subsys_api +def test_resolve_client_id_non_json_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + """A 200 with a non-JSON body raises a clear unexpected-response error, not an + empty client_id / misleading 'OAuth not configured'.""" + monkeypatch.setattr(httpx, "get", lambda *a, **k: _FakeResponse(200, "interstitial")) + with pytest.raises(PlatformUnavailableError, match="non-JSON"): + _resolve_client_id("https://reporails.com") + + +@pytest.mark.unit +@pytest.mark.subsys_api +def test_resolve_client_id_returns_client_id_on_200(monkeypatch: pytest.MonkeyPatch) -> None: + """A 200 JSON body returns the embedded client id.""" + monkeypatch.setattr(httpx, "get", lambda *a, **k: _FakeResponse(200, '{"client_id": "abc123"}')) + assert _resolve_client_id("https://reporails.com") == "abc123" diff --git a/tests/unit/test_capability_paths.py b/tests/unit/test_capability_paths.py index 3d1f175f..0f7cc566 100644 --- a/tests/unit/test_capability_paths.py +++ b/tests/unit/test_capability_paths.py @@ -142,7 +142,7 @@ def test_canonicalize_handles_empty_string() -> None: def test_canonicalize_referenced_singular_and_plural() -> None: """`referenced` and `references` both canonicalize to `referenced`, agent-agnostic.""" for keyword in ("referenced", "references"): - for agent in ("claude", "gemini", "codex"): + for agent in ("claude", "antigravity", "codex"): assert canonicalize_capability(keyword, agent) == "referenced" @@ -151,7 +151,7 @@ def test_canonicalize_referenced_singular_and_plural() -> None: def test_is_capability_keyword_recognizes_referenced() -> None: """`referenced` and `references` are accepted as capability keywords by every agent.""" for keyword in ("referenced", "references"): - for agent in ("claude", "gemini"): + for agent in ("claude", "antigravity"): assert is_capability_keyword(keyword, agent) is True diff --git a/tests/unit/test_cluster_topics.py b/tests/unit/test_cluster_topics.py new file mode 100644 index 00000000..bebc41f9 --- /dev/null +++ b/tests/unit/test_cluster_topics.py @@ -0,0 +1,111 @@ +"""Regression guard for `cluster_topics` — the mapper's grouping stage. + +The resulting `cluster_id` partition is load-bearing for downstream grouping +and scoring, so the *partition* must stay stable. These tests pin the +behaviors of the current grouping (distinct groups stay distinct, a chain of +near-neighbors does not all collapse into one group, headings are excluded, +small inputs fall back cleanly). A change to the grouping method that would +scramble those assignments trips these tests before it can ship a silent +scoring drift. +""" + +from __future__ import annotations + +import math + +import pytest + +from reporails_cli.core.mapper.cluster import cluster_topics +from reporails_cli.core.platform.dto.ruleset import Atom + +_DIM = 384 + + +def _atom(vec: list[float], *, kind: str = "excitation", charge_value: int = 1) -> Atom: + """Build an atom whose int8 embedding points along `vec` (L2-normalized downstream).""" + quant = tuple(int(max(-127, min(127, round(x * 100)))) for x in vec) + return Atom( + line=1, + text="t", + kind=kind, + charge="DIRECTIVE", + charge_value=charge_value, + modality="direct", + specificity="named", + embedding_int8=quant, + ) + + +def _axis(i: int) -> list[float]: + v = [0.0] * _DIM + v[i] = 1.0 + return v + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_orthogonal_groups_get_distinct_clusters() -> None: + """Three mutually orthogonal topic directions yield three clusters, one per group.""" + atoms = [_atom(_axis(g)) for g in (0, 1, 2) for _ in range(2)] + clusters = cluster_topics(atoms) + assert len(clusters) == 3 + # Each orthogonal pair shares one id; the three ids are distinct. + ids = [a.cluster_id for a in atoms] + assert ids[0] == ids[1] and ids[2] == ids[3] and ids[4] == ids[5] + assert len({ids[0], ids[2], ids[4]}) == 3 + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_grouping_resists_chaining() -> None: + """An arc of points (each close to its neighbor, ends far apart) must NOT collapse to one group. + + A cheaper grouping method would chain the whole arc into a single group; + the current one splits it. This is the regression guard against swapping in + a method that scrambles the partition. + """ + arc = [] + for i in range(8): + theta = math.radians(i * 20) + v = [0.0] * _DIM + v[0] = math.cos(theta) + v[1] = math.sin(theta) + arc.append(_atom(v)) + clusters = cluster_topics(arc) + assert len(clusters) >= 2 + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_heading_atoms_excluded_from_clustering() -> None: + """Heading atoms are not clustered — they keep the default cluster_id of -1.""" + heading = _atom(_axis(0), kind="heading") + body = [_atom(_axis(0)), _atom(_axis(0))] + cluster_topics([heading, *body]) + assert heading.cluster_id == -1 + assert body[0].cluster_id == body[1].cluster_id != -1 + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_single_embedded_atom_falls_back_to_one_cluster() -> None: + """Fewer than two embedded atoms collapse to a single fallback cluster.""" + clusters = cluster_topics([_atom(_axis(0))]) + assert len(clusters) == 1 + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_no_embeddings_returns_single_cluster() -> None: + """Atoms without embeddings still resolve to one fallback cluster, not a crash.""" + bare = Atom( + line=1, + text="t", + kind="excitation", + charge="DIRECTIVE", + charge_value=1, + modality="direct", + specificity="named", + ) + clusters = cluster_topics([bare, bare]) + assert len(clusters) == 1 diff --git a/tests/unit/test_embedded_marker_refs.py b/tests/unit/test_embedded_marker_refs.py new file mode 100644 index 00000000..92ee42e2 --- /dev/null +++ b/tests/unit/test_embedded_marker_refs.py @@ -0,0 +1,64 @@ +"""Link/citation/code-span charge words must not promote a neutral atom to AMBIGUOUS.""" + +import pytest + +from reporails_cli.core.mapper.parse import _scan_neutral_for_embedded_markers +from reporails_cli.core.platform.dto.ruleset import Atom + + +def _neutral(text: str) -> Atom: + return Atom( + line=1, + text=text, + kind="excitation", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity="abstract", + rule="p3_neutral", + ) + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_charge_word_inside_link_label_does_not_flag_ambiguous(): + atom = _neutral("See the [never push to main](rules/no-push.md) rule for context.") + _scan_neutral_for_embedded_markers([atom]) + assert atom.charge == "NEUTRAL" + assert atom.embedded_charge_markers == [] + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_charge_word_in_citation_reference_does_not_flag_ambiguous(): + atom = _neutral("This follows prior guidance.\n[avoid force-add]: rules/no-force.md") + _scan_neutral_for_embedded_markers([atom]) + assert atom.charge == "NEUTRAL" + assert atom.embedded_charge_markers == [] + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_charge_word_in_code_span_does_not_flag_ambiguous(): + atom = _neutral("The flag `never-fail` controls retry handling.") + _scan_neutral_for_embedded_markers([atom]) + assert atom.charge == "NEUTRAL" + assert atom.embedded_charge_markers == [] + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_genuine_inline_constraint_language_still_flags_ambiguous(): + atom = _neutral("You must never bypass the validation step here.") + _scan_neutral_for_embedded_markers([atom]) + assert atom.charge == "AMBIGUOUS" + assert any(m.startswith("constraint:") for m in atom.embedded_charge_markers) + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_link_label_charge_word_with_real_marker_outside_still_flags(): + atom = _neutral("Never bypass it; see [the deletion guide](g.md) for why.") + _scan_neutral_for_embedded_markers([atom]) + assert atom.charge == "AMBIGUOUS" + assert any(m.startswith("constraint:") for m in atom.embedded_charge_markers) diff --git a/tests/unit/test_heal_scope_safety.py b/tests/unit/test_heal_scope_safety.py new file mode 100644 index 00000000..f4c932dd --- /dev/null +++ b/tests/unit/test_heal_scope_safety.py @@ -0,0 +1,211 @@ +"""Scope-safety guard for `ails check --heal`. + +`--heal` writes files. An implicit whole-project rewrite (no target) is refused +before any pipeline work; an explicit target, a `--dry-run` preview, or the +`--cwd` opt-in is required. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from reporails_cli.interfaces.cli.main import app + +runner = CliRunner() + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_heal_without_target_is_refused() -> None: + """Bare `ails check --heal` (whole-project, no target) exits 2 and writes nothing. + + The guard fires before the pipeline runs, so this stays fast and ML-free. + """ + result = runner.invoke(app, ["check", "--heal"]) + assert result.exit_code == 2 + assert "needs an explicit target" in result.stdout + + +@pytest.mark.unit +@pytest.mark.subsys_heal +@pytest.mark.parametrize("dot", [".", "./"]) +def test_heal_against_dot_is_refused(dot: str) -> None: + """`ails check . --heal` resolves to the whole project root with no narrowing, + so it is refused like a bare `--heal` (a non-empty target is not enough).""" + result = runner.invoke(app, ["check", dot, "--heal"]) + assert result.exit_code == 2 + assert "needs an explicit target" in result.stdout + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_has_api_key_true_with_env(monkeypatch: pytest.MonkeyPatch) -> None: + """`--heal` is gated on auth; an env key reads as authenticated.""" + from reporails_cli.core.platform.adapters.api_client import has_api_key + + monkeypatch.setenv("AILS_API_KEY", "k") + assert has_api_key() is True + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_has_api_key_false_when_anonymous(monkeypatch: pytest.MonkeyPatch) -> None: + """No env key + isolated HOME (no credentials) reads as anonymous — `--heal` is gated off.""" + from reporails_cli.core.platform.adapters.api_client import has_api_key + + monkeypatch.delenv("AILS_API_KEY", raising=False) + assert has_api_key() is False + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_heal_refusal_is_json_parseable_under_json_format() -> None: + """Under `-f json` the refusal is a parseable JSON error, not Rich text — a + machine consumer still gets structured output (exit 2).""" + result = runner.invoke(app, ["check", "--heal", "-f", "json"]) + assert result.exit_code == 2 + payload = json.loads(result.stdout) + assert payload["error"] == "heal_requires_target" + + +@pytest.mark.unit +@pytest.mark.subsys_heal +@pytest.mark.parametrize("second", ["CLAUDE.md", "skills"]) +def test_heal_dot_plus_token_still_refused(second: str) -> None: + """`ails check . --heal`: the `.` keeps every file in scope, so a second + narrower token must NOT rescue the whole-project rewrite — it is still refused. + Regression: a second token previously flipped `whole_project_heal` to False. + + Runs in an isolated cwd carrying a CLAUDE.md so the second token resolves to a + real in-tree target (a file, or the detected agent's `skills` capability); + otherwise a "path not found" exits before the scope guard, which made this test + depend on the runner's cwd happening to carry a root CLAUDE.md.""" + with runner.isolated_filesystem(): + Path("CLAUDE.md").write_text("# Project\n", encoding="utf-8") + result = runner.invoke(app, ["check", ".", second, "--heal"]) + assert result.exit_code == 2 + assert "needs an explicit target" in result.stdout + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_mechanical_fixes_bounded_to_allowed_files(tmp_path: object) -> None: + """Mechanical fixes write only within `allowed_files`. A mapped file outside it + (e.g. an in-tree symlink whose real path escapes the heal target) is never + rewritten. Regression: the mechanical pass rewrote every mapped file, bypassing + the scope guard the additive set already passed.""" + from pathlib import Path + + from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes + from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap + + base = Path(str(tmp_path)) + in_scope = base / "in_scope.md" + out_scope = base / "out_scope.md" + in_scope.write_text("# In\nRun pyproject.toml here.\n", encoding="utf-8") + out_scope.write_text("# Out\nRun pyproject.toml here.\n", encoding="utf-8") + out_before = out_scope.read_text(encoding="utf-8") + + def _atom(file_path: str) -> Atom: + return Atom( + line=2, + text="Run pyproject.toml here.", + kind="paragraph", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity=0.0, + unformatted_code=["pyproject.toml"], + file_path=file_path, + ) + + rmap = RulesetMap( + schema_version="1", + embedding_model="m", + generated_at="now", + files=(), + atoms=(_atom(str(in_scope)), _atom(str(out_scope))), + ) + + fixes = apply_mechanical_fixes(rmap, base, allowed_files={in_scope.resolve()}) + + assert out_scope.read_text(encoding="utf-8") == out_before, "out-of-scope file was rewritten" + assert "`pyproject.toml`" in in_scope.read_text(encoding="utf-8"), "in-scope file not healed" + assert all(Path(f.file_path).resolve() == in_scope.resolve() for f in fixes) + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_mechanical_fixes_skip_suppressed_lines(tmp_path: object) -> None: + """Heal leaves a line the author annotated with `ails-disable-line` unmodified, + while still fixing an unsuppressed line carrying the same token. Regression: heal + rewrote suppressed lines because the write path ignored the suppression index.""" + from pathlib import Path + + from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes + from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap + + f = Path(str(tmp_path)) / "CLAUDE.md" + f.write_text("# P\nRun pyproject.toml here.\nAlso pyproject.toml there.\n", encoding="utf-8") + + def _atom(line: int) -> Atom: + return Atom( + line=line, + text="x", + kind="paragraph", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity=0.0, + unformatted_code=["pyproject.toml"], + file_path=str(f), + ) + + rmap = RulesetMap(schema_version="1", embedding_model="m", generated_at="now", files=(), atoms=(_atom(2), _atom(3))) + + # Suppress line 2; line 3 stays armed. + apply_mechanical_fixes(rmap, Path(str(tmp_path)), suppressed={f.resolve(): {2}}) + + out = f.read_text(encoding="utf-8").splitlines() + assert out[1] == "Run pyproject.toml here.", "suppressed line was rewritten" + assert out[2] == "Also `pyproject.toml` there.", "unsuppressed line not healed" + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_mechanical_fixes_skip_files_with_imports(tmp_path: object) -> None: + """A file whose @imports expand is skipped: `atom.line` is in import-expanded space + but the fixer edits the RAW file, so a write would target the wrong physical line. + Regression: heal corrupted (or mis-fixed) lines on @import-bearing files.""" + from pathlib import Path + + from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes + from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap + + base = Path(str(tmp_path)) + (base / "frag.md").write_text("imported one\nimported two\n", encoding="utf-8") + f = base / "CLAUDE.md" + raw = "@frag.md\nRun pyproject.toml here.\n" + f.write_text(raw, encoding="utf-8") + + atom = Atom( + line=2, + text="x", + kind="paragraph", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity=0.0, + unformatted_code=["pyproject.toml"], + file_path=str(f), + ) + rmap = RulesetMap(schema_version="1", embedding_model="m", generated_at="now", files=(), atoms=(atom,)) + + fixes = apply_mechanical_fixes(rmap, base) + + assert f.read_text(encoding="utf-8") == raw, "an @import-bearing file was rewritten" + assert fixes == [] diff --git a/tests/unit/test_json_canonical_rule_ids.py b/tests/unit/test_json_canonical_rule_ids.py new file mode 100644 index 00000000..9a549e02 --- /dev/null +++ b/tests/unit/test_json_canonical_rule_ids.py @@ -0,0 +1,48 @@ +"""`-f json` / `--format github` trailing JSON emit canonical rule IDs. + +A bare client-check token (`format`, `orphan`, …) is canonicalized to +`::` on the wire, with the raw token preserved under `label` +for baseline stability. Already-canonical server IDs pass through untouched. +""" + +from __future__ import annotations + +import pytest + +from reporails_cli.core.platform.runtime.merger import CombinedResult, CombinedStats, FindingItem +from reporails_cli.formatters.json import format_combined_result + + +def _result(*findings: FindingItem) -> CombinedResult: + return CombinedResult( + findings=tuple(findings), + cross_file=(), + quality=None, + per_file_analysis=(), + stats=CombinedStats(total_findings=len(findings), errors=0, warnings=len(findings), infos=0), + offline=True, + hints=(), + cross_file_coordinates=(), + ) + + +def _finding(rule: str) -> FindingItem: + return FindingItem(file="CLAUDE.md", line=1, severity="warning", rule=rule, message="m") + + +@pytest.mark.unit +@pytest.mark.subsys_diagnostic +def test_canonical_id_passes_through_without_label() -> None: + data = format_combined_result(_result(_finding("CORE:C:0042"))) + entry = data["files"]["CLAUDE.md"]["findings"][0] + assert entry["rule"] == "CORE:C:0042" + assert "label" not in entry + + +@pytest.mark.unit +@pytest.mark.subsys_diagnostic +def test_top_rules_uses_canonical_ids() -> None: + data = format_combined_result(_result(_finding("format"), _finding("format"))) + top = data["top_rules"] + assert top[0]["rule"] == "CORE:E:0003" + assert top[0]["count"] == 2 diff --git a/tests/unit/test_json_formatter.py b/tests/unit/test_json_formatter.py index 611f613f..34b735a1 100644 --- a/tests/unit/test_json_formatter.py +++ b/tests/unit/test_json_formatter.py @@ -87,11 +87,13 @@ def test_per_finding_leverage_added_without_touching_severity(self) -> None: data = format_combined_result(_result(findings=findings)) entries = data["files"]["a.md"]["findings"] by_rule = {e["rule"]: e for e in entries} + # `bold` canonicalizes to CORE:E:0003 on the wire (raw token kept under `label`). assert by_rule["CORE:C:0044"]["leverage"] == "gate_mover" - assert by_rule["bold"]["leverage"] == "cosmetic" + assert by_rule["CORE:E:0003"]["leverage"] == "cosmetic" + assert by_rule["CORE:E:0003"]["label"] == "bold" # Raw severity is the unchanged machine baseline. assert by_rule["CORE:C:0044"]["severity"] == "warning" - assert by_rule["bold"]["severity"] == "info" + assert by_rule["CORE:E:0003"]["severity"] == "info" @pytest.mark.unit @pytest.mark.subsys_diagnostic @@ -257,13 +259,17 @@ def test_category_derived_from_rule_id(self, rule_id: str, expected: str) -> Non @pytest.mark.unit @pytest.mark.subsys_diagnostic - def test_category_empty_for_bare_token_rule_id(self) -> None: - """Bare-token rule ids (issue #31 D) yield empty category, not a crash.""" + def test_bare_token_canonicalized_to_category(self) -> None: + """Bare-token rule ids (issue #31 D) are canonicalized on the wire, so they + now carry a category and preserve the raw token under `label`.""" from reporails_cli.core.platform.runtime.merger import FindingItem findings = (FindingItem(file="a.md", line=1, severity="warning", rule="format", message="x"),) data = format_combined_result(_result(findings=findings)) - assert data["files"]["a.md"]["findings"][0]["category"] == "" + entry = data["files"]["a.md"]["findings"][0] + assert entry["rule"] == "CORE:E:0003" + assert entry["label"] == "format" + assert entry["category"] == "efficiency" class TestSurfaceCategoryBreakdown: @@ -285,8 +291,9 @@ def test_breakdown_sums_to_finding_count_for_well_formed_rules(self) -> None: @pytest.mark.unit @pytest.mark.subsys_diagnostic - def test_breakdown_excludes_bare_token_rule_ids(self) -> None: - """Bare-token rule ids (issue #31 D) drop out of the breakdown, leaving sum < finding_count.""" + def test_breakdown_includes_canonicalized_bare_tokens(self) -> None: + """Bare-token rule ids (issue #31 D) are canonicalized before the breakdown, + so the breakdown now sums to finding_count instead of dropping them.""" from reporails_cli.core.platform.runtime.merger import FindingItem findings = ( @@ -296,5 +303,5 @@ def test_breakdown_excludes_bare_token_rule_ids(self) -> None: data = format_combined_result(_result(findings=findings)) sh = data["surface_health"][0] assert sh["finding_count"] == 2 - assert sum(sh["category_breakdown"].values()) == 1 - assert sh["category_breakdown"] == {"coherence": 1} + assert sum(sh["category_breakdown"].values()) == 2 + assert sh["category_breakdown"] == {"coherence": 1, "efficiency": 1} diff --git a/tests/unit/test_mechanical.py b/tests/unit/test_mechanical.py index 9a946e40..3ef7e9a9 100644 --- a/tests/unit/test_mechanical.py +++ b/tests/unit/test_mechanical.py @@ -361,7 +361,14 @@ def test_skipped_when_scoped(self, tmp_path: Path) -> None: type=RuleType.MECHANICAL, severity=Severity.MEDIUM, match=FileMatch(type="skill"), - checks=[Check(id="CORE.S.0015.entrypoint", type="mechanical", check="skill_entrypoint_present")], + checks=[ + Check( + id="CORE.S.0015.entrypoint", + type="mechanical", + check="skill_entrypoint_present", + project_scope=True, + ) + ], ) classified = _cf(tmp_path, ".claude/skills/alpha/SKILL.md", file_type="skill") rules = {"CORE:S:0015": rule} @@ -372,7 +379,7 @@ def test_skipped_when_scoped(self, tmp_path: Path) -> None: class TestScopedProjectChecks: """Project-aggregate checks are skipped under a targeted (scoped) run.""" - def _rule(self, rule_id: str, check_name: str, args: dict) -> Rule: + def _rule(self, rule_id: str, check_name: str, args: dict, project_scope: bool = False) -> Rule: return Rule( id=rule_id, title=f"Rule {rule_id}", @@ -380,14 +387,22 @@ def _rule(self, rule_id: str, check_name: str, args: dict) -> Rule: type=RuleType.MECHANICAL, severity=Severity.CRITICAL, match=FileMatch(), - checks=[Check(id=f"{rule_id}:check:0001", type="mechanical", check=check_name, args=args)], + checks=[ + Check( + id=f"{rule_id}:check:0001", + type="mechanical", + check=check_name, + args=args, + project_scope=project_scope, + ) + ], ) @pytest.mark.unit @pytest.mark.subsys_lint def test_file_count_fires_unscoped_skipped_scoped(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - rules = {"CORE:S:0010": self._rule("CORE:S:0010", "file_count", {"min": 2})} + rules = {"CORE:S:0010": self._rule("CORE:S:0010", "file_count", {"min": 2}, project_scope=True)} classified = _cf(tmp_path, "CLAUDE.md") # single file → count 1 < min 2 assert len(run_mechanical_checks(rules, tmp_path, classified, scoped=False)) == 1 assert len(run_mechanical_checks(rules, tmp_path, classified, scoped=True)) == 0 @@ -396,7 +411,7 @@ def test_file_count_fires_unscoped_skipped_scoped(self, tmp_path: Path) -> None: @pytest.mark.subsys_lint def test_aggregate_byte_size_fires_unscoped_skipped_scoped(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("this content is well over five bytes") - rules = {"CORE:E:0001": self._rule("CORE:E:0001", "aggregate_byte_size", {"max": 5})} + rules = {"CORE:E:0001": self._rule("CORE:E:0001", "aggregate_byte_size", {"max": 5}, project_scope=True)} classified = _cf(tmp_path, "CLAUDE.md") assert len(run_mechanical_checks(rules, tmp_path, classified, scoped=False)) == 1 assert len(run_mechanical_checks(rules, tmp_path, classified, scoped=True)) == 0 diff --git a/tests/unit/test_mechanical_fixers.py b/tests/unit/test_mechanical_fixers.py index 793a076c..599fa1a9 100644 --- a/tests/unit/test_mechanical_fixers.py +++ b/tests/unit/test_mechanical_fixers.py @@ -68,3 +68,17 @@ def test_idempotent_on_already_wrapped(self) -> None: assert lines[0] == "Use `pyproject.toml` for config.\n" assert fixes == [] + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_token_only_as_substring_left_untouched(self) -> None: + """Regression: the substring fallback wrapped a token mid-word (`npm` inside + `npmrc`), corrupting prose. A token with no word-boundaried occurrence outside + a link must be left unchanged, not wrapped mid-word.""" + lines = ["Edit your npmrc file by hand.\n"] + atoms = [_atom(1, lines[0], ["npm"])] + + fixes = fix_unformatted_code(atoms, lines) + + assert lines[0] == "Edit your npmrc file by hand.\n" + assert fixes == [] diff --git a/tests/unit/test_narrow_path_targets.py b/tests/unit/test_narrow_path_targets.py new file mode 100644 index 00000000..b0336a1f --- /dev/null +++ b/tests/unit/test_narrow_path_targets.py @@ -0,0 +1,75 @@ +"""Regression tests for `_narrow_to_path_targets` symlink handling. + +A directory target must keep an in-tree symlinked instruction file (e.g. a +hub-symlinked rule). The earlier `f.resolve()`-only membership test dropped it, +because resolution follows the symlink out of the target directory. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from reporails_cli.interfaces.cli.main import _narrow_to_path_targets, _resolved_within_target + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_symlinked_file_under_dir_target_is_preserved(tmp_path: Path) -> None: + """A symlink inside a directory target survives narrowing even though it + resolves to a file outside the target.""" + target = tmp_path / "rules" + target.mkdir() + external = tmp_path / "external" / "real.md" + external.parent.mkdir() + external.write_text("rule body") + link = target / "link.md" + try: + link.symlink_to(external) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform/filesystem") + + kept = _narrow_to_path_targets([link], {target.resolve()}) + + assert kept == [link] + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_unrelated_file_is_dropped(tmp_path: Path) -> None: + """A real file outside every target is still excluded.""" + target = tmp_path / "rules" + target.mkdir() + outside = tmp_path / "other" / "note.md" + outside.parent.mkdir() + outside.write_text("x") + + kept = _narrow_to_path_targets([outside], {target.resolve()}) + + assert kept == [] + + +@pytest.mark.unit +@pytest.mark.subsys_heal +def test_resolved_within_target_excludes_escaping_symlink(tmp_path: Path) -> None: + """A symlink under the target whose real file escapes the target is NOT a heal-write + candidate — heal writes through to the real file, which sits outside the named scope.""" + target = tmp_path / "rules" + target.mkdir() + external = tmp_path / "external" / "real.md" + external.parent.mkdir() + external.write_text("rule body") + link = target / "link.md" + try: + link.symlink_to(external) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform/filesystem") + + # In-scope by logical path (kept for scanning) but excluded from the heal write set. + assert _narrow_to_path_targets([link], {target.resolve()}) == [link] + assert _resolved_within_target(link, target) is False + + real = target / "real_in_scope.md" + real.write_text("x") + assert _resolved_within_target(real, target) is True diff --git a/tests/unit/test_rule_examples_consistency.py b/tests/unit/test_rule_examples_consistency.py new file mode 100644 index 00000000..dcdd9446 --- /dev/null +++ b/tests/unit/test_rule_examples_consistency.py @@ -0,0 +1,53 @@ +"""`ails explain` and `ails rules -f md` agree on Pass / Fail example presence. + +Both surfaces extract examples through `load_rule_examples`, and both name an +absent example block rather than omitting it silently. +""" + +from __future__ import annotations + +import pytest + +from reporails_cli.formatters.mcp import format_rule as mcp_format_rule +from reporails_cli.formatters.text.rules import format_rule + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_explain_renders_examples_when_present() -> None: + data = {"title": "X", "examples": {"pass": "PASS-BODY", "fail": "FAIL-BODY"}} + out = format_rule("CORE:S:0001", data) + assert "Examples:" in out + assert "Pass:" in out and "PASS-BODY" in out + assert "Fail:" in out and "FAIL-BODY" in out + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_explain_names_absent_examples() -> None: + """A rule with no parseable Pass / Fail is named as such, not silently omitted.""" + out = format_rule("CORE:S:0001", {"title": "X", "examples": {"pass": None, "fail": None}}) + assert "this rule has no Pass / Fail examples" in out + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_explain_without_examples_key_is_back_compat() -> None: + """Callers that pass no `examples` key (JSON) get no Examples section.""" + out = format_rule("CORE:S:0001", {"title": "X"}) + assert "Examples:" not in out + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_mcp_explain_renders_examples_in_parity_with_cli() -> None: + """The MCP `explain` surface shows the same Pass / Fail examples as `ails explain`.""" + out = mcp_format_rule("CORE:S:0001", {"title": "X", "examples": {"pass": "PB", "fail": "FB"}}) + assert "Examples:" in out and "PB" in out and "FB" in out + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_mcp_explain_names_absent_examples() -> None: + out = mcp_format_rule("CORE:S:0001", {"title": "X", "examples": {"pass": None, "fail": None}}) + assert "no Pass / Fail examples" in out diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index 8541b631..c5c51c7d 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -683,3 +684,94 @@ def test_exclude_unions_across_overlapping_surfaces(self, tmp_path: Path) -> Non assert (draft_dir / "draft.mdc").as_posix() not in all_paths, ( "exclude on cursor.rules must also drop the file from cursor.bugbot_rules" ) + + +class TestRepoScopedDiscoveryIgnoresHomeConfig: + """A repo scan must not let the developer's global ~/ config decide detection. + + Regression: a global ``~/.codex/config.toml`` made codex look distinctive on + a bare ``AGENTS.md`` repo, collapsing detection from generic to codex (and, + with a global ``default_agent``, dropping every finding). Repo-scoped + discovery now drops user-scope (``~/...``) patterns; home surfaces stay + reachable only via an explicit capability target. + """ + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_global_codex_config_does_not_hijack_agents_md_detection(self, tmp_path: Path) -> None: + # The autouse _isolate_home fixture points HOME at a clean temp dir; + # plant the global codex config there to recreate the leak trigger. + home = Path(os.environ["HOME"]) + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "config.toml").write_text('model = "x"\n', encoding="utf-8") + + repo = tmp_path / "repo" + repo.mkdir() + (repo / "AGENTS.md").write_text("# Agents\n\nAlways do the thing.\n", encoding="utf-8") + + clear_agent_cache() + detected_ids = {a.agent_type.id for a in detect_agents(repo)} + + # Generic owns a bare cross-agent AGENTS.md. The global codex config must + # not make codex the sole detected agent (it did before the fix). + assert "generic" in detected_ids, f"home config hijacked detection: {detected_ids}" + assert "codex" not in detected_ids, f"global ~/.codex/config.toml leaked into a repo scan: {detected_ids}" + + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.skipif( + os.name == "nt", + reason="auto-memory slug keying is POSIX-path-shaped; Windows uses a different convention", + ) + def test_project_auto_memory_surfaces_in_repo_scan(self, tmp_path: Path) -> None: + """The project's own slug-keyed `~/.claude/projects//memory/` survives a repo scan. + + Regression: the home-config external-drop also dropped the project's auto-memory, + which is slug-keyed to THIS project (not cross-project) and so is project-specific. + """ + from reporails_cli.core.discovery.agent_discovery import discover_from_config + + repo = tmp_path / "repo" + repo.mkdir() + (repo / "CLAUDE.md").write_text("# root\n", encoding="utf-8") + + # Auto-memory lives at ~/.claude/projects//memory/, slug = resolved path, / -> -. + home = Path(os.environ["HOME"]) + slug = str(repo.resolve()).replace("/", "-") + mem_dir = home / ".claude" / "projects" / slug / "memory" + mem_dir.mkdir(parents=True) + (mem_dir / "MEMORY.md").write_text("# Memory\n", encoding="utf-8") + + clear_agent_cache() + result = discover_from_config(repo, "claude", repo_scoped=True) + assert result is not None + instructions, _rules, _configs = result + names = {p.as_posix() for p in instructions} + assert (mem_dir / "MEMORY.md").as_posix() in names, "project auto-memory must surface in a repo scan" + + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.skipif( + os.name == "nt", + reason="auto-memory slug keying is POSIX-path-shaped; Windows uses a different convention", + ) + def test_foreign_project_auto_memory_does_not_surface(self, tmp_path: Path) -> None: + """Only the scanned project's slug-keyed memory surfaces — a sibling project's does not.""" + from reporails_cli.core.discovery.agent_discovery import discover_from_config + + repo = tmp_path / "repo" + repo.mkdir() + (repo / "CLAUDE.md").write_text("# root\n", encoding="utf-8") + + home = Path(os.environ["HOME"]) + foreign_slug = str((tmp_path / "other").resolve()).replace("/", "-") + foreign_mem = home / ".claude" / "projects" / foreign_slug / "memory" + foreign_mem.mkdir(parents=True) + (foreign_mem / "MEMORY.md").write_text("# Other\n", encoding="utf-8") + + clear_agent_cache() + result = discover_from_config(repo, "claude", repo_scoped=True) + assert result is not None + instructions, _rules, _configs = result + names = {p.as_posix() for p in instructions} + assert (foreign_mem / "MEMORY.md").as_posix() not in names, "another project's auto-memory must not leak in" diff --git a/tests/unit/test_scorecard.py b/tests/unit/test_scorecard.py index c7aa0b72..1bd9c45f 100644 --- a/tests/unit/test_scorecard.py +++ b/tests/unit/test_scorecard.py @@ -60,3 +60,64 @@ def test_subdirectory_main_file_classifies_as_nested(self, tmp_path: Path) -> No names = {s.name: s.file_count for s in surfaces} assert names.get("Nested") == 1 assert "Main" not in names + + +@dataclass +class _Quality: + display_score: float + + +@dataclass +class _Stats: + errors: int = 0 + warnings: int = 0 + infos: int = 0 + + +@dataclass +class _Finding: + severity: str + rule: str = "CORE:R:0001" + message: str = "example finding" + + +@dataclass +class _VerdictResult: + quality: _Quality | None + stats: _Stats + findings: tuple = () + + +class TestVerdictCaption: + """The 'still your worklist' caption must point at a list that actually rendered.""" + + def _capture(self, result: object) -> str: + from reporails_cli.formatters.text.scorecard import _render_verdict_block, console + + with console.capture() as cap: + _render_verdict_block(result, has_quality=True, n_atoms=0, elapsed_ms=0) + # Collapse Rich soft-wrap newlines so the caption is matchable as one string. + return " ".join(cap.get().split()) + + @pytest.mark.unit + @pytest.mark.subsys_diagnostic + def test_caption_shown_when_error_findings_listed(self) -> None: + """High score + a visible error finding -> the bridging caption renders.""" + result = _VerdictResult( + quality=_Quality(display_score=8.5), + stats=_Stats(errors=2), + findings=(_Finding(severity="error"),), + ) + assert "still your worklist" in self._capture(result) + + @pytest.mark.unit + @pytest.mark.subsys_diagnostic + def test_caption_suppressed_when_no_error_findings_listed(self) -> None: + """Anon/free: stats count errors but they are gated out of the list -> no caption.""" + result = _VerdictResult( + quality=_Quality(display_score=8.5), + stats=_Stats(errors=2), # stats count gated errors + findings=(), # but nothing rendered in the worklist below + ) + out = self._capture(result) + assert "still your worklist" not in out diff --git a/tests/unit/test_strict_exit_and_exclude.py b/tests/unit/test_strict_exit_and_exclude.py new file mode 100644 index 00000000..3f3ff99b --- /dev/null +++ b/tests/unit/test_strict_exit_and_exclude.py @@ -0,0 +1,100 @@ +"""Regression tests for two 0.5.12 fixes lacking a natural home: strict-exit +path-form alignment and the `exclude_files` re-filter on the `@import` link-walk. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +@dataclass +class _Finding: + file: str + + +@dataclass +class _Result: + findings: tuple[_Finding, ...] + + +class TestStrictExitPathNormalization: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux + def test_strict_exits_on_user_scope_target_with_displayed_error(self, tmp_path: Path) -> None: + """Regression: `_should_exit_strict` keyed `capability_paths` with `_relativize_paths` + while the display filter used `normalize_finding_path`. For a user-scope (`~/...`) + target the two diverged, so a strict run could exit 0 despite a displayed error. + The membership must use the same normalization the finding path carries.""" + from reporails_cli.interfaces.cli.main import _should_exit_strict + + # A finding on a user-scope file, carrying the normalized `~/...` form. + user_file = Path.home() / ".claude" / "subagent_memory.md" + finding = _Finding(file="~/.claude/subagent_memory.md") + result = _Result(findings=(finding,)) + + assert _should_exit_strict(True, {user_file}, tmp_path, result) is True + + @pytest.mark.unit + @pytest.mark.subsys_cli_ux + def test_strict_no_exit_when_finding_outside_scope(self, tmp_path: Path) -> None: + from reporails_cli.interfaces.cli.main import _should_exit_strict + + user_file = Path.home() / ".claude" / "subagent_memory.md" + result = _Result(findings=(_Finding(file="other.md"),)) + assert _should_exit_strict(True, {user_file}, tmp_path, result) is False + + +class TestExcludeFilesDropsLinkWalked: + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_drop_excluded_filters_classified(self, tmp_path: Path) -> None: + """Regression: the @import link-walk re-discovered files agent discovery already + excluded. `_drop_excluded` re-applies exclude_files to the classified set.""" + from reporails_cli.core.lint.rule_runner import _drop_excluded + + @dataclass + class _CF: + path: Path + + keep = _CF(path=tmp_path / "CLAUDE.md") + drop = _CF(path=tmp_path / "VENDORED.md") + + out = _drop_excluded([keep, drop], ["VENDORED.md"], tmp_path) + + assert keep in out + assert drop not in out + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_drop_excluded_noop_without_patterns(self, tmp_path: Path) -> None: + from reporails_cli.core.lint.rule_runner import _drop_excluded + + @dataclass + class _CF: + path: Path + + items = [_CF(path=tmp_path / "a.md")] + assert _drop_excluded(items, None, tmp_path) == items + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_drop_excluded_keeps_explicitly_targeted_file(self, tmp_path: Path) -> None: + """An explicitly-targeted file matching an exclude glob is NOT dropped — naming a + file overrides the project exclusion. Regression: filtering the full classified + set returned a falsely-clean empty result for a file the user asked to validate.""" + from reporails_cli.core.lint.rule_runner import _drop_excluded + + @dataclass + class _CF: + path: Path + + explicit = _CF(path=tmp_path / "VENDORED.md") + walked = _CF(path=tmp_path / "other_VENDORED.md") + + out = _drop_excluded([explicit, walked], ["*VENDORED.md"], tmp_path, keep=[explicit.path]) + + assert explicit in out, "explicitly-targeted file was wrongly excluded" + assert walked not in out, "link-walked excluded file should still be dropped" diff --git a/tests/unit/test_suppression.py b/tests/unit/test_suppression.py new file mode 100644 index 00000000..b57ba749 --- /dev/null +++ b/tests/unit/test_suppression.py @@ -0,0 +1,153 @@ +"""Tests for core/lint/suppression.py — inline per-line finding suppression.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from reporails_cli.core.lint.suppression import ( + apply_suppressions, + build_index, + parse_directives, +) +from reporails_cli.core.platform.dto.models import LocalFinding +from reporails_cli.core.platform.runtime.merger import merge_results +from reporails_cli.formatters.text.display_constants import rule_aliases + + +class TestParseDirectives: + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_same_line_html_comment(self) -> None: + text = "Do the thing. \n" + assert parse_directives(text) == {1: {"CORE:C:0049"}} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_multiple_rules_space_and_comma(self) -> None: + text = "x\ny \n" + assert parse_directives(text) == {2: {"CORE:C:0049", "CORE:C:0046"}} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_slug_token_accepted(self) -> None: + text = "y \n" + assert parse_directives(text) == {1: {"italic-constraints"}} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_bare_directive_names_nothing(self) -> None: + # No rule named → targeted-only contract: suppress nothing. + assert parse_directives("y \n") == {} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_no_directive(self) -> None: + assert parse_directives("just prose\nmore prose\n") == {} + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + # Line 2: directive for CORE:C:0049. Line 4: same rule, no directive. + (tmp_path / "CLAUDE.md").write_text( + "# Title\nDescribe behavior. \nspacer\nAnother ambiguous instruction.\n", + encoding="utf-8", + ) + return tmp_path + + +def _result(project: Path): + findings = [ + LocalFinding("CLAUDE.md", 2, "warning", "CORE:C:0049", "ambiguous", source="client_check"), + LocalFinding("CLAUDE.md", 4, "warning", "CORE:C:0049", "ambiguous", source="client_check"), + ] + return merge_results([], findings, None, project_root=project) + + +class TestApplySuppressions: + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_suppressed_line_silent_sibling_still_fires(self, project: Path) -> None: + result = _result(project) + assert len(result.findings) == 2 + + out = apply_suppressions(result, project_root=project, alias_fn=rule_aliases) + + # Both directions: the annotated line is gone, the un-annotated sibling stays. + lines = sorted(f.line for f in out.findings) + assert lines == [4] + assert out.stats.total_findings == 1 + assert out.stats.warnings == 1 + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_unnamed_rule_on_line_does_not_suppress(self, project: Path) -> None: + # Directive names CORE:C:0049 only; a different rule on the same line still fires. + findings = [ + LocalFinding("CLAUDE.md", 2, "warning", "CORE:C:0046", "conflict", source="client_check"), + ] + result = merge_results([], findings, None, project_root=project) + out = apply_suppressions(result, project_root=project, alias_fn=rule_aliases) + assert len(out.findings) == 1 + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_canonical_id_directive_matches_client_token(self, tmp_path: Path) -> None: + # Author copies the displayed canonical ID; finding carries the raw client token. + (tmp_path / "CLAUDE.md").write_text( + "constraint first \n", encoding="utf-8" + ) + findings = [LocalFinding("CLAUDE.md", 1, "warning", "ordering", "order", source="client_check")] + result = merge_results([], findings, None, project_root=tmp_path) + out = apply_suppressions(result, project_root=tmp_path, alias_fn=rule_aliases) + assert out.findings == () + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_no_directive_file_unchanged(self, tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("plain prose\n", encoding="utf-8") + findings = [LocalFinding("CLAUDE.md", 1, "warning", "CORE:C:0049", "x", source="client_check")] + result = merge_results([], findings, None, project_root=tmp_path) + out = apply_suppressions(result, project_root=tmp_path, alias_fn=rule_aliases) + assert out is result + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_default_alias_is_exact_rule_token(self, project: Path) -> None: + # Without an alias_fn only the exact raw token matches. + result = _result(project) + out = apply_suppressions(result, project_root=project) + assert sorted(f.line for f in out.findings) == [4] + + +class TestBuildIndex: + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_missing_file_skipped(self, tmp_path: Path) -> None: + assert build_index(["does-not-exist.md"], tmp_path) == {} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_directive_line_tracks_import_expansion(self, tmp_path: Path) -> None: + """Regression: build_index parsed the raw file, but findings carry line numbers + from the import-EXPANDED content. With an `@import` above a directive, the two + diverged and the suppression silently missed. The index must key the directive + at its EXPANDED line so `(file, finding.line)` matches.""" + (tmp_path / "frag.md").write_text("imported line one\nimported line two\n", encoding="utf-8") + main = tmp_path / "CLAUDE.md" + # Directive is on raw line 2, but @frag.md expands to 2 lines, pushing it to + # expanded line 3. + main.write_text("@frag.md\nDo the thing. \n", encoding="utf-8") + + from reporails_cli.core.mapper.imports import expand_imports + + expanded = expand_imports(main.read_text(encoding="utf-8"), main) + expected_line = next(i for i, line in enumerate(expanded.splitlines(), start=1) if "ails-disable-line" in line) + assert expected_line != 2, "import expansion did not move the directive off its raw line" + + index = build_index(["CLAUDE.md"], tmp_path) + + assert ("CLAUDE.md", expected_line) in index, f"directive not keyed at expanded line: {index}" + assert index[("CLAUDE.md", expected_line)] == {"CORE:C:0049"} + assert ("CLAUDE.md", 2) not in index, "directive keyed at the raw line (pre-expansion)" diff --git a/uv.lock b/uv.lock index ec6f3c94..555670df 100644 --- a/uv.lock +++ b/uv.lock @@ -1531,7 +1531,7 @@ wheels = [ [[package]] name = "reporails-cli" -version = "0.5.11" +version = "0.5.12" source = { editable = "." } dependencies = [ { name = "httpx" },