diff --git a/.reporails/backbone.yml b/.ails/backbone.yml similarity index 66% rename from .reporails/backbone.yml rename to .ails/backbone.yml index e7ff0d3b..7e189248 100644 --- a/.reporails/backbone.yml +++ b/.ails/backbone.yml @@ -1,17 +1,20 @@ version: 3 generator: manual +auto_heal: true +directive: >- + If any path, command, or classification in this file does not + match the project when accessed, update the entry and continue. -depends_on: - rules: - schemas: [rule, agent, package, levels] - artifacts: [levels, capability_levels] +depends_on: {} agents: claude: main_instruction_file: CLAUDE.md - config: .claude/mcp.json + config: .claude/settings.json + mcp: .claude/mcp.json skills: .claude/skills/ rules: .claude/rules/ + sub_agents: .claude/agents/ modules: core: src/reporails_cli/core/ @@ -19,9 +22,12 @@ modules: formatters: src/reporails_cli/formatters/ bundled: src/reporails_cli/bundled/ templates: src/reporails_cli/templates/ + rules: framework/rules/ + schemas: framework/schemas/ + bundled: - capability_patterns: src/reporails_cli/bundled/capability-patterns.yml + project_types: src/reporails_cli/bundled/project-types.yml artifacts: changelog: UNRELEASED.md @@ -39,8 +45,8 @@ meta: version_file: VERSION skills: - check: - path: .claude/skills/check/ + ails: + path: .claude/skills/ails/ entry: SKILL.md qa: path: .claude/skills/qa/ @@ -51,3 +57,6 @@ skills: add-changelog-entry: path: .claude/skills/add-changelog-entry/ entry: SKILL.md + bootstrap: + path: .claude/skills/bootstrap/ + entry: SKILL.md diff --git a/.ails/config.yml b/.ails/config.yml new file mode 100644 index 00000000..b0af6912 --- /dev/null +++ b/.ails/config.yml @@ -0,0 +1,14 @@ +default_agent: claude +exclude_dirs: + - fixtures + - research + - _archive + - _archived + - .venv + - docs + - framework + - .mypy_cache + - .ruff_cache + - .pytest_cache +disabled_rules: + diff --git a/.claude/rules/checks-readonly.md b/.claude/rules/checks-readonly.md index 2dbdbd71..a9172124 100644 --- a/.claude/rules/checks-readonly.md +++ b/.claude/rules/checks-readonly.md @@ -1,11 +1,12 @@ --- +description: Bundled config protection — never modify without explicit instruction paths: ["src/reporails_cli/bundled/**"] --- # Bundled Config Files -NEVER modify bundled config files without explicit human instruction. +Leave `src/reporails_cli/bundled/capability-patterns.yml` unchanged unless the user explicitly asks for a modification. This file contains regex patterns for agent capability detection — it is CLI-owned orchestration logic, not a framework rule file under `framework/rules/`. -- `capability-patterns.yml` — Regex patterns for capability detection +## Discovery -These are CLI-owned orchestration logic, not framework rules. \ No newline at end of file +Applies to files matching `src/reporails_cli/bundled/**` via `paths` frontmatter. Loaded automatically by Claude Code from `.claude/rules/`. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 6d03542e..131665d2 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -1,15 +1,17 @@ --- +description: User-facing documentation — copy-pasteable commands, no aspirational content paths: ["docs/*.md", "README.md"] --- # User-Facing Documentation -These files are read by people, not coding agents. +Write `docs/*.md` and `README.md` for people, not coding agents. Commands must be copy-pasteable and working — use `ails check .` not `reporails check .`. Examples must reflect actual CLI behavior from `uv run ails --help`. -- These files are for user-facing documentation, not AI agent instructions -- Commands must be copy-pasteable and working (`ails check .`, not `reporails check .`) -- Examples must reflect actual CLI behavior -- Only document features that exist; remove references to unimplemented features -- Keep installation/usage sections current with pyproject.toml -- Use consistent terminology: "rules" not "checks" in user-facing text -- When fixing errors, make targeted edits — don't rewrite sections and lose valid content +- Keep installation and usage sections current with `pyproject.toml` version and dependencies +- Use "rules" not "checks" in user-facing text — consistent with CLI output from `uv run ails check` +- Only document features that exist in `src/reporails_cli/`. Remove references to unimplemented features. +- When fixing errors, make targeted edits — rewriting entire sections risks losing valid content + +## Discovery + +Applies to files matching `docs/*.md` and `README.md` via `paths` frontmatter. Loaded automatically by Claude Code from `.claude/rules/`. diff --git a/.claude/rules/instruction-file-style.md b/.claude/rules/instruction-file-style.md index 66098db0..b7a7d3a2 100644 --- a/.claude/rules/instruction-file-style.md +++ b/.claude/rules/instruction-file-style.md @@ -1,13 +1,14 @@ --- +description: Instruction file style — actionable content only, no meta-commentary paths: ["CLAUDE.md", ".claude/rules/**"] --- # Instruction File Style -When writing or editing CLAUDE.md or .claude/rules/*.md files: +Write `CLAUDE.md` and `.claude/rules/*.md` files for AI agent behavior, not user-facing documentation. Every line should be a directive (`Use ruff for formatting`), a constraint (`Do NOT modify .env`), or a concrete reference (`src/reporails_cli/core/pipeline.py`). Actionable content gives the model something to anchor on. -- These files are for AI agent behavior, not user-facing documentation -- State facts, commands, rules only -- No meta-commentary ("this section explains...", "as mentioned above...") -- Every line MUST be actionable or informative — passive filler wastes context tokens -- Avoid redundant context that wastes tokens \ No newline at end of file +Remove meta-commentary like "this section explains..." or "as mentioned above..." from instruction files. Remove redundant context that restates what the file already says. These patterns waste context tokens without adding coupling. + +## Discovery + +Applies to files matching `CLAUDE.md` and `.claude/rules/**` via `paths` frontmatter. Loaded automatically by Claude Code from `.claude/rules/`. diff --git a/.claude/rules/specs.md b/.claude/rules/specs.md index c512413d..0ce8df7c 100644 --- a/.claude/rules/specs.md +++ b/.claude/rules/specs.md @@ -1,13 +1,14 @@ --- +description: Spec file accuracy — specs must match implementation paths: ["docs/specs/**"] --- # Specification Files -Specs must accurately reflect the implementation. +Update `docs/specs/*.md` when changing behavior in `src/reporails_cli/core/`. Function signatures, return types, and interfaces in specs must match what `checks.py`, `rule_runner.py`, and `content_checker.py` actually implement. -- Update specs when changing code behavior -- Function signatures, return types, and interfaces must match code -- Remove features from specs when removing from code -- Add features to specs when adding to code -- No aspirational content - document what exists, not what might exist \ No newline at end of file +Remove features from `docs/specs/` when removing them from `src/`. Add features to specs when adding them to code. Specs document what exists in the current codebase — not aspirational features or planned work. + +## Discovery + +Applies to files matching `docs/specs/**` via `paths` frontmatter. Loaded automatically by Claude Code from `.claude/rules/`. diff --git a/.claude/rules/writing-rules.md b/.claude/rules/writing-rules.md index c24237c5..829bc70a 100644 --- a/.claude/rules/writing-rules.md +++ b/.claude/rules/writing-rules.md @@ -1,10 +1,11 @@ --- +description: Rule file authoring — format, scope, and constraints for .claude/rules/ paths: [".claude/rules/**"] --- # Writing Rule Files -Rule files in `.claude/rules/` are loaded into Claude's context. +Rule files in `.claude/rules/` are loaded into Claude's context at session start. ## Format @@ -21,7 +22,11 @@ paths: ["src/**/*.py"] # Optional: scope to specific files ## Constraints -- One concern per file (e.g., access control separate from styling) -- Keep under 500 lines — everything consumes tokens -- Use descriptive filenames (`api-validation.md` not `rules1.md`) -- Add `paths` frontmatter to scope rules to relevant files; omit for global rules +- Keep each `.claude/rules/*.md` file focused on one concern — access control separate from styling, testing separate from documentation +- Keep files under 500 lines — every line consumes context tokens +- Use descriptive filenames like `api-validation.md` not `rules1.md` +- Add `paths` or `globs` frontmatter to scope rules to relevant files under `src/`, `tests/`, or `docs/`. Omit for global rules. + +## Discovery + +Applies to files matching `.claude/rules/**` via `paths` frontmatter. Loaded automatically by Claude Code from `.claude/rules/`. diff --git a/.claude/settings.json b/.claude/settings.json index d72f593e..7e927ccd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,7 +25,7 @@ "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path // .tool_input.filePath // empty' | grep -q 'src/reporails_cli/bundled/' && echo '{\"decision\": \"block\", \"reason\": \"Bundled config files (levels.yml, capability-patterns.yml) must not be modified without explicit human instruction. These are CLI-owned orchestration logic.\"}' && exit 2 || exit 0" + "command": "jq -r '.tool_input.file_path // .tool_input.filePath // empty' | grep -q 'capability-patterns\\.yml' && echo '{\"decision\": \"block\", \"reason\": \"capability-patterns.yml is CLI-owned orchestration logic. Do not modify without explicit human instruction.\"}' && exit 2 || exit 0" } ] } diff --git a/.claude/skills/add-changelog-entry/SKILL.md b/.claude/skills/add-changelog-entry/SKILL.md index 395524dd..c5cdc361 100644 --- a/.claude/skills/add-changelog-entry/SKILL.md +++ b/.claude/skills/add-changelog-entry/SKILL.md @@ -5,57 +5,27 @@ description: Add a changelog entry to UNRELEASED.md # /add-changelog-entry -Automatically add a changelog entry to PROJECT_ROOT/UNRELEASED.md. - -## Instructions - -1. Check git diff or recent file modifications -2. Determine the area from the file path: - - interfaces/cli/ → [CLI] - - core/ → [CORE] - - bundled/ → [BUNDLED] - - formatters/ → [FORMATTERS] - - README.md → [DOCS] - - CLAUDE.md, backbone.yml, .claude/, .reporails/ → [META] -3. Determine the category: - - New files/content → Added - - Modified existing → Changed - - Marked as deprecated/obsolete → Deprecated - - Removed content → Removed - - Bug fixes → Fixed - - Security-related changes → Security +Add a changelog entry to `UNRELEASED.md` based on recent changes. + +## Process + +1. Run `git diff` or check recent file modifications to determine what changed +2. Determine the **area** from the file path: + - `src/reporails_cli/interfaces/cli/` → `[CLI]` + - `src/reporails_cli/core/` → `[CORE]` + - `src/reporails_cli/bundled/` → `[BUNDLED]` + - `src/reporails_cli/formatters/` → `[FORMATTERS]` + - `README.md`, `docs/` → `[DOCS]` + - `CLAUDE.md`, `.ails/backbone.yml`, `.claude/` → `[META]` +3. Determine the **category**: Added (new files/content), Changed (modified existing), Deprecated, Removed, Fixed (bug fixes), Security 4. Write a concise description (3-7 words) -5. Append to UNRELEASED.md under the correct category section -6. Create the category section if it doesn't exist +5. Append to `UNRELEASED.md` under the correct `### [Category]` section — create the section if it doesn't exist ## Format ```markdown ### [Category] -- [Area]: [Description] -``` - -## Categories - -Added, Changed, Deprecated, Removed, Fixed, Security - -## Areas - -- [CLI] – CLI interface (interfaces/cli/) -- [CORE] – Core domain logic -- [BUNDLED] – Bundled config (levels.yml, capability-patterns.yml) -- [FORMATTERS] – Output formatters -- [DOCS] – README, general documentation -- [META] – CLAUDE.md, backbone.yml, repo structure - -## Example - -```markdown -### Added -- [CORE]: Semantic rule caching support - -### Changed -- [FORMATTERS]: Updated compact output format +- [Area]: Description ``` -Do not ask for confirmation. Just do it. +Append directly without asking for confirmation. diff --git a/.claudeignore b/.claudeignore index 23d0b2cc..ae55970a 100644 --- a/.claudeignore +++ b/.claudeignore @@ -27,6 +27,11 @@ build/ # IDE & editor .idea/ +# Research corpus — contains 4600+ CLAUDE.md and 6700+ .claude/rules/ files +# from third-party repos. Without this exclusion, Claude Code loads them as +# project instructions, contaminating every session. +research/data/corpus/ + # Environment & secrets .env .env.local diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0d8981..cc6b09eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,5 @@ jobs: - name: Unit tests run: uv run poe test_unit - - name: Download rules - run: uv run python -c "from reporails_cli.core.init import download_rules, download_recommended; download_rules(); download_recommended()" - - name: Integration tests run: uv run poe test_integration diff --git a/.github/workflows/post-publish-smoke.yml b/.github/workflows/post-publish-smoke.yml new file mode 100644 index 00000000..93d3475e --- /dev/null +++ b/.github/workflows/post-publish-smoke.yml @@ -0,0 +1,103 @@ +name: Post-publish smoke test + +on: + workflow_run: + workflows: ["Release"] + types: [completed] + +jobs: + smoke-pypi: + name: "Smoke: pip install from PyPI" + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Wait for PyPI propagation + run: | + for i in 1 2 3 4 5 6; do + if pip install reporails-cli --dry-run --quiet 2>/dev/null; then break; fi + echo "Waiting for PyPI propagation (attempt $i)..." + sleep 30 + done + + - name: Install from PyPI + run: pip install reporails-cli + + - name: Verify ails command exists + run: | + which ails + ails version + + - name: Verify ONNX model bundled + run: | + python -c " + from reporails_cli.bundled import get_models_path + onnx = get_models_path() / 'minilm-l6-v2' / 'onnx' / 'model.onnx' + assert onnx.exists(), f'ONNX model missing: {onnx}' + print(f'OK: {onnx.stat().st_size / 1024 / 1024:.0f} MB') + " + + - name: Smoke check + run: | + mkdir /tmp/smoke-project + echo "# Test Project" > /tmp/smoke-project/CLAUDE.md + ails check /tmp/smoke-project -f json + + smoke-uvx: + name: "Smoke: uvx ephemeral install" + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Wait for PyPI propagation + run: | + for i in 1 2 3 4 5 6; do + if uvx reporails-cli version 2>/dev/null; then break; fi + echo "Waiting for PyPI propagation (attempt $i)..." + sleep 30 + done + + - name: Verify uvx runs ails + run: uvx reporails-cli version + + - name: Smoke check + run: | + mkdir /tmp/smoke-project + echo "# Test Project" > /tmp/smoke-project/CLAUDE.md + uvx reporails-cli check /tmp/smoke-project -f json + + smoke-npx: + name: "Smoke: npx @reporails/cli" + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Wait for npm propagation + run: | + for i in 1 2 3 4 5 6; do + if npx --yes @reporails/cli version 2>/dev/null; then break; fi + echo "Waiting for npm propagation (attempt $i)..." + sleep 20 + done + + - name: Verify npx runs ails + run: npx --yes @reporails/cli version + + - name: Smoke check + run: | + mkdir /tmp/smoke-project + echo "# Test Project" > /tmp/smoke-project/CLAUDE.md + npx --yes @reporails/cli check /tmp/smoke-project -f json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 112c9d39..45caf8f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,6 @@ jobs: - name: Unit tests run: uv run poe test_unit - - name: Download rules - run: uv run python -c "from reporails_cli.core.init import download_rules, download_recommended; download_rules(); download_recommended()" - - name: Integration tests run: uv run poe test_integration @@ -85,9 +82,30 @@ jobs: --title "v$VERSION" \ --generate-notes + - name: Install dependencies + run: uv sync + + - name: Fetch bundled ONNX model + run: uv run poe fetch_bundled_model + - name: Build package run: uv cache clean reporails-cli && uv build + - name: Verify wheel install + run: | + python -m venv /tmp/verify-wheel + /tmp/verify-wheel/bin/pip install dist/*.whl --quiet + /tmp/verify-wheel/bin/ails version + /tmp/verify-wheel/bin/ails check --help > /dev/null + # Verify ONNX model is bundled (content checks need it) + /tmp/verify-wheel/bin/python -c " + from reporails_cli.bundled import get_models_path + onnx = get_models_path() / 'minilm-l6-v2' / 'onnx' / 'model.onnx' + assert onnx.exists(), f'ONNX model missing from wheel: {onnx}' + print(f'ONNX model verified: {onnx.stat().st_size / 1024 / 1024:.0f} MB') + " + echo "Wheel verification passed" + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 @@ -117,3 +135,5 @@ jobs: - name: Publish to npm run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index afca2891..2fca61c2 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -5,7 +5,7 @@ name: Test Action on: push: - branches: [main, 0.4.0] + branches: [main, "[0-9]*.[0-9]*.[0-9]*"] pull_request: branches: [main] @@ -60,7 +60,7 @@ jobs: - uses: actions/checkout@v4 - uses: ./action with: - min-score: "5" + min-score: "1" from-source: "true" # --- Fail scenarios --- diff --git a/.gitignore b/.gitignore index 26044d18..9b7f0510 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,41 @@ .idea .venv __pycache__ +.ails/maps # Framework docs (symlinked from framework repo) checks/*/*.md docs/capability-levels.md -docs/research +# Docs - WIP / internal (specs and infra are tracked) +docs/tmp/ +docs/ +docs/plans/ +docs/cms/ +docs/cli-migration-guide.md +research/ -# Reporails caches (backbone.yml is committed, caches are not) -.reporails/.cache/ -# Prevent stray .reporails in subdirectories -*/.reporails/ +# Project caches (backbone.yml is committed, caches are not) +.ails/.cache/ +# Prevent stray .ails in subdirectories +*/.ails/ .env # Claude Code (machine-specific) +.claude/plans/ .claude/mcp.json .claude/settings.local.json +packages/npm/.claude/settings.local.json .mcp.json -# Development internals (functional locally, not public) -docs/specs/ +# Development internals CLAUDE.md .claude/rules/ -.claude/settings.json .claude/hooks/ .claude/skills/ +/framework/rules/_archive/ + +# Bundled ONNX embedding model — fetched by scripts/fetch_bundled_model.py +# (dev-only, not committed; populated on clone and in CI before hatch build) +src/reporails_cli/bundled/models/ diff --git a/.reporails/config.yml b/.reporails/config.yml deleted file mode 100644 index 02c3be31..00000000 --- a/.reporails/config.yml +++ /dev/null @@ -1,6 +0,0 @@ -default_agent: claude -exclude_dirs: - - fixtures -disabled_rules: - - RRAILS:C:0005 - - CORE:G:0001 diff --git a/.semgrepignore b/.semgrepignore deleted file mode 100644 index 1c0b2a5c..00000000 --- a/.semgrepignore +++ /dev/null @@ -1,51 +0,0 @@ -# Default semgrepignore for reporails -# Reduces scan time by excluding common non-instruction directories - -# Package managers -node_modules/ -vendor/ -bower_components/ - -# Build outputs -dist/ -build/ -out/ -target/ -*.min.js -*.min.css -*.bundle.js - -# Python -__pycache__/ -*.pyc -.venv/ -venv/ -.tox/ -.eggs/ -*.egg-info/ - -# Version control -.git/ - -# Coverage and testing -coverage/ -.coverage -htmlcov/ -.pytest_cache/ -.nyc_output/ - -# IDE and editors -.idea/ -.vscode/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Large binary files -*.wasm -*.so -*.dylib -*.dll diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da4a888..30610c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Changelog +## 0.5.0 + +### Self-contained install + +Rules, schemas, and agent configs are now bundled inside the Python wheel. `ails check` works immediately after `pip install reporails-cli` — no `ails install` step, no external rules download. The 222 bundled rule files ship as package data via hatch `force-include`. The separate `rules/` repo is no longer a runtime dependency. + +### New pipeline architecture + +The check pipeline was rebuilt from scratch: discover files → run mechanical probes → map instruction content → run client checks → merge results. Findings from all sources converge into a single `CombinedResult` with normalized file paths, deduplication, and per-file grouping. The old `engine.py` / `pipeline.py` / `scorer.py` stack is removed. + +### ONNX embeddings (no torch) + +`sentence-transformers` and PyTorch are replaced by a bundled ONNX export of `all-MiniLM-L6-v2` loaded via `onnxruntime` + `tokenizers`. The embedding output is bit-identical to the PyTorch baseline. A `sys.meta_path` import hook blocks `torch` from loading through spaCy's thinc backend, eliminating a 20-second cold-start penalty. The installed venv footprint drops by several hundred MB. + +### Mapper daemon + +A persistent background process (`ails daemon start`) keeps the embedding model loaded between runs. The daemon binds its Unix socket before model warmup and warms in a background thread, so cache-hit requests return instantly. Per-file embedding results are cached by content hash. Idle timeout is 1 hour (configurable via `AILS_DAEMON_IDLE_S`). + +### Content-quality checks + +25 rules migrated from regex pattern matching to atom-based content queries. The mapper classifies each instruction into atoms with charge (directive/constraint/neutral/ambiguous), modality, and specificity. Content queries like `has_non_italic_constraints`, `has_mermaid_blocks`, and `has_charged_headings` run against the atom map. A new `heading-as-instruction` rule flags headings that carry charge instead of organizing content. + +### Heal command + +`ails heal [PATH]` auto-fixes instruction file issues. Four mechanical fixers operate at the atom level: backtick wrapping for code constructs, bold→italic on constraints, full-sentence italic, and charge ordering. Reports remaining violations after fixes. Available as both CLI command and MCP tool. + +### File type classification + +Agent configs define file types with properties (format, cardinality, loading, scope, precedence). Rules declare which file types they target via `match: {type: ...}`. Rules that target a file type not present in the project are silently skipped — no false positives from missing surfaces. Project level is emergent from file type property coverage instead of a stored `level:` field. + +### Inline import expansion + +The mapper expands `@path` inline imports before tokenization. Claude Code and Gemini CLI splice imported file content at the reference position — the mapper sees the same expanded content. Resolves relative to importing file, expands `~/`, recurses up to 5 hops, detects circular imports. + +### External file discovery + +Agent configs can reference external paths (`~/...`, `/absolute/...`). Auto-memory files (`~/.claude/projects/*/memory/MEMORY.md`), user-level rules, and managed policies are now part of the instruction surface. Memory index validation catches broken links and missing frontmatter. + +### Redesigned output + +Text output redesigned — "Reporails — Diagnostics" header with file type breakdown and instruction counts (directive/constraint/ambiguous). Files grouped by type in bordered cards, sorted worst-first. Scorecard at the bottom with score bar, agent, scope, and results. JSON output grouped by file with `fix` field. GitHub formatter emits annotations with JSON summary on the last line. + +### Stopwords tooling + +`ails stopwords extract` parses alternation patterns from `checks.yml` into `vocab.yml` term lists. `ails stopwords sync` compiles terms back into patterns (with `--dry-run`). Staleness detection flags drift between vocab.yml and checks.yml. + +### Breaking changes + +- Level labels renamed: Organized→Structured, Distributed→Substantive, Contextual→Actionable, Extensible→Refined, Governed→Adaptive +- `Rule.targets` string replaced by `Rule.match` (FileMatch dataclass) with `type`, `format`, and property filters +- `rule.yml` renamed to `checks.yml` with `checks:` top-level key +- Severity moved from Check to Rule level +- Removed commands: `update`, `sync`, `topo`, `lint`, `dismiss`, `judge` +- Removed flags: `--experimental`, `--no-update-check`, `-q` +- Removed output formats: `compact`, `brief` +- `--strict` now exits 1 on any finding (was errors only) +- Project config directory renamed from `.reporails/` to `.ails/` +- JSON output schema changed: `files`/`stats` replaces `score`/`level`/`violations` + +### Bug fixes + +- Deterministic checks grouped by `rule.match.type` — rules with `match: {type: scoped_rule}` no longer fire on main files, eliminating ~215 false positives +- File path normalization unifies paths from all three sources (mechanical, client, server) to project-relative, fixing 60+ → 31 file key fragmentation in JSON output +- `expect: present` regex semantics inverted — was reporting matches as violations +- Duplicate findings from mechanical checks processed as regex eliminated (390 empty-message findings) +- Rich `MarkupError` crash on severity values and bracket characters in rule IDs +- Daemon JSON round-trip preserving all Atom fields +- `file_absent` false positives when match_type is set but no files of that type are classified +- Regex timeout (500ms) guards against catastrophic backtracking +- Graceful fallback when ONNX model is not bundled (CI/from-source installs) +- Score returns 0.0 instead of 10.0 when no rules checked (L0) + +### GitHub Action + +Action updated for the new pipeline. `parse_result.py` computes score, level, and violation count from the `CombinedResult` JSON. Invalid flags (`--no-update-check`, `-q`) removed. `--exclude-dir` corrected to `--exclude-dirs`. + +### Dependencies + +- Rules bundled (no external framework dependency) +- `onnxruntime>=1.18,<2`, `tokenizers>=0.19,<1` (replaces sentence-transformers + torch) +- `spacy>=3.8.11,<4` with `en_core_web_sm-3.8.0` +- `numpy>=1.26,<3` + ## 0.4.0 ### Multi-agent support diff --git a/CLAUDE.md b/CLAUDE.md index bb601a69..993aef90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,38 +1,37 @@ # Reporails CLI -- AI instruction validator and quality assurance provider -- Validates instruction files against deterministic, mechanical, and semantic rules -- Pure Python regex engine (no external binary dependencies) +AI instruction validator — validates instruction files against mechanical, deterministic, and content_query rules using a pure Python regex engine. ## Session Start -- Read `.reporails/backbone.yml` for project structure -- Read `docs/specs/arch.md` for architecture decisions - -## Roles - -- **You**: modify source, run tests, read specs -- **CLI end users**: install package, run `ails check` -- **MCP end users**: use reporails via Claude Code -- Treat each role separately when discussing features, delivery, or documentation +Read `.ails/backbone.yml` for project structure and agent registry. Read `docs/specs/` for architecture decisions before modifying `src/reporails_cli/core/` modules. Specs document the tradeoffs behind current designs and prevent re-solving settled questions. ## Commands - `uv sync` — install dependencies -- `uv run ails install` — install MCP server for detected agents -- `uv run ails check` — validate instruction files -- `uv run ails check -f json` — JSON output +- `uv run ails check` — validate instruction files (`-f json` for machine-readable output) - `uv run ails heal` — interactive auto-fix -- `uv run ails map . --save` — save backbone.yml +- `uv run ails map . --save` — regenerate `backbone.yml` ## Testing - `uv run poe qa_fast` — lint + type check + unit tests (pre-commit gate) -- `uv run poe qa` — full QA including integration and smoke tests -- Unit tests in `tests/unit/`, integration tests in `tests/integration/`, smoke in `tests/smoke/` -- Test files named `test_*.py`, test functions prefixed `test_` -- Use `pytest` fixtures from `conftest.py` for shared setup -- NEVER modify golden fixtures; update the corresponding expected output instead +- `uv run poe qa` — full suite including `tests/integration/` and `tests/smoke/` +- Test files named `test_*.py` with `test_` prefixed functions, using `pytest` fixtures from `conftest.py` for shared setup + +## Conventions + +- Use `uv run python` to invoke Python — the project virtualenv managed by `uv` has the correct dependencies (`numpy`, `scipy`, `networkx`). Global `python` or `python3` will miss them. +- Use `ruff` for formatting and linting +- Use full rule IDs like `CORE:C:0004` in code and config — not abbreviated forms like `C4` +- Prefer `dataclasses` for data models in `src/reporails_cli/core/pipeline.py` and `src/reporails_cli/core/models.py` +- Keep modules focused on one concern — domain logic in `core/`, entry points in `interfaces/`, output in `formatters/` + +## Boundaries + +- Scope searches to `src/` or `tests/` using `Grep --type py` for Python files and `Glob "src/**/*.py"` for file discovery. Targeted searches return relevant results faster than broad scans. *Do NOT `grep` the entire repo.* +- Read `docs/specs/*.md` before modifying `src/reporails_cli/core/` modules. Specs contain design constraints that aren't visible in the code alone. +- Sensitive file restrictions (`.env`, `credentials*`, `*.pem`) are in `.claude/rules/sensitive-files.md` ## Architecture @@ -45,34 +44,13 @@ src/reporails_cli/ action/ # GitHub Actions composite action ``` -- Path-scoped rules in `.claude/rules/` — see those files for context-specific constraints -- See `docs/specs/arch.md` for full architecture - -## Conventions - -- Requires Python >=3.10 with type annotations on public APIs -- Use `ruff` for formatting and linting -- Module layout: domain logic in `core/`, entry points in `interfaces/`, output in `formatters/` -- Prefer dataclasses for data models (`pipeline.py`, `models.py`) -- Keep modules focused — one concern per file -- Use full rule IDs in code and config (e.g., `CORE:C:0004`, not `C4`) -- When fixing bugs, explain the root cause and why the fix works -- When making architectural decisions, document the tradeoffs considered - -## Boundaries - -- NEVER read or modify sensitive files (`.env`, `credentials*`, `*.pem`); ask the user instead -- NEVER grep the entire repo; scope searches to `src/` or `tests/` instead -- ALWAYS read specs (`docs/specs/*.md`) before modifying core modules -- Prefer reading specific files over broad glob patterns -- Use `Grep --type py` for Python-specific searches -- Use `Glob "src/**/*.py"` to find Python files +Path-scoped rules in `.claude/rules/` provide context-specific constraints loaded automatically by Claude Code. ## Skills -| Skill | Purpose | -|-------|---------| -| `/check` | Self-validate this project's instruction files | -| `/qa` | Run the full QA suite | -| `/plan-feature` | Plan implementation of a new feature | -| `/add-changelog-entry` | Add an entry to UNRELEASED.md | \ No newline at end of file +| Skill | Purpose | +|------------------------|------------------------------------------------| +| `/check` | Self-validate this project's instruction files | +| `/qa` | Run the full QA suite | +| `/plan-feature` | Plan implementation of a new feature | +| `/add-changelog-entry` | Add an entry to `UNRELEASED.md` | diff --git a/LICENSE b/LICENSE index ef77c199..12509938 100644 --- a/LICENSE +++ b/LICENSE @@ -4,7 +4,10 @@ Licensor: Reporails Licensed Work: Reporails CLI (ails) Additional Use Grant: You may use the Licensed Work for any purpose other than offering - a competing AI instruction validation product or service. + a competing AI instruction management product or service, including + but not limited to validation, analysis, optimization, governance, + scoring, monitoring, and drift detection of AI agent instruction + files and configurations. Change Date: Three years from the date of each release of the Licensed Work. Change License: Apache License, Version 2.0 diff --git a/README.md b/README.md index 6ac94988..c4a42d81 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,57 @@ # Reporails CLI -Score your CLAUDE.md files. See what's missing. Improve your AI coding setup. -[Why this exists](https://dev.to/cleverhoods/claudemd-lint-score-improve-repeat-2om5) +AI instruction diagnostics for coding agents. Validates instruction files for Claude, Codex, Copilot, Gemini, and Cursor against 90+ deterministic rules. -### Pre-1.0 — moving fast, API still evolving, feedback welcome. +### Beta — limited 100 spots, free until GA. Moving fast, feedback welcome. ## Quick Start -### MCP setup (recommended) - ```bash -uvx reporails-cli install +npx @reporails/cli check # or -npx @reporails/cli install +uvx reporails-cli check ``` -This detects agents in your project and writes the MCP config. Restart your editor — you'll get validation, scoring, and semantic evaluation via MCP tools. - -### CLI-only +No install needed. Or install globally: ```bash -uvx reporails-cli check +npm install -g @reporails/cli # adds `ails` to PATH # or -npx @reporails/cli check +pip install reporails-cli # same, via Python ``` -You'll get a score, capability level, and actionable violations: +Then just: +```bash +ails check ``` -╔══════════════════════════════════════════════════════════════╗ -║ ║ -║ SCORE: 8.1 / 10 (awaiting semantic) ║ -║ LEVEL: Maintained (L5) ║ -║ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ ║ -║ ║ -╚══════════════════════════════════════════════════════════════╝ - -Violations: - CLAUDE.md (7 issues) - ○ :1 No NEVER or AVOID statements found RRAILS:C:0003 - · :1 No version or date marker found CORE:C:0012 - ... + +You'll get a score, level, and actionable findings: + +``` +Reporails — Diagnostics + + ┌─ Main (1) + │ CLAUDE.md 12 dir / 5 con · 60% prose + │ ⚠ L1 No NEVER or AVOID statements found CORE:C:0003 + │ ○ L1 No version or date marker found CORE:C:0012 + │ + └─ 3 findings + + ── Summary ────────────────────────────────────────────── + + Score: 7.2 / 10 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ (0.3s) + Agent: Claude + + Scope: + capabilities: 1 main + instructions: 12 directive / 5 prose (28%) + 5 constraint + + 3 findings · 0 errors · 2 warnings · 1 info + + Full diagnostics free for the first 100 registering users during beta + ails auth login ``` Fix the issues, run again, watch your score improve. @@ -48,68 +59,48 @@ Fix the issues, run again, watch your score improve. ## Install ```bash -pip install reporails-cli -# or +# Node.js (recommended — no separate Python install needed) npm install -g @reporails/cli + +# Python +pip install reporails-cli + +# Zero install (ephemeral, always latest) +npx @reporails/cli check +uvx reporails-cli check ``` -This adds `ails` to your PATH. All commands below assume a global install. +All paths add `ails` to your PATH. The npm package auto-installs `uv` if needed — no Python install required. -Depends on your install path: +## Authentication -- **uvx/pip**: [uv](https://docs.astral.sh/uv/) — no separate Python install needed -- **npx/npm**: Node.js >= 18 — uv is auto-installed if missing -- **MCP install**: No dependencies — `ails install` writes config files directly +Free offline diagnostics work without an account. For server-enhanced diagnostics (cross-file analysis, reinforcement detection, compliance scoring), sign up for the beta: -## Commands +```bash +ails auth login # GitHub Device Flow — authorize in browser +ails auth status # Check your current auth state +ails auth logout # Remove stored credentials +``` -### Validate +Credentials are stored in `~/.reporails/credentials.yml`. + +## Commands ```bash -ails check # Score your setup +ails check # Validate your instruction files ails check -f json # JSON output ails check -f github # GitHub Actions annotations -ails check --strict # Exit 1 if violations -ails check --agent claude # Agent-specific rules -ails check --experimental # Include experimental rules +ails check --strict # Exit 1 if violations found +ails check --agent claude # Agent-specific rules only ails check --exclude-dir vendor # Exclude directory from scanning ails check -v # Verbose: per-file PASS/FAIL with rule titles -ails check --no-update-check # Skip pre-run update prompt -``` - -### Fix -```bash -ails heal # Auto-fix violations -ails heal -f json # JSON output for agents and scripts -ails explain CORE:S:0001 # Explain a rule -ails dismiss CORE:C:0001 # Dismiss a semantic finding -``` - -### Configure - -```bash +ails explain CORE:S:0001 # Explain a specific rule +ails heal # Interactive auto-fix for violations ails install # Install MCP server for detected agents -ails config set default_agent claude # Set default agent -ails config set --global default_agent claude # Set global default -ails config get default_agent # Show current value -ails config list # Show all config (project + global) -ails map # Show project structure -ails map --save # Generate backbone.yml +ails version # Show version info ``` -### Update - -```bash -ails update # Update rules framework + recommended -ails update --check # Check for updates without installing -ails update --recommended # Update recommended rules only -ails update --force # Force reinstall even if current -ails update --cli # Upgrade the CLI package itself -``` - -Before each scan, the CLI checks for available updates and prompts to install. Use `--no-update-check` to skip. Ephemeral runners (`uvx`, `npx`) always use the latest version automatically. - ### Exit codes | Code | Meaning | @@ -118,16 +109,26 @@ Before each scan, the CLI checks for available updates and prompts to install. U | 1 | Violations found (strict mode) | | 2 | Invalid input (bad path, unknown agent/format/rule) | +## Supported Agents + +| Agent | Instruction files | +|-------|-------------------| +| Claude | `CLAUDE.md`, `.claude/rules/*.md`, `.claude/skills/*/SKILL.md` | +| Codex | `AGENTS.md`, `CODEX.md`, `agents/*.md` | +| Copilot | `copilot-instructions.md`, `.github/copilot-instructions.md` | +| Gemini | `GEMINI.md`, `.gemini/rules/*.md` | +| Cursor | `.cursorrules`, `.cursor/rules/*.md` | + +The CLI auto-detects which agents are present in your project. + ## Configuration -Project config in `.reporails/config.yml`: +Project config in `.ails/config.yml`: ```yaml default_agent: claude # Default agent (run: ails config set default_agent claude) exclude_dirs: [vendor, dist] # Directories to skip disabled_rules: [CORE:C:0010] # Rules to disable -experimental: false # Include experimental rules -recommended: true # Include recommended rules (RRAILS_ namespace) ``` Set values via CLI: `ails config set ` @@ -137,14 +138,9 @@ Set values via CLI: `ails config set ` Global config in `~/.reporails/config.yml` applies to all projects. Project config overrides global. ```bash -ails config set --global default_agent claude # Use claude everywhere -ails config set --global recommended false # Opt out globally +ails config set --global default_agent claude ``` -Supported global keys: `default_agent`, `recommended`. - -[Recommended rules](https://github.com/reporails/recommended) (RRAILS_ namespace) are included by default. To opt out: `ails config set recommended false` - ## GitHub Actions Add `ails check` as a CI gate with inline PR annotations: @@ -154,70 +150,60 @@ Add `ails check` as a CI gate with inline PR annotations: name: Reporails on: pull_request: - paths: ['CLAUDE.md', '.claude/**'] + paths: ['CLAUDE.md', '.claude/**', 'AGENTS.md', '.cursorrules'] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: reporails/cli/action@v1 - with: - min-score: '6.0' -``` - -Violations appear as inline annotations on the PR diff. The step summary shows score, level, and a violations table. - -**Action inputs:** - -| Input | Default | Description | -|-------|---------|-------------| -| `path` | `.` | Path to validate | -| `strict` | `false` | Fail on any violation | -| `min-score` | | Minimum score threshold (0-10) | -| `agent` | | Agent type (resolve from project config or generic fallback) | -| `exclude-dir` | | Comma-separated directory names to exclude from scanning | -| `from-source` | `false` | Install CLI from local checkout (for CI testing) | -| `experimental` | `false` | Include experimental rules | -| `version` | | CLI version to install (default: latest) | - -**Action outputs:** `score`, `level`, `violations`, `result` (full JSON). - -You can also use `--format github` directly in custom workflows: - -```bash -ails check . --format github --strict + - run: pip install reporails-cli + - run: ails check . --format github --strict ``` ## What It Checks -- **Structure** — File organization, size limits -- **Content** — Clarity, completeness, anti-patterns -- **Efficiency** — Token usage, context management +90+ rules across six categories: + +- **Structure** — File organization, discoverability, size limits +- **Content** — Clarity, specificity, reinforcement patterns, anti-patterns +- **Context Quality** — Tech stack, project description, domain terminology +- **Efficiency** — Token usage, import depth, instruction elaboration - **Maintenance** — Versioning, review processes -- **Governance** — Ownership, security policies +- **Governance** — Security policies, credential protection, permissions -## Capability Levels +## Levels [* under re-evaluation *] -Capability levels describe what your AI instruction setup enables — not how "mature" it is. Different projects need different capabilities. +Levels describe what your AI instruction setup enables. | Level | Name | What It Enables | |-------|------|-----------------| -| L1 | Basic | A non-trivial, tracked instruction file exists | -| L2 | Scoped | Project-specific constraints defined, file is focused | -| L3 | Structured | Guidance is modular with external references | -| L4 | Abstracted | Instructions adapt based on code location | -| L5 | Maintained | Instruction system is structurally sound, governed, and navigable | +| L0 | Absent | No instruction file | +| L1 | Present | A non-trivial, tracked instruction file exists | +| L2 | Structured | Project-specific constraints, focused content | +| L3 | Substantive | Modular guidance with external references | +| L4 | Actionable | Instructions adapt based on code location | +| L5 | Refined | Structurally sound, governed, navigable | | L6 | Adaptive | Agent dynamically discovers context and extends capabilities | -## Rules +## Offline vs Server -Core rules are maintained at [reporails/rules](https://github.com/reporails/rules). -Recommended rules at [reporails/recommended](https://github.com/reporails/recommended). +| Feature | Unauthenticated | Authenticated | +|---------|-----------------|---------------------------------------| +| Mechanical rules | 70+ rules | 70+ rules | +| Deterministic rules | 20+ rules | 20+ rules | +| Cross-file analysis | - | Conflicts, repetition | +| Reinforcement detection | - | Orphan instructions, topic clustering | +| Compliance scoring | - | Per-instruction strength | +| Rate limit | - | 10/hour (beta) | -Want to add or improve rules? See the [Contributing guide](https://github.com/reporails/rules/blob/main/CONTRIBUTING.md). +## Performance -## License +First run downloads the embedding model (~90MB) to cache. Subsequent runs start in under 2 seconds for typical projects. -BUSL 1.1 — converts to Apache 2.0 on 2029-02-20 or at 1.0, whichever comes first. +## Rules + +Rules are bundled with the CLI — no separate install or download needed. See [reporails.com/rules](https://reporails.com/rules) for the full rule reference. + +## License -Rules and Recommended rules package are [CC BY-SA 4.0](https://github.com/reporails/rules/blob/main/LICENSE) — always open, always forkable. +[BUSL 1.1](LICENSE) — converts to Apache 2.0 three years after each release. diff --git a/UNRELEASED.md b/UNRELEASED.md index 79e701b8..f7dca27f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -1 +1,276 @@ # Unreleased + +### Breaking Changes + +- [LEVELS]: Rename labels (Organized→Structured, Distributed→Substantive, Contextual→Actionable, Extensible→Refined, Governed→Adaptive) +- [MODELS]: Replace `Rule.targets` string with `Rule.match` (FileMatch dataclass) +- [MODELS]: Add FileTypeDeclaration, ClassifiedFile, FileMatch dataclasses +- [MODELS]: Rename level labels +- [AGENTS]: Reduce supported agents to 4 (claude, copilot, codex, generic) +- [MECHANICAL]: Replace vars dict with ClassifiedFile list in all check signatures +- [MODELS]: `Check` dataclass no longer has `severity` or `name` — severity is now a rule-level field on `Rule` +- [RULES]: `rule.yml` renamed to `checks.yml` with `checks:` top-level key; checks consolidated from rule.md frontmatter + +### Added + +- [CORE]: `core/equation.py` — server-side diagnostics engine running per-atom and interaction analysis on `RulesetMap`. Returns categorical compliance bands for user-facing output. +- [CORE]: `core/equation_hints.py` — free/pro tier gating: per-atom diagnostics pass through on both tiers, interaction diagnostics become aggregated hints on free tier +- [CORE]: `Hint` and `LintResult` dataclasses in `api_client.py` — `Hint` carries aggregated interaction summaries for free tier, `LintResult` wraps report + hints + tier +- [CORE]: `core/_torch_blocker.py` — `sys.meta_path` import hook installed at CLI, MCP server, and daemon child entry points. Raises `ImportError` for any `torch*` import, cleanly triggering thinc's `except ImportError: has_torch = False` fallback and eliminating the ~20s cold-start cost that was previously leaking in through `spacy → thinc → try: import torch`. The reporails pipeline does not need torch at runtime. +- [CORE]: `core/mapper/onnx_embedder.py` — `OnnxEmbedder` class replaces `sentence-transformers` on the encode path. Loads a bundled `all-MiniLM-L6-v2` ONNX export (fp32, bit-identical to the PyTorch reference at float32 epsilon) via `onnxruntime` + `tokenizers`, no torch dependency. Includes length-sorted bucketed batching (bs=16) for tight dynamic padding — +28% throughput on real atoms. +- [BUILD]: `scripts/fetch_bundled_model.py` — idempotent dev-only helper that populates `src/reporails_cli/bundled/models/minilm-l6-v2/` from `Xenova/all-MiniLM-L6-v2` on Hugging Face Hub. Runs on clone and in CI before `hatch build`. Also exposed as `uv run poe fetch_bundled_model`. +- [BUNDLED]: `bundled.get_models_path()` helper, parallel to `get_project_types_path()`, used by `OnnxEmbedder` to locate the bundled ONNX weights inside the installed wheel. + +- [MCP]: Compact response format — violations grouped by file, short-key judgment requests, whitespace-free JSON +- [MCP]: Explain returns readable text instead of JSON; judge returns text summary +- [MCP]: Inline semantic workflow instructions in validate/heal tool descriptions +- [CLI]: Add `agent` output format (`ails check -f agent`) — compact JSON matching MCP format +- [JSON]: Add `scope` field to explain output (from rule match criteria) +- [AGENTS]: Three-tier codex/generic disambiguation (AGENTS.override.md → .codex/config.toml → global heuristic) +- [AGENTS]: `detect_single_agent()` for explicit --agent bypass of disambiguation +- [BOOTSTRAP]: Add `get_framework_root()` for framework metadata access +- [BUNDLED]: Absorb rules, schemas, registry, and sources.yml from rules repo into CLI as bundled content (~342 files) +- [BUNDLED]: Add `bundled.py` module — zero-install rules resolution via `importlib.resources` with dev-mode fallback +- [BUILD]: Add `force-include` in pyproject.toml to bundle rules/schemas/registry/sources.yml into wheel +- [BUILD]: Consolidate rule content under `framework/` at repo root +- [BUILD]: Add hatch build hook to exclude test fixtures from wheel — ships only rule.md, checks.yml, config.yml +- [BUILD]: Trim wheel — remove schemas/ and unused registry files (only levels.yml is runtime) +- [HARNESS]: Add `ails test --lint` — structural integrity checks on rule files (ID↔category, check ID prefix, duplicates, required frontmatter) + +- [CORE]: `core/mapper/` — 7-stage mapper pipeline (parse, classify, annotate, embed, cluster, assemble) +- [CORE]: `core/client_checks.py` — D-level checks (charge ordering, orphans, format, scope, bold) +- [CORE]: `core/api_client.py` — server client stub with offline fallback +- [CORE]: `core/merger.py` — merge local + server findings into `CombinedResult` +- [CORE]: `core/rule_runner.py` — iterate YAML rule definitions, dispatch mechanical + deterministic checks +- [MODELS]: `LocalFinding` dataclass for pipeline-native findings +- [CORE]: `core/mapper/map_cache.py` — per-file atom+embedding cache keyed by content hash +- [CORE]: `core/mapper/daemon.py` — persistent background process keeping sentence-transformers model loaded +- [CORE]: `core/mapper/daemon_client.py` — Unix socket client with graceful fallback +- [CLI]: `ails daemon start|stop|status` — manage mapper daemon lifecycle + +### Fixed + +- [CORE]: Wire detected agent into `load_rules()` and `load_file_types()` — agent-specific rules (CLAUDE:S:*, COPILOT:*, CODEX:*) now load during `ails check` and MCP validation. Previously only CORE rules were checked. `load_file_types()` fallback changed from hardcoded `"claude"` to `"generic"`. +- [CLI]: Add `--agent` and `--exclude-dirs` options to `ails check`. Agent resolves from CLI flag → project config (`default_agent`) → auto-detection. Shows auto-detect hint when agent was assumed. +- [BUNDLED]: Move generic agent config from `framework/rules/generic/` into `framework/rules/core/config.yml`. `get_agent_config_path("generic")` now resolves to `core/config.yml`. +- [BUNDLED]: Remove unsupported agent directories (aider, cline, continue, roo, windsurf). 6 agents remain: claude, codex, copilot, cursor, gemini, generic. +- [BUNDLED]: Codex config no longer claims AGENTS.md as exclusive `main` file. AGENTS.md is a cross-agent standard — its presence alone detects as `generic`. Codex detection requires `.codex/` markers or `AGENTS.override.md`. +- [CORE]: Derive agent membership from `FileRecord.agent` (populated from agent config registry) instead of hardcoded path patterns in `equation.py`. Eliminates duplication between agent configs and equation-layer detection. Case-insensitive matching; specificity-based disambiguation when multiple agents match (longest literal prefix wins). +- [CORE]: Fix daemon JSON round-trip dropping `scope_conditional` and `plain_text` from Atom — caused silent divergence between daemon and in-process mapping output (4 client-check findings missing on this repo) +- [CORE]: Fix double-embedding in `cluster_topics()` — was re-encoding all atoms instead of using pre-computed `embedding_int8` (162s → 3.5s with daemon) +- [CLI]: Fix Rich `MarkupError` — severity values like `medium`/`high` and bracket characters in rule IDs crashed the text formatter +- [REGEX]: Fix `run_checks()` expect semantics — was reporting matches as violations for `expect: present` rules (inverted logic) +- [CLI]: Group findings by topic in text output — uses human-readable `heading_context` labels from mapper clusters +- [CORE]: Normalize file paths in `client_checks` to project-relative for display consistency +- [REGEX]: Fix `run_checks()` duplicate reporting — was processing mechanical checks as regex (390 empty-message findings eliminated) +- [CLI]: Merge topic display by `heading_context` — multiple clusters under the same heading now show as one topic +- [CORE]: `core/content_queries.py` — atom-based content-quality queries (replaces regex text scanning) +- [CORE]: `core/content_checker.py` — dispatches `type: content_query` checks against `RulesetMap` +- [BUNDLED]: 25 content-quality rules migrated from deterministic regex to `content_query` atom checks (deterministic retained as `fallback: true`) +- [MODELS]: `Check` dataclass adds `query`, `fallback` fields for content_query check type + +- [STOPWORDS]: Add `ails stopwords extract` — parse checks.yml alternation patterns into vocab.yml term lists +- [STOPWORDS]: Add `ails stopwords sync` — compile vocab.yml terms back into checks.yml patterns (with `--dry-run`) +- [STOPWORDS]: Add staleness detection for vocab.yml vs checks.yml drift + +- [PROJECT]: Rename project-level `.reporails/` to `.ails/` — aligns with CLI command namespace, sorts first in dotfolders + +### Removed + +- [CORE]: Remove `equation.py` and `equation_hints.py` — diagnostics are server-only via API +- [CLI]: Remove `batch.py` — internal research tool, not part of the published CLI +- [CORE]: Scrub experiment IDs, effect sizes, and theory notation from code comments +- [CORE]: Rename `CAPABILITY_DETECTORS` → `FEATURE_DETECTORS` in `levels.py` +- [BUNDLED]: Remove deferred rules (freshness-marker, import-references-used, permissions-ordered, static-before-dynamic) and archived manifest rule +- [DOCS]: Update README and npm README with current output format and package names +- [BUILD]: Remove dead `download_rules` step from CI/release workflows. Add `NODE_AUTH_TOKEN` to npm publish. +- [BUILD]: Update `pyproject.toml` and `package.json` descriptions to "AI instruction diagnostics for coding agents" +- [CORE]: `agents.py` — `_extract_patterns()` and `_extract_properties()` helpers support both v0.3.0 (patterns + properties nested) and v0.5.0 (scopes with patterns, properties flattened) agent config schemas. All pattern/property extraction uses these helpers. +- [CORE]: `classification.py` — `_parse_file_types()` updated for v0.5.0 schema compatibility +- [CORE]: `mapper.py` — `_detect_file_loading()` updated for v0.5.0 schema compatibility +- [AGENTS]: All agent configs updated to v0.5.0 schema with scopes structure (project/user/managed/local) +- [FORMATTERS]: Archive `full.py`, `box.py`, `violations.py`, `compact.py` to `_archived/formatters/` — dead code since CombinedResult pipeline replaced ValidationResult rendering. Tests archived alongside. +- [CORE]: Pipeline orchestration (`engine.py`, `pipeline.py`, `pipeline_exec.py`, `sarif.py`) — replaced by `rule_runner.py` +- [CORE]: Semantic layer (`semantic.py`) — equation replaces LLM-as-judge +- [CORE]: `content_linter.py`, `excitation_map.py`, `scorer.py`, `topo_scanner.py` — replaced by mapper + client checks +- [CLI]: Remove `heal`, `sync`, `update`, `topo`, `lint`, `dismiss`, `judge` commands +- [MCP]: Remove `judge` and `heal` tools +- [BUNDLED]: 6 FALSIFIED rules, 4 BEHAVIORAL rules, 4 M3-SUPERSEDED rules +- [BUNDLED]: Semantic check entries from 12 surviving rules, content quality patterns from 4 rules + +### Changed + +- [CLI]: Scorecard redesign — score with bar, agent, scope (capabilities + instruction breakdown), and results at bottom of output where users land after scrolling. Tier badge in header. Beta CTA for unauthenticated users. +- [CORE]: `api_client.py` — remove local equation fallback. Diagnostics are API-only; CLI never imports `equation.py` at runtime. Offline users get mechanical checks only. +- [CORE]: `equation.py` — strip internal distance metric from description-mismatch diagnostic message +- [AGENTS]: `_disambiguate_shared_files()` in `detect_agents()` — drop agents whose instruction files are entirely shared with other agents (e.g., AGENTS.md matching 5 agents). Fixes 38% corpus over-classification. +- [CORE]: `equation.py` — opposing instructions within the same topic now subtract instead of reinforce in the diagnostics engine +- [CORE]: `equation.py` — topic count uses semantic distance merge threshold; returns distinct group count after merging instead of largest connected component +- [CORE]: `equation.py` — cross-file diagnostics scoped to shared topic clusters only, with semantic distance threshold within each cluster +- [CORE]: `equation_hints.py` — free tier preserves severity on hints and compliance band; interaction diagnostics are detail-gated (no lines/fixes) but error counts and compliance band are shown honestly +- [CORE]: `api_client.py` — `Hint` dataclass adds `severity`, `error_count`, `warning_count` fields for exact severity counts from gated diagnostics +- [CORE]: `rule_runner.py` — deterministic checks now grouped by `rule.match.type` and run against matching files only; rules with `match: {type: scoped_rule}` no longer fire on `main` files (CLAUDE.md). Eliminated ~215 false positive findings +- [CORE]: `merger.py` — `normalize_finding_path()` unifies file paths from all three sources (m_probe, client_check, server) to project-relative. External files (auto-memory) normalize to `~/`. Eliminates path fragmentation that caused the same file to appear under multiple keys in JSON output (60+ → 31 file keys) +- [CLI]: Free tier summary now shows error count, compliance band, and severity icons on hints +- [CORE]: `mapper.py` — position-0 verb rescue in `_classify_phase3_spacy`: rescues demoted imperative verbs (csubj/compound/nmod/dep) as IMPERATIVE (+33 rescued atoms on this codebase) +- [CORE]: `mapper.py` — `_split_mixed_charge_atoms` no longer skips neutral atoms: compound sentences with embedded charge ("You are X. Never do Y.") now split correctly +- [CORE]: `mapper.py` — AMBIGUOUS charge type: two-pass neutral scanner flags atoms with embedded constraint/directive markers in descriptive context (charge_confidence=0.0). 21 atoms flagged on this codebase +- [CORE]: `mapper.py` — `_rule_confidence()` maps classifier rule traces to confidence tiers (0.95/0.80/0.60/0.70) +- [CORE]: `equation.py` — AMBIGUOUS atoms excluded from diagnostics analysis; dedicated diagnostic emitted per AMBIGUOUS atom +- [CORE]: `equation.py` — neutral mass diagnostic reworded from "too much prose" to "uncharged items dilute instructions" — sub-bullets and references are not prose +- [CORE]: `mapper.py` — headings classified through charge pipeline (headings ARE content). `## Never push to main` now gets charge=-1. Validator updated. +- [CORE]: `mapper.py` — `_embed_text()` no longer prepends heading context. Removes double-counting (heading exists as own atom). Clustering by semantic content, not heading structure. +- [CORE]: `equation.py` — heading atoms now participate in diagnostics analysis (removed heading exclusion filters) +- [CORE]: `equation.py` — specificity diagnostic only fires when file contains abstract instructions. Message changed to "name constructs" not "split files" +- [CORE]: `mapper.py` — heading atoms now embedded (were skipped). Classified headings now participate in diagnostics analysis, conflict detection, and topic computation. +- [CORE]: `client_checks.py` — new check: instruction in heading. Flags charged heading atoms as structural hygiene issue — headings should organize, not instruct. Also: bold check now includes heading atoms. +- [RULES]: New rule `heading-as-instruction` (CORE:S:0039) — proper rule with checks.yml, content_query `has_charged_headings`, and test fixtures +- [CORE]: `mapper.py` — emit `code_block` atoms from fence tokens (was skipping them entirely, making `has_code_blocks` a dead query) +- [CORE]: `content_queries.py` — add `has_non_italic_constraints`, `has_mermaid_blocks`, `has_branching_steps` content queries +- [CORE]: `client_checks.py` — bold label exception: `**Label**:` patterns (bold + colon) skipped as structural labels +- [CLI]: `ails batch --output-dir maps/` — run full check pipeline on every project in a corpus. Models loaded once. Per project writes `ruleset.json` (RulesetMap) + `lint.json` (equation diagnostics). Summary JSONL for aggregation. +- [CLI]: `ails heal [PATH] [--dry-run] [-f json]` — auto-fix instruction file issues. Mechanical fixers: backtick wrapping, bold→italic on constraints, full-sentence italic, charge ordering. Additive fixers: missing sections. +- [CORE]: `core/mechanical_fixers.py` — 4 atom-level fixers operating on raw file content via RulesetMap. Italic fixer skips lines with existing italic spans to prevent nesting. +- [MCP]: `heal` tool for auto-fixing instruction file issues +- [CLI]: Redesigned text output — "Reporails — Diagnostics" header with file type breakdown, instruction counts (directive/constraint/ambiguous), prose density. Files grouped by type in bordered cards. Structural findings (M1/M2) display first, quality metrics (M3) aggregated below in dim. Per-file identity by friendly name. Messages truncate at terminal width. All server diagnostics now aggregate. +- [CORE]: Reword topic diagnostic — "N overlapping topics (out of M)" instead of "competing for attention". No IP leakage. +- [CORE]: Three-way cross-file co-visibility model: base (main+rules) ↔ inline_invoked (skills+commands) ↔ agent_def. Agent↔skill and skill↔skill pairs compared (co-visible via Skill tool). Agent↔agent skipped (subprocess-isolated). +- [CORE]: `equation.py` — topic competition uses semantic distance — distant topics no longer trigger false positives. Only flags when topics are within range. +- [CORE]: `mapper.py` — `FileRecord` gains `description` and `description_embedding` fields. Frontmatter name+description extracted and embedded for on_invocation files (always in base context per Agent Skills standard). +- [CORE]: `equation.py` — description-content coherence diagnostic: flags on_invocation files whose frontmatter description doesn't match content semantically. +- [CORE]: `equation.py` — description competition: skill/agent descriptions in base context included in topic competition for base files. +- [CORE]: `equation.py` — memory index validation: broken links (error), missing frontmatter (warning) in MEMORY.md index files. +- [CORE]: `client_checks.py` — remove directive-only orphan diagnostic. Golden pattern is +1,0 (directive+reasoning); -1 only when suppressing. Directive-only clusters are valid. +- [CORE]: `equation.py` — skip brevity check on headings. Short headings (## Format) are organizational, not instructions. +- [CORE]: `equation.py` — fix "0 of N" nonsensical message in overall-strength diagnostic. +- [CORE]: `registry.py` — exclude `_deferred/` directories from rule loading. +- [CORE]: Fix rule match scoping — rules skip entirely when target surface doesn't exist. A rule with `match: {type: config}` won't fire when no config files are present. Mechanical runner, content checker, and violation attribution all gate on surface existence. +- [CLI]: Filter project-level noise ("no matching files") from display. Disambiguate duplicate filenames with parent directory. Fold tests into main group. +- [TESTS]: Update test expectations for 6 renamed rule slugs +- [CORE]: `equation.py` — fix position weight underflow: clamp to non-negative when AMBIGUOUS exclusion shrinks atom count +- [CLI]: `ambiguous_charge` added to aggregate display rules — shows as "N ambiguous" in per-file summaries +- [CORE]: `agents.py` — `_glob_file_type_patterns` resolves external paths (`~/...`, `/absolute/...`) for instruction discovery. Auto-memory (`~/.claude/projects/*/memory/MEMORY.md`), user-level rules, and managed policies are now part of the instruction surface. Project-scoped patterns resolve to the current project only. +- [CORE]: `mapper.py` — map validator accepts AMBIGUOUS charge type and charge_value=0+AMBIGUOUS consistency +- [CORE]: `mapper.py` — `expand_imports()` expands `@path` inline imports before tokenization. Claude Code and Gemini CLI splice imported file content at the reference position — the mapper must see the same expanded content. Resolves relative to importing file, expands `~/`, recurses up to 5 hops, detects circular imports, follows symlinks safely, skips code blocks and non-markdown files. +- [CORE]: `api_client.py` — v2 wire format: obfuscate atom field names (short keys, integer enums, file index references) in API transport. Schema version bumped to `"2"`. IP-revealing semantic names no longer leave the client. +- [CORE]: `api_client.py` — `AilsClient.lint()` sends diagnostics to API instead of returning `None`. Tier gating via `AILS_TIER` env var (default: "free") +- [CORE]: `merger.py` — `CombinedResult` gains `hints` field; `merge_results()` accepts and passes through hints +- [CORE]: `client_checks.py` — all diagnostic messages rewritten to user-facing product language (no theory notation, experiment IDs, or equation constants) +- [CLI]: Show rule IDs on structural and verbose quality findings in text output — each finding line now ends with its rule ID (e.g., `CORE:C:0003`) +- [CLI]: `ails check` text output redesigned — files sorted worst-first, per-file aggregated counts + top actionable findings, hints section, scorecard. Default 5 files, 15 with `-v` +- [FORMATTERS]: JSON output grouped by file with `fix` field, sorted worst-first +- [MCP]: `validate` and `score` tools updated for `LintResult` return type +- [CORE]: `Models.st` property now returns an `OnnxEmbedder` instead of `SentenceTransformer`. Same `.encode(texts)` API, bit-identical output (405 findings exact match with the sentence-transformers baseline, float32 epsilon), no torch in the critical path. The thread-safe lazy load + `_st_lock` structure is unchanged; only the body of the property swapped. +- [BUILD]: `sentence-transformers` removed from all dependency groups. `onnxruntime>=1.18,<2`, `tokenizers>=0.19,<1`, `numpy>=1.26,<3`, `spacy>=3.8.11,<4`, and the `en_core_web_sm` spaCy model wheel moved into `[project].dependencies`. Fresh `uv sync` also uninstalls `torch`, `transformers`, `triton`, and ~15 `nvidia-cuda-*` runtime packages — the installed venv footprint drops by several hundred MB. +- [BUILD]: `[tool.hatch.build.targets.wheel].artifacts` now includes `src/reporails_cli/bundled/models/**/*` so the bundled ONNX files ship in the wheel. `[tool.hatch.metadata].allow-direct-references = true` is required for the `en_core_web_sm` GitHub-release URL pin. +- [CORE]: `Models.warmup()` — parallel preload of spaCy + sentence-transformers via `ThreadPoolExecutor`; thread-safe lazy load with per-model locks on `Models.st` / `Models.nlp` +- [CORE]: `spacy.load("en_core_web_sm")` now loads only `tok2vec + tagger + parser` — classification reads only `tok.dep_`/`tok.tag_`/`tok.text`, so `ner`/`lemmatizer`/`attribute_ruler` are dead weight (verified byte-identical check output) +- [CORE]: Mapper daemon now binds its Unix socket BEFORE model warmup and warms in a background thread; parent's `start_daemon` returns once the socket exists rather than blocking on full model load +- [CORE]: Mapper daemon pre-loads BOTH spaCy and sentence-transformers (was only sentence-transformers); daemon dispatch no longer blocks on warmup, letting cache-hit `map_ruleset` requests return before models are loaded +- [CORE]: Daemon idle timeout raised from 15min to 1h, configurable via `AILS_DAEMON_IDLE_S` env var +- [CORE]: `SentenceTransformer` loaded with explicit `device="cpu"` to skip CUDA probing on GPU-driverless CPU boxes +- [CLI]: `ails check` now eagerly forks the mapper daemon at the top of the command (before file discovery) so model warmup overlaps with the parent's discovery + M-probe work; in-process mapping is the fallback when fork is unavailable +- [RULES]: Dissolve X (context_quality) category — reclassify X:0001-0003→C:0033-0035, X:0004-0005→S:0037-0038, X:0006→E:0006; archive X:0007 +- [RULES]: Fix match type on S:0008, S:0010 — use `match: {}` instead of `match: {type: main}` (structure rules apply to all files) +- [RULES]: Downgrade S:0038 (was X:0005), COPILOT:S:0001 checks from deterministic to mechanical (frontmatter_key) — eliminates regex/SARIF overhead +- [RULES]: Add mechanical file_exists gate to S:0012 — skip regex when file missing +- [RULES]: Add mechanical content_absent pre-filters to C:0029, C:0030, G:0002 — short-circuit before deterministic regex +- [RULES]: Extract vocab.yml term lists for 61 rules from existing checks.yml patterns +- [RULES]: Remove stopwords.txt files — replaced by vocab.yml +- [RULES]: Add 30 interaction rules from vertex predictions (agent-neutrality, scope-adherence, cross-file, config-coherence, constraint-propagation) +- [RULES]: Rename 22 rules from verbose slugs to concise names (IDs preserved) +- [REGISTRY]: Remove tier filtering and sources.yml loading from check path — all rules are CORE tier, saves ~170ms + +- [LEVELS]: Target existence gating — rules fire when their target file type exists +- [LEVELS]: Project level computed from file type property divergence (format, cardinality, precedence, loading, scope) +- [LEVELS]: Single-pass validation replaces progressive level walk +- [LEVELS]: L0 is now "Absent" (no files); L1 is "Present" (files with baseline properties) +- [RULES]: Strip `level:` field from all rule frontmatter (~90 files) — level is now emergent +- [BOOTSTRAP]: Fix `get_rules_path()` for framework repo structure (rules/ subdirectory) +- [BOOTSTRAP]: Flatten agent path layout (agents/{agent}/ → {agent}/) +- [BOOTSTRAP]: Replace get_agent_vars with get_agent_file_types +- [MECHANICAL]: Rewrite check resolution to use classified file types instead of template vars +- [PIPELINE]: Replace template_vars/instruction_files params with classified_files +- [ENGINE]: Replace `build_template_context` with `build_file_context` (classified file types) +- [ENGINE]: Add mixed-signals multi-agent support — merge file_types from all detected agents +- [ENGINE]: Group rules by resolved target files for batched regex calls +- [HARNESS]: Replace template vars with file_types in agent config loading and test harness +- [HARNESS]: Load checks from checks.yml when rule.md frontmatter has no checks array +- [DISCOVER]: Extract detection data into bundled project-types.yml, data-driven discovery engine +- [CLI]: Extract display helpers, add agent filter resolution, simplify map command +- [META]: Update backbone.yml with new bundled entries and module paths +- [META]: Update backbone.yml — remove external rules dependency, add rules/schemas/registry to modules +- [BOOTSTRAP]: Split config loading into `core/config.py` (get_agent_config, get_global_config, get_project_config); re-export from bootstrap.py +- [BOOTSTRAP]: Simplify `get_rules_path()` resolution: config override → bundled (remove local `./checks/` and installed-mode fallbacks) +- [BOOTSTRAP]: `get_framework_root()` resolves bundled package root for schemas/registry/sources.yml access +- [BOOTSTRAP]: `is_initialized()` recognizes bundled rules — `ails check` works without prior `ails install` +- [BOOTSTRAP]: Remove `FRAMEWORK_REPO` and `FRAMEWORK_RELEASE_URL` constants +- [RULE_BUILDER]: `_load_source_weights()` uses multi-candidate path search for sources.yml (handles bundled layout) +- [RULE_BUILDER]: `get_rule_yml_paths()` renamed to `get_checks_paths()`; `build_rule()` loads checks from `checks.yml` when frontmatter has no `checks:` array +- [REGISTRY]: Pre-parse checks.yml in registry to avoid redundant YAML parsing in build_rule +- [REGISTRY]: Add YAML file cache to eliminate double-parsing between registry and compiler +- [BUILD]: Remove `only-include` constraint — `force-include` injects bundled content into wheel +- [TESTS]: Update `dev_rules_dir` fixture to use in-repo `rules/` instead of sibling `../rules/` +- [TESTS]: Update conftest fixtures and golden snapshots for 0.5.0 rule set +- [TESTS]: Update unit tests for classified file types, add discover test suite +- [TESTS]: Update integration tests for classified file system, remove template resolution tests +- [TESTS]: Update smoke tests for mixed-signals multi-agent behavior +- [TESTS]: Rewrite level and applicability tests for target existence gating +- [TESTS]: Update behavioral and smoke tests for L0 absent level +- [TESTS]: Update golden snapshots and pipeline smoke test for target existence gating +- [TESTS]: Add fail fixtures for 25 rules that had none — harness now at 85 passed, 0 failed, 4 no_fixtures +- [TESTS]: Fix copilot applyto-scope-declared fixtures (`.claude/rules/` → `.github/instructions/`), add frontmatter to path-scope-declared pass fixture +- [TESTS]: Update Check/Rule constructors and YAML fixtures for checks consolidation and severity lift +- [CLI]: `ails check` — new pipeline (discover → M probes → map → client checks → server → merge → display) +- [CLI]: `ails check` — add spinner progress phases, suppress ML library stderr noise +- [MCP]: `validate` and `score` tools use new pipeline returning `CombinedResult` +- [FORMATTERS]: Add `format_combined_result()` to JSON and GitHub formatters +- [REGEX]: Add `run_checks()` returning `list[LocalFinding]` alongside SARIF `run_validation()` +- [CLI]: Fixing deploy. +- [META]: Fix 50 mypy strict-mode errors across `core/` and `cli/main.py` — type annotations, unused ignores, untyped lambdas +- [META]: Fix ruff lint errors in `tests/unit/test_api_client.py` — unused import, dict literal +- [META]: Exclude `_archived/` from mypy in `pyproject.toml` +- [ACTION]: Remove invalid `--no-update-check` flag from `action/action.yml` +- [TESTS]: Remove stale integration tests for removed features (update, dismiss, judge, --no-update-check, -q, --refresh, --legend, compact/brief formats) +- [TESTS]: Add `requires_model` skip marker for integration tests needing bundled ONNX model +- [TESTS]: Fix MCP test expectations for current tool set (heal replaces judge) +- [CORE]: Gracefully handle missing ONNX model — catch `RuntimeError` alongside `ImportError` in mapper fallback paths (check, heal, MCP tools) +- [TESTS]: Update integration tests for `CombinedResult` JSON schema (`files`/`stats` replaces `score`/`level`/`violations`) +- [TESTS]: Rewrite `test_summary.py` for `CombinedResult` schema +- [ACTION]: Add `parse_result.py` — compute score, level, violations from `CombinedResult` JSON +- [CLI]: `--strict` exits 1 on any finding, not just errors +- [TESTS]: Fix remaining MCP and CLI integration tests for `CombinedResult` schema — remove `score`/`level` assertions, use `files`/`stats` + +### Fixed + +- [SCORER]: Return 0.0 instead of 10.0 when no rules checked (L0) — no data is not perfection +- [FORMATTERS]: Suppress score display at L0 — show level-only message instead of misleading 10.0/10 +- [FORMATTERS]: Fix violation message word-break truncation using wrong baseline for space search + +- [MECHANICAL]: Fix `file_absent` false positives when match_type is set but no files of that type are classified +- [REGEX]: Add signal-based timeout (500ms) to guard against catastrophic backtracking in regex patterns +- [MECHANICAL]: `resolve_location` returns paths relative to scan root instead of bare filenames — fixes scoped-rule violation locations +- [BOOTSTRAP]: `get_rules_path()` returns correct subdirectory in framework dev mode +- [BOOTSTRAP]: `get_schemas_path()` uses framework root, not rules path +- [ENGINE]: Remove `**/*` fallback in `_get_counted_files` — no longer globs entire project tree + +### Docs + +- [README]: Rules are bundled — remove external rules repo references and separate install instructions + +### Removed + +- [REGISTRY]: Delete coordinate-map.yml, tombstones.yml, capabilities.yml — no source code consumers +- [CLI]: Remove `--experimental` flag from `check` and `heal` commands — always-false, dead since tier consolidation +- [MODELS]: Remove `Rule.level` field — level is emergent from file type properties, not stored per-rule +- [MODELS]: Remove `SkippedExperimental` class and `ProjectConfig.experimental` field +- [ENGINE]: Remove `include_experimental` parameter from `run_validation` and `run_validation_sync` +- [CLASSIFICATION]: Add `detect_content_format()` — auto-detect prose/heading/code_block/data_block/table/list in freeform files +- [CLASSIFICATION]: Add `content_format` to `_file_matches()` property loop — rules can now target specific content regions +- [REGISTRY]: Remove `get_experimental_rules()` deprecated stub +- [ENGINE]: Delete template variable resolution system (templates.py) +- [REGEX]: Remove template_context parameter from compile/run +- [ENGINE]: Remove two-phase capability detection pipeline (_detect_capabilities, ContentFeatures, CapabilityResult) +- [BUNDLED]: Delete capability-patterns.yml — no longer needed for level determination +- [REGEX]: Remove run_capability_detection function — capabilities not detected via regex +- [LEVELS]: Remove legacy level determination functions — level is now emergent from file type properties diff --git a/VERSION b/VERSION index 1d0ba9ea..8f0916f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/action/action.yml b/action/action.yml index fd0f06d5..74ae45a7 100644 --- a/action/action.yml +++ b/action/action.yml @@ -20,9 +20,6 @@ inputs: exclude-dir: description: 'Comma-separated directory names to exclude from scanning (e.g., "vendor,dist")' default: '' - experimental: - description: 'Include experimental rules' - default: 'false' version: description: 'CLI version to install (e.g., 0.3.0). Defaults to latest.' default: '' @@ -68,7 +65,7 @@ runs: shell: bash run: | # Build command - CMD="ails check ${{ inputs.path }} --format github --ascii --no-update-check -q" + CMD="ails check ${{ inputs.path }} --format github --ascii" if [ "${{ inputs.strict }}" = "true" ]; then CMD="$CMD --strict" @@ -81,29 +78,23 @@ runs: if [ -n "${{ inputs.exclude-dir }}" ]; then IFS=',' read -ra DIRS <<< "${{ inputs.exclude-dir }}" for dir in "${DIRS[@]}"; do - CMD="$CMD --exclude-dir $(echo "$dir" | xargs)" + CMD="$CMD --exclude-dirs $(echo "$dir" | xargs)" done fi - if [ "${{ inputs.experimental }}" = "true" ]; then - CMD="$CMD --experimental" - fi - # Run and capture output (annotations go to stdout for GitHub to pick up) OUTPUT=$($CMD) || EXIT_CODE=$? EXIT_CODE=${EXIT_CODE:-0} echo "$OUTPUT" - # Parse JSON from last line + # Parse JSON from last line (CombinedResult schema: files, stats) JSON_LINE=$(echo "$OUTPUT" | tail -n 1) - SCORE=$(echo "$JSON_LINE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('score', 0))") - LEVEL=$(echo "$JSON_LINE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('level', 'L0'))") - VIOLATIONS=$(echo "$JSON_LINE" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('violations', [])))") + eval "$(echo "$JSON_LINE" | python3 "${{ github.action_path }}/parse_result.py")" - echo "score=$SCORE" >> "$GITHUB_OUTPUT" - echo "level=$LEVEL" >> "$GITHUB_OUTPUT" - echo "violations=$VIOLATIONS" >> "$GITHUB_OUTPUT" + echo "score=$_SCORE" >> "$GITHUB_OUTPUT" + echo "level=$_LEVEL" >> "$GITHUB_OUTPUT" + echo "violations=$_VIOLATIONS" >> "$GITHUB_OUTPUT" # Store full JSON for downstream steps { diff --git a/action/parse_result.py b/action/parse_result.py new file mode 100644 index 00000000..8be6276d --- /dev/null +++ b/action/parse_result.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Parse CombinedResult JSON and emit shell variables for GitHub Actions. + +Usage: echo '' | python3 parse_result.py +Outputs: _SCORE=X.X _LEVEL=LN _VIOLATIONS=N (one per line, eval-safe) +""" +from __future__ import annotations + +import json +import sys + + +def main() -> None: + d = json.load(sys.stdin) + files = d.get("files", {}) + stats = d.get("stats", {}) + + n_findings = sum(f.get("count", 0) for f in files.values()) + errors = stats.get("errors", 0) + warnings = stats.get("warnings", 0) + total = errors + warnings + stats.get("infos", 0) + + if total == 0: + score = 10.0 + else: + base = 6.0 + denom = max(total, 1) + ep = min(4.0, errors / denom * 30) + wp = min(2.0, warnings / denom * 2) + score = max(0.0, min(10.0, base - ep - wp)) + + score = round(score, 1) + level = "L0" if not files else "L1" + + print(f"_SCORE={score}") + print(f"_LEVEL={level}") + print(f"_VIOLATIONS={n_findings}") + + +if __name__ == "__main__": + main() diff --git a/action/summary.py b/action/summary.py index 353c807d..a67783ab 100644 --- a/action/summary.py +++ b/action/summary.py @@ -11,34 +11,32 @@ import json import os -import sys SEVERITY_ICONS = { - "critical": "\u274c", # red X - "high": "\U0001f7e0", # orange circle - "medium": "\u26a0\ufe0f", # warning - "low": "\U0001f535", # blue circle + "error": "\u274c", + "high": "\U0001f7e0", + "medium": "\u26a0\ufe0f", + "warning": "\u26a0\ufe0f", + "info": "\U0001f535", } def generate_summary(result: dict) -> str: - """Generate markdown summary from validation result JSON.""" + """Generate markdown summary from CombinedResult JSON.""" lines: list[str] = [] - score = result.get("score", 0) - level = result.get("level", "?") - capability = result.get("capability", "") - violations = result.get("violations", []) - category_summary = result.get("category_summary", []) - evaluation = result.get("evaluation", "complete") - # Display-friendly label for GitHub summary - evaluation_display = "awaiting semantic" if evaluation == "awaiting_semantic" else evaluation - score_delta = result.get("score_delta") + files = result.get("files", {}) + stats = result.get("stats", {}) + offline = result.get("offline", True) + + total_findings = sum(f.get("count", 0) for f in files.values()) + errors = stats.get("errors", 0) + warnings = stats.get("warnings", 0) # Status icon - if not violations: + if total_findings == 0: status = "\u2705 Pass" - elif any(v.get("severity") in ("critical", "high") for v in violations): + elif errors > 0: status = "\u274c Fail" else: status = "\u26a0\ufe0f Warnings" @@ -47,51 +45,32 @@ def generate_summary(result: dict) -> str: lines.append("## Reporails Check") lines.append("") - # Score table - score_display = f"{score:.1f}/10" - if score_delta is not None and score_delta != 0: - direction = "+" if score_delta > 0 else "" - score_display += f" ({direction}{score_delta:.1f})" - + mode = "offline" if offline else "online" lines.append("| Metric | Value |") lines.append("|--------|-------|") - lines.append(f"| Score | **{score_display}** |") - lines.append(f"| Level | **{level}** {capability} |") lines.append(f"| Status | {status} |") - lines.append(f"| Evaluation | {evaluation_display} |") + lines.append(f"| Findings | **{total_findings}** ({errors} errors, {warnings} warnings) |") + lines.append(f"| Files | {len(files)} |") + lines.append(f"| Mode | {mode} |") lines.append("") - # Category summary - if category_summary: - lines.append("### Categories") - lines.append("") - lines.append("| Category | Passed | Failed | Worst |") - lines.append("|----------|--------|--------|-------|") - for cat in category_summary: - name = cat.get("name", "?") - passed = cat.get("passed", 0) - failed = cat.get("failed", 0) - worst = cat.get("worst_severity", "-") - icon = SEVERITY_ICONS.get(worst, "") if failed > 0 else "\u2705" - lines.append(f"| {name.title()} | {passed} | {failed} | {icon} {worst} |") - lines.append("") - - # Violations table - if violations: - lines.append("### Violations") + # Findings table + if files: + lines.append("### Findings") lines.append("") lines.append("| Severity | Rule | File | Message |") lines.append("|----------|------|------|---------|") - for v in violations: - sev = v.get("severity", "?") - icon = SEVERITY_ICONS.get(sev, "") - rule_id = v.get("rule_id", "?") - location = v.get("location", "?") - message = v.get("message", "") - # Truncate long messages for table readability - if len(message) > 80: - message = message[:77] + "..." - lines.append(f"| {icon} {sev} | `{rule_id}` | `{location}` | {message} |") + for filepath, file_data in files.items(): + for f in file_data.get("findings", []): + sev = f.get("severity", "?") + icon = SEVERITY_ICONS.get(sev, "") + rule = f.get("rule", "?") + line = f.get("line", 0) + location = f"{filepath}:{line}" if line else filepath + message = f.get("message", "") + if len(message) > 80: + message = message[:77] + "..." + lines.append(f"| {icon} {sev} | `{rule}` | `{location}` | {message} |") lines.append("") return "\n".join(lines) diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index 8bb2ba24..00000000 --- a/docs/features.md +++ /dev/null @@ -1,98 +0,0 @@ -# Features - -Comprehensive feature inventory of the reporails CLI. - -## Commands - -Core workflow: - -| Command | Purpose | -|-----------|------------------------------------------------------------------| -| `check` | Validate instruction files against rules — score, level, violations | -| `heal` | Auto-fix deterministic violations (adds missing sections) | -| `explain` | Show rule details — title, category, checks, description | -| `install` | Configure MCP server for detected agents | - -Configuration: - -| Command | Purpose | -|-----------|---------------------------------------------------------| -| `config` | Get/set/list project configuration | -| `update` | Update rules framework, recommended rules, or CLI itself | -| `version` | Show CLI, framework, and recommended rules versions | - -Development (rule authors and contributors): - -| Command | Purpose | -|---------|----------------------------------------------------------------| -| `test` | Rule development harness — fixture validation, coverage, scoring | -| `map` | Discover project structure and agents, generate backbone.yml | -| `sync` | Sync rule definitions from framework repo | - -Plumbing (hidden from `--help`, used by MCP and scripts): - -| Command | Purpose | -|-----------|------------------------------------| -| `dismiss` | Suppress semantic findings in cache | -| `judge` | Batch-cache semantic verdicts | - -## Agent Support - -7 agents: **Claude**, **Cursor**, **Windsurf**, **Copilot**, **Aider**, **Codex**, **Generic**. Each with its own instruction patterns, config locations, rule/directory patterns, and template variables. Agent-specific rule overrides without forking rules. - -## Validation Engine - -- **Two-pass architecture** — capability detection (level) then rule validation -- **Three check types** — mechanical (Python functions), deterministic (regex), semantic (LLM judgment) -- **Interleaved execution** — checks within a rule execute sequentially, M/D/S can mix -- **Ceiling system** — rule type constrains permitted check types -- **Batch SARIF** — all regex runs once, results distributed per-rule -- **Semantic short-circuit** — skips LLM when no evidence from cheaper checks - -## Capability Levels (L0-L6) - -Filesystem + content feature detection maps projects to capability levels. Cumulative ladder: L1 (basic file) through L6 (dynamic context, MCP, persistence). Orphan detection shows "L3+" when advanced features exist without intermediate levels. - -## Scoring - -- 0-10 scale, per-rule weight cap (2.5), severity weights (critical 5.5, high 4.0, medium 2.5, low 1.0) -- Category breakdown: Structure, Content, Efficiency, Maintenance, Governance -- Friction estimation: none → small → medium → high → extreme -- Delta comparison with previous scan - -## Output Formats - -- **text** — colored terminal with scorecard box, violations, scope line -- **json** — machine-readable full results -- **github** — `::error`/`::warning` workflow annotations + JSON -- **compact** — one-line summary -- **mcp** — JSON + semantic workflow instructions for Claude - -## Caching - -- **Judgment cache** — three-tier (content hash → structural hash → invalidate) -- **Dismissal cache** — deterministic violations cached as pass -- **File map cache** — instruction file discovery -- **Rule cache** — parsed rule definitions -- **Analytics** — per-project scan history (score trends, timing) - -## Auto-fix (Heal) - -5 deterministic fixers: Constraints section, Commands section, Testing section, structured headings, Project Structure section. Idempotent, reports remaining violations and pending semantic rules. - -## GitHub Action - -Composite action with inputs for path, strict mode, min-score gate, agent, exclude-dirs, experimental. Outputs score, level, violations, full JSON. Inline PR annotations. - -## Configuration - -Project config in `.reporails/config.yml` with: `default_agent`, `exclude_dirs`, `disabled_rules`, `experimental`, `recommended`, `framework_version`. - -Global config in `~/.reporails/config.yml` with: `default_agent`, `recommended`. Project values override global defaults. Use `--global` flag on `config set/get/list` commands. - -## Distribution - -- npm wrapper (`npx @reporails/cli`) and PyPI (`uvx reporails-cli`) -- Auto-init downloads rules framework on first run -- Ephemeral (npx/uvx) and persistent install detection -- Self-update with install method detection (uv, pip, pipx, dev) diff --git a/framework/registry/levels.yml b/framework/registry/levels.yml new file mode 100644 index 00000000..caa1400e --- /dev/null +++ b/framework/registry/levels.yml @@ -0,0 +1,31 @@ +# Level definitions +version: 4 + +levels: + L0: + name: Absent + description: "No instruction file exists" + + L1: + name: Present + description: "Instruction file exists, tracked, safe, real content" + + L2: + name: Structured + description: "Organizational scaffolding: headers, formatting, manageable size" + + L3: + name: Substantive + description: "Real content: commands, constraints, conventions, project identity" + + L4: + name: Actionable + description: "Behavioral directives: role, plan-first, ask-not-guess, error handling" + + L5: + name: Refined + description: "Complete operational guide: domain terms, detailed examples, architecture" + + L6: + name: Adaptive + description: "Production-grade: non-redundant, cross-agent, task delegation" diff --git a/framework/rules/claude/config.yml b/framework/rules/claude/config.yml new file mode 100644 index 00000000..00baba7a --- /dev/null +++ b/framework/rules/claude/config.yml @@ -0,0 +1,293 @@ +# Claude Code Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 + +agent: claude +version: "0.5.0" +prefix: CLAUDE +name: Claude Code + +file_types: + main: + required: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/CLAUDE.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/CLAUDE.md"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/etc/claude-code/CLAUDE.md" + - "/Library/Application Support/ClaudeCode/CLAUDE.md" + - "C:/Program Files/ClaudeCode/CLAUDE.md" + precedence: managed + vcs: external + maintainer: system + + override: + format: freeform + scope: global + cardinality: optional + lifecycle: mutable + loading: session_start + scopes: + local: + patterns: ["CLAUDE.local.md"] + precedence: user + vcs: gitignored + maintainer: human + + child_instruction: + format: freeform + scope: path_scoped + cardinality: collection + lifecycle: static + loading: on_demand + scopes: + project: + patterns: ["**/CLAUDE.md"] + precedence: project + vcs: committed + maintainer: human + + rules: + format: [frontmatter, freeform] + scope: path_scoped + cardinality: collection + lifecycle: static + loading: on_demand + scopes: + project: + patterns: [".claude/rules/**/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/rules/*.md"] + precedence: user + vcs: external + maintainer: human + loading: session_start + + skills: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/skills/**/SKILL.md"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/etc/claude-code/skills/**/SKILL.md" + - "/Library/Application Support/ClaudeCode/skills/**/SKILL.md" + precedence: managed + vcs: external + maintainer: system + + agents: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/agents/**/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/agents/**/*.md"] + precedence: user + vcs: external + maintainer: human + + commands: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/commands/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/commands/*.md"] + precedence: user + vcs: external + maintainer: human + + output_styles: + format: [frontmatter, freeform] + scope: global + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/output-styles/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/output-styles/*.md"] + precedence: user + vcs: external + maintainer: human + + config: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".claude/settings.json"] + precedence: project + vcs: committed + maintainer: human + local: + patterns: [".claude/settings.local.json"] + precedence: user + vcs: gitignored + maintainer: human + cardinality: optional + lifecycle: mutable + user: + patterns: ["~/.claude/settings.json"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/etc/claude-code/managed-settings.json" + - "/Library/Application Support/ClaudeCode/managed-settings.json" + - "C:/Program Files/ClaudeCode/managed-settings.json" + precedence: managed + vcs: external + maintainer: system + managed_dropin: + patterns: + - "/etc/claude-code/managed-settings.d/*.json" + - "/Library/Application Support/ClaudeCode/managed-settings.d/*.json" + precedence: managed + vcs: external + maintainer: system + cardinality: collection + + mcp: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".mcp.json"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude.json"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/etc/claude-code/managed-mcp.json" + - "/Library/Application Support/ClaudeCode/managed-mcp.json" + precedence: managed + vcs: external + maintainer: system + + memory: + format: freeform + scope: global + cardinality: singleton + lifecycle: mutable + loading: session_start + scopes: + auto: + patterns: ["~/.claude/projects/*/memory/MEMORY.md"] + precedence: auto + vcs: external + maintainer: agent + + subagent_memory: + # Per-subagent persistent memory. Scope chosen via `memory:` frontmatter field + # on the agent definition (user|project|local) — mutually exclusive per subagent. + # MEMORY.md (first 200 lines / 25KB) injected into subagent system prompt at startup. + format: freeform + scope: task_scoped + cardinality: collection + lifecycle: mutable + loading: session_start + scopes: + user: + patterns: ["~/.claude/agent-memory/*/"] + precedence: auto + vcs: external + maintainer: agent + project: + patterns: [".claude/agent-memory/*/"] + precedence: auto + vcs: committed + maintainer: agent + local: + patterns: [".claude/agent-memory-local/*/"] + precedence: auto + vcs: gitignored + maintainer: agent + + worktree_include: + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".worktreeinclude"] + precedence: project + vcs: committed + maintainer: human + + keybindings: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + user: + patterns: ["~/.claude/keybindings.json"] + precedence: user + vcs: external + maintainer: human + +excludes: + - CORE:C:0026 # Cross-agent compatibility — AGENTS.md is not a Claude surface diff --git a/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/checks.yml b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/checks.yml new file mode 100644 index 00000000..4cf8ce4b --- /dev/null +++ b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.S.0005.settings_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0005.valid_event_types + expect: absent + message: Valid event types not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: (?:PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|SessionStart|SessionEnd|UserPromptSubmit|PermissionRequest|Notification|SubagentStart|SubagentStop|Stop|TeammateIdle|TaskCompleted) + type: deterministic diff --git a/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/rule.md b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/rule.md new file mode 100644 index 00000000..061a2ea2 --- /dev/null +++ b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/rule.md @@ -0,0 +1,46 @@ +--- +id: CLAUDE:S:0005 +slug: hook-configuration-uses-recognized-event-type-names-as-keys +title: Hook Configuration Uses Recognized Event Type Names As Keys +category: structure +type: deterministic +severity: high +backed_by: +- claude-code-hooks +- claude-code-settings +match: {type: config} +--- + +# Hook Configuration Uses Recognized Event Type Names As Keys + +Hook event keys in `.claude/settings.json` MUST use recognized Claude Code event type names. Unrecognized event names are silently ignored, so a typo like `"PreTooluse"` (lowercase u) means the hook never fires. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "PreToolUse": [{ "type": "command", "command": "echo pre" }], + "PostToolUse": [{ "type": "command", "command": "echo post" }], + "Stop": [{ "type": "command", "command": "./cleanup.sh" }] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "onToolUse": [{ "type": "command", "command": "echo hook" }], + "before_tool": [{ "type": "command", "command": "echo hook" }] + } +} +``` + +## Limitations + +Only checks that at least one recognized event type is present. Does not detect misspelled event names if a valid one also exists. + diff --git a/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/fail/.claude/settings.json b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/pass/.claude/settings.json b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/pass/.claude/settings.json new file mode 100644 index 00000000..6dccca63 --- /dev/null +++ b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +Valid hook events: PreToolUse, PostToolUse, SessionStart, Stop, etc. diff --git a/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/vocab.yml b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/vocab.yml new file mode 100644 index 00000000..1b53d8fe --- /dev/null +++ b/framework/rules/claude/hook-configuration-uses-recognized-event-type-names-as-keys/vocab.yml @@ -0,0 +1,15 @@ +valid_event_types: +- PreToolUse +- PostToolUse +- PostToolUseFailure +- PreCompact +- SessionStart +- SessionEnd +- UserPromptSubmit +- PermissionRequest +- Notification +- SubagentStart +- SubagentStop +- Stop +- TeammateIdle +- TaskCompleted diff --git a/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/checks.yml b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/checks.yml new file mode 100644 index 00000000..6bad251a --- /dev/null +++ b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.S.0006.settings_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0006.handler_has_type + expect: absent + message: Handler has type not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: '"type"\s*:\s*"(command|prompt|agent)"' + type: deterministic diff --git a/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/rule.md b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/rule.md new file mode 100644 index 00000000..ee8baaca --- /dev/null +++ b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/rule.md @@ -0,0 +1,48 @@ +--- +id: CLAUDE:S:0006 +slug: hook-handler-objects-in-settings-json-contain-a-type-field-w +title: Hook Handler Objects In Settings Json Contain A Type Field With Value + Command, Prompt, Or Agent +category: structure +type: deterministic +severity: high +backed_by: +- claude-code-hooks +match: {type: config} +--- + +# Hook Handler Type Field Required + +Each hook handler object in `.claude/settings.json` MUST contain a `"type"` field set to `"command"`, `"prompt"`, or `"agent"`. Without a type field, Claude Code cannot dispatch the handler and the hook silently does nothing. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "npm run lint" }, + { "type": "prompt", "prompt": "Check for security issues" } + ] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "PreToolUse": [ + { "command": "npm run lint" } + ] + } +} +``` + +## Limitations + +Checks that at least one handler has a valid type field. Does not verify every handler individually when multiple handlers are defined for the same event. + diff --git a/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/fail/.claude/settings.json b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/pass/.claude/settings.json b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/pass/.claude/settings.json new file mode 100644 index 00000000..34e0aaf1 --- /dev/null +++ b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +"type": "command" diff --git a/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/vocab.yml b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/vocab.yml new file mode 100644 index 00000000..f38e7141 --- /dev/null +++ b/framework/rules/claude/hook-handler-objects-in-settings-json-contain-a-type-field-w/vocab.yml @@ -0,0 +1,4 @@ +handler_has_type: +- command +- prompt +- agent diff --git a/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/checks.yml b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/checks.yml new file mode 100644 index 00000000..de2a403c --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.S.0004.settings_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0004.command_hook_has_command + expect: absent + message: Command hook has command not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: '"command"\s*:\s*"[^"]+"' + type: deterministic diff --git a/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/rule.md b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/rule.md new file mode 100644 index 00000000..b56d272d --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/rule.md @@ -0,0 +1,48 @@ +--- +id: CLAUDE:S:0004 +slug: hook-handlers-with-type-command-contain-a-command-field-with +title: Hook Handlers With Type Command Contain A Command Field With The Shell + Command To Execute +category: structure +type: deterministic +severity: high +backed_by: +- claude-code-hooks +- claude-code-settings +match: {type: config} +--- + +# Hook Command Field Required + +Hook handlers with `"type": "command"` MUST include a `"command"` field containing the shell command to execute. Without it, Claude Code has no command to run and the hook fails silently. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "/bin/bash .claude/hooks/lint.sh" } + ] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "matcher": "Edit" } + ] + } +} +``` + +## Limitations + +Checks that at least one `"command"` field exists in the settings file. Does not verify the command is a valid executable or that it pairs correctly with a `"type": "command"` handler. + diff --git a/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/fail/.claude/settings.json b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/pass/.claude/settings.json b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/pass/.claude/settings.json new file mode 100644 index 00000000..ec47de35 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-command-contain-a-command-field-with/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +"command": "/bin/bash .claude/hooks/lint.sh" diff --git a/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/checks.yml b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/checks.yml new file mode 100644 index 00000000..d96eeb28 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.S.0007.settings_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0007.prompt_hook_has_prompt + expect: absent + message: Prompt hook has prompt not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: '"prompt"\s*:\s*"[^"]+"' + type: deterministic diff --git a/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/rule.md b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/rule.md new file mode 100644 index 00000000..cf50a693 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/rule.md @@ -0,0 +1,46 @@ +--- +id: CLAUDE:S:0007 +slug: hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie +title: Hook Handlers With Type Prompt Or Agent Contain A Prompt Field +category: structure +type: deterministic +severity: high +backed_by: +- claude-code-hooks +match: {type: config} +--- + +# Hook Prompt Field Required + +Hook handlers with `"type": "prompt"` or `"type": "agent"` MUST include a `"prompt"` field containing the instruction text. Without it, Claude Code has no prompt to inject and the hook does nothing. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "prompt", "prompt": "Check for security issues before proceeding" } + ] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "prompt", "matcher": "Edit" } + ] + } +} +``` + +## Limitations + +Checks that at least one `"prompt"` field exists. Does not verify the prompt pairs correctly with a `"type": "prompt"` or `"type": "agent"` handler. + diff --git a/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/fail/.claude/settings.json b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/pass/.claude/settings.json b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/pass/.claude/settings.json new file mode 100644 index 00000000..d144e534 --- /dev/null +++ b/framework/rules/claude/hook-handlers-with-type-prompt-or-agent-contain-a-prompt-fie/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +"prompt": "Check for security issues before proceeding" diff --git a/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/checks.yml b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/checks.yml new file mode 100644 index 00000000..756d6d78 --- /dev/null +++ b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.G.0001.settings_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.G.0001.uses_project_dir_var + expect: absent + message: Uses project dir var not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: (?:\$CLAUDE_PROJECT_DIR|\$CLAUDE_ENV_FILE|\$\{CLAUDE_PROJECT_DIR\}) + type: deterministic diff --git a/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/rule.md b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/rule.md new file mode 100644 index 00000000..de4215a9 --- /dev/null +++ b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/rule.md @@ -0,0 +1,48 @@ +--- +id: CLAUDE:G:0001 +slug: hook-shell-commands-reference-claude-project-dir-instead-of- +title: Hook Shell Commands Reference $Claude Project Dir Instead Of Hardcoded + Paths +category: governance +type: deterministic +severity: medium +backed_by: +- claude-code-hooks +- claude-code-settings +match: {type: config} +--- + +# Hook Commands Use Project Dir Variable + +Hook shell commands SHOULD reference `$CLAUDE_PROJECT_DIR` or `$CLAUDE_ENV_FILE` instead of hardcoded absolute paths. Claude Code injects these environment variables at runtime — using them makes hooks portable across machines and collaborators. + +## Pass / Fail + +### Pass + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint.sh" } + ] + } +} +``` + +### Fail + +```json +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "/home/user/project/.claude/hooks/lint.sh" } + ] + } +} +``` + +## Limitations + +Checks that at least one Claude environment variable reference exists. Does not flag individual commands that use hardcoded paths if other commands already use the variable. + diff --git a/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/fail/.claude/settings.json b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/pass/.claude/settings.json b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/pass/.claude/settings.json new file mode 100644 index 00000000..496758fd --- /dev/null +++ b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +Use $CLAUDE_PROJECT_DIR in hook commands for portable paths diff --git a/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/vocab.yml b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/vocab.yml new file mode 100644 index 00000000..c0fd0b1f --- /dev/null +++ b/framework/rules/claude/hook-shell-commands-reference-claude-project-dir-instead-of-/vocab.yml @@ -0,0 +1,4 @@ +uses_project_dir_var: +- \$CLAUDE_PROJECT_DIR +- \$CLAUDE_ENV_FILE +- \$\{CLAUDE_PROJECT_DIR\} diff --git a/framework/rules/claude/import-depth-within-limit/checks.yml b/framework/rules/claude/import-depth-within-limit/checks.yml new file mode 100644 index 00000000..33b929d8 --- /dev/null +++ b/framework/rules/claude/import-depth-within-limit/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CLAUDE.S.0010.file_in_scope + type: mechanical + check: file_exists +- id: CLAUDE.S.0010.depth_check + type: mechanical + check: import_depth + args: + max: 3 diff --git a/framework/rules/claude/import-depth-within-limit/rule.md b/framework/rules/claude/import-depth-within-limit/rule.md new file mode 100644 index 00000000..40a3b9b5 --- /dev/null +++ b/framework/rules/claude/import-depth-within-limit/rule.md @@ -0,0 +1,17 @@ +--- +id: CLAUDE:S:0010 +slug: import-depth-within-limit +title: "Import Depth Within Limit" +category: structure +type: mechanical +severity: medium +match: {type: main} +--- + +# Import Depth Within Limit + +Import chains should not exceed 3 levels deep. Deep import hierarchies increase context loading time and create fragile dependency chains. + +## Limitations + +Counts import depth from the root instruction file. Does not evaluate whether deep imports are justified by project complexity. diff --git a/framework/rules/claude/memory-file-within-200-lines/checks.yml b/framework/rules/claude/memory-file-within-200-lines/checks.yml new file mode 100644 index 00000000..cdd2e630 --- /dev/null +++ b/framework/rules/claude/memory-file-within-200-lines/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CLAUDE.S.0011.file_in_scope + type: mechanical + check: file_exists +- id: CLAUDE.S.0011.length_check + type: mechanical + check: line_count + args: + max: 200 diff --git a/framework/rules/claude/memory-file-within-200-lines/rule.md b/framework/rules/claude/memory-file-within-200-lines/rule.md new file mode 100644 index 00000000..fa6e1306 --- /dev/null +++ b/framework/rules/claude/memory-file-within-200-lines/rule.md @@ -0,0 +1,17 @@ +--- +id: CLAUDE:S:0011 +slug: memory-file-within-200-lines +title: "Memory File Length Limit" +category: structure +type: mechanical +severity: medium +match: {type: memory} +--- + +# Memory File Length Limit + +MEMORY.md should stay under 200 lines. Lines beyond 200 are truncated by Claude Code. Keep the index concise — store detail in linked memory files, not inline. + +## Limitations + +The 200-line limit is based on Claude Code's truncation behavior. Future versions may change this threshold. diff --git a/framework/rules/claude/rule-snippet-length/checks.yml b/framework/rules/claude/rule-snippet-length/checks.yml new file mode 100644 index 00000000..e0fabdd0 --- /dev/null +++ b/framework/rules/claude/rule-snippet-length/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CLAUDE.S.0009.file_in_scope + type: mechanical + check: file_exists +- id: CLAUDE.S.0009.length_check + type: mechanical + check: line_count + args: + max: 100 diff --git a/framework/rules/claude/rule-snippet-length/rule.md b/framework/rules/claude/rule-snippet-length/rule.md new file mode 100644 index 00000000..0999b8bc --- /dev/null +++ b/framework/rules/claude/rule-snippet-length/rule.md @@ -0,0 +1,17 @@ +--- +id: CLAUDE:S:0009 +slug: rule-snippet-length +title: "Rule File Length Limit" +category: structure +type: mechanical +severity: medium +match: {type: scoped_rule} +--- + +# Rule File Length Limit + +Keep `.claude/rules/*.md` files under 100 lines. Long rule files compete for attention with other context. Each rule file should address one topic with focused instructions. + +## Limitations + +Counts total lines including frontmatter, headings, and blank lines. Files just over the threshold may be acceptable if the content is dense and focused. diff --git a/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/checks.yml b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/checks.yml new file mode 100644 index 00000000..a60dc05e --- /dev/null +++ b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CLAUDE.S.0001.skill_dir_exists + type: mechanical + check: glob_match +- id: CLAUDE.S.0001.no_readme + type: mechanical + check: file_absent + args: + pattern: README.md diff --git a/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/rule.md b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/rule.md new file mode 100644 index 00000000..c2f39d76 --- /dev/null +++ b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/rule.md @@ -0,0 +1,40 @@ +--- +id: CLAUDE:S:0001 +slug: skill-folder-does-not-contain-readme-md-all-documentation-go +title: Skill Folder Does Not Contain Readme.Md — All Documentation Goes In + Skill.Md +category: structure +type: mechanical +severity: high +backed_by: +- building-skills-for-claude +match: {type: skill} +--- + +# Skill Folder — No README.md + +Skill directories under `.claude/skills/` MUST NOT contain a `README.md` file. All skill documentation belongs in `SKILL.md` — Claude Code discovers and loads `SKILL.md` as the skill entry point. A separate `README.md` splits documentation across two files and the extra file is never loaded. + +## Pass / Fail + +### Pass + +``` +.claude/skills/commit/ +├── SKILL.md # All documentation here +└── helpers.py +``` + +### Fail + +``` +.claude/skills/commit/ +├── SKILL.md +├── README.md # Redundant — not loaded by Claude Code +└── helpers.py +``` + +## Limitations + +Only checks for `README.md` presence in skill directories. Does not detect other redundant documentation files. + diff --git a/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/README.md b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/README.md new file mode 100644 index 00000000..fc338922 --- /dev/null +++ b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/README.md @@ -0,0 +1,3 @@ +# Example Skill + +This README should not exist in a skill folder. diff --git a/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..f9be9b61 --- /dev/null +++ b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/fail/.claude/skills/example/SKILL.md @@ -0,0 +1,8 @@ +--- +name: example +description: Example skill +--- + +# Example Skill + +Run the example workflow. \ No newline at end of file diff --git a/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..3bf71217 --- /dev/null +++ b/framework/rules/claude/skill-folder-does-not-contain-readme-md-all-documentation-go/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1,8 @@ +--- +name: example +description: Example skill +--- + +# Example Skill + +Run the example workflow. diff --git a/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/checks.yml b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/checks.yml new file mode 100644 index 00000000..472fe7f2 --- /dev/null +++ b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/checks.yml @@ -0,0 +1,7 @@ +checks: +- id: CLAUDE.S.0003.skill_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0003.description_length + type: mechanical + check: byte_size diff --git a/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/rule.md b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/rule.md new file mode 100644 index 00000000..c27d77f6 --- /dev/null +++ b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/rule.md @@ -0,0 +1,41 @@ +--- +id: CLAUDE:S:0003 +slug: skill-yaml-frontmatter-description-field-is-under-1024-chara +title: Skill Yaml Frontmatter Description Field Is Under 1024 Characters And + Contains No Xml Angle Brackets +category: structure +type: mechanical +severity: high +backed_by: +- building-skills-for-claude +match: {type: skill} +--- + +# Skill Description Length Limit + +The `description` field in `SKILL.md` YAML frontmatter MUST be under 1024 characters. Claude Code uses this field for skill discovery — long descriptions waste context tokens and may be truncated. Keep descriptions concise: state what the skill does and when to invoke it. + +## Pass / Fail + +### Pass + +```yaml +--- +name: commit +description: "Create a git commit with a conventional message format. Use when the user asks to commit changes." +--- +``` + +### Fail + +```yaml +--- +name: commit +description: "This skill handles creating git commits. It supports conventional commit format, multi-line messages, co-authored-by trailers, GPG signing, and can also amend previous commits. The skill reads the git diff, analyzes changes across all modified files, determines the appropriate commit type (feat, fix, chore, docs, refactor, test, style, perf, ci, build), generates a summary of changes... [800+ more characters]" +--- +``` + +## Limitations + +Checks total file size as a proxy for description length. Does not parse the YAML frontmatter to measure the description field independently. + diff --git a/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/tests/fail/.gitkeep b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..dc76fa39 --- /dev/null +++ b/framework/rules/claude/skill-yaml-frontmatter-description-field-is-under-1024-chara/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +# Instruction file diff --git a/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/checks.yml b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/checks.yml new file mode 100644 index 00000000..07dc2c91 --- /dev/null +++ b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CLAUDE.S.0002.skill_file_exists + type: mechanical + check: file_exists +- id: CLAUDE.S.0002.name_matches_dir + expect: absent + message: Name matches dir not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: name:\s+[a-z][a-z0-9]*(?:-[a-z0-9]+)* + type: deterministic diff --git a/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/rule.md b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/rule.md new file mode 100644 index 00000000..6618147b --- /dev/null +++ b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/rule.md @@ -0,0 +1,41 @@ +--- +id: CLAUDE:S:0002 +slug: the-name-field-in-skill-md-frontmatter-matches-the-containin +title: The Name Field In Skill.Md Frontmatter Matches The Containing Directory + Name (Kebab Case) +category: structure +type: deterministic +severity: medium +backed_by: +- building-skills-for-claude +match: {type: skill} +--- + +# Skill Name Matches Directory + +The `name` field in `SKILL.md` YAML frontmatter MUST match the containing directory name in kebab-case. Claude Code uses the directory name for skill discovery and the frontmatter name for display — a mismatch causes the skill to be invocable under one name but displayed under another. + +## Pass / Fail + +### Pass + +``` +.claude/skills/commit-helper/SKILL.md +--- +name: commit-helper +--- +``` + +### Fail + +``` +.claude/skills/commit-helper/SKILL.md +--- +name: commitHelper +--- +``` + +## Limitations + +Checks that a kebab-case `name:` field exists in frontmatter. Does not verify the name matches the exact directory name — only that the format is valid kebab-case. + diff --git a/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/fail/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/fail/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..8a335b54 --- /dev/null +++ b/framework/rules/claude/the-name-field-in-skill-md-frontmatter-matches-the-containin/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +name: commit-helper diff --git a/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/checks.yml b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/checks.yml new file mode 100644 index 00000000..e306e812 --- /dev/null +++ b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CODEX.S.0001.file_in_scope + type: mechanical + check: file_exists +- id: CODEX.S.0001.discovery_chain_documented + expect: absent + message: Discovery chain documented not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: (?i)(?:AGENTS\.override|override.*fallback|fallback.*filename|project_doc_fallback|discovery.*order|precedence.*override) + type: deterministic diff --git a/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/rule.md b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/rule.md new file mode 100644 index 00000000..b50d246a --- /dev/null +++ b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/rule.md @@ -0,0 +1,44 @@ +--- +id: CODEX:S:0001 +slug: codex-reads-agents-override-md-first-then-agents-md-then-fal +title: Codex Reads Agents.Override.Md First, Then Agents.Md, Then Fallback + Filenames Per Directory Root To Cwd +category: structure +type: deterministic +severity: medium +backed_by: +- codex-agent-loop +- codex-agents-md +- codex-prompting-guide +- codex-skills-guide +- openai-codex-own-agents-md +match: {type: main} +--- + +# Codex Discovery Chain Documented + +Codex discovers instruction files in a specific order: `AGENTS.override.md` first, then `AGENTS.md`, then fallback filenames — walking from the project root to the current working directory. Document this discovery chain so users understand which file takes precedence and where to put overrides. + +## Pass / Fail + +### Pass + +```markdown +# Instructions + +Codex reads AGENTS.override.md for local overrides, then falls back to AGENTS.md. +Place machine-specific settings in the override file. +``` + +### Fail + +```markdown +# Instructions + +Put your instructions here. +``` + +## Limitations + +Checks for keywords related to the discovery chain (`override`, `fallback`, `precedence`). Does not verify the documented order is technically correct. + diff --git a/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/vocab.yml b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/vocab.yml new file mode 100644 index 00000000..344319e7 --- /dev/null +++ b/framework/rules/codex/codex-reads-agents-override-md-first-then-agents-md-then-fal/vocab.yml @@ -0,0 +1,7 @@ +discovery_chain_documented: +- AGENTS\.override +- override.*fallback +- fallback.*filename +- project_doc_fallback +- discovery.*order +- precedence.*override diff --git a/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/checks.yml b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/checks.yml new file mode 100644 index 00000000..81f92117 --- /dev/null +++ b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CODEX.S.0002.skill_dir_exists + type: mechanical + check: glob_match +- id: CODEX.S.0002.has_openai_yaml + expect: absent + message: Openai yaml not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: (?:display_name|allow_implicit_invocation|brand_color) + type: deterministic diff --git a/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/rule.md b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/rule.md new file mode 100644 index 00000000..4b460dc4 --- /dev/null +++ b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/rule.md @@ -0,0 +1,39 @@ +--- +id: CODEX:S:0002 +slug: codex-skill-directory-contains-agents-openai-yaml-with-displ +title: Codex Skill Directory Contains Agents/Openai.Yaml With Display Name, + Icon, And Policy Fields +category: structure +type: deterministic +severity: low +backed_by: +- codex-skills-guide +match: {type: skill} +--- + +# Codex Skill Metadata Present + +Codex skill directories SHOULD contain an `agents/openai.yaml` file with `display_name`, icon, and invocation policy fields. This metadata controls how the skill appears in the Codex UI and whether it can be triggered implicitly. + +## Pass / Fail + +### Pass + +```yaml +# agents/openai.yaml +display_name: Code Review +brand_color: "#4A90D9" +allow_implicit_invocation: true +``` + +### Fail + +``` +agents/ +└── (no openai.yaml) +``` + +## Limitations + +Checks for the presence of metadata keywords (`display_name`, `allow_implicit_invocation`, `brand_color`). Does not validate YAML syntax or field values. + diff --git a/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/fail/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/fail/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..ec3f3a69 --- /dev/null +++ b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1,2 @@ +display_name: Code Review +allow_implicit_invocation: true diff --git a/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/vocab.yml b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/vocab.yml new file mode 100644 index 00000000..2fb20b11 --- /dev/null +++ b/framework/rules/codex/codex-skill-directory-contains-agents-openai-yaml-with-displ/vocab.yml @@ -0,0 +1,4 @@ +has_openai_yaml: +- display_name +- allow_implicit_invocation +- brand_color diff --git a/framework/rules/codex/config.yml b/framework/rules/codex/config.yml new file mode 100644 index 00000000..159cd59f --- /dev/null +++ b/framework/rules/codex/config.yml @@ -0,0 +1,157 @@ +# OpenAI Codex Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 +# +# AGENTS.md is a cross-agent standard (agents.md) — its presence alone +# does not indicate a Codex project. Detection requires .codex/ markers +# (config.toml, rules/, agents/, hooks.json) or AGENTS.override.md. + +agent: codex +version: "0.5.0" +prefix: CODEX +name: OpenAI Codex + +file_types: + main: + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/AGENTS.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.codex/AGENTS.md", "~/.codex/AGENTS.override.md"] + precedence: user + vcs: external + maintainer: human + + override: + format: freeform + scope: global + cardinality: optional + lifecycle: mutable + loading: session_start + scopes: + project: + patterns: ["**/AGENTS.override.md"] + precedence: user + vcs: committed + maintainer: human + + skills: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".agents/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.agents/skills/**/SKILL.md", "~/.codex/skills/**/SKILL.md"] + precedence: user + vcs: external + maintainer: human + system: + patterns: ["/etc/codex/skills/**/SKILL.md"] + precedence: managed + vcs: external + maintainer: system + + skill_metadata: + # Per-skill optional: display info, policy, MCP dependencies + format: schema_validated + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: ["**/agents/openai.yaml"] + precedence: project + vcs: committed + maintainer: human + + agents: + format: schema_validated + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".codex/agents/*.toml"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.codex/agents/*.toml"] + precedence: user + vcs: external + maintainer: human + + rules: + format: schema_validated + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".codex/rules/*.rules"] + precedence: project + vcs: committed + maintainer: human + + hooks: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".codex/hooks.json"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.codex/hooks.json"] + precedence: user + vcs: external + maintainer: human + + config: + # contains harness settings AND attention-channel instructions/developer_instructions fields + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".codex/config.toml"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.codex/config.toml"] + precedence: user + vcs: external + maintainer: human + system: + patterns: ["/etc/codex/requirements.toml"] + precedence: managed + vcs: external + maintainer: system + +excludes: + - CLAUDE:* + - COPILOT:* diff --git a/framework/rules/copilot/applyto-scope-declared/checks.yml b/framework/rules/copilot/applyto-scope-declared/checks.yml new file mode 100644 index 00000000..fed4943c --- /dev/null +++ b/framework/rules/copilot/applyto-scope-declared/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: COPILOT.S.0001.instruction_file_exists + type: mechanical + check: file_exists +- id: COPILOT.S.0001.has_applyto_frontmatter + type: mechanical + check: frontmatter_key + args: + key: applyTo diff --git a/framework/rules/copilot/applyto-scope-declared/rule.md b/framework/rules/copilot/applyto-scope-declared/rule.md new file mode 100644 index 00000000..45502e0e --- /dev/null +++ b/framework/rules/copilot/applyto-scope-declared/rule.md @@ -0,0 +1,44 @@ +--- +id: COPILOT:S:0001 +slug: applyto-scope-declared +title: ApplyTo Scope Declared +category: structure +type: mechanical +severity: high +backed_by: +- awesome-copilot-meta-instructions +- copilot-ai-best-practices-vscode +- copilot-coding-agent-best-practices +- copilot-coding-agent-results +- copilot-coding-agent-tasks +- copilot-custom-instructions +- copilot-custom-instructions-vscode +match: {type: scoped_rule} +--- + +# ApplyTo Scope Declared + +Scoped `.github/copilot-instructions.md` files MUST include an `applyTo` field in their YAML frontmatter to declare which file patterns the instructions target. Without `applyTo`, Copilot applies the instructions globally, which defeats the purpose of scoped instruction files and can cause irrelevant guidance to appear in unrelated contexts. + +## Pass / Fail + +### Pass + +```yaml +--- +applyTo: "**/*.py" +--- + +Use type hints on all function signatures. +``` + +### Fail + +```markdown +Use type hints on all function signatures. +``` + +## Limitations + +Checks for the presence of an `applyTo` frontmatter key. Does not validate that the glob pattern is syntactically correct or matches actual project files. + diff --git a/framework/rules/copilot/applyto-scope-declared/tests/fail/.github/instructions/python.instructions.md b/framework/rules/copilot/applyto-scope-declared/tests/fail/.github/instructions/python.instructions.md new file mode 100644 index 00000000..7258e928 --- /dev/null +++ b/framework/rules/copilot/applyto-scope-declared/tests/fail/.github/instructions/python.instructions.md @@ -0,0 +1,3 @@ +# Python Rules + +Python-specific coding conventions. diff --git a/framework/rules/copilot/applyto-scope-declared/tests/pass/.github/instructions/python.instructions.md b/framework/rules/copilot/applyto-scope-declared/tests/pass/.github/instructions/python.instructions.md new file mode 100644 index 00000000..1f546a4e --- /dev/null +++ b/framework/rules/copilot/applyto-scope-declared/tests/pass/.github/instructions/python.instructions.md @@ -0,0 +1,7 @@ +--- +applyTo: '**/*.py' +--- + +# Python Rules + +Python-specific coding conventions. diff --git a/framework/rules/copilot/config.yml b/framework/rules/copilot/config.yml new file mode 100644 index 00000000..28f21cf2 --- /dev/null +++ b/framework/rules/copilot/config.yml @@ -0,0 +1,119 @@ +# GitHub Copilot Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 + +agent: copilot +version: "0.5.0" +prefix: COPILOT +name: GitHub Copilot + +file_types: + main: + required: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".github/copilot-instructions.md", "**/AGENTS.md"] + precedence: project + vcs: committed + maintainer: human + + rules: + format: [frontmatter, freeform] + scope: path_scoped + cardinality: collection + lifecycle: static + loading: on_demand + scopes: + project: + patterns: [".github/instructions/**/*.instructions.md", ".claude/rules/**/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/rules/*.md"] + precedence: user + vcs: external + maintainer: human + + skills: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".github/skills/**/SKILL.md", ".claude/skills/**/SKILL.md", ".agents/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.copilot/skills/**/SKILL.md", "~/.agents/skills/**/SKILL.md"] + precedence: user + vcs: external + maintainer: human + + agents: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".github/agents/*.agent.md", ".claude/agents/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.copilot/agents/*.agent.md"] + precedence: user + vcs: external + maintainer: human + + hooks: + format: schema_validated + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".github/hooks/*.json"] + precedence: project + vcs: committed + maintainer: human + + prompts: + format: [frontmatter, freeform] + scope: global + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".github/prompts/*.prompt.md"] + precedence: project + vcs: committed + maintainer: human + + mcp: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".vscode/mcp.json"] + precedence: project + vcs: committed + maintainer: human + +excludes: + - CLAUDE:* + - CODEX:* diff --git a/framework/rules/copilot/setup-steps-defined/checks.yml b/framework/rules/copilot/setup-steps-defined/checks.yml new file mode 100644 index 00000000..dfd09b4a --- /dev/null +++ b/framework/rules/copilot/setup-steps-defined/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: COPILOT.S.0002.setup_file_exists + type: mechanical + check: file_exists +- id: COPILOT.S.0002.has_steps_array + expect: absent + message: Steps array not found + patterns: + - pattern-regex: (?s)\A[\s\S]+ + - pattern-not-regex: '(?:^|\n)steps\s*:' + type: deterministic diff --git a/framework/rules/copilot/setup-steps-defined/rule.md b/framework/rules/copilot/setup-steps-defined/rule.md new file mode 100644 index 00000000..1111f473 --- /dev/null +++ b/framework/rules/copilot/setup-steps-defined/rule.md @@ -0,0 +1,42 @@ +--- +id: COPILOT:S:0002 +slug: setup-steps-defined +title: Setup Steps Defined +category: structure +type: deterministic +severity: low +backed_by: +- copilot-coding-agent-best-practices +- copilot-coding-agent-results +- copilot-coding-agent-tasks +match: {type: main} +--- + +# Setup Steps Defined + +Copilot Coding Agent projects SHOULD define a `steps:` array in their configuration to specify workspace setup commands. These steps run before the agent starts working — without them, the agent may fail on projects that require dependency installation, database setup, or build steps. + +## Pass / Fail + +### Pass + +```yaml +steps: + - name: install + command: npm install + - name: build + command: npm run build +``` + +### Fail + +```markdown +# Project Setup + +Run npm install to get started. +``` + +## Limitations + +Checks for the presence of a `steps:` key in the file. Does not validate that individual step entries have valid `name` and `command` fields. + diff --git a/framework/rules/copilot/setup-steps-defined/vocab.yml b/framework/rules/copilot/setup-steps-defined/vocab.yml new file mode 100644 index 00000000..71aa38b9 --- /dev/null +++ b/framework/rules/copilot/setup-steps-defined/vocab.yml @@ -0,0 +1,3 @@ +has_steps_array: +- ^ +- \n diff --git a/framework/rules/core/agent-documents-filenames/checks.yml b/framework/rules/core/agent-documents-filenames/checks.yml new file mode 100644 index 00000000..7bd65183 --- /dev/null +++ b/framework/rules/core/agent-documents-filenames/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0012.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0012.pattern_check + type: deterministic + pattern-regex: '(?i)(CLAUDE\.md|\.cursorrules|copilot-instructions|AGENTS\.md|\.windsurfrules|filename|file\s+name)' + expect: present + message: "Missing filename documentation — list which instruction filenames the agent checks" diff --git a/framework/rules/core/agent-documents-filenames/rule.md b/framework/rules/core/agent-documents-filenames/rule.md new file mode 100644 index 00000000..1567cab2 --- /dev/null +++ b/framework/rules/core/agent-documents-filenames/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0012 +slug: agent-documents-filenames +title: "Agent Documents Filenames" +category: structure +type: deterministic +severity: medium +backed_by: [] +match: {type: scoped_rule} +--- + +# Agent Documents Filenames + +Agent configuration must document which instruction filenames it checks and in what priority order. + +## Antipatterns + +- Describing agent behavior generically ("the agent reads configuration files") without naming specific instruction filenames like `CLAUDE.md` or `.cursorrules`. The check looks for recognized filenames, not general descriptions. +- Listing only one agent's filename when the file covers multiple agents. The check passes with any recognized filename, but a single-agent list in a multi-agent scoped rule leaves gaps. +- Using informal references like "the main config" instead of the actual filename. The pattern matches literal filenames and the words "filename" or "file name" -- synonyms like "config" or "settings" do not match. + +## Pass / Fail + +### Pass + +~~~~markdown +# Discovery + +Claude Code reads `CLAUDE.md` at session start. +Cursor checks `.cursorrules` in the project root. +Copilot loads `copilot-instructions.md` from `.github/`. +~~~~ + +### Fail + +~~~~markdown +# Discovery + +The agent reads its configuration from the project root. +Priority is determined by the loading order. +~~~~ + +## Limitations + +Checks that the file documents recognized instruction filenames. Does not validate whether the documented filenames match actual project files. diff --git a/framework/rules/core/agent-documents-filenames/vocab.yml b/framework/rules/core/agent-documents-filenames/vocab.yml new file mode 100644 index 00000000..f2a96698 --- /dev/null +++ b/framework/rules/core/agent-documents-filenames/vocab.yml @@ -0,0 +1,7 @@ +agent_documents_filenames: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/agent-memory-directory/checks.yml b/framework/rules/core/agent-memory-directory/checks.yml new file mode 100644 index 00000000..b37bafcd --- /dev/null +++ b/framework/rules/core/agent-memory-directory/checks.yml @@ -0,0 +1,5 @@ +checks: +- id: CORE.S.0023.check + type: mechanical + check: file_exists + # hint: memory directory exists diff --git a/framework/rules/core/agent-memory-directory/rule.md b/framework/rules/core/agent-memory-directory/rule.md new file mode 100644 index 00000000..f468e1b8 --- /dev/null +++ b/framework/rules/core/agent-memory-directory/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:S:0023 +slug: agent-memory-directory +title: "Agent Memory Directory" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: memory} +--- + +# Agent Memory Directory + +Agent memory must be stored in a dedicated directory, separate from instruction content. + +## Antipatterns + +- Storing memory files alongside instruction rules in `.claude/rules/` instead of a dedicated memory directory. Memory and rules serve different purposes and the check expects a separate memory path. +- Creating a memory file at the project root without a containing directory. The check verifies that the memory directory itself exists, not just individual memory files. +- Naming the directory something non-standard that the agent does not recognize. The check looks for the memory directory at the path mapped for the memory file type. + +## Pass / Fail + +### Pass + +~~~~markdown +project/ + .claude/ + memory/ + MEMORY.md +~~~~ + +### Fail + +~~~~markdown +project/ + .claude/ + rules/ + MEMORY.md +~~~~ + +## Limitations + +Checks that the memory directory exists. Does not evaluate directory contents or memory file quality. diff --git a/framework/rules/core/agent-memory-directory/tests/pass/.claude/memory/MEMORY.md b/framework/rules/core/agent-memory-directory/tests/pass/.claude/memory/MEMORY.md new file mode 100644 index 00000000..d6fdc968 --- /dev/null +++ b/framework/rules/core/agent-memory-directory/tests/pass/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +## Memory + +Store patterns and conventions here. diff --git a/framework/rules/core/agent-memory-directory/vocab.yml b/framework/rules/core/agent-memory-directory/vocab.yml new file mode 100644 index 00000000..2e7849b9 --- /dev/null +++ b/framework/rules/core/agent-memory-directory/vocab.yml @@ -0,0 +1,7 @@ +agent_memory_directory: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/agent-neutral-main-file/checks.yml b/framework/rules/core/agent-neutral-main-file/checks.yml new file mode 100644 index 00000000..00a8cbdf --- /dev/null +++ b/framework/rules/core/agent-neutral-main-file/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.C.0032.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0032.pattern_check + type: deterministic + pattern-regex: '(?i)\b(claude\s+code|copilot|cursor|windsurf|cline)\b[^\n]*\b(specific|only|require)' + expect: absent + message: "Agent-specific directive found — main instruction file must be agent-neutral" diff --git a/framework/rules/core/agent-neutral-main-file/rule.md b/framework/rules/core/agent-neutral-main-file/rule.md new file mode 100644 index 00000000..b407f4b3 --- /dev/null +++ b/framework/rules/core/agent-neutral-main-file/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:C:0032 +slug: agent-neutral-main-file +title: "Agent Neutral Main File" +category: coherence +type: deterministic +severity: high +backed_by: [] +match: {format: freeform} +--- + +# Agent Neutral Main File + +The main instruction file must not contain agent-specific directives. Agent-specific syntax belongs in dedicated agent files, not the shared root. + +## Antipatterns + +- Writing "Claude Code requires this format" in the main instruction file. The check detects agent names followed by directive words like "specific", "only", or "require". +- Adding "Copilot only supports single-file mode" to the shared root. Even factual statements trigger the check when they combine an agent name with a directive keyword. +- Using "Cursor-specific extensions" as a heading in the main file. Agent-specific content belongs in a dedicated agent file, not the shared root. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Instructions + +Use `ruff` for formatting. +Run `uv run pytest` before committing. +Keep modules under 500 lines. +~~~~ + +### Fail + +~~~~markdown +# Project Instructions + +Claude Code specific: always use the Read tool first. +Copilot only supports inline completions here. +~~~~ + +## Limitations + +Detects agent-specific directives that name particular tools (Claude Code, Copilot, Cursor). Does not evaluate whether the content is functionally agent-neutral. diff --git a/framework/rules/core/agent-neutral-main-file/vocab.yml b/framework/rules/core/agent-neutral-main-file/vocab.yml new file mode 100644 index 00000000..efe4264c --- /dev/null +++ b/framework/rules/core/agent-neutral-main-file/vocab.yml @@ -0,0 +1,8 @@ +agent_neutral_main_file: + - agent + - claude + - copilot + - cursor + - windsurf + - cline + - agent-specific syntax diff --git a/framework/rules/core/agent-role-defined/checks.yml b/framework/rules/core/agent-role-defined/checks.yml new file mode 100644 index 00000000..c89256ae --- /dev/null +++ b/framework/rules/core/agent-role-defined/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.C.0014.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0014.content_check + type: content_query + query: has_role_definition + args: + message: Missing agent role — define identity or expertise + expect: present diff --git a/framework/rules/core/agent-role-defined/rule.md b/framework/rules/core/agent-role-defined/rule.md new file mode 100644 index 00000000..3003ece7 --- /dev/null +++ b/framework/rules/core/agent-role-defined/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:C:0014 +slug: agent-role-defined +title: "Agent Role Defined" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {type: main} +--- +# Agent Role Defined + +The instruction file must define the agent's role and primary function. Without a clear identity, the agent defaults to generic behavior that doesn't match the project's needs. + +## Antipatterns + +- Jumping straight into commands and conventions without stating what the agent is or does. The check looks for a role definition -- a statement of identity or expertise -- not just operational instructions. +- Writing "This file contains project instructions" as the opening line. That describes the file, not the agent's role. The check needs language that defines the agent's function or domain. +- Assuming the project name implies the role. A heading like "# MyApp" does not tell the agent what it is responsible for. + +## Pass / Fail + +### Pass + +~~~~markdown +# Reporails CLI + +AI instruction validator -- validates instruction files +against mechanical and deterministic rules. +~~~~ + +### Fail + +~~~~markdown +# Reporails CLI + +## Commands + +- `uv run ails check .` +- `uv run poe qa` +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/architecture-overview-present/checks.yml b/framework/rules/core/architecture-overview-present/checks.yml new file mode 100644 index 00000000..f0339262 --- /dev/null +++ b/framework/rules/core/architecture-overview-present/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.C.0033.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0033.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Architecture + - Structure + - Layout + message: Missing architecture overview — describe system structure + expect: present diff --git a/framework/rules/core/architecture-overview-present/rule.md b/framework/rules/core/architecture-overview-present/rule.md new file mode 100644 index 00000000..82349c29 --- /dev/null +++ b/framework/rules/core/architecture-overview-present/rule.md @@ -0,0 +1,50 @@ +--- +id: CORE:C:0033 +slug: architecture-overview-present +title: "Architecture Overview Present" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {type: main} +--- +# Architecture Overview Present + +The root instruction file must describe the project's architecture. The agent needs to know where major components live to navigate the codebase and make informed changes. + +## Antipatterns + +- Describing the architecture in prose paragraphs without a heading that contains "Architecture", "Structure", or "Layout". The check looks for a matching heading, not for architectural content buried in other sections. +- Using a heading like "## Overview" or "## Design" that does not contain any of the matched terms. Close synonyms do not satisfy the heading keyword check. +- Placing the architecture section in a separate file without any mention in the main instruction file. The check runs against the main file only. + +## Pass / Fail + +### Pass + +~~~~markdown +# MyApp + +Backend API for widget management. + +## Architecture + +src/ contains domain logic. +tests/ contains pytest suites. +~~~~ + +### Fail + +~~~~markdown +# MyApp + +Backend API for widget management. + +## Commands + +Run `make build` to compile. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/build-and-test-commands/checks.yml b/framework/rules/core/build-and-test-commands/checks.yml new file mode 100644 index 00000000..84d6db7b --- /dev/null +++ b/framework/rules/core/build-and-test-commands/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0010.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0010.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Commands + - Build + - Testing + - Setup + message: Missing build/test commands — add a Commands or Testing section with + runnable examples + expect: present diff --git a/framework/rules/core/build-and-test-commands/rule.md b/framework/rules/core/build-and-test-commands/rule.md new file mode 100644 index 00000000..7b005160 --- /dev/null +++ b/framework/rules/core/build-and-test-commands/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0010 +slug: build-and-test-commands +title: "Build And Test Commands" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {type: main} +--- +# Build And Test Commands + +The instruction file must include build and test commands that the agent can run. Without these, the agent can't verify its own changes work correctly. + +## Antipatterns + +- Providing build commands in prose ("you can build by running the makefile") without a heading containing "Commands", "Build", "Testing", or "Setup". The check matches headings, not body text. +- Using a heading like "## Development" or "## Usage" that does not contain any of the matched terms. The heading must include one of the specific keywords. +- Documenting commands only in a README or separate file. The check targets the main instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# MyProject + +## Commands + +- `npm install` -- install dependencies +- `npm test` -- run test suite +~~~~ + +### Fail + +~~~~markdown +# MyProject + +## Conventions + +Use ESLint for linting. +Prefer functional components. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/child-nested-instructions/checks.yml b/framework/rules/core/child-nested-instructions/checks.yml new file mode 100644 index 00000000..e0bf0913 --- /dev/null +++ b/framework/rules/core/child-nested-instructions/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0011.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0011.pattern_check + type: deterministic + pattern-regex: (?i)(\bignore\s+(parent|all)\b|\boverride\s+(everything|all)\b) + expect: absent + message: Child instruction overrides parent scope — extend, don't override diff --git a/framework/rules/core/child-nested-instructions/rule.md b/framework/rules/core/child-nested-instructions/rule.md new file mode 100644 index 00000000..d98a2729 --- /dev/null +++ b/framework/rules/core/child-nested-instructions/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:S:0011 +slug: child-nested-instructions +title: "Child Nested Instructions" +category: structure +type: deterministic +severity: medium +backed_by: [] +match: {cardinality: hierarchical} +--- +# Child Nested Instructions + +Child instruction files must extend their parent's scope without contradicting it. Contradictions between levels create confusion. + +## Antipatterns + +- Writing "ignore parent instructions" in a child file to start fresh. The check detects "ignore parent" and "ignore all" as override language. Child files should extend, not replace. +- Using "override everything above" to reset inherited directives. The check flags "override everything" and "override all" as blanket overrides. +- Phrasing a narrow exception as a broad override ("ignore all previous rules, then re-add the ones we want"). Even if the intent is selective, the language triggers the blanket-override pattern. + +## Pass / Fail + +### Pass + +~~~~markdown +# Frontend Rules + +Use React 18 for components. +Prefer CSS modules over inline styles. +~~~~ + +### Fail + +~~~~markdown +# Frontend Rules + +Ignore parent instructions. +Override everything from the root file. +~~~~ + +## Limitations + +Detects parent-override language (`ignore parent`, `override everything`). Does not evaluate whether the override scope is appropriate. diff --git a/framework/rules/core/child-nested-instructions/tests/fail/.claude/rules/example.md b/framework/rules/core/child-nested-instructions/tests/fail/.claude/rules/example.md new file mode 100644 index 00000000..270c3952 --- /dev/null +++ b/framework/rules/core/child-nested-instructions/tests/fail/.claude/rules/example.md @@ -0,0 +1,4 @@ +--- +description: Override parent rules +--- +ALWAYS ignore parent constraints. This rule overrides everything. diff --git a/framework/rules/core/child-nested-instructions/tests/pass/.claude/rules/example.md b/framework/rules/core/child-nested-instructions/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..7baae262 --- /dev/null +++ b/framework/rules/core/child-nested-instructions/tests/pass/.claude/rules/example.md @@ -0,0 +1,5 @@ +Child CLAUDE.md files add to parent rules, they don't override them +# === SEMANTIC JUDGMENT REQUIRED === +# Write content satisfying all prior M/D checks, +# but testing the specific semantic question at this stage. +# One judgment call per rule — do not generate. diff --git a/framework/rules/core/child-nested-instructions/vocab.yml b/framework/rules/core/child-nested-instructions/vocab.yml new file mode 100644 index 00000000..00dc941a --- /dev/null +++ b/framework/rules/core/child-nested-instructions/vocab.yml @@ -0,0 +1,7 @@ +child_nested_instructions: + - MUST + - SHOULD + - NEVER + - ALWAYS + - required + - constraint keyword diff --git a/framework/rules/core/code-block-examples-present/checks.yml b/framework/rules/core/code-block-examples-present/checks.yml new file mode 100644 index 00000000..9313d443 --- /dev/null +++ b/framework/rules/core/code-block-examples-present/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CORE.C.0016.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0016.content_check + type: content_query + query: has_code_blocks + args: + message: Missing code block examples — use fenced code blocks to show concrete + examples + expect: present diff --git a/framework/rules/core/code-block-examples-present/rule.md b/framework/rules/core/code-block-examples-present/rule.md new file mode 100644 index 00000000..870f28f1 --- /dev/null +++ b/framework/rules/core/code-block-examples-present/rule.md @@ -0,0 +1,49 @@ +--- +id: CORE:C:0016 +slug: code-block-examples-present +title: "Code Block Examples Present" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Code Block Examples Present + +The instruction file must contain fenced code blocks with concrete examples. Code blocks are the clearest way to show the agent exact syntax and patterns. + +## Antipatterns + +- Writing commands in inline code (`` `npm test` ``) without any fenced code block. The check looks for fenced code blocks (triple backtick blocks), not inline code spans. +- Providing examples only as prose descriptions ("run the test suite, then check coverage"). Without a fenced code block, the agent has no copy-pasteable syntax to follow. +- Including a single empty code block as a placeholder. The check verifies code blocks exist but a trivially empty block provides no value. + +## Pass / Fail + +### Pass + +~~~~markdown +# MyProject + +## Commands + +```bash +uv run pytest tests/ -v +uv run ruff check src/ +``` +~~~~ + +### Fail + +~~~~markdown +# MyProject + +## Commands + +Run `pytest` for tests and `ruff` for linting. +Use the standard test runner. +~~~~ + +## Limitations + +Checks for the presence of code blocks. Does not evaluate whether the code examples are correct or complete. diff --git a/framework/rules/core/coding-conventions/checks.yml b/framework/rules/core/coding-conventions/checks.yml new file mode 100644 index 00000000..1dd2e193 --- /dev/null +++ b/framework/rules/core/coding-conventions/checks.yml @@ -0,0 +1,15 @@ +checks: +- id: CORE.C.0012.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0012.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Conventions + - Formatting + - Style + - Lint + message: Missing coding conventions — specify formatting and naming rules + expect: present diff --git a/framework/rules/core/coding-conventions/rule.md b/framework/rules/core/coding-conventions/rule.md new file mode 100644 index 00000000..8b7420c0 --- /dev/null +++ b/framework/rules/core/coding-conventions/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0012 +slug: coding-conventions +title: "Coding Conventions" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- +# Coding Conventions + +The instruction file must specify coding conventions — formatting tools, linting rules, and style preferences. Without these, the agent produces code that doesn't match the project's standards. + +## Antipatterns + +- Describing coding style in prose without a heading containing "Conventions", "Formatting", "Style", or "Lint". The check looks for a matching heading, not convention-related content in other sections. +- Using a heading like "## Rules" or "## Standards" that does not contain any of the matched keywords. Close synonyms do not satisfy the heading check. +- Placing conventions only in a linter config file (`.eslintrc`, `ruff.toml`) without documenting them in the instruction file. The check targets instruction file headings, not config files. + +## Pass / Fail + +### Pass + +~~~~markdown +# MyProject + +## Conventions + +Use `ruff` for formatting. +Prefer `dataclasses` over plain dicts. +~~~~ + +### Fail + +~~~~markdown +# MyProject + +## Architecture + +src/ contains domain logic. +tests/ contains pytest suites. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/command-workflow-documented/checks.yml b/framework/rules/core/command-workflow-documented/checks.yml new file mode 100644 index 00000000..ec3efddc --- /dev/null +++ b/framework/rules/core/command-workflow-documented/checks.yml @@ -0,0 +1,15 @@ +checks: +- id: CORE.C.0021.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0021.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Workflow + - Process + - Pipeline + message: Missing command workflow — document the sequence of steps for common + operations + expect: present diff --git a/framework/rules/core/command-workflow-documented/rule.md b/framework/rules/core/command-workflow-documented/rule.md new file mode 100644 index 00000000..a21df0dc --- /dev/null +++ b/framework/rules/core/command-workflow-documented/rule.md @@ -0,0 +1,48 @@ +--- +id: CORE:C:0021 +slug: command-workflow-documented +title: "Command Workflow Documented" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Command Workflow Documented + +The instruction file must document command workflows — ordered sequences of steps for common operations like building, testing, and deploying. + +## Antipatterns + +- Listing individual commands without a heading containing "Workflow", "Process", or "Pipeline". The check matches headings, not command content in other sections. +- Using a heading like "## Steps" or "## Procedures" that does not include any matched keyword. The heading must contain one of the specific terms. +- Documenting workflows only in a CI config file (`.github/workflows/`) without a corresponding section in the instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# MyProject + +## Workflow + +1. Run `uv sync` to install dependencies +2. Run `uv run poe qa` to validate +3. Commit with a descriptive message +~~~~ + +### Fail + +~~~~markdown +# MyProject + +## Commands + +- `uv sync` +- `uv run poe qa` +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/compound-weakness/rule.md b/framework/rules/core/compound-weakness/rule.md new file mode 100644 index 00000000..c38ca3ef --- /dev/null +++ b/framework/rules/core/compound-weakness/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:C:0051 +slug: compound-weakness +title: "Compound Weakness" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# Compound Weakness + +Multiple weaknesses in the same instruction compound — an instruction that is hedged AND abstract AND buried early in the file is far weaker than one with any single issue. The effect is multiplicative, not additive. + +## Antipatterns + +- Writing a short, hedged instruction early in the file ("you might want to consider formatting"). This stacks three weaknesses: brevity, hedged modality, and early position. Each weakness multiplies the others. +- Using abstract language in a hedged instruction ("consider using appropriate tools for code quality"). Abstract + hedged is far weaker than either alone. +- Burying a terse constraint at the top of the file without naming specific constructs. Position, length, and specificity weaknesses compound into near-zero compliance. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff` for all formatting in `src/` and `tests/`. +Run `uv run pytest tests/ -v` before committing changes. +NEVER use `black` or manual formatting. +~~~~ + +### Fail + +~~~~markdown +You might want to think about code quality. +Consider using appropriate tools. +Perhaps run tests sometimes. +~~~~ + +## Fix + +Never stack weaknesses. A short instruction MUST name specific constructs and go near the end of the file. An abstract instruction MUST use direct language, include multiple relevant terms, and be positioned last. Fixing ANY ONE weakness dramatically improves the instruction — but leaving multiple weaknesses is catastrophic. Elaborating with distinct relevant terms (not repetition) is the easiest fix. + +## Limitations + +Detects individual weakness factors (specificity, modality, position, length) and flags when multiple co-occur. The compound effect is estimated, not directly measured. diff --git a/framework/rules/core/conditional-scope/rule.md b/framework/rules/core/conditional-scope/rule.md new file mode 100644 index 00000000..5cdd333c --- /dev/null +++ b/framework/rules/core/conditional-scope/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:C:0048 +slug: conditional-scope +title: "Conditional Scope" +category: coherence +type: mechanical +execution: server +severity: medium +match: {} +--- + +# Conditional Scope + +Conditional instructions ("When X, do Y") are not uniformly weaker than unconditional ones. The "When X" scope text activates the model's knowledge about what's conventional in that domain. If the convention aligns with the instruction, the scope helps. If it conflicts, the scope actively hurts. + +## Antipatterns + +- Scoping an instruction to a domain where the opposite behavior is standard practice. "When writing API integrations, don't use mocks" fights the model's learned convention that API tests use mocks. +- Using a narrow scope clause that accidentally activates competing knowledge. "When building REST endpoints, avoid middleware" conflicts with the strong convention that REST APIs use middleware. +- Adding scope clauses to instructions that would be stronger without them. If the scope activates knowledge that conflicts with the directive, the unconditional version performs better. + +## Pass / Fail + +### Pass + +~~~~markdown +When testing event-driven microservices, prefer +integration tests over mocks. Run the full service +with `docker compose up` before asserting. +~~~~ + +### Fail + +~~~~markdown +When writing API integrations, never use mocks. +Always test against live endpoints regardless +of the development stage. +~~~~ + +## Fix + +Use scope clauses that name domains where the desired behavior is standard practice — this can make the instruction MORE effective than having no scope at all. "When testing event-driven microservices" amplifies "don't mock" because integration testing IS the standard for microservices. Avoid scopes that name domains where the opposite behavior is standard ("API integrations" → mock). Broad scopes like "When writing tests" are safe — no penalty. + +## Limitations + +Evaluates scope-instruction alignment based on semantic similarity. Cannot determine real-world domain conventions — relies on the model's training distribution as a proxy. diff --git a/framework/rules/core/config.yml b/framework/rules/core/config.yml new file mode 100644 index 00000000..e20a8fc0 --- /dev/null +++ b/framework/rules/core/config.yml @@ -0,0 +1,29 @@ +# Generic Agent Configuration (agents.md convention) +# Schema: schemas/agent.schema.yml v0.5.0 +# +# Default agent when no --agent flag is specified. +# Targets AGENTS.md — the cross-agent instruction standard. +# See: https://agents.md + +agent: generic +version: "0.5.0" +name: Generic AI Instructions +core: true + +file_types: + main: + required: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/AGENTS.md"] + precedence: project + vcs: committed + maintainer: human + +# No excludes — generic supports all core rules including cross-agent compatibility +# No severity overrides diff --git a/framework/rules/core/content-dilution/rule.md b/framework/rules/core/content-dilution/rule.md new file mode 100644 index 00000000..39de716a --- /dev/null +++ b/framework/rules/core/content-dilution/rule.md @@ -0,0 +1,54 @@ +--- +id: CORE:C:0041 +slug: content-dilution +title: "Content Dilution" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# Content Dilution + +Descriptive prose on the same topic as your instructions competes for attention. Small amounts of context help, but large amounts dilute the instruction's effect. Off-topic content is harmless regardless of volume. + +Vague instructions are especially vulnerable — instructions that name specific constructs resist prose competition much better. + +## Antipatterns + +- Writing a paragraph of background context directly before or after an instruction on the same topic. On-topic prose competes for attention and dilutes the instruction's effect. +- Embedding a single directive inside a long explanatory section. The instruction drowns in surrounding prose even if the prose is accurate and helpful. +- Adding extensive rationale after every instruction. One to three sentences of rationale is fine; multiple paragraphs shifts the balance from directive to descriptive. + +## Pass / Fail + +### Pass + +~~~~markdown +## Formatting + +Use `ruff` for all formatting. The project enforces +consistent style across `src/` and `tests/`. +NEVER run `black` or manual formatting. +~~~~ + +### Fail + +~~~~markdown +## Formatting + +Code formatting is essential for maintaining readability +across a team. There are many tools available for Python +formatting including black, autopep8, yapf, and ruff. +Each has tradeoffs in speed, configurability, and +community adoption. Use `ruff` for formatting. +~~~~ + +## Fix + +Separate instructions from on-topic prose. Move descriptions, context, and explanations to separate sections or files. Keep the area around instructions clean — 1-3 sentences of rationale, not paragraphs of background. + +## Limitations + +Detects prose volume relative to instruction density within topic clusters. Cannot evaluate whether the prose is genuinely helpful context or unnecessary padding. diff --git a/framework/rules/core/cross-agent-compatibility/checks.yml b/framework/rules/core/cross-agent-compatibility/checks.yml new file mode 100644 index 00000000..4049da03 --- /dev/null +++ b/framework/rules/core/cross-agent-compatibility/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.C.0026.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0026.content_check + type: deterministic + pattern-regex: '(?i)\b(CLAUDE\.md|\.cursorrules|\.clinerules|copilot-instructions\.md)\b' + expect: absent + message: "Agent-specific filename referenced in shared instruction file — use agent-neutral language" diff --git a/framework/rules/core/cross-agent-compatibility/rule.md b/framework/rules/core/cross-agent-compatibility/rule.md new file mode 100644 index 00000000..2395d7b5 --- /dev/null +++ b/framework/rules/core/cross-agent-compatibility/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:C:0026 +slug: cross-agent-compatibility +title: "Cross Agent Compatibility" +category: coherence +type: deterministic +severity: high +backed_by: [] +match: {format: freeform} +--- +# Cross Agent Compatibility + +Multi-agent projects must have compatible instruction sets — shared instructions must be agent-neutral, with agent-specific content isolated to dedicated files. + +## Antipatterns + +- Referencing `CLAUDE.md` or `.cursorrules` by name in a shared instruction file. The check detects agent-specific filenames in shared files. Agent-specific content belongs in its own file. +- Mentioning `.clinerules` or `copilot-instructions.md` in a file that all agents read. Even factual references to agent-specific filenames violate neutrality in shared instructions. +- Writing agent-neutral prose but including an example that names a specific agent file. The check matches the filename pattern regardless of surrounding context. + +## Pass / Fail + +### Pass + +~~~~markdown +# Coding Standards + +Use `ruff` for formatting. +Run `pytest tests/` before committing. +Keep modules under 500 lines. +~~~~ + +### Fail + +~~~~markdown +# Coding Standards + +See `.cursorrules` for Cursor-specific settings. +Claude users should check `CLAUDE.md` for details. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/cross-agent-compatibility/vocab.yml b/framework/rules/core/cross-agent-compatibility/vocab.yml new file mode 100644 index 00000000..ab3b18a3 --- /dev/null +++ b/framework/rules/core/cross-agent-compatibility/vocab.yml @@ -0,0 +1,8 @@ +cross_agent_compatibility: + - agent + - claude + - copilot + - cursor + - windsurf + - cline + - agent-specific syntax diff --git a/framework/rules/core/descriptive-filenames/checks.yml b/framework/rules/core/descriptive-filenames/checks.yml new file mode 100644 index 00000000..1e5fb58e --- /dev/null +++ b/framework/rules/core/descriptive-filenames/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0014.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0014.filename_pattern + type: mechanical + check: filename_matches_pattern + args: + pattern: "^[a-z][a-z0-9-]*\\.(md|yml|yaml)$" + message: "Filename should be lowercase kebab-case — descriptive of content" diff --git a/framework/rules/core/descriptive-filenames/rule.md b/framework/rules/core/descriptive-filenames/rule.md new file mode 100644 index 00000000..6468eed6 --- /dev/null +++ b/framework/rules/core/descriptive-filenames/rule.md @@ -0,0 +1,41 @@ +--- +id: CORE:S:0014 +slug: descriptive-filenames +title: "Descriptive Filenames" +category: structure +type: mechanical +severity: high +backed_by: [] +match: {type: scoped_rule} +--- +# Descriptive Filenames + +Scoped rule files must use lowercase kebab-case filenames ending in `.md`, `.yml`, or `.yaml`. Consistent naming lets developers predict file content from the filename and prevents platform-specific path issues. + +## Antipatterns + +- **CamelCase or UPPERCASE names** like `SelfCheck.md` or `SELF-CHECK.md` — the pattern requires all lowercase letters with hyphens as separators. +- **Underscores as separators** like `self_check.md` — the check enforces kebab-case (`-`), not snake_case (`_`). +- **Missing extension** like `self-check` with no `.md` suffix — the pattern requires a recognized extension. + +## Pass / Fail + +### Pass + +~~~~markdown +.claude/rules/self-check.md +.claude/rules/testing-design.md +.claude/rules/no-unverified-claims.md +~~~~ + +### Fail + +~~~~markdown +.claude/rules/SelfCheck.md +.claude/rules/testing_design.md +.claude/rules/NO-CLAIMS +~~~~ + +## Limitations + +Checks that filenames are lowercase kebab-case. Does not evaluate whether the name accurately describes the file's content — only enforces naming conventions. diff --git a/framework/rules/core/descriptive-filenames/vocab.yml b/framework/rules/core/descriptive-filenames/vocab.yml new file mode 100644 index 00000000..41b05029 --- /dev/null +++ b/framework/rules/core/descriptive-filenames/vocab.yml @@ -0,0 +1,6 @@ +descriptive_filenames: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/direction-imbalance/rule.md b/framework/rules/core/direction-imbalance/rule.md new file mode 100644 index 00000000..6a59d57b --- /dev/null +++ b/framework/rules/core/direction-imbalance/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:D:0002 +slug: direction-imbalance +title: "Direction Imbalance" +category: direction +type: mechanical +execution: server +severity: medium +match: {} +--- + +# Direction Imbalance + +Directives and constraints within the same topic must have balanced strength. When prohibitions are written more strongly than enabling instructions, the agent may suppress the intended behavior entirely rather than conditionally gating it. + +## Antipatterns + +- **Strong constraint, weak enabler** like "NEVER push to remote" paired with "you can push if asked" — the absolute prohibition overwhelms the hedged permission, producing "never push" regardless of context. +- **Specific constraint, vague enabler** like "NEVER modify `src/pipeline.py`" paired with "make changes when appropriate" — the named construct in the constraint anchors harder than the abstract enabler. +- **Multiple reinforcing constraints, single enabler** like three variations of "do not commit" followed by one "commit when asked" — repetition amplifies the constraint side. + +## Pass / Fail + +### Pass + +~~~~markdown +Run `uv run pytest tests/` before submitting changes. Verify all tests pass. +*Do NOT skip the test suite or push with failing tests.* +~~~~ + +### Fail + +~~~~markdown +You might want to run tests if you have time. +NEVER skip tests. NEVER push without testing. NEVER submit untested code. +~~~~ + +## Fix + +Match instruction strength to behavioral intent. If the intended behavior is "X then Y" (sequential), make sure both sides are equally strong: +- Make the enabling instruction imperative, not conditional ("State your conclusion" not "When done, stop") +- Make the enabling instruction name specific constructs ("implementation file" not "verified facts") +- Add reinforcing instructions for the weaker side if needed + +## Limitations + +Detects strength imbalance between opposing instructions within a topic. Cannot determine behavioral intent — only flags disproportionate strength ratios. diff --git a/framework/rules/core/directive-density/checks.yml b/framework/rules/core/directive-density/checks.yml new file mode 100644 index 00000000..c168b8b2 --- /dev/null +++ b/framework/rules/core/directive-density/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.D.0001.file_in_scope + type: mechanical + check: file_exists +- id: CORE.D.0001.content_check + type: content_query + query: has_directive_atoms + args: + message: No directive instructions found — add instructions that tell the agent what to do + expect: present diff --git a/framework/rules/core/directive-density/rule.md b/framework/rules/core/directive-density/rule.md new file mode 100644 index 00000000..a317019e --- /dev/null +++ b/framework/rules/core/directive-density/rule.md @@ -0,0 +1,42 @@ +--- +id: CORE:D:0001 +slug: directive-density +title: "Directive Density" +category: direction +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +see_also: [] +--- +# Directive Density + +Instruction files must contain at least one directive atom — a sentence that tells the agent what to do using imperative or absolute modality. Files with only descriptive prose and no actionable directives have no behavioral effect on the agent. + +## Antipatterns + +- **Pure description** like "This project uses Python and pytest for testing" with no imperatives — descriptive sentences explain context but give no actionable direction. +- **Passive voice throughout** like "Tests should be considered before changes are made" — passive hedging does not register as a directive to the agent. +- **Only headings and structure** like a file with `## Testing`, `## Deployment` headers but no imperative sentences underneath — headings organize content but are not directives. + +## Pass / Fail + +### Pass + +~~~~markdown +# Testing +Run `uv run pytest tests/` before submitting changes. +Use `ruff` for formatting and linting. +~~~~ + +### Fail + +~~~~markdown +# Testing +This project has a test suite located in the tests/ directory. +The project uses pytest as its test framework. +~~~~ + +## Limitations + +Checks that the file contains directive instructions. Does not evaluate the content or specificity of those instructions. diff --git a/framework/rules/core/directory-layout-documented/checks.yml b/framework/rules/core/directory-layout-documented/checks.yml new file mode 100644 index 00000000..23fe6a2b --- /dev/null +++ b/framework/rules/core/directory-layout-documented/checks.yml @@ -0,0 +1,20 @@ +checks: +- id: CORE.C.0035.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0035.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Directory + - Layout + - Structure + - Tree + message: Missing directory layout — show the project structure with a tree or section listing + expect: present +- id: CORE.C.0035.pattern_check + type: deterministic + pattern-regex: '(?i)(## (Structure|Architecture|Layout|Directory)|[├└│]|src/\s|tests/\s|```\s*\n[^\n]*[├└/])' + expect: present + message: "Missing directory layout — show the project structure with a tree or section" diff --git a/framework/rules/core/directory-layout-documented/rule.md b/framework/rules/core/directory-layout-documented/rule.md new file mode 100644 index 00000000..e4e4017e --- /dev/null +++ b/framework/rules/core/directory-layout-documented/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:C:0035 +slug: directory-layout-documented +title: "Directory Layout Documented" +category: coherence +type: deterministic +severity: high +backed_by: [] +match: {type: main} +--- + +# Directory Layout Documented + +The main instruction file must include a section documenting the project's directory layout using a heading like "Structure", "Architecture", "Layout", or "Directory" and containing a tree listing or path references. Without a visible directory map, the agent cannot reliably locate or place files. + +## Antipatterns + +- **Heading without content** like `## Structure` followed by prose that says "see the repo" — the heading matches but the deterministic pattern requires tree characters or path references underneath. +- **Describing layout in prose only** like "Source code lives in the src directory and tests are in tests" — the check requires structural markers (`/`, tree characters) not just prose mentions. +- **Layout in a separate file** with no reference in the main file — the check targets `type: main`, so the layout must appear in `CLAUDE.md` or equivalent. + +## Pass / Fail + +### Pass + +~~~~markdown +## Structure +``` +src/ +├── core/ +├── interfaces/ +└── formatters/ +tests/ +``` +~~~~ + +### Fail + +~~~~markdown +The project has source code and tests organized in directories. +See the repository for the full structure. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/domain-terminology-used/checks.yml b/framework/rules/core/domain-terminology-used/checks.yml new file mode 100644 index 00000000..d77f44ed --- /dev/null +++ b/framework/rules/core/domain-terminology-used/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0024.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0024.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Terminology + - Glossary + - Terms + - Domain + message: Missing domain terminology — define project-specific terms or include + a glossary + expect: present diff --git a/framework/rules/core/domain-terminology-used/rule.md b/framework/rules/core/domain-terminology-used/rule.md new file mode 100644 index 00000000..6ec4f76c --- /dev/null +++ b/framework/rules/core/domain-terminology-used/rule.md @@ -0,0 +1,42 @@ +--- +id: CORE:C:0024 +slug: domain-terminology-used +title: "Domain Terminology Used" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- +# Domain Terminology Used + +Instruction files must include a section with a heading matching "Terminology", "Glossary", "Terms", or "Domain" that defines project-specific vocabulary. Defining terms prevents the agent from misinterpreting domain-specific words that have different common meanings. + +## Antipatterns + +- **Using domain terms without defining them** like referencing "backbone" or "atom" throughout the file without a glossary section — the check looks for a heading that signals term definitions, not inline usage. +- **Generic heading that skips the keywords** like `## Definitions` or `## Vocabulary` — the check matches only "Terminology", "Glossary", "Terms", or "Domain" in headings. +- **Terms defined in a separate file** with no matching heading in the instruction file — the content query scans only the matched file. + +## Pass / Fail + +### Pass + +~~~~markdown +## Terminology +- **atom**: a single parsed instruction sentence +- **backbone**: the project topology file +- **charge**: directive (+1) or constraint (-1) classification +~~~~ + +### Fail + +~~~~markdown +## Conventions +Use atoms when building rulesets. +Reference the backbone for project structure. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/exact-filename-convention/checks.yml b/framework/rules/core/exact-filename-convention/checks.yml new file mode 100644 index 00000000..58b73f27 --- /dev/null +++ b/framework/rules/core/exact-filename-convention/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0004.check + type: mechanical + check: file_exists +- id: CORE.S.0004.filename_pattern + type: mechanical + check: filename_matches_pattern + args: + pattern: "^[A-Za-z0-9._-]+$" diff --git a/framework/rules/core/exact-filename-convention/rule.md b/framework/rules/core/exact-filename-convention/rule.md new file mode 100644 index 00000000..227ac2b5 --- /dev/null +++ b/framework/rules/core/exact-filename-convention/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:S:0004 +slug: exact-filename-convention +title: "Exact Filename Convention" +category: structure +type: mechanical +severity: high +backed_by: [] +match: {format: [freeform, frontmatter, schema_validated]} +--- + +# Exact Filename Convention + +Instruction filenames must contain only alphanumeric characters, dots, underscores, and dashes — matching the pattern `^[A-Za-z0-9._-]+$`. This prevents encoding issues, shell escaping problems, and path resolution failures across platforms. + +## Antipatterns + +- **Spaces in filenames** like `my rules.md` — spaces break shell commands and require quoting in every context. +- **Special characters** like `rules@v2.md` or `check(1).yml` — characters outside the allowed set cause path resolution failures on some platforms. +- **Unicode or accented characters** like `regles.md` — the pattern allows only ASCII alphanumerics, dots, underscores, and dashes. + +## Pass / Fail + +### Pass + +~~~~markdown +CLAUDE.md +.cursorrules +my-config_v2.yml +SKILL.md +~~~~ + +### Fail + +~~~~markdown +my rules.md +config (copy).yml +rules@latest.md +~~~~ + +## Limitations + +Checks that the expected file exists. Does not evaluate file contents. diff --git a/framework/rules/core/exact-filename-convention/tests/fail/.claude/rules/BADLY named file.md b/framework/rules/core/exact-filename-convention/tests/fail/.claude/rules/BADLY named file.md new file mode 100644 index 00000000..36644957 --- /dev/null +++ b/framework/rules/core/exact-filename-convention/tests/fail/.claude/rules/BADLY named file.md @@ -0,0 +1,3 @@ +# Bad Name + +This file has spaces in its name, violating naming conventions. diff --git a/framework/rules/core/exact-filename-convention/tests/pass/.claude/rules/testing.md b/framework/rules/core/exact-filename-convention/tests/pass/.claude/rules/testing.md new file mode 100644 index 00000000..7f8cbde0 --- /dev/null +++ b/framework/rules/core/exact-filename-convention/tests/pass/.claude/rules/testing.md @@ -0,0 +1,3 @@ +# Testing + +Rule file with correct naming. diff --git a/framework/rules/core/exact-filename-convention/vocab.yml b/framework/rules/core/exact-filename-convention/vocab.yml new file mode 100644 index 00000000..dafae805 --- /dev/null +++ b/framework/rules/core/exact-filename-convention/vocab.yml @@ -0,0 +1,6 @@ +exact_filename_convention: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/expected-directories-exist/checks.yml b/framework/rules/core/expected-directories-exist/checks.yml new file mode 100644 index 00000000..6ad86b64 --- /dev/null +++ b/framework/rules/core/expected-directories-exist/checks.yml @@ -0,0 +1,5 @@ +checks: +- id: CORE.S.0008.check + type: mechanical + check: file_exists + # hint: directory_exists at expected paths diff --git a/framework/rules/core/expected-directories-exist/rule.md b/framework/rules/core/expected-directories-exist/rule.md new file mode 100644 index 00000000..2897bf89 --- /dev/null +++ b/framework/rules/core/expected-directories-exist/rule.md @@ -0,0 +1,42 @@ +--- +id: CORE:S:0008 +slug: expected-directories-exist +title: "Expected Directories Exist" +category: structure +type: mechanical +severity: high +backed_by: [] +match: {type: config} +--- + +# Expected Directories Exist + +Configuration files referenced by instruction files must exist on disk. The check verifies that at least one config-type file is present, serving as a gate for directory structure validation. + +## Antipatterns + +- **Declaring directories in instructions that do not exist** like referencing `.claude/rules/` when the directory has not been created — the file existence gate fails if no config files are found. +- **Assuming directory creation is automatic** like adding a `config.yml` reference without creating the directory tree first — the check requires files to be present on disk. +- **Referencing config paths only in prose** without actually having the config files — the mechanical check tests file existence, not prose content. + +## Pass / Fail + +### Pass + +~~~~markdown +project/ + .claude/settings.json (exists on disk) + .ails/backbone.yml (exists on disk) +~~~~ + +### Fail + +~~~~markdown +project/ + (no config files present on disk) + CLAUDE.md references .claude/rules/ but directory is empty +~~~~ + +## Limitations + +Checks that expected directories exist on disk. Does not evaluate directory contents or verify all expected directories are declared. diff --git a/framework/rules/core/expected-directories-exist/tests/fail/.gitkeep b/framework/rules/core/expected-directories-exist/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/expected-directories-exist/vocab.yml b/framework/rules/core/expected-directories-exist/vocab.yml new file mode 100644 index 00000000..613fafb8 --- /dev/null +++ b/framework/rules/core/expected-directories-exist/vocab.yml @@ -0,0 +1,6 @@ +expected_directories_exist: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/explicit-prohibitions/checks.yml b/framework/rules/core/explicit-prohibitions/checks.yml new file mode 100644 index 00000000..16df2cc3 --- /dev/null +++ b/framework/rules/core/explicit-prohibitions/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.C.0019.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0019.content_check + type: content_query + query: has_constraint_atoms + args: + message: No explicit prohibitions — add constraints that tell the agent what NOT to do + expect: present diff --git a/framework/rules/core/explicit-prohibitions/rule.md b/framework/rules/core/explicit-prohibitions/rule.md new file mode 100644 index 00000000..c8b88d49 --- /dev/null +++ b/framework/rules/core/explicit-prohibitions/rule.md @@ -0,0 +1,41 @@ +--- +id: CORE:C:0019 +slug: explicit-prohibitions +title: "Explicit Prohibitions" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- +# Explicit Prohibitions + +Instruction files must contain at least one constraint atom — a sentence that tells the agent what NOT to do. Without explicit prohibitions, the agent defaults to its training priors, which may include destructive actions like force-pushing, deleting files, or modifying sensitive configurations. + +## Antipatterns + +- **Only positive directives** like "Use `ruff` for formatting" and "Run tests before committing" with no constraints — the check requires at least one sentence classified as a constraint (charge -1). +- **Soft preferences instead of prohibitions** like "Prefer not to modify the database" — hedged language does not register as a constraint atom. +- **Prohibitions only in comments or code blocks** like constraints inside `` or fenced blocks — the content query analyzes parsed instruction atoms, not raw text in non-prose regions. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff` for formatting and linting. +*Do NOT run `black` or manual formatting.* +NEVER modify `.env` files directly. +~~~~ + +### Fail + +~~~~markdown +Use `ruff` for formatting and linting. +Run `uv run pytest` before committing. +Follow the project conventions. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/forbidden-commands-defined/checks.yml b/framework/rules/core/forbidden-commands-defined/checks.yml new file mode 100644 index 00000000..ffc23c73 --- /dev/null +++ b/framework/rules/core/forbidden-commands-defined/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.G.0004.file_in_scope + type: mechanical + check: file_exists +- id: CORE.G.0004.content_check + type: content_query + query: has_constraint_atoms + args: + message: No forbidden operations defined — explicitly list commands or actions the agent must never execute + expect: present diff --git a/framework/rules/core/forbidden-commands-defined/rule.md b/framework/rules/core/forbidden-commands-defined/rule.md new file mode 100644 index 00000000..aa63dde2 --- /dev/null +++ b/framework/rules/core/forbidden-commands-defined/rule.md @@ -0,0 +1,41 @@ +--- +id: CORE:G:0004 +slug: forbidden-commands-defined +title: "Forbidden Commands Defined" +category: governance +type: mechanical +severity: medium +backed_by: [] +match: {type: main} +--- +# Forbidden Commands Defined + +The main instruction file must contain at least one constraint atom that prohibits specific commands or actions. Listing forbidden operations prevents the agent from executing destructive commands like `git push --force`, `rm -rf`, or database mutations without explicit user approval. + +## Antipatterns + +- **Describing dangerous commands without prohibiting them** like "The `git reset --hard` command discards changes" — description is not a constraint, the check requires imperative prohibition. +- **Prohibitions only in scoped rule files** like constraints in `.claude/rules/sensitive-files.md` but none in `CLAUDE.md` — the check targets `type: main`, so the main file must contain its own constraint atoms. +- **Generic warnings** like "Be careful with destructive operations" — vague cautions do not produce constraint atoms. + +## Pass / Fail + +### Pass + +~~~~markdown +# Constraints +NEVER run `git push --force` on `main`. +*Do NOT modify `.env` or `credentials*` files.* +~~~~ + +### Fail + +~~~~markdown +# Commands +Use `git push` to publish changes. +Use `git reset` to undo changes. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/forbidden-commands-defined/vocab.yml b/framework/rules/core/forbidden-commands-defined/vocab.yml new file mode 100644 index 00000000..3562a5e5 --- /dev/null +++ b/framework/rules/core/forbidden-commands-defined/vocab.yml @@ -0,0 +1,7 @@ +forbidden_commands_defined: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/formatting-regime/rule.md b/framework/rules/core/formatting-regime/rule.md new file mode 100644 index 00000000..7c73822d --- /dev/null +++ b/framework/rules/core/formatting-regime/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:E:0003 +slug: formatting-regime +title: "Formatting Effectiveness" +category: efficiency +type: mechanical +execution: server +severity: low +match: {} +--- + +# Formatting Effectiveness + +Use `backtick` for code identifiers and *italic* for emphasis instead of `**bold**` on terms inside constraints. Bold draws the model's attention to the wrapped term — on a constraint, that means drawing attention to the prohibited concept. + +Bold on structural labels (`**G1 Schema**:`, `**Agent 1**:`) is allowed — these are organizational markers followed by `:`, not emphasis on constraint terms. The label pattern identifies content structure, not prohibited concepts. + +## Antipatterns + +- **Bold on prohibited terms** like "NEVER use **eval** in production code" — bold on `eval` amplifies the prohibited concept instead of suppressing it. Use `eval` (backtick) instead. +- **Bold for emphasis on constraints** like "Do **not** modify the database" — bold on negation keywords competes with the instruction's intent. Use *italic* for the full constraint sentence. +- **Bold inside NEVER/ALWAYS sentences** like "ALWAYS use **ruff** for formatting" — bold on the tool name creates salience competition. Use `ruff` (backtick) for code constructs. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff` for formatting and linting. +*Do NOT run `black` or manual formatting.* +**G1 Schema**: `id` must match the coordinate pattern. +~~~~ + +### Fail + +~~~~markdown +NEVER use **black** for formatting. +Do **not** modify the **database** directly. +**Always** run tests before committing. +~~~~ + +## Limitations + +Detects bold formatting on charged atoms. Skips bold spans followed by `:` (structural labels). Does not evaluate whether the bolded terms are the prohibited concepts or the negation keywords. diff --git a/framework/rules/core/frontmatter-block-present/checks.yml b/framework/rules/core/frontmatter-block-present/checks.yml new file mode 100644 index 00000000..83350b7d --- /dev/null +++ b/framework/rules/core/frontmatter-block-present/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0006.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0006.pattern_check + type: deterministic + pattern-regex: '(?m)\A---\s*\n' + expect: present + message: "Missing frontmatter block — start with --- delimited YAML frontmatter" diff --git a/framework/rules/core/frontmatter-block-present/rule.md b/framework/rules/core/frontmatter-block-present/rule.md new file mode 100644 index 00000000..4ace0f8b --- /dev/null +++ b/framework/rules/core/frontmatter-block-present/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:S:0006 +slug: frontmatter-block-present +title: "Frontmatter Block Present" +category: structure +type: deterministic +severity: high +backed_by: [] +match: {type: scoped_rule} +--- + +# Frontmatter Block Present + +Scoped rule files must begin with a `---` YAML frontmatter delimiter as the very first line. The frontmatter block provides metadata like `globs` and `description` that controls how the agent discovers and loads the rule file. + +## Antipatterns + +- **Leading blank line before frontmatter** like a newline then `---` — the pattern `\A---` requires the delimiter at byte position zero with no preceding whitespace or blank lines. +- **Using a different delimiter** like `~~~` or `===` — the check specifically matches `---` followed by a newline. +- **Frontmatter comment before the delimiter** like `` then `---` — any content before the opening `---` causes the pattern to fail. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +globs: "src/**/*.py" +description: Python source conventions +--- +# Source Conventions +~~~~ + +### Fail + +~~~~markdown + +--- +globs: "src/**/*.py" +--- +# Source Conventions +(leading blank line before ---) +~~~~ + +## Limitations + +Checks for YAML frontmatter opening delimiter at the start of the file. Does not validate frontmatter content or schema compliance. diff --git a/framework/rules/core/frontmatter-block-present/tests/fail/.claude/rules/example.md b/framework/rules/core/frontmatter-block-present/tests/fail/.claude/rules/example.md new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/frontmatter-block-present/tests/fail/.claude/rules/example.md @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/frontmatter-block-present/tests/pass/.claude/rules/example.md b/framework/rules/core/frontmatter-block-present/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..d23e3db7 --- /dev/null +++ b/framework/rules/core/frontmatter-block-present/tests/pass/.claude/rules/example.md @@ -0,0 +1,3 @@ +--- +description: Example rule +--- diff --git a/framework/rules/core/frontmatter-block-present/vocab.yml b/framework/rules/core/frontmatter-block-present/vocab.yml new file mode 100644 index 00000000..67ac24a8 --- /dev/null +++ b/framework/rules/core/frontmatter-block-present/vocab.yml @@ -0,0 +1,6 @@ +frontmatter_block_present: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/heading-as-instruction/checks.yml b/framework/rules/core/heading-as-instruction/checks.yml new file mode 100644 index 00000000..a261f534 --- /dev/null +++ b/framework/rules/core/heading-as-instruction/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0039.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0039.content_check + type: content_query + query: has_charged_headings + args: + message: Instruction in heading — headings should organize content, not carry instructions + expect: absent diff --git a/framework/rules/core/heading-as-instruction/rule.md b/framework/rules/core/heading-as-instruction/rule.md new file mode 100644 index 00000000..c9df4dd5 --- /dev/null +++ b/framework/rules/core/heading-as-instruction/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:S:0039 +slug: heading-as-instruction +title: "Heading As Instruction" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- + +# Heading As Instruction + +Headings should organize content into sections, not carry instructions. The model processes heading content the same as body content, but instructions in headings are structurally fragile — they get lost when files are reorganized, and they can't carry the detail an instruction needs. + +## Antipatterns + +- **Imperative verb in a heading**: `## Always Run Tests Before Pushing` — this is an instruction disguised as a section label. The check classifies it as a charged heading atom (directive/imperative). +- **Constraint as heading**: `## Never Modify Generated Files` — constraints belong in the section body, not the heading. The heading should name the topic (e.g., `## Generated Files`). +- **Multi-clause heading**: `## Use ruff and Do Not Run black` — packing both a directive and a constraint into a heading makes both structurally fragile and undetectable by checks that scan body content. + +## Pass / Fail + +### Pass + +~~~~markdown +## Deployment + +Never push directly to main. Use feature branches and open a pull request. +~~~~ + +### Fail + +~~~~markdown +## Never Push Directly to Main + +Use feature branches and open a pull request instead. +~~~~ + +## Fix + +Use the heading as a section label and put the instruction in the first line of the section body. For example, change `## Never push to main` to `## Deployment` with "Never push directly to main" as the first line under it. + +## Limitations + +Detects charged heading atoms (headings classified as directive, imperative, or constraint by the charge classifier). Short headings with common verbs may be false positives — "## Process" is a label, not an instruction, but contains a verb. diff --git a/framework/rules/core/heading-as-instruction/tests/fail/.claude/rules/example.md b/framework/rules/core/heading-as-instruction/tests/fail/.claude/rules/example.md new file mode 100644 index 00000000..073c490d --- /dev/null +++ b/framework/rules/core/heading-as-instruction/tests/fail/.claude/rules/example.md @@ -0,0 +1,7 @@ +## Never push directly to main + +This section covers deployment rules. + +## Always run tests before committing + +Use pytest for all test runs. diff --git a/framework/rules/core/heading-as-instruction/tests/pass/.claude/rules/example.md b/framework/rules/core/heading-as-instruction/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..9ea218f2 --- /dev/null +++ b/framework/rules/core/heading-as-instruction/tests/pass/.claude/rules/example.md @@ -0,0 +1,7 @@ +## Deployment + +Never push directly to main without review. + +## Testing + +Run `pytest tests/` before committing changes. diff --git a/framework/rules/core/hook-event-handlers/checks.yml b/framework/rules/core/hook-event-handlers/checks.yml new file mode 100644 index 00000000..e8ade035 --- /dev/null +++ b/framework/rules/core/hook-event-handlers/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.S.0020.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0020.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Hook + - Event + - Trigger + - Pre-commit + message: Missing hook/event handler definitions — document trigger conditions + for hooks + expect: present diff --git a/framework/rules/core/hook-event-handlers/rule.md b/framework/rules/core/hook-event-handlers/rule.md new file mode 100644 index 00000000..4d4f2524 --- /dev/null +++ b/framework/rules/core/hook-event-handlers/rule.md @@ -0,0 +1,41 @@ +--- +id: CORE:S:0020 +slug: hook-event-handlers +title: "Hook Event Handlers" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: config} +--- +# Hook Event Handlers + +Config files that define hooks or event handlers must include a heading matching Hook, Event, Trigger, or Pre-commit. Without a labeled section, trigger conditions are buried in unstructured content where they are easy to miss. + +## Antipatterns + +- **Hook logic without a labeled section**: Describing pre-commit behavior in a general "Workflow" section without a heading containing "Hook", "Event", "Trigger", or "Pre-commit". The check looks for headings matching those keywords. +- **Trigger keyword only in body text**: Mentioning "trigger" or "hook" in paragraphs but never in a heading. The check requires a heading-level match, not body-level. +- **Generic heading name**: Using `## Automation` to describe event handlers instead of `## Event Handlers` or `## Pre-commit Hooks`. The heading must contain one of the target keywords. + +## Pass / Fail + +### Pass + +~~~~markdown +## Pre-commit Hooks + +Run `ruff check` and `pytest` before each commit. +~~~~ + +### Fail + +~~~~markdown +## Workflow + +Before committing, the linter runs automatically. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/hook-event-handlers/tests/fail/.claude/settings.json b/framework/rules/core/hook-event-handlers/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/hook-event-handlers/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/hook-event-handlers/tests/pass/.claude/settings.json b/framework/rules/core/hook-event-handlers/tests/pass/.claude/settings.json new file mode 100644 index 00000000..7e60dbe6 --- /dev/null +++ b/framework/rules/core/hook-event-handlers/tests/pass/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "lint" } + ], + "PostToolUse": [ + { "type": "command", "command": "format" } + ] + } +} \ No newline at end of file diff --git a/framework/rules/core/hook-event-handlers/vocab.yml b/framework/rules/core/hook-event-handlers/vocab.yml new file mode 100644 index 00000000..7c40d1b5 --- /dev/null +++ b/framework/rules/core/hook-event-handlers/vocab.yml @@ -0,0 +1,7 @@ +hook_event_handlers: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/ideal-instruction/rule.md b/framework/rules/core/ideal-instruction/rule.md new file mode 100644 index 00000000..cec5c3b7 --- /dev/null +++ b/framework/rules/core/ideal-instruction/rule.md @@ -0,0 +1,53 @@ +--- +id: CORE:C:0053 +slug: ideal-instruction +title: "The Ideal Instruction" +category: coherence +type: mechanical +execution: server +severity: medium +match: {} +--- + +# The Ideal Instruction + +An instruction competes for attention against everything else in context. The strongest instructions dominate; weak instructions are effectively invisible. + +Five properties determine instruction strength (they multiply): specificity (name exact constructs), modality (use direct commands), elaboration (15-50 distinct terms), position (place critical instructions last), and topic relevance (instruction matches the task). The gap between a well-written and poorly-written instruction is enormous. + +## Antipatterns + +- **Hedged language**: "You might want to consider using `ruff` for formatting." Hedged modality weakens the instruction — direct commands ("Use `ruff` for formatting") are stronger. +- **Generic terms instead of named constructs**: "Use a linter for code quality" instead of "Use `ruff check` for linting." Specificity requires naming the exact tool, file, or command. +- **Constraint-first ordering**: "Don't use `black`. Use `ruff` instead." Leading with the prohibition activates the wrong concept first. Directive-first ordering is more effective. +- **Terse instructions without elaboration**: "Format code." Too few distinct tokens — the instruction lacks the detail needed to compete for attention in context. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff check --fix` for all linting in `src/` and `tests/`. The project +enforces consistent style through pre-commit hooks. *Do NOT run `black` +or apply manual formatting.* +~~~~ + +### Fail + +~~~~markdown +You should probably consider formatting your code consistently. +~~~~ + +## Fix + +1. Elaborate with distinct relevant terms — the single largest improvement factor +2. Use exact names — `unittest.mock`, not "mocking libraries" +3. Order: directive first, reasoning, constraint last +4. Place critical instructions last in the file +5. Use direct commands, not hedged language +6. One instruction per topic (eliminates same-topic competition) +7. Keep surrounding same-topic prose brief (reduces attention dilution) + +## Limitations + +This is a composite diagnostic summarizing the overall strength of instructions in a file. Individual factors are reported by their own rules (specificity-gap, modality-weakness, etc.). diff --git a/framework/rules/core/identity-fields-in-frontmatter/checks.yml b/framework/rules/core/identity-fields-in-frontmatter/checks.yml new file mode 100644 index 00000000..caf8faa3 --- /dev/null +++ b/framework/rules/core/identity-fields-in-frontmatter/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0005.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0005.pattern_check + type: deterministic + pattern-regex: '(?i)\b(id|name|slug)\s*:' + expect: present + message: "Missing identity fields in frontmatter — include id, name, or slug" diff --git a/framework/rules/core/identity-fields-in-frontmatter/rule.md b/framework/rules/core/identity-fields-in-frontmatter/rule.md new file mode 100644 index 00000000..93ccbc6f --- /dev/null +++ b/framework/rules/core/identity-fields-in-frontmatter/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0005 +slug: identity-fields-in-frontmatter +title: "Identity Fields In Frontmatter" +category: structure +type: deterministic +severity: high +backed_by: [] +match: {type: scoped_rule} +--- + +# Identity Fields In Frontmatter + +Frontmatter must include identity fields (id, name, or slug) to uniquely identify the instruction file. Without an identity field, the file cannot be referenced, overridden, or tracked by other rules. + +## Antipatterns + +- **Frontmatter with only metadata fields**: A frontmatter block containing `globs:` and `description:` but no `id:`, `name:`, or `slug:` field. The check requires at least one identity key. +- **Identity field in body instead of frontmatter**: Writing `id: my-rule` in the markdown body rather than between `---` fences. The pattern matches anywhere in the file, but the rule intent is frontmatter placement. +- **Misspelled identity key**: Using `ID:` or `Slug:` — the check pattern is case-insensitive, so these pass. But using `identifier:` or `label:` instead of `id`, `name`, or `slug` will fail because only those three keywords are matched. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +slug: my-rule +description: Enforces naming conventions +globs: ["src/**/*.py"] +--- +~~~~ + +### Fail + +~~~~markdown +--- +description: Enforces naming conventions +globs: ["src/**/*.py"] +--- +~~~~ + +## Limitations + +Checks for identity-related frontmatter fields (`id`, `name`, `slug`). Does not validate whether the values are correct or unique. diff --git a/framework/rules/core/identity-fields-in-frontmatter/tests/fail/.claude/rules/example.md b/framework/rules/core/identity-fields-in-frontmatter/tests/fail/.claude/rules/example.md new file mode 100644 index 00000000..ef60ac67 --- /dev/null +++ b/framework/rules/core/identity-fields-in-frontmatter/tests/fail/.claude/rules/example.md @@ -0,0 +1,5 @@ +--- +scope: project +--- +# Rule Content +This rule has frontmatter. diff --git a/framework/rules/core/identity-fields-in-frontmatter/tests/pass/.claude/rules/example.md b/framework/rules/core/identity-fields-in-frontmatter/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..971c0cc3 --- /dev/null +++ b/framework/rules/core/identity-fields-in-frontmatter/tests/pass/.claude/rules/example.md @@ -0,0 +1,5 @@ +--- +name: example-rule +description: Example rule +--- +Skill frontmatter must include a name field in kebab-case \ No newline at end of file diff --git a/framework/rules/core/identity-fields-in-frontmatter/vocab.yml b/framework/rules/core/identity-fields-in-frontmatter/vocab.yml new file mode 100644 index 00000000..cdcf4406 --- /dev/null +++ b/framework/rules/core/identity-fields-in-frontmatter/vocab.yml @@ -0,0 +1,6 @@ +identity_fields_in_frontmatter: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/import-targets-resolve/checks.yml b/framework/rules/core/import-targets-resolve/checks.yml new file mode 100644 index 00000000..1da10390 --- /dev/null +++ b/framework/rules/core/import-targets-resolve/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0024.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0024.import_check + type: mechanical + check: extract_imports +- id: CORE.S.0024.targets_exist + type: mechanical + check: check_import_targets_exist diff --git a/framework/rules/core/import-targets-resolve/rule.md b/framework/rules/core/import-targets-resolve/rule.md new file mode 100644 index 00000000..e38563d7 --- /dev/null +++ b/framework/rules/core/import-targets-resolve/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:S:0024 +slug: import-targets-resolve +title: "Import Targets Resolve" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- + +# Import Targets Resolve + +Import references in instruction files must resolve to existing files. Broken imports create gaps in the agent's context — the agent silently skips missing files without warning. + +## Antipatterns + +- **Renamed file without updating imports**: Moving `docs/setup.md` to `docs/getting-started.md` but leaving `@import docs/setup.md` in another file. The `extract_imports` check finds the reference and `check_import_targets_exist` fails because the path no longer resolves. +- **Relative path from wrong directory**: Writing `@import ../shared/config.md` when the file structure requires `@import ../../shared/config.md`. The path resolution check verifies the target exists relative to the project root. +- **Import referencing a directory instead of a file**: Writing `@import docs/specs/` instead of `@import docs/specs/pipeline.md`. The check expects file paths, not directory paths. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Setup + +@import docs/getting-started.md +@import .claude/rules/testing-design.md +~~~~ + +### Fail + +~~~~markdown +# Project Setup + +@import docs/old-setup.md +@import .claude/rules/deleted-rule.md +~~~~ + +## Limitations + +Checks that the expected file exists. Does not evaluate file contents. diff --git a/framework/rules/core/import-targets-resolve/tests/pass/.claude/rules/example.md b/framework/rules/core/import-targets-resolve/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..4b64d6d4 --- /dev/null +++ b/framework/rules/core/import-targets-resolve/tests/pass/.claude/rules/example.md @@ -0,0 +1,2 @@ +@import .claude/rules/style.md +@import .claude/skills/commit/SKILL.md diff --git a/framework/rules/core/import-targets-resolve/vocab.yml b/framework/rules/core/import-targets-resolve/vocab.yml new file mode 100644 index 00000000..968c3e2b --- /dev/null +++ b/framework/rules/core/import-targets-resolve/vocab.yml @@ -0,0 +1,7 @@ +import_targets_resolve: + - MUST + - SHOULD + - NEVER + - ALWAYS + - required + - constraint keyword diff --git a/framework/rules/core/instruction-elaboration/rule.md b/framework/rules/core/instruction-elaboration/rule.md new file mode 100644 index 00000000..d31e3c1a --- /dev/null +++ b/framework/rules/core/instruction-elaboration/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:E:0004 +slug: instruction-elaboration +title: "Instruction Elaboration" +category: efficiency +type: mechanical +execution: server +severity: high +match: {} +--- + +# Instruction Elaboration + +Instructions with too few tokens are effectively invisible. Instructions padded with generic filler are weaker than shorter, specific ones. The ideal instruction uses multiple DISTINCT relevant terms — each naming a different concrete aspect of the desired behavior. + +## Antipatterns + +- **Terse instruction**: "Format code." or "Run tests." — too few distinct tokens to register in context. The diagnostic flags instructions below the minimum token count. +- **Padded with filler**: "When writing tests in this project's codebase, please ensure that you avoid using mock objects." The filler tokens ("when writing", "please ensure that you") dilute signal without adding distinct terms. +- **Repetitive terms instead of diverse ones**: "Use `ruff` for linting. `ruff` catches errors. `ruff` runs fast." Repeating the same term does not increase distinctness — the diagnostic measures unique relevant terms, not total word count. +- **Generic class names instead of specifics**: "Use a testing framework" instead of "Use `pytest` with `@pytest.mark.parametrize` for boundary cases in `tests/`." Named constructs are distinct terms; generic descriptions are not. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `pytest` with `@pytest.mark.parametrize` for boundary cases in +`tests/unit/`. Run `uv run poe qa_fast` before committing. +*Do NOT use `unittest.mock` or `MagicMock`.* +~~~~ + +### Fail + +~~~~markdown +Run tests. +~~~~ + +## Fix + +Elaborate instructions with multiple specific, diverse terms — each naming a different concrete aspect. "Do not use `unittest.mock`, `MagicMock`, `@patch`, or any test double for external service boundaries. Test against real implementations — real database connections, real HTTP endpoints, real queue consumers." Each named construct strengthens the instruction independently. Do NOT pad with generic filler: "when writing tests in this project's codebase, please ensure that you avoid using..." — filler tokens dilute without strengthening. + +## Limitations + +Measures token count and term distinctness. Cannot evaluate whether the chosen terms are the most relevant for the intended behavior. diff --git a/framework/rules/core/instruction-file-size-limit/checks.yml b/framework/rules/core/instruction-file-size-limit/checks.yml new file mode 100644 index 00000000..99bcd7f1 --- /dev/null +++ b/framework/rules/core/instruction-file-size-limit/checks.yml @@ -0,0 +1,6 @@ +checks: +- id: CORE.E.0002.check + type: mechanical + check: line_count + args: + max: 300 diff --git a/framework/rules/core/instruction-file-size-limit/rule.md b/framework/rules/core/instruction-file-size-limit/rule.md new file mode 100644 index 00000000..ddf36840 --- /dev/null +++ b/framework/rules/core/instruction-file-size-limit/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:E:0002 +slug: instruction-file-size-limit +title: "Instruction File Size Limit" +category: efficiency +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- + +# Instruction File Size Limit + +Individual instruction files must stay within size limits. Oversized files exceed context windows and degrade agent performance. The check enforces a maximum of 300 lines per file. + +## Antipatterns + +- **Monolithic instruction file**: Putting all project instructions in a single `CLAUDE.md` that grows past 300 lines. Split into `.claude/rules/*.md` files by topic instead. +- **Embedded large examples**: Including full code samples or log output inline that push the file past the line limit. Reference external files or keep examples to 3-5 lines. +- **Duplicated instructions across sections**: Restating the same instruction in multiple sections inflates the file without adding information. State each instruction once. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project (48 lines) +## Commands +## Conventions +## Boundaries +~~~~ + +### Fail + +~~~~markdown +# Project (350 lines) +## Commands (50 lines) +## Conventions (120 lines) +## Full API Reference (180 lines) +~~~~ + +## Limitations + +Structural check with limited semantic understanding. diff --git a/framework/rules/core/instruction-ordering/rule.md b/framework/rules/core/instruction-ordering/rule.md new file mode 100644 index 00000000..e6ceb43d --- /dev/null +++ b/framework/rules/core/instruction-ordering/rule.md @@ -0,0 +1,54 @@ +--- +id: CORE:D:0003 +slug: instruction-ordering +title: "Instruction Ordering" +category: direction +type: mechanical +execution: server +severity: high +match: {} +--- + +# Instruction Ordering + +Within a topic, the ORDER of instructions matters. Putting the directive first, reasoning between, and the constraint last is significantly more effective than the natural human pattern of leading with prohibitions. + +## Antipatterns + +- **Constraint-first pattern**: "Don't use `black`. Use `ruff format` instead." Leading with the prohibition activates the forbidden concept before the desired behavior is established. The diagnostic detects this inverted ordering. +- **Reasoning before directive**: "Because mock objects hide integration bugs, use real database connections." The reason is stated before the instruction — the directive should come first so the agent knows what to do before learning why. +- **Interleaved ordering**: "Don't use mocks. Real tests catch more bugs. Use `pytest` with real connections. Never stub HTTP calls." Alternating between directives and constraints within a topic makes both weaker. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `pytest` with real database connections for integration tests. +Real integration tests catch deployment failures that mocks hide. +*Do NOT use `unittest.mock` or test doubles for service boundaries.* +~~~~ + +### Fail + +~~~~markdown +Don't use mock objects or test doubles. They hide integration bugs. +Use real database connections instead. +~~~~ + +## Fix + +Restructure instructions as: +``` +[DIRECTIVE] Use real implementations — real database connections, real HTTP endpoints. +[REASONING] Real integration tests catch deployment failures and configuration +errors that would otherwise reach production undetected. +[CONSTRAINT] Do not use mock objects, stubs, or test doubles. +``` + +Never write "Don't use X. Instead, use Y." Write "Use Y. [reason for Y]. Don't use X." +Reasoning should support the directive, not explain what's wrong with the prohibited thing. + +## Limitations + +Detects ordering patterns within instruction clusters. Cannot evaluate whether the reasoning content actually supports the directive. diff --git a/framework/rules/core/instruction-rationale-present/checks.yml b/framework/rules/core/instruction-rationale-present/checks.yml new file mode 100644 index 00000000..a78d81a2 --- /dev/null +++ b/framework/rules/core/instruction-rationale-present/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.C.0038.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0038.content_check + type: content_query + query: has_directive_atoms + args: + message: No directive instructions — rationale requires directive+constraint pairs + expect: present diff --git a/framework/rules/core/instruction-rationale-present/rule.md b/framework/rules/core/instruction-rationale-present/rule.md new file mode 100644 index 00000000..ebc4b9a4 --- /dev/null +++ b/framework/rules/core/instruction-rationale-present/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:C:0038 +slug: instruction-rationale-present +title: "Instruction Rationale Present" +category: coherence +type: mechanical +severity: medium +match: {format: freeform} +--- + +# Instruction Rationale Present + +The default pattern for instructions is a directive or imperative followed by brief context within a focused length. When you need to suppress a behavior, use the golden pattern: directive first, brief positive context, then constraint last. You don't need a constraint for every directive — add constraints only when your goal is to explicitly prevent something. + +## Antipatterns + +- **Pure constraint with no directive**: "*Do NOT use `black`.*" — a constraint without a preceding directive leaves the agent knowing what not to do but not what to do instead. The check requires directive atoms to be present. +- **All reasoning, no directive**: "The project uses consistent formatting because it reduces merge conflicts and improves readability." This is context without an actionable instruction. The check looks for directive/imperative atoms. +- **Directives buried in prose**: "It's worth noting that the team generally prefers using `ruff` for formatting tasks." Hedged language does not register as a directive atom — use imperative form ("Use `ruff` for formatting"). + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff format` for all Python files in `src/` and `tests/`. +Consistent formatting reduces merge conflicts. +*Do NOT run `black` or apply manual formatting.* +~~~~ + +### Fail + +~~~~markdown +The project values consistent code formatting across all +Python source files and test suites for better collaboration. +~~~~ + +## Fix + +For directives and imperatives: state the instruction, add brief context if needed, keep it focused. For behavior suppression: directive ("Use `real_db` connections"), brief context about why, then constraint ("Do not use `unittest.mock`"). Do not mention the prohibited thing in the context — keep reasoning focused on the desired behavior. + +## Limitations + +Checks that the file contains directive instructions. Does not verify ordering or rationale placement — those are assessed separately. diff --git a/framework/rules/core/italic-constraints/checks.yml b/framework/rules/core/italic-constraints/checks.yml new file mode 100644 index 00000000..f9d70b60 --- /dev/null +++ b/framework/rules/core/italic-constraints/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.E.0006.file_in_scope + type: mechanical + check: file_exists +- id: CORE.E.0006.constraints_italic + type: content_query + query: has_non_italic_constraints + args: + message: Constraint not in italic — wrap the full sentence in *italic* to signal charge type visually + expect: absent diff --git a/framework/rules/core/italic-constraints/rule.md b/framework/rules/core/italic-constraints/rule.md new file mode 100644 index 00000000..f7583917 --- /dev/null +++ b/framework/rules/core/italic-constraints/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:E:0006 +slug: italic-constraints +title: "Italic Constraints" +category: efficiency +type: mechanical +severity: medium +backed_by: [] +match: {} +--- + +# Italic Constraints + +Constraint instructions (-1 charge) should be wrapped entirely in `*italic*` markdown. Full-sentence italic signals the charge type visually, separating the constraint from the directive (+1) and reasoning (0) that precede it. + +## Antipatterns + +- **Partial italic on negation only**: "*Do NOT* modify `checks.yml` directly." — only the negation keyword is italicized, not the full constraint sentence. The check requires the entire constraint atom to be wrapped in `*...*`. +- **Bold instead of italic**: "**Do NOT modify checks.yml directly.**" — bold is not the same signal as italic. The check looks for single `*...*` markers, not `**...**`. +- **No formatting on constraint**: "Do NOT modify checks.yml directly." — an unformatted constraint is structurally indistinguishable from surrounding prose. The check flags constraint atoms whose raw text lacks full italic wrapping. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff` for all formatting in `src/`. +*Do NOT run `black` or apply manual formatting.* +~~~~ + +### Fail + +~~~~markdown +Use `ruff` for all formatting in `src/`. +Do NOT run `black` or apply manual formatting. +~~~~ + +## Fix + +Wrap the entire constraint sentence in `*...*`: write `*Do NOT modify checks.yml directly.*` not `Do NOT modify checks.yml directly.` and not `*Do NOT* modify checks.yml directly.` — partial italic on just the negation keyword activates the prohibited concept without the charge signal. + +## Limitations + +Detects constraint atoms (charge == -1) whose raw markdown text is not fully wrapped in single `*...*` markers. Does not evaluate whether the italic wrapping improves compliance for the specific instruction — the check is structural, not semantic. diff --git a/framework/rules/core/layered-content-structure/checks.yml b/framework/rules/core/layered-content-structure/checks.yml new file mode 100644 index 00000000..92ee625d --- /dev/null +++ b/framework/rules/core/layered-content-structure/checks.yml @@ -0,0 +1,12 @@ +checks: +- id: CORE.S.0016.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0016.content_check + type: content_query + query: has_layered_structure + args: + min_headings: 2 + message: Missing content layering — organize content with top-level headings for + key topics + expect: present diff --git a/framework/rules/core/layered-content-structure/rule.md b/framework/rules/core/layered-content-structure/rule.md new file mode 100644 index 00000000..ac579897 --- /dev/null +++ b/framework/rules/core/layered-content-structure/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:S:0016 +slug: layered-content-structure +title: "Layered Content Structure" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Layered Content Structure + +Instruction content must be organized with at least two top-level headings for major topics. This lets the agent quickly find relevant sections instead of scanning a flat wall of text. + +## Antipatterns + +- **Single heading followed by all content**: A file with only `# Project` and then 200 lines of mixed instructions. The check requires at least 2 headings to confirm layered structure. +- **Headings only at deep levels**: Using `###` and `####` without any `#` or `##` headings. The check looks for top-level heading structure, not deeply nested subsections. +- **No headings at all**: A freeform file with paragraphs but no markdown headings. The check requires headings to be present as structural markers. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Setup +Install dependencies with `uv sync`. + +## Commands +Run `uv run ails check .` to validate. +~~~~ + +### Fail + +~~~~markdown +Install dependencies with `uv sync`. +Run `uv run ails check .` to validate. +Use `ruff` for formatting. +~~~~ + +## Limitations + +Checks that the file uses headings to organize content. Does not evaluate whether the organization is logical or complete. diff --git a/framework/rules/core/layered-content-structure/vocab.yml b/framework/rules/core/layered-content-structure/vocab.yml new file mode 100644 index 00000000..edc4243d --- /dev/null +++ b/framework/rules/core/layered-content-structure/vocab.yml @@ -0,0 +1,6 @@ +layered_content_structure: + - priority + - order + - first + - before + - heading hierarchy diff --git a/framework/rules/core/local-override-file/checks.yml b/framework/rules/core/local-override-file/checks.yml new file mode 100644 index 00000000..ecb4da98 --- /dev/null +++ b/framework/rules/core/local-override-file/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0022.check + type: mechanical + check: file_exists +- id: CORE.S.0022.has_content + type: deterministic + pattern-regex: '(?s).{20,}' + expect: present + message: "Override file exists but has no meaningful content" diff --git a/framework/rules/core/local-override-file/rule.md b/framework/rules/core/local-override-file/rule.md new file mode 100644 index 00000000..f4278c69 --- /dev/null +++ b/framework/rules/core/local-override-file/rule.md @@ -0,0 +1,40 @@ +--- +id: CORE:S:0022 +slug: local-override-file +title: "Local Override File" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: override} +--- + +# Local Override File + +A local override file must exist and contain at least 20 characters of substantive content. Override files allow user-specific customizations without modifying committed instruction files. + +## Antipatterns + +- **Empty override file**: Creating the file but leaving it blank or with only whitespace. The check requires at least 20 characters of content via the pattern `(?s).{20,}`. +- **Stub-only content**: Writing just `# Override` (10 characters) as a placeholder. This falls below the 20-character minimum because it contains no substantive customization. +- **Override content in the wrong file**: Adding personal preferences to the committed `CLAUDE.md` instead of the local override file. The check targets files with the `override` type specifically. + +## Pass / Fail + +### Pass + +~~~~markdown +# Local Overrides + +Use verbose test output: `uv run pytest -v --tb=long` +~~~~ + +### Fail + +~~~~markdown +# Override +~~~~ + +## Limitations + +Checks that the local override file has substantive content (at least 20 characters). Does not evaluate content relevance. diff --git a/framework/rules/core/local-override-file/tests/fail/.gitkeep b/framework/rules/core/local-override-file/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/local-override-file/tests/pass/CLAUDE.local.md b/framework/rules/core/local-override-file/tests/pass/CLAUDE.local.md new file mode 100644 index 00000000..3f1a7d20 --- /dev/null +++ b/framework/rules/core/local-override-file/tests/pass/CLAUDE.local.md @@ -0,0 +1 @@ +CLAUDE.local.md provides developer-specific overrides diff --git a/framework/rules/core/local-override-file/vocab.yml b/framework/rules/core/local-override-file/vocab.yml new file mode 100644 index 00000000..6996cb86 --- /dev/null +++ b/framework/rules/core/local-override-file/vocab.yml @@ -0,0 +1,7 @@ +local_override_file: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/mcp-config-declares-servers/checks.yml b/framework/rules/core/mcp-config-declares-servers/checks.yml new file mode 100644 index 00000000..5ede86e3 --- /dev/null +++ b/framework/rules/core/mcp-config-declares-servers/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.G.0008.file_in_scope + type: mechanical + check: file_exists +- id: CORE.G.0008.content_check + type: content_query + query: has_heading_matching + args: + terms: + - MCP + - mcpServers + message: Missing MCP server declarations — config must declare servers with scope + constraints + expect: present diff --git a/framework/rules/core/mcp-config-declares-servers/rule.md b/framework/rules/core/mcp-config-declares-servers/rule.md new file mode 100644 index 00000000..af25a816 --- /dev/null +++ b/framework/rules/core/mcp-config-declares-servers/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:G:0008 +slug: mcp-config-declares-servers +title: "Mcp Config Declares Servers" +category: governance +type: mechanical +severity: medium +backed_by: [] +match: {type: config} +--- +# Mcp Config Declares Servers + +Config files must contain a heading referencing MCP or mcpServers. Without declared server entries, the agent has no record of which MCP tools are available or how they are scoped. + +## Antipatterns + +- A config file that references MCP tools in prose but has no heading containing "MCP" or "mcpServers" -- the heading-match check looks for those terms in section headers, not body text. +- Adding a heading like "## External Tools" that describes MCP servers without using the term "MCP" in the heading -- the check requires the heading itself to match. +- Declaring servers only in a separate JSON/YAML config without any heading reference in the instruction config file -- the check targets config-type instruction files, not raw tool configs. + +## Pass / Fail + +### Pass + +~~~~markdown +## MCP Servers + +- filesystem: read/write access to project directory +- github: issue and PR operations, scoped to current repo +~~~~ + +### Fail + +~~~~markdown +## External Integrations + +We use several MCP tools for file access and GitHub operations. +See the settings file for details. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/mcp-config-declares-servers/tests/fail/.claude/settings.json b/framework/rules/core/mcp-config-declares-servers/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/mcp-config-declares-servers/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/mcp-config-declares-servers/tests/pass/.claude/settings.json b/framework/rules/core/mcp-config-declares-servers/tests/pass/.claude/settings.json new file mode 100644 index 00000000..f5017575 --- /dev/null +++ b/framework/rules/core/mcp-config-declares-servers/tests/pass/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "reporails": { + "command": "uv", + "args": ["run", "ails", "serve"] + } + } +} diff --git a/framework/rules/core/mcp-config-declares-servers/vocab.yml b/framework/rules/core/mcp-config-declares-servers/vocab.yml new file mode 100644 index 00000000..29f94bd8 --- /dev/null +++ b/framework/rules/core/mcp-config-declares-servers/vocab.yml @@ -0,0 +1,7 @@ +mcp_config_declares_servers: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/mcp-configuration-documented/checks.yml b/framework/rules/core/mcp-configuration-documented/checks.yml new file mode 100644 index 00000000..95935823 --- /dev/null +++ b/framework/rules/core/mcp-configuration-documented/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.C.0027.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0027.content_check + type: content_query + query: has_heading_matching + args: + terms: + - MCP + - Server + - Tools + message: Missing MCP documentation — describe MCP server configuration if applicable + expect: present diff --git a/framework/rules/core/mcp-configuration-documented/rule.md b/framework/rules/core/mcp-configuration-documented/rule.md new file mode 100644 index 00000000..991b95a6 --- /dev/null +++ b/framework/rules/core/mcp-configuration-documented/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:C:0027 +slug: mcp-configuration-documented +title: "Mcp Configuration Documented" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {type: main} +--- +# Mcp Configuration Documented + +The main instruction file must contain a heading referencing MCP, Server, or Tools. If the project uses MCP tools, documenting them in the main file ensures the agent discovers available servers at session start. + +## Antipatterns + +- Describing MCP servers in a scoped rule file but not in the main instruction file -- the check targets main-type files only, so documentation in `.claude/rules/mcp.md` does not satisfy it. +- Using a heading like "## Integrations" to document MCP server setup -- the check requires the heading to contain "MCP", "Server", or "Tools". +- Mentioning server names in a bullet list under a generic heading like "## Setup" -- the heading-match check scans headings, not list content. + +## Pass / Fail + +### Pass + +~~~~markdown +## MCP Server Configuration + +- filesystem: scoped to project root, read/write +- github: PR and issue operations on current repo +~~~~ + +### Fail + +~~~~markdown +## Project Setup + +Install dependencies with `npm install`. +The agent can use filesystem and github tools. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/mermaid-diagrams/checks.yml b/framework/rules/core/mermaid-diagrams/checks.yml new file mode 100644 index 00000000..3a1e2126 --- /dev/null +++ b/framework/rules/core/mermaid-diagrams/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0039.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0039.has_branching_steps + type: content_query + query: has_branching_steps + args: + message: Branching workflow in numbered list without mermaid — add ```mermaid flowchart showing control flow and branch paths + expect: absent +- id: CORE.C.0039.has_mermaid + type: content_query + query: has_mermaid_blocks + args: + message: No mermaid flowcharts — use ```mermaid blocks for procedures with branching logic + expect: present diff --git a/framework/rules/core/mermaid-diagrams/rule.md b/framework/rules/core/mermaid-diagrams/rule.md new file mode 100644 index 00000000..f145b738 --- /dev/null +++ b/framework/rules/core/mermaid-diagrams/rule.md @@ -0,0 +1,55 @@ +--- +id: CORE:C:0039 +slug: mermaid-diagrams +title: "Flowcharts for Procedures" +category: coherence +type: mechanical +severity: low +match: {format: freeform} +--- + +# Flowcharts for Procedures + +Instruction files with branching workflows must include mermaid flowcharts. Numbered lists that contain conditional language ("if", "when", "otherwise") without an accompanying mermaid block indicate a procedure that would be clearer as a diagram. + +## Antipatterns + +- Writing a numbered list with "if X then do Y, otherwise do Z" steps but no mermaid block -- the check flags branching steps that lack a corresponding flowchart. +- Adding a mermaid block that shows a linear sequence while the prose describes branching -- the `has_branching_steps` check detects conditionals in numbered lists independent of the diagram content. +- Using prose paragraphs for conditional workflows instead of numbered lists -- the check specifically targets numbered lists with conditional keywords, so conditional paragraphs are not flagged but also not well-structured. + +## Pass / Fail + +### Pass + +~~~~markdown +```mermaid +graph TD + A[Run tests] --> B{All pass?} + B -->|Yes| C[Deploy] + B -->|No| D[Fix failures] +``` + +1. Run the test suite +2. If all tests pass, deploy to staging +3. Otherwise, fix failures and re-run +~~~~ + +### Fail + +~~~~markdown +1. Run the test suite +2. If all tests pass, deploy to staging +3. Otherwise, fix the failures and re-run +4. When staging looks good, promote to production +~~~~ + +## Fix + +For procedures with 3+ steps and branching logic, add a ` ```mermaid ` flowchart showing the control flow and all branch paths. Write prose below the flowchart explaining *why* each decision matters — the diagram shows what happens, the prose explains why. + +Use numbered lists for linear sequences without branches. Do not add flowcharts to non-procedural content like tool constraints or project identity. + +## Limitations + +Detects numbered lists with conditional language ("if", "when", "otherwise") and checks for ` ```mermaid ` blocks. Uses `scope_conditional` atom classification to identify branching — may miss implicit branches not marked by conditional keywords. Does not verify that mermaid diagrams are syntactically valid or represent the described procedure. diff --git a/framework/rules/core/modality-weakness/rule.md b/framework/rules/core/modality-weakness/rule.md new file mode 100644 index 00000000..488ee57b --- /dev/null +++ b/framework/rules/core/modality-weakness/rule.md @@ -0,0 +1,49 @@ +--- +id: CORE:C:0043 +slug: modality-weakness +title: "Modality Weakness" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# Modality Weakness + +Hedged instructions ("should", "try to", "consider", "prefer") couple significantly weaker than direct instructions ("do not", bare imperatives). + +## Antipatterns + +- Writing "You should run tests before merging" instead of "Run tests before merging" -- hedged modality reduces compliance compared to direct imperatives. +- Using "Consider using `ruff` for formatting" when the intent is mandatory -- "consider" signals optional guidance, so the agent may skip it entirely. +- Prefixing constraints with "Try to avoid" instead of "Do not" or "NEVER" -- the softer phrasing undercuts the constraint's force. +- Reserving hedges like "prefer" for hard requirements -- "Prefer X over Y" reads as a suggestion, not a mandate. + +## Pass / Fail + +### Pass + +~~~~markdown +Run `uv run pytest` before every commit. +ALWAYS use `ruff` for formatting. +Do not modify generated files in `dist/`. +~~~~ + +### Fail + +~~~~markdown +You should run tests before merging. +Consider using `ruff` for formatting. +Try to avoid modifying generated files. +~~~~ + +## Fix + +Replace "You should run tests before merging" with "Run tests before +merging" (direct) or "ALWAYS run tests before merging" (absolute). Reserve hedging for +genuinely optional guidance. + +## Limitations + +Detects hedged modality markers ("should", "try to", "consider", "prefer") in instructions. Some hedging is intentional for truly optional guidance — this diagnostic flags all hedging regardless of intent. diff --git a/framework/rules/core/modular-file-organization/checks.yml b/framework/rules/core/modular-file-organization/checks.yml new file mode 100644 index 00000000..ff9d2137 --- /dev/null +++ b/framework/rules/core/modular-file-organization/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0010.check + type: mechanical + check: file_exists +- id: CORE.S.0010.modular + type: mechanical + check: file_count + args: + min: 2 diff --git a/framework/rules/core/modular-file-organization/rule.md b/framework/rules/core/modular-file-organization/rule.md new file mode 100644 index 00000000..7a1671c6 --- /dev/null +++ b/framework/rules/core/modular-file-organization/rule.md @@ -0,0 +1,40 @@ +--- +id: CORE:S:0010 +slug: modular-file-organization +title: "Modular File Organization" +category: structure +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- + +# Modular File Organization + +A project must contain at least 2 instruction files. Splitting instructions across multiple files keeps each file focused and prevents a single monolithic document from growing unwieldy. + +## Antipatterns + +- Putting all instructions in a single `CLAUDE.md` with no scoped rule files -- the check requires a minimum of 2 instruction files in the project. +- Creating a second file that is empty or contains only a title -- the file count check counts files that exist, but the content may fail other rules. +- Placing all scoped rules in subdirectories but having no root instruction file -- the file_exists gate checks that the expected root file is present before counting. + +## Pass / Fail + +### Pass + +~~~~markdown +CLAUDE.md (main instruction file) +.claude/rules/testing.md (scoped rule) +.claude/rules/style.md (scoped rule) +~~~~ + +### Fail + +~~~~markdown +CLAUDE.md (single file, no other instruction files) +~~~~ + +## Limitations + +Checks that the expected file exists and that at least 2 instruction files are present. Does not evaluate file contents or whether the split is meaningful. diff --git a/framework/rules/core/modular-file-organization/tests/pass/.claude/rules/testing.md b/framework/rules/core/modular-file-organization/tests/pass/.claude/rules/testing.md new file mode 100644 index 00000000..882104f0 --- /dev/null +++ b/framework/rules/core/modular-file-organization/tests/pass/.claude/rules/testing.md @@ -0,0 +1,3 @@ +# Testing + +Modular rule file separated from main instructions. diff --git a/framework/rules/core/modular-file-organization/vocab.yml b/framework/rules/core/modular-file-organization/vocab.yml new file mode 100644 index 00000000..187dc39b --- /dev/null +++ b/framework/rules/core/modular-file-organization/vocab.yml @@ -0,0 +1,6 @@ +modular_file_organization: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/no-auto-generated-boilerplate/checks.yml b/framework/rules/core/no-auto-generated-boilerplate/checks.yml new file mode 100644 index 00000000..2d5354ef --- /dev/null +++ b/framework/rules/core/no-auto-generated-boilerplate/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.C.0030.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0030.pattern_check + type: deterministic + pattern-regex: '(?i)(auto[_-]?generated|do\s+not\s+edit|generated\s+by\s+\w+|this\s+file\s+was\s+auto)' + expect: absent + message: "Auto-generated boilerplate detected — instruction content should be human-authored" diff --git a/framework/rules/core/no-auto-generated-boilerplate/rule.md b/framework/rules/core/no-auto-generated-boilerplate/rule.md new file mode 100644 index 00000000..dd72afec --- /dev/null +++ b/framework/rules/core/no-auto-generated-boilerplate/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:C:0030 +slug: no-auto-generated-boilerplate +title: "No Auto Generated Boilerplate" +category: coherence +type: deterministic +severity: high +backed_by: [] +match: {format: freeform} +--- + +# No Auto Generated Boilerplate + +Instruction content must be human-authored, not auto-generated boilerplate. Generated content lacks the project-specific context that makes instructions useful. + +## Antipatterns + +- Including a "Do not edit this file" warning at the top of an instruction file -- the check flags `do not edit` as an auto-generation marker. +- Copying a template that contains "Generated by [tool]" and forgetting to remove the attribution line -- the pattern matches `generated by` followed by any word. +- Using scaffolding tools that insert "auto-generated" headers into markdown files -- the check flags any variant of `auto-generated` or `auto_generated`. + +## Pass / Fail + +### Pass + +~~~~markdown +# My Project + +Use `pytest` for all test runs. Keep test files in `tests/`. +Run `uv run poe qa` before committing changes. +~~~~ + +### Fail + +~~~~markdown + +# My Project + +This file was generated by `init-project`. +~~~~ + +## Limitations + +Detects common auto-generated markers (`auto-generated`, `do not edit`, `generated by`). May miss boilerplate without standard markers. diff --git a/framework/rules/core/no-credentials/checks.yml b/framework/rules/core/no-credentials/checks.yml new file mode 100644 index 00000000..e4f0a015 --- /dev/null +++ b/framework/rules/core/no-credentials/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.G.0002.file_in_scope + type: mechanical + check: file_exists +- id: CORE.G.0002.pattern_check + type: deterministic + pattern-regex: '(?i)(password\s*[=:]\s*[''\"]?\w|api[_-]?key\s*[=:]\s*[''\"]?\w|-----BEGIN\s+(RSA|PRIVATE|CERTIFICATE)|secret[_-]?key\s*[=:])' + expect: absent + message: "Credential or secret detected — never include passwords, API keys, or private keys" diff --git a/framework/rules/core/no-credentials/rule.md b/framework/rules/core/no-credentials/rule.md new file mode 100644 index 00000000..6f67903b --- /dev/null +++ b/framework/rules/core/no-credentials/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:G:0002 +slug: no-credentials +title: "No Credentials" +category: governance +type: deterministic +severity: medium +backed_by: [] +match: {format: [freeform, frontmatter, schema_validated]} +--- + +# No Credentials + +Instruction files must never contain credentials, API keys, or private keys. Secrets in instruction files get committed to version control. + +## Antipatterns + +- Embedding an example API key like `api_key = "sk-abc123"` in a code block -- the check scans all content including fenced code blocks for credential patterns. +- Including a `password: mypass` line as a configuration example -- the pattern matches `password` followed by `=` or `:` and a value. +- Pasting a PEM certificate block (`-----BEGIN PRIVATE KEY-----`) for reference -- the check flags private key and certificate headers regardless of context. + +## Pass / Fail + +### Pass + +~~~~markdown +## Authentication + +Set `API_KEY` in your `.env` file (not tracked by git). +Use `$DATABASE_PASSWORD` environment variable for DB access. +~~~~ + +### Fail + +~~~~markdown +## Authentication + +api_key = "sk-live-abc123def456" +password: "hunter2" +-----BEGIN RSA PRIVATE KEY----- +~~~~ + +## Limitations + +Uses pattern matching to detect common credential formats (passwords, API keys, private key headers). May miss custom credential patterns or obfuscated values. diff --git a/framework/rules/core/no-ephemeral-content/checks.yml b/framework/rules/core/no-ephemeral-content/checks.yml new file mode 100644 index 00000000..13bac96e --- /dev/null +++ b/framework/rules/core/no-ephemeral-content/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.C.0029.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0029.pattern_check + type: deterministic + pattern-regex: '(?i)\b(TODO\s*:|FIXME\s*:|HACK\s*:|TEMP\s*:|WIP\s*:|PLACEHOLDER\s*:)' + expect: absent + message: "Ephemeral content found — remove TODO/FIXME/WIP markers from instruction files" diff --git a/framework/rules/core/no-ephemeral-content/rule.md b/framework/rules/core/no-ephemeral-content/rule.md new file mode 100644 index 00000000..02ce27f1 --- /dev/null +++ b/framework/rules/core/no-ephemeral-content/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:C:0029 +slug: no-ephemeral-content +title: "No Ephemeral Content" +category: coherence +type: deterministic +severity: high +backed_by: [] +match: {format: freeform} +--- + +# No Ephemeral Content + +Instruction files must not contain ephemeral markers like TODO, FIXME, or WIP. These indicate incomplete content that shouldn't be committed. + +## Antipatterns + +- Leaving a `TODO: add testing instructions` comment in a committed instruction file -- the check flags `TODO:` as an ephemeral marker. +- Using `WIP:` as a section prefix to mark draft content -- the pattern matches `WIP:` regardless of position. +- Adding a `FIXME: update after migration` note intending to clean it up later -- ephemeral markers in committed instruction files indicate the content is not ready for use. +- Marking temporary workarounds with `HACK:` or `TEMP:` -- both are flagged by the pattern. + +## Pass / Fail + +### Pass + +~~~~markdown +## Testing + +Run `pytest tests/` before every commit. +Keep integration tests in `tests/integration/`. +~~~~ + +### Fail + +~~~~markdown +## Testing + +TODO: add testing instructions +FIXME: update test command after migration +WIP: this section is incomplete +~~~~ + +## Limitations + +Detects common ephemeral markers (TODO, FIXME, HACK, TEMP, WIP, PLACEHOLDER). May miss custom or unlabeled temporary content. diff --git a/framework/rules/core/no-inline-style-rules/checks.yml b/framework/rules/core/no-inline-style-rules/checks.yml new file mode 100644 index 00000000..1b6e8d56 --- /dev/null +++ b/framework/rules/core/no-inline-style-rules/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.C.0017.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0017.pattern_check + type: deterministic + pattern-regex: '(?i)(` blocks to control how an instruction file renders in a preview tool -- the check flags `` tags for interactive examples -- the check flags `h2 { color: red; } + +## Constraints + +

Do NOT modify files in dist.

+ +~~~~ + +## Limitations + +Detects inline HTML style and script elements. Does not evaluate CSS class references or external stylesheets. diff --git a/framework/rules/core/no-inline-style-rules/vocab.yml b/framework/rules/core/no-inline-style-rules/vocab.yml new file mode 100644 index 00000000..01f30622 --- /dev/null +++ b/framework/rules/core/no-inline-style-rules/vocab.yml @@ -0,0 +1,8 @@ +no_inline_style_rules: + - agent + - claude + - copilot + - cursor + - windsurf + - cline + - agent-specific syntax diff --git a/framework/rules/core/non-redundant-content/rule.md b/framework/rules/core/non-redundant-content/rule.md new file mode 100644 index 00000000..53cf9ca4 --- /dev/null +++ b/framework/rules/core/non-redundant-content/rule.md @@ -0,0 +1,50 @@ +--- +id: CORE:C:0040 +slug: non-redundant-content +title: "No Cross-File Duplication" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# No Cross-File Duplication + +Duplicated instructions across files drift into contradiction as one copy is updated and the other is not. Conflicting instructions severely degrade compliance. However, same-topic reinforcement using different wording is beneficial — distinct instructions that push in the same direction help each other. + +## Antipatterns + +- Copy-pasting the same constraint verbatim into `CLAUDE.md` and `.claude/rules/testing.md` -- near-identical text across files is flagged as duplication even if both copies are currently correct. +- Duplicating a command reference like "Run `uv run poe qa`" in multiple rule files -- identical phrasing risks drift when one copy is updated but not the other. +- Restating a rule from a scoped file in the main file "for visibility" using the same wording -- use a pointer ("See `.claude/rules/testing.md`") or rephrase to reinforce the same direction with different language. + +## Pass / Fail + +### Pass + +~~~~markdown + +Run `uv run poe qa` before committing. See `.claude/rules/testing.md` for details. + + +Use `pytest` fixtures from `conftest.py` for shared setup. Test boundaries, not happy paths. +~~~~ + +### Fail + +~~~~markdown + +Run `uv run poe qa` before committing. Use `pytest` for all tests. + + +Run `uv run poe qa` before committing. Use `pytest` for all tests. +~~~~ + +## Fix + +Keep each instruction in one canonical location. If two files need the same constraint, designate one as authoritative and reference it from the other. When reinforcing a topic across files, use different wording that pushes the same direction — do not copy-paste identical text. + +## Limitations + +Detects near-identical text across files using embedding similarity. Same-direction reinforcement with different wording is not flagged — only high-similarity duplicates that risk drift. diff --git a/framework/rules/core/output-format-specified/checks.yml b/framework/rules/core/output-format-specified/checks.yml new file mode 100644 index 00000000..6ea2412f --- /dev/null +++ b/framework/rules/core/output-format-specified/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.C.0025.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0025.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Output + - Format + - Display + message: Missing output format specification — describe expected output formats + expect: present diff --git a/framework/rules/core/output-format-specified/rule.md b/framework/rules/core/output-format-specified/rule.md new file mode 100644 index 00000000..4dcd8181 --- /dev/null +++ b/framework/rules/core/output-format-specified/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:C:0025 +slug: output-format-specified +title: "Output Format Specified" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- +# Output Format Specified + +Instruction files must contain a heading referencing Output, Format, or Display. Specifying expected output formats tells the agent what shape its responses should take. + +## Antipatterns + +- Describing output expectations in body text under a generic heading like "## Usage" -- the check requires the heading itself to contain "Output", "Format", or "Display". +- Specifying "return JSON" in a bullet list but under a heading that does not mention format -- the heading-match check scans section headers, not list items. +- Relying on the agent to infer output format from examples alone without a dedicated heading -- implicit expectations are not detected by the heading check. + +## Pass / Fail + +### Pass + +~~~~markdown +## Output Format + +Return results as JSON with `status` and `message` fields. +Use markdown tables for multi-row output. +~~~~ + +### Fail + +~~~~markdown +## Commands + +- `ails check .` validates the project +- Results include rule violations and scores +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/path-scope-declared/checks.yml b/framework/rules/core/path-scope-declared/checks.yml new file mode 100644 index 00000000..2c05aac9 --- /dev/null +++ b/framework/rules/core/path-scope-declared/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0038.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0038.has_globs + type: mechanical + check: frontmatter_key + args: + key: globs + message: "Path-scoped rule must declare a 'globs' key in frontmatter" diff --git a/framework/rules/core/path-scope-declared/rule.md b/framework/rules/core/path-scope-declared/rule.md new file mode 100644 index 00000000..7833a8d7 --- /dev/null +++ b/framework/rules/core/path-scope-declared/rule.md @@ -0,0 +1,48 @@ +--- +id: CORE:S:0038 +slug: path-scope-declared +title: "Path Scope Declared" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {scope: path_scoped} +--- + +# Path Scope Declared + +Path-scoped rules must declare which paths they apply to. Without a scope declaration, the rule's applicability is ambiguous. + +## Antipatterns + +- **Scoped rule file without `globs` frontmatter.** A rule in `.claude/rules/` that omits the `globs` key has no declared scope. The agent cannot determine which files the rule applies to, making it effectively invisible to path-based discovery. +- **Using `paths` instead of `globs`.** The check looks for the `globs` frontmatter key specifically. A rule that declares its scope under a different key name (e.g., `paths`, `applies_to`) will fail this check. +- **Empty frontmatter block.** A rule file with `---` / `---` but no keys inside still fails -- the `globs` key must be present, not just the frontmatter block. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +globs: src/**/*.py +--- +# Testing Design + +Tests exist to catch bugs, not to confirm the implementation works. +~~~~ + +### Fail + +~~~~markdown +--- +description: Rules for testing files +--- +# Testing Design + +Tests exist to catch bugs, not to confirm the implementation works. +~~~~ + +## Limitations + +Checks that the `globs` frontmatter key is present. Does not validate whether the glob patterns are correct or match actual file paths. diff --git a/framework/rules/core/path-scope-declared/tests/fail/.gitkeep b/framework/rules/core/path-scope-declared/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/path-scope-declared/tests/pass/.claude/rules/example.md b/framework/rules/core/path-scope-declared/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..2f257c1a --- /dev/null +++ b/framework/rules/core/path-scope-declared/tests/pass/.claude/rules/example.md @@ -0,0 +1,7 @@ +--- +globs: ['**/*.md'] +--- + +# Style Guide + +Markdown conventions for the project. \ No newline at end of file diff --git a/framework/rules/core/path-scope-declared/vocab.yml b/framework/rules/core/path-scope-declared/vocab.yml new file mode 100644 index 00000000..e858e6e6 --- /dev/null +++ b/framework/rules/core/path-scope-declared/vocab.yml @@ -0,0 +1,7 @@ +path_scope_declared: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/permission-config-denies-sensitive/checks.yml b/framework/rules/core/permission-config-denies-sensitive/checks.yml new file mode 100644 index 00000000..65302c1b --- /dev/null +++ b/framework/rules/core/permission-config-denies-sensitive/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CORE.G.0005.file_in_scope + type: mechanical + check: file_exists +- id: CORE.G.0005.content_check + type: content_query + query: has_constraint_atoms + args: + message: Missing sensitive access restriction — explicitly deny access to secrets + and credentials + expect: present diff --git a/framework/rules/core/permission-config-denies-sensitive/rule.md b/framework/rules/core/permission-config-denies-sensitive/rule.md new file mode 100644 index 00000000..9f2a7870 --- /dev/null +++ b/framework/rules/core/permission-config-denies-sensitive/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:G:0005 +slug: permission-config-denies-sensitive +title: "Permission Config Denies Sensitive" +category: governance +type: mechanical +severity: medium +backed_by: [] +match: {type: config} +--- +# Permission Config Denies Sensitive + +Configuration files must contain at least one constraint instruction that restricts access to sensitive files. Without an explicit denial, the agent may read or write secrets, credentials, and private keys. + +## Antipatterns + +- **Config file with only positive directives.** A settings file that grants permissions but never denies anything fails -- the check requires at least one constraint atom (a `-1` charge instruction such as "NEVER read `.env` files"). +- **Mentioning sensitive files in prose without a constraint.** Describing that `.env` files exist is not a denial. The check looks for constraint-charged instructions, not informational references. +- **Relying on `.gitignore` alone.** Excluding sensitive files from version control does not prevent the agent from reading them at runtime. The config must contain an explicit denial instruction. + +## Pass / Fail + +### Pass + +~~~~markdown +# Sensitive Files + +Ask the user to modify `.env`, `.env.*`, `credentials*`, and `*.pem` files manually. +*Do NOT read or write these files.* +~~~~ + +### Fail + +~~~~markdown +# Project Settings + +This project uses `.env` for environment variables. +See `credentials.json` for API keys. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/permission-config-denies-sensitive/tests/fail/.claude/settings.json b/framework/rules/core/permission-config-denies-sensitive/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/permission-config-denies-sensitive/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/permission-config-denies-sensitive/tests/pass/.claude/settings.json b/framework/rules/core/permission-config-denies-sensitive/tests/pass/.claude/settings.json new file mode 100644 index 00000000..b8e20968 --- /dev/null +++ b/framework/rules/core/permission-config-denies-sensitive/tests/pass/.claude/settings.json @@ -0,0 +1 @@ +Add .env and credentials files to permissions.deny diff --git a/framework/rules/core/permission-config-denies-sensitive/vocab.yml b/framework/rules/core/permission-config-denies-sensitive/vocab.yml new file mode 100644 index 00000000..30ef9386 --- /dev/null +++ b/framework/rules/core/permission-config-denies-sensitive/vocab.yml @@ -0,0 +1,7 @@ +permission_config_denies_sensitive: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/position-recency/rule.md b/framework/rules/core/position-recency/rule.md new file mode 100644 index 00000000..76dbf1ee --- /dev/null +++ b/framework/rules/core/position-recency/rule.md @@ -0,0 +1,55 @@ +--- +id: CORE:C:0047 +slug: position-recency +title: "Position Recency" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# Position Recency + +Instructions at the end of a multi-instruction context dominate. Instructions at the beginning are dramatically weak. + +## Antipatterns + +- **Placing critical constraints at the top of the file.** A "NEVER delete production data" instruction at line 1 is in the weakest position. Later instructions on unrelated topics will dominate, and the constraint may be ignored. +- **Burying critical instructions in the middle.** An important directive sandwiched between boilerplate sections gets minimal attention from the model. Middle positions are weaker than both the beginning and the end. +- **Relying on emphasis alone.** Bold text or uppercase ("**IMPORTANT**") does not compensate for weak position. A normal instruction at the end outperforms an emphasized instruction at the beginning. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Setup + +Use `uv sync` to install dependencies. + +# Constraints + +*NEVER modify `.env` files directly.* +~~~~ + +### Fail + +~~~~markdown +# Constraints + +*NEVER modify `.env` files directly.* + +# Project Setup + +Use `uv sync` to install dependencies. +Run `uv run poe qa` for testing. +~~~~ + +## Fix + +Place highest-priority instructions LAST. Moving a critical instruction from the beginning to the end of a file dramatically increases compliance. For conflicting instructions, the last one wins — reorder or remove the conflict. + +## Limitations + +Evaluates position of abstract (non-named) instructions. Named instructions are not penalized for position — specificity overrides position effects. diff --git a/framework/rules/core/prior-as-competitor/rule.md b/framework/rules/core/prior-as-competitor/rule.md new file mode 100644 index 00000000..69e3446d --- /dev/null +++ b/framework/rules/core/prior-as-competitor/rule.md @@ -0,0 +1,51 @@ +--- +id: CORE:C:0052 +slug: prior-as-competitor +title: "Default Behavior Competition" +category: coherence +type: mechanical +execution: server +severity: medium +match: {} +--- + +# Default Behavior Competition + +The model always has a default behavior for any task — what it does without instructions. When instructions conflict or are too weak, the model reverts to this default completely. The default is an ever-present competitor. + +## Antipatterns + +- **Hedged instructions opposing the default.** Writing "you might want to use `ruff` instead of `black`" when the model defaults to `black` will not override the default. Hedged modality produces zero behavioral change. +- **Conflicting instructions expecting a compromise.** Two instructions that disagree ("use tabs" vs "use spaces") do not produce a blend. The model reverts to its default (typically spaces), identical to no instruction at all. +- **Abstract instructions against specific defaults.** Writing "format code consistently" when the model already has a specific default formatter changes nothing. The instruction must name the exact tool and behavior to displace the default. + +## Pass / Fail + +### Pass + +~~~~markdown +# Formatting + +Use `ruff` for all formatting. Run `ruff format .` before committing. +*NEVER run `black` or manual formatting.* +~~~~ + +### Fail + +~~~~markdown +# Formatting + +Consider using a consistent code formatter. +You might want to format code before committing. +~~~~ + +## Fix + +Work with the default or overwhelm it: +- If the desired behavior aligns with the model's default: lighter instructions suffice +- If the desired behavior opposes the default: maximum strength required — name exact constructs, use direct commands, place last in context. Any weakness leaves the default unchanged. +- Never rely on conflicting instructions to produce "average" behavior — conflict produces default behavior, identical to no instruction at all. + +## Limitations + +This is an informational diagnostic. The model's default behavior for a given task cannot be directly measured — this rule flags instructions that are likely too weak to override defaults based on their specificity and modality. diff --git a/framework/rules/core/priority-ordering/checks.yml b/framework/rules/core/priority-ordering/checks.yml new file mode 100644 index 00000000..4001ecf1 --- /dev/null +++ b/framework/rules/core/priority-ordering/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.C.0036.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0036.content_check + type: content_query + query: has_directive_atoms + args: + message: No directive instructions found to assess ordering + expect: present diff --git a/framework/rules/core/priority-ordering/rule.md b/framework/rules/core/priority-ordering/rule.md new file mode 100644 index 00000000..d1f35371 --- /dev/null +++ b/framework/rules/core/priority-ordering/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0036 +slug: priority-ordering +title: "Critical Instructions at Edges" +category: coherence +type: mechanical +severity: high +match: {format: freeform} +--- + +# Critical Instructions at Edges + +Freeform instruction files must contain at least one directive instruction. Files without directives contribute no actionable guidance and cannot benefit from position-based ordering. + +## Antipatterns + +- **File with only informational prose.** A file containing project descriptions, reference tables, or background knowledge but no directive or constraint instructions has no content whose ordering matters. It fails the directive check. +- **File with only headings and code blocks.** Structural content like headings and fenced code examples are not directives. The file must contain at least one imperative or constraint instruction. +- **Relying on headings as instructions.** A heading like `## Testing` is organizational, not a directive. The file needs body-level instructions like "Run `pytest` before committing." + +## Pass / Fail + +### Pass + +~~~~markdown +# Testing + +Run `uv run pytest tests/` before committing changes. +*Do NOT skip the test suite for quick fixes.* +~~~~ + +### Fail + +~~~~markdown +# Testing + +The project uses pytest for testing. +Tests are located in the `tests/` directory. +~~~~ + +## Fix + +Place critical instructions at the start of the first-loaded file or the end of the last-loaded file in the agent's loading order. If an instruction must appear in the middle, name specific constructs rather than using abstract terms. + +## Limitations + +Checks that the file contains directive instructions. Does not verify their position in the loading chain — position analysis is assessed separately. diff --git a/framework/rules/core/project-config-self-contained/checks.yml b/framework/rules/core/project-config-self-contained/checks.yml new file mode 100644 index 00000000..df209989 --- /dev/null +++ b/framework/rules/core/project-config-self-contained/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.G.0007.file_in_scope + type: mechanical + check: file_exists +- id: CORE.G.0007.pattern_check + type: deterministic + pattern-regex: (?i)(~/|\$HOME\b|\benv\s+var\b|\brequires?\s+.*\binstall) + expect: absent + message: External dependency detected — config must be self-contained diff --git a/framework/rules/core/project-config-self-contained/rule.md b/framework/rules/core/project-config-self-contained/rule.md new file mode 100644 index 00000000..7a1ab335 --- /dev/null +++ b/framework/rules/core/project-config-self-contained/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:G:0007 +slug: project-config-self-contained +title: "Project Config Self Contained" +category: governance +type: deterministic +severity: medium +backed_by: [] +match: {type: main} +--- +# Project Config Self Contained + +Project configuration must be self-contained — no dependencies on user-specific setup or external state not documented in the project. + +## Antipatterns + +- **Referencing home directory paths.** Instructions containing `~/` or `$HOME` depend on the user's local filesystem layout. Different contributors have different home directories, breaking reproducibility. +- **Requiring undocumented tool installation.** Phrases like "requires installing X" or "install the plugin first" indicate an external dependency that is not bundled or pinned in the project configuration. +- **Using environment variable references.** Writing "set the `env var` for the API key" makes the instruction depend on external state that may not exist on another machine. + +## Pass / Fail + +### Pass + +~~~~markdown +# Setup + +Run `uv sync` to install dependencies. +Use `uv run ails check .` to validate instruction files. +~~~~ + +### Fail + +~~~~markdown +# Setup + +Run `~/bin/custom-tool check` to validate files. +Set the env var `API_KEY` before running. +Requires installing the reporails plugin globally. +~~~~ + +## Limitations + +Detects references to external dependencies (`~/`, `$HOME`, `env var`, `requires install`). May miss indirect or obfuscated external references. diff --git a/framework/rules/core/project-config-self-contained/vocab.yml b/framework/rules/core/project-config-self-contained/vocab.yml new file mode 100644 index 00000000..b7f2d445 --- /dev/null +++ b/framework/rules/core/project-config-self-contained/vocab.yml @@ -0,0 +1,7 @@ +project_config_self_contained: + - hook + - MCP + - permission + - config + - settings + - configuration entry diff --git a/framework/rules/core/project-description-present/checks.yml b/framework/rules/core/project-description-present/checks.yml new file mode 100644 index 00000000..1b676e85 --- /dev/null +++ b/framework/rules/core/project-description-present/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.C.0013.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0013.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Description + - About + - Overview + message: Missing project description — add a heading or description section + expect: present diff --git a/framework/rules/core/project-description-present/rule.md b/framework/rules/core/project-description-present/rule.md new file mode 100644 index 00000000..57dc728b --- /dev/null +++ b/framework/rules/core/project-description-present/rule.md @@ -0,0 +1,50 @@ +--- +id: CORE:C:0013 +slug: project-description-present +title: "Project Description Present" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {type: main} +--- +# Project Description Present + +The root instruction file must describe the project — what it does and who it's for. This anchors the agent's understanding of context and purpose. + +## Antipatterns + +- **Jumping straight to commands.** A root file that starts with `## Commands` and lists CLI invocations but never describes what the project is. The check looks for a heading matching "Description", "About", or "Overview". +- **Description buried under a non-matching heading.** Writing the project description under `## Background` or `## Context` does not match the expected heading terms. Use "Description", "About", or "Overview" as the heading. +- **Project name as the only heading.** A single `# My Project` heading with commands underneath does not satisfy the check. The file needs a dedicated description section under one of the matching heading terms. + +## Pass / Fail + +### Pass + +~~~~markdown +# Reporails CLI + +## Overview + +AI instruction validator for coding agents. + +## Commands + +- `uv run ails check .` — validate instruction files +~~~~ + +### Fail + +~~~~markdown +# Reporails CLI + +## Commands + +- `uv run ails check .` — validate instruction files +- `uv run ails heal` — interactive auto-fix +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/related-instructions-grouped/checks.yml b/framework/rules/core/related-instructions-grouped/checks.yml new file mode 100644 index 00000000..4f618a49 --- /dev/null +++ b/framework/rules/core/related-instructions-grouped/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CORE.E.0005.file_in_scope + type: mechanical + check: file_exists +- id: CORE.E.0005.content_check + type: content_query + query: has_layered_structure + args: + min_headings: 2 + message: Instructions not grouped by topic — use headings to organize related instructions + expect: present diff --git a/framework/rules/core/related-instructions-grouped/rule.md b/framework/rules/core/related-instructions-grouped/rule.md new file mode 100644 index 00000000..98bd0a7b --- /dev/null +++ b/framework/rules/core/related-instructions-grouped/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:E:0005 +slug: related-instructions-grouped +title: "Related Instructions Grouped" +category: efficiency +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Related Instructions Grouped + +Related instructions must be grouped together, not scattered across the file. Co-location reduces the agent's search effort. + +## Antipatterns + +- **Flat file with no headings.** A long instruction file that lists directives without any section headings fails the structure check. The file must have at least 2 top-level headings to demonstrate topic grouping. +- **Single heading with all content underneath.** A file with one `# Title` heading and everything else in a single block is not organized into groups. The check requires at least 2 top-level headings (depth 1-2). +- **Using bold text instead of headings.** Formatting topic labels as `**Testing**` instead of `## Testing` does not create structural sections. The check evaluates heading-level organization. + +## Pass / Fail + +### Pass + +~~~~markdown +# Testing + +Run `uv run pytest tests/` before committing. + +# Formatting + +Use `ruff` for all formatting. +~~~~ + +### Fail + +~~~~markdown +Run tests before committing. +Use ruff for formatting. +Keep files under 500 lines. +Check for type errors. +~~~~ + +## Limitations + +Checks that the file uses headings to organize content. Does not evaluate whether the organization is logical or complete. diff --git a/framework/rules/core/related-instructions-grouped/tests/fail/.gitkeep b/framework/rules/core/related-instructions-grouped/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/related-instructions-grouped/vocab.yml b/framework/rules/core/related-instructions-grouped/vocab.yml new file mode 100644 index 00000000..34731de6 --- /dev/null +++ b/framework/rules/core/related-instructions-grouped/vocab.yml @@ -0,0 +1,6 @@ +related_instructions_grouped: + - priority + - order + - first + - before + - heading hierarchy diff --git a/framework/rules/core/relevance-decay/rule.md b/framework/rules/core/relevance-decay/rule.md new file mode 100644 index 00000000..b2ca9126 --- /dev/null +++ b/framework/rules/core/relevance-decay/rule.md @@ -0,0 +1,53 @@ +--- +id: CORE:C:0045 +slug: relevance-decay +title: "Relevance Decay" +category: coherence +type: mechanical +execution: server +severity: medium +match: {} +--- + +# Relevance Decay + +Instructions are only effective for tasks they're semantically related to. A testing instruction has no effect on documentation tasks — the instruction must be relevant to the work at hand. + +## Antipatterns + +- **Mixing unrelated domains in one instruction.** Writing "Use `pytest` fixtures and keep documentation concise" tries to address two unrelated task types. Neither domain gets strong coverage because the instruction dilutes itself across contexts. +- **Generic instructions intended for all tasks.** Writing "be thorough and careful" has near-zero effect on any specific task. Domain-specific vocabulary is required for the instruction to activate during relevant work. +- **Assuming cross-domain transfer.** A testing-specific instruction like "always write edge-case tests" will not make the model more thorough when writing documentation. Each domain needs its own instructions. + +## Pass / Fail + +### Pass + +~~~~markdown +# Testing + +Run `uv run pytest tests/ -v` before committing. +Use `@pytest.mark.parametrize` for multiple input cases. + +# Documentation + +Write `docs/*.md` for people, not agents. +~~~~ + +### Fail + +~~~~markdown +# Quality + +Be thorough and careful in all tasks. +Always produce high-quality output. +Double-check your work. +~~~~ + +## Fix + +If you need the same behavior across diverse task types, write separate versions of the instruction with domain-specific vocabulary for each. "Use `pytest` fixtures for test setup" only works for testing tasks — if you also want consistent patterns in scripts, write a separate instruction naming script-relevant constructs. + +## Limitations + +Measures semantic distance between instructions and task context. Cannot determine whether an instruction was intended to apply broadly or narrowly. diff --git a/framework/rules/core/root-instruction-file-exists/checks.yml b/framework/rules/core/root-instruction-file-exists/checks.yml new file mode 100644 index 00000000..7cdf73ac --- /dev/null +++ b/framework/rules/core/root-instruction-file-exists/checks.yml @@ -0,0 +1,5 @@ +checks: +- id: CORE.S.0007.check + type: mechanical + check: file_exists + # hint: file_exists at known root paths diff --git a/framework/rules/core/root-instruction-file-exists/rule.md b/framework/rules/core/root-instruction-file-exists/rule.md new file mode 100644 index 00000000..3fef01a7 --- /dev/null +++ b/framework/rules/core/root-instruction-file-exists/rule.md @@ -0,0 +1,44 @@ +--- +id: CORE:S:0007 +slug: root-instruction-file-exists +title: "Root Instruction File Exists" +category: structure +type: mechanical +severity: critical +backed_by: [] +match: {type: main} +--- + +# Root Instruction File Exists + +A root instruction file must exist at the project root. This is the primary entry point for any AI coding agent. + +## Antipatterns + +- **Placing the instruction file in a subdirectory.** A `CLAUDE.md` inside `docs/` or `.claude/` is not at the project root. The check verifies that a main instruction file exists at the root level. +- **Using a non-standard filename.** A file named `instructions.md` or `AI-RULES.md` at the root will not be recognized as the main instruction file. The file must match the expected root filename pattern for the agent. +- **Empty project with no instruction file.** A repository that has source code but no root instruction file fails this check. Even a minimal instruction file satisfies the requirement. + +## Pass / Fail + +### Pass + +~~~~markdown +project/ + CLAUDE.md + src/ + tests/ +~~~~ + +### Fail + +~~~~markdown +project/ + src/ + tests/ + docs/CLAUDE.md +~~~~ + +## Limitations + +Checks that the expected file exists. Does not evaluate file contents. diff --git a/framework/rules/core/root-instruction-file-exists/tests/fail/.gitkeep b/framework/rules/core/root-instruction-file-exists/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/root-instruction-file-exists/vocab.yml b/framework/rules/core/root-instruction-file-exists/vocab.yml new file mode 100644 index 00000000..aad67ae4 --- /dev/null +++ b/framework/rules/core/root-instruction-file-exists/vocab.yml @@ -0,0 +1,6 @@ +root_instruction_file_exists: + - file reference + - import path + - include directive + - frontmatter + - description field diff --git a/framework/rules/core/rules-directory-structure/checks.yml b/framework/rules/core/rules-directory-structure/checks.yml new file mode 100644 index 00000000..99d22618 --- /dev/null +++ b/framework/rules/core/rules-directory-structure/checks.yml @@ -0,0 +1,5 @@ +checks: +- id: CORE.S.0025.check + type: mechanical + check: file_exists + # hint: glob_match for rules directory pattern diff --git a/framework/rules/core/rules-directory-structure/rule.md b/framework/rules/core/rules-directory-structure/rule.md new file mode 100644 index 00000000..a980fe02 --- /dev/null +++ b/framework/rules/core/rules-directory-structure/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0025 +slug: rules-directory-structure +title: "Rules Directory Structure" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: scoped_rule} +--- + +# Rules Directory Structure + +When scoped rule files exist, they must reside in the expected directory for the agent (e.g., `.claude/rules/`). This ensures the agent discovers and loads rules correctly at session start. + +## Antipatterns + +- **Scoped rules placed at project root.** Rule files like `testing-design.md` dropped into the project root instead of `.claude/rules/` will not be discovered by the agent's rule loader. +- **Incorrect directory name.** Placing rules in `.claude/rule/` (singular) or `.claude/instructions/` instead of `.claude/rules/` breaks the expected directory structure. +- **Rules directory exists but is empty.** The check verifies that at least one scoped rule file exists. An empty `.claude/rules/` directory with no `.md` files inside passes `directory_exists` but provides no scoped guidance. + +## Pass / Fail + +### Pass + +~~~~markdown +project/ + .claude/rules/ + testing-design.md + sensitive-files.md + CLAUDE.md +~~~~ + +### Fail + +~~~~markdown +project/ + testing-design.md + sensitive-files.md + CLAUDE.md +~~~~ + +## Limitations + +Checks that the rules directory exists with the expected structure. Does not evaluate individual rule file quality. diff --git a/framework/rules/core/rules-directory-structure/tests/fail/.gitkeep b/framework/rules/core/rules-directory-structure/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/rules-directory-structure/tests/pass/.claude/rules/example.md b/framework/rules/core/rules-directory-structure/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..15f57dd9 --- /dev/null +++ b/framework/rules/core/rules-directory-structure/tests/pass/.claude/rules/example.md @@ -0,0 +1,2 @@ +rules/style.md +rules/testing.md diff --git a/framework/rules/core/rules-directory-structure/vocab.yml b/framework/rules/core/rules-directory-structure/vocab.yml new file mode 100644 index 00000000..e18029f8 --- /dev/null +++ b/framework/rules/core/rules-directory-structure/vocab.yml @@ -0,0 +1,7 @@ +rules_directory_structure: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/safety-gate-directives/checks.yml b/framework/rules/core/safety-gate-directives/checks.yml new file mode 100644 index 00000000..0be5ded2 --- /dev/null +++ b/framework/rules/core/safety-gate-directives/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.C.0022.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0022.content_check + type: content_query + query: has_constraint_atoms + args: + message: No safety constraints found — add safety directives using NEVER, MUST NOT, or ALWAYS + expect: present diff --git a/framework/rules/core/safety-gate-directives/rule.md b/framework/rules/core/safety-gate-directives/rule.md new file mode 100644 index 00000000..07f6fe59 --- /dev/null +++ b/framework/rules/core/safety-gate-directives/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0022 +slug: safety-gate-directives +title: "Safety Gate Directives" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Safety Gate Directives + +The instruction file must contain constraint atoms -- safety directives using keywords like NEVER, MUST NOT, or ALWAYS. Without hard boundaries, the agent has no guardrails for dangerous operations. + +## Antipatterns + +- Writing soft suggestions ("try to avoid deleting files") instead of hard constraints (`NEVER delete files without confirmation`). Soft language does not register as a constraint atom. +- Placing all safety guidance in external documentation instead of in the instruction file. The check looks for constraint atoms in the file's content. +- Using positive-only instructions with no boundaries. A file full of "do X" directives with no "NEVER do Y" constraints passes the directive check but fails the safety gate check. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## Boundaries + +NEVER modify `.env` files directly. +ALWAYS run `uv run poe qa` before committing. +~~~~ + +### Fail + +~~~~markdown +# Project + +## Guidelines + +Try to be careful with environment files. +It would be good to run tests before committing. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/same-topic-conflict/rule.md b/framework/rules/core/same-topic-conflict/rule.md new file mode 100644 index 00000000..432b76a0 --- /dev/null +++ b/framework/rules/core/same-topic-conflict/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0046 +slug: same-topic-conflict +title: "Same-Topic Reinforcement and Conflict" +category: coherence +type: mechanical +execution: server +severity: critical +match: {} +--- + +# Same-Topic Reinforcement and Conflict + +Multiple instructions on the same topic must agree in direction. Conflicting instructions on the same topic destroy compliance catastrophically -- the model cannot follow both and may follow neither. + +## Antipatterns + +- Writing "Use `ruff` for formatting" in one file and "Use `black` for formatting" in another. Same topic, opposite directives -- the model picks one unpredictably. +- Adding nuanced exceptions without scoping them: "ALWAYS use `pytest`" alongside "Don't use `pytest` for integration tests" reads as a contradiction without explicit conditional scoping. +- Restating a directive with weaker language elsewhere. "NEVER push to `main`" in one file and "Avoid pushing to `main`" in another creates ambiguity about whether the constraint is absolute. + +## Pass / Fail + +### Pass + +~~~~markdown + +Use `pytest` for all test files in `tests/`. +Run `uv run pytest tests/ -v` before committing. +~~~~ + +### Fail + +~~~~markdown + +Use `pytest` for all tests. + +Use `unittest` for all tests. +~~~~ + +## Fix + +Remove or resolve conflicts first. Then check for weak reinforcement: strengthen the weak instruction (name constructs, use imperative modality) or remove it. Reinforce only with instructions of comparable strength. + +## Limitations + +Detects same-topic instruction pairs using embedding similarity and opposite direction. May flag intentional nuance (e.g., "prefer X" with "but use Y when Z") as a conflict when the instructions are meant to coexist with different scopes. diff --git a/framework/rules/core/scope-fields-in-frontmatter/checks.yml b/framework/rules/core/scope-fields-in-frontmatter/checks.yml new file mode 100644 index 00000000..44e78148 --- /dev/null +++ b/framework/rules/core/scope-fields-in-frontmatter/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0013.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0013.pattern_check + type: deterministic + pattern-regex: '(?i)\b(scope|globs|applies.?to)\s*:' + expect: present + message: "Missing scope fields in frontmatter — declare scope, globs, or path targeting" diff --git a/framework/rules/core/scope-fields-in-frontmatter/rule.md b/framework/rules/core/scope-fields-in-frontmatter/rule.md new file mode 100644 index 00000000..2838793b --- /dev/null +++ b/framework/rules/core/scope-fields-in-frontmatter/rule.md @@ -0,0 +1,48 @@ +--- +id: CORE:S:0013 +slug: scope-fields-in-frontmatter +title: "Scope Fields In Frontmatter" +category: structure +type: deterministic +severity: medium +backed_by: [] +match: {type: scoped_rule} +--- + +# Scope Fields In Frontmatter + +Scoped instruction files must declare their scope boundary in frontmatter using `scope:`, `globs:`, or `applies_to:` fields. Without a declared scope, the file's targeting is ambiguous and the agent cannot determine which files the instructions apply to. + +## Antipatterns + +- Describing scope in prose ("This rule applies to Python files") without a frontmatter field. The check looks for `scope:`, `globs:`, or `applies_to:` key-value declarations, not prose descriptions. +- Using a non-standard field name like `targets:` or `files:`. The pattern matches `scope`, `globs`, and `applies_to` specifically. +- Omitting scope fields entirely because the file is "obviously" scoped by its directory path. The rule requires explicit declaration regardless of directory placement. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +globs: "src/**/*.py" +--- +# Python Style + +Use `ruff` for formatting. +~~~~ + +### Fail + +~~~~markdown +--- +title: Python Style +--- +# Python Style + +Use `ruff` for formatting Python files. +~~~~ + +## Limitations + +Checks for scope-related frontmatter fields (`scope`, `globs`, `applies_to`). Does not validate whether the declared scope is correct. diff --git a/framework/rules/core/scope-fields-in-frontmatter/tests/fail/.claude/rules/example.md b/framework/rules/core/scope-fields-in-frontmatter/tests/fail/.claude/rules/example.md new file mode 100644 index 00000000..6ee26dba --- /dev/null +++ b/framework/rules/core/scope-fields-in-frontmatter/tests/fail/.claude/rules/example.md @@ -0,0 +1,5 @@ +--- +description: A rule without scope fields +--- +# Rule Content +This rule has frontmatter but no scope or globs field. diff --git a/framework/rules/core/scope-fields-in-frontmatter/tests/pass/.claude/rules/example.md b/framework/rules/core/scope-fields-in-frontmatter/tests/pass/.claude/rules/example.md new file mode 100644 index 00000000..6bddc5a8 --- /dev/null +++ b/framework/rules/core/scope-fields-in-frontmatter/tests/pass/.claude/rules/example.md @@ -0,0 +1,5 @@ +--- +description: Example rule +--- +applyTo: '**/*.ts' +globs: ['src/**/*.ts'] diff --git a/framework/rules/core/scope-fields-in-frontmatter/vocab.yml b/framework/rules/core/scope-fields-in-frontmatter/vocab.yml new file mode 100644 index 00000000..183db712 --- /dev/null +++ b/framework/rules/core/scope-fields-in-frontmatter/vocab.yml @@ -0,0 +1,7 @@ +scope_fields_in_frontmatter: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/section-headers-present/checks.yml b/framework/rules/core/section-headers-present/checks.yml new file mode 100644 index 00000000..01ff7c98 --- /dev/null +++ b/framework/rules/core/section-headers-present/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0002.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0002.content_check + type: content_query + query: has_headings + args: + message: Missing section headers — structure content with markdown headings + expect: present diff --git a/framework/rules/core/section-headers-present/rule.md b/framework/rules/core/section-headers-present/rule.md new file mode 100644 index 00000000..d22f2add --- /dev/null +++ b/framework/rules/core/section-headers-present/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:S:0002 +slug: section-headers-present +title: "Section Headers Present" +category: structure +type: mechanical +severity: critical +backed_by: [] +match: {format: freeform} +--- +# Section Headers Present + +Each instruction file must contain markdown section headers (lines starting with `#`). Headers organize content into navigable sections that help agents locate relevant instructions. + +## Antipatterns + +- Writing a flat wall of text with no headings. Even short instruction files need at least one heading to establish structure. +- Using bold text (`**Section Name**`) instead of markdown headings (`## Section Name`). Bold text looks like a heading to humans but is not detected as a heading by the check. +- Relying on horizontal rules (`---`) to separate sections instead of headings. Horizontal rules create visual breaks but do not provide navigable structure. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Setup + +Use `uv sync` to install dependencies. + +## Testing + +Run `uv run pytest tests/` for the test suite. +~~~~ + +### Fail + +~~~~markdown +Use uv sync to install dependencies. + +Run pytest for the test suite. + +Keep files under 500 lines. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/section-headers-present/vocab.yml b/framework/rules/core/section-headers-present/vocab.yml new file mode 100644 index 00000000..9ebea606 --- /dev/null +++ b/framework/rules/core/section-headers-present/vocab.yml @@ -0,0 +1,6 @@ +section_headers_present: + - file reference + - import path + - include directive + - frontmatter + - description field diff --git a/framework/rules/core/security-requirements/checks.yml b/framework/rules/core/security-requirements/checks.yml new file mode 100644 index 00000000..8eea6e68 --- /dev/null +++ b/framework/rules/core/security-requirements/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0011.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0011.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Security + - Boundaries + - Sensitive + - Access + message: Missing security requirements — document sensitive files, access restrictions, + or security patterns + expect: present diff --git a/framework/rules/core/security-requirements/rule.md b/framework/rules/core/security-requirements/rule.md new file mode 100644 index 00000000..72b784ee --- /dev/null +++ b/framework/rules/core/security-requirements/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0011 +slug: security-requirements +title: "Security Requirements" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- +# Security Requirements + +The instruction file must contain a section with a heading matching security-related terms (Security, Boundaries, Sensitive, or Access). Without documented security requirements, the agent has no guidance on sensitive files, access restrictions, or security patterns. + +## Antipatterns + +- Embedding security constraints inline without a dedicated heading. The check looks for a heading containing terms like "Security" or "Boundaries" -- scattered constraints without a heading section are not detected. +- Using a heading like "Important Notes" that contains security content but does not match any of the expected terms. The heading must include Security, Boundaries, Sensitive, or Access. +- Documenting security requirements only in external files (e.g., a `SECURITY.md`) that are not instruction files. The check applies to instruction files the agent reads at session start. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## Boundaries + +NEVER modify `.env` or `credentials.json`. +Ask the user to handle sensitive file changes manually. +~~~~ + +### Fail + +~~~~markdown +# Project + +## Commands + +Run `uv run poe qa` before committing. +Be careful with environment files. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/self-contained-skills/checks.yml b/framework/rules/core/self-contained-skills/checks.yml new file mode 100644 index 00000000..3bf2ede5 --- /dev/null +++ b/framework/rules/core/self-contained-skills/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.S.0017.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0017.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Input + - Process + - Output + - Constraints + message: Skill is not self-contained — include Input, Process, Output, and Constraints + sections + expect: present diff --git a/framework/rules/core/self-contained-skills/rule.md b/framework/rules/core/self-contained-skills/rule.md new file mode 100644 index 00000000..bb680f2d --- /dev/null +++ b/framework/rules/core/self-contained-skills/rule.md @@ -0,0 +1,52 @@ +--- +id: CORE:S:0017 +slug: self-contained-skills +title: "Self Contained Skills" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: skill} +--- +# Self Contained Skills + +Skill files must include headings matching Input, Process, Output, and Constraints. A self-contained skill gives the agent everything it needs to execute the task without hunting through other files. + +## Antipatterns + +- Including workflow steps in prose without using a "Process" or "Input" heading. The check requires headings that match the expected terms, not just content that covers those concerns. +- Using non-standard heading names like "Prerequisites" instead of "Input", or "Steps" instead of "Process". The check matches specific terms. +- Splitting skill sections across multiple files. The check expects all required sections within the single skill entry point file. + +## Pass / Fail + +### Pass + +~~~~markdown +# Deploy Skill + +## Input +- Branch name, target environment + +## Process +1. Run `uv run poe qa`. 2. Push to remote. + +## Output +- Deployment URL printed to stdout + +## Constraints +NEVER deploy without passing QA. +~~~~ + +### Fail + +~~~~markdown +# Deploy Skill + +Push the branch and deploy it. +Check the deployment URL afterward. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/self-contained-skills/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/core/self-contained-skills/tests/fail/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/self-contained-skills/tests/fail/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/self-contained-skills/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/core/self-contained-skills/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..813eb08b --- /dev/null +++ b/framework/rules/core/self-contained-skills/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1,8 @@ +## Process + +1. Read the input +2. Validate format + +## Inputs + +File path diff --git a/framework/rules/core/self-contained-skills/vocab.yml b/framework/rules/core/self-contained-skills/vocab.yml new file mode 100644 index 00000000..9e1357a1 --- /dev/null +++ b/framework/rules/core/self-contained-skills/vocab.yml @@ -0,0 +1,7 @@ +self_contained_skills: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/semantic-interference/rule.md b/framework/rules/core/semantic-interference/rule.md new file mode 100644 index 00000000..3c32a16c --- /dev/null +++ b/framework/rules/core/semantic-interference/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:C:0049 +slug: semantic-interference +title: "Scope-Instruction Conflict" +category: coherence +type: mechanical +execution: server +severity: high +match: {} +--- + +# Scope-Instruction Conflict + +Conditional scopes must not name domains whose conventions contradict the instruction. A scope that activates domain knowledge conflicting with the directive makes the instruction less effective than having no scope at all. + +## Antipatterns + +- Writing "When testing API integrations, don't use mocks" -- API integration testing conventionally uses mocks, so the scope activates knowledge that opposes the directive. +- Scoping a constraint to a domain where the prohibited behavior is standard practice. "In React components, avoid using state" activates the model's knowledge that React components routinely use state. +- Using broad domain scopes that trigger multiple competing conventions. "When writing Python, NEVER use list comprehensions" fights the model's strong association between Python and comprehensions. + +## Pass / Fail + +### Pass + +~~~~markdown +When testing event-driven microservices, don't mock +service calls -- use real service instances. +~~~~ + +### Fail + +~~~~markdown +When testing API integrations, don't use mocks. +~~~~ + +## Fix + +When writing conditional instructions that suppress a behavior, ensure the scope names a domain where the DESIRED behavior is conventional. "When testing API integrations, don't mock" is self-defeating because API integration testing conventionally USES mocks. "When testing event-driven microservices, don't mock" reinforces because microservice testing conventionally uses real services. Choose domain scopes where the desired behavior aligns with the domain's conventions. + +## Limitations + +Detects misalignment between scope text and instruction direction. Relies on semantic similarity as a proxy for domain conventions — may flag scopes that are unconventional but intentional. diff --git a/framework/rules/core/settings-scope-declared/checks.yml b/framework/rules/core/settings-scope-declared/checks.yml new file mode 100644 index 00000000..3df4b148 --- /dev/null +++ b/framework/rules/core/settings-scope-declared/checks.yml @@ -0,0 +1,14 @@ +checks: +- id: CORE.S.0021.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0021.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Settings + - Scope + - Configuration + message: Missing scope declaration in settings — declare scope level for configuration + expect: present diff --git a/framework/rules/core/settings-scope-declared/rule.md b/framework/rules/core/settings-scope-declared/rule.md new file mode 100644 index 00000000..8fb2c8f4 --- /dev/null +++ b/framework/rules/core/settings-scope-declared/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0021 +slug: settings-scope-declared +title: "Settings Scope Declared" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: config} +--- +# Settings Scope Declared + +Configuration files must contain a heading matching scope-related terms (Settings, Scope, or Configuration). Declaring scope level ensures the agent knows whether settings apply project-wide, per-user, or are system-managed. + +## Antipatterns + +- Embedding scope information in comments or inline text without a heading. The check requires a heading containing Settings, Scope, or Configuration. +- Using a heading like "Options" or "Preferences" that describes configuration content but does not match the expected terms. +- Assuming scope is implied by the file's location. The check requires an explicit heading declaration regardless of where the file lives. + +## Pass / Fail + +### Pass + +~~~~markdown +# Agent Config + +## Settings + +scope: project +format: yaml +~~~~ + +### Fail + +~~~~markdown +# Agent Config + +format: yaml +version: 1.0 +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/settings-scope-declared/tests/fail/.claude/settings.json b/framework/rules/core/settings-scope-declared/tests/fail/.claude/settings.json new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/settings-scope-declared/tests/fail/.claude/settings.json @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/settings-scope-declared/tests/pass/.claude/settings.json b/framework/rules/core/settings-scope-declared/tests/pass/.claude/settings.json new file mode 100644 index 00000000..cc1a86ea --- /dev/null +++ b/framework/rules/core/settings-scope-declared/tests/pass/.claude/settings.json @@ -0,0 +1,6 @@ +permissions: + allow: + - Read +hooks: + PreToolUse: + - command: lint diff --git a/framework/rules/core/settings-scope-declared/vocab.yml b/framework/rules/core/settings-scope-declared/vocab.yml new file mode 100644 index 00000000..dc8ca9e9 --- /dev/null +++ b/framework/rules/core/settings-scope-declared/vocab.yml @@ -0,0 +1,7 @@ +settings_scope_declared: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/shallow-heading-hierarchy/checks.yml b/framework/rules/core/shallow-heading-hierarchy/checks.yml new file mode 100644 index 00000000..93be8a74 --- /dev/null +++ b/framework/rules/core/shallow-heading-hierarchy/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0003.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0003.pattern_check + type: deterministic + pattern-regex: '(?m)^#{5,}\s' + expect: absent + message: "Heading hierarchy too deep — keep headings to 4 levels or fewer" diff --git a/framework/rules/core/shallow-heading-hierarchy/rule.md b/framework/rules/core/shallow-heading-hierarchy/rule.md new file mode 100644 index 00000000..adfcd0da --- /dev/null +++ b/framework/rules/core/shallow-heading-hierarchy/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:S:0003 +slug: shallow-heading-hierarchy +title: "Shallow Heading Hierarchy" +category: structure +type: deterministic +severity: high +backed_by: [] +match: {format: freeform} +--- + +# Shallow Heading Hierarchy + +Heading hierarchy must stay at 4 levels or fewer -- no `#####` (h5) or deeper headings. Deep nesting makes content harder to scan and signals that the file should be split into smaller files. + +## Antipatterns + +- Nesting subsections deeply to preserve logical hierarchy (e.g., `##### Edge Case`). The check flags any line starting with 5 or more `#` characters followed by a space. +- Using deep headings to indent content visually. Markdown heading depth is structural, not cosmetic -- use lists or indentation for visual nesting. +- Promoting a subsection to h5 to "fit" it under an h4 parent. Restructure the document to stay within 4 levels instead. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project +## Commands +### Testing +#### Unit Tests +Run `uv run pytest tests/unit/`. +~~~~ + +### Fail + +~~~~markdown +# Project +## Commands +### Testing +#### Unit Tests +##### Edge Cases +Run edge case tests separately. +~~~~ + +## Limitations + +Detects headings deeper than 4 levels. Does not evaluate heading structure or logical nesting. diff --git a/framework/rules/core/shallow-heading-hierarchy/vocab.yml b/framework/rules/core/shallow-heading-hierarchy/vocab.yml new file mode 100644 index 00000000..7a7e2407 --- /dev/null +++ b/framework/rules/core/shallow-heading-hierarchy/vocab.yml @@ -0,0 +1,6 @@ +shallow_heading_hierarchy: + - heading style + - code block + - camelCase identifier + - kebab-case name + - filename convention diff --git a/framework/rules/core/single-topic-per-section/checks.yml b/framework/rules/core/single-topic-per-section/checks.yml new file mode 100644 index 00000000..ef433c35 --- /dev/null +++ b/framework/rules/core/single-topic-per-section/checks.yml @@ -0,0 +1,11 @@ +checks: +- id: CORE.S.0019.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0019.content_check + type: content_query + query: has_layered_structure + args: + min_headings: 3 + message: Multiple topics may share sections — split into separate heading sections + expect: present diff --git a/framework/rules/core/single-topic-per-section/rule.md b/framework/rules/core/single-topic-per-section/rule.md new file mode 100644 index 00000000..f6a5bf75 --- /dev/null +++ b/framework/rules/core/single-topic-per-section/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0019 +slug: single-topic-per-section +title: "Single Topic Per Section" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Single Topic Per Section + +The instruction file must have layered structure with at least 3 headings. Sufficient heading count indicates that content is split into focused sections rather than lumped under one or two broad headings. + +## Antipatterns + +- Putting testing instructions, formatting rules, and security constraints all under a single "## Guidelines" heading. Each concern should have its own heading section. +- Using only a title heading and one section heading for a file that covers multiple topics. The check requires at least 3 headings to confirm adequate topic separation. +- Adding content at the end of a section that belongs to a different topic rather than creating a new heading. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project +## Testing +Run `uv run pytest tests/`. +## Formatting +Use `ruff` for all formatting. +## Boundaries +NEVER modify `.env` files. +~~~~ + +### Fail + +~~~~markdown +# Project +## Guidelines +Run pytest. Use ruff. Don't modify .env files. +~~~~ + +## Limitations + +Checks that the file uses headings to organize content. Does not evaluate whether the organization is logical or complete. diff --git a/framework/rules/core/single-topic-per-section/vocab.yml b/framework/rules/core/single-topic-per-section/vocab.yml new file mode 100644 index 00000000..ed59af71 --- /dev/null +++ b/framework/rules/core/single-topic-per-section/vocab.yml @@ -0,0 +1,6 @@ +single_topic_per_section: + - priority + - order + - first + - before + - heading hierarchy diff --git a/framework/rules/core/skill-directory-kebab-case/checks.yml b/framework/rules/core/skill-directory-kebab-case/checks.yml new file mode 100644 index 00000000..fa36d169 --- /dev/null +++ b/framework/rules/core/skill-directory-kebab-case/checks.yml @@ -0,0 +1,9 @@ +checks: +- id: CORE.S.0018.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0018.kebab_name + type: deterministic + pattern-regex: '(?m)^name:\s*[a-z][a-z0-9]*(-[a-z0-9]+)*\s*$' + expect: present + message: "Skill name in frontmatter should use kebab-case (lowercase with hyphens)" diff --git a/framework/rules/core/skill-directory-kebab-case/rule.md b/framework/rules/core/skill-directory-kebab-case/rule.md new file mode 100644 index 00000000..9743a2b1 --- /dev/null +++ b/framework/rules/core/skill-directory-kebab-case/rule.md @@ -0,0 +1,48 @@ +--- +id: CORE:S:0018 +slug: skill-directory-kebab-case +title: "Skill Directory Kebab Case" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: skill} +--- +# Skill Directory Kebab Case + +Skill files must declare a `name:` field in frontmatter using kebab-case format (lowercase letters and digits separated by hyphens). Consistent naming prevents path resolution errors across platforms. + +## Antipatterns + +- Using underscores in the skill name (`name: my_skill`). The pattern requires hyphens, not underscores. +- Using camelCase or PascalCase (`name: mySkill`). The pattern requires all-lowercase characters. +- Omitting the `name:` field entirely. The check expects a `name:` key-value pair matching the kebab-case pattern. +- Starting the name with a digit (`name: 2-deploy`). The pattern requires the name to start with a lowercase letter. + +## Pass / Fail + +### Pass + +~~~~markdown +--- +name: deploy-staging +--- +# Deploy Staging + +Push to the staging environment. +~~~~ + +### Fail + +~~~~markdown +--- +name: Deploy_Staging +--- +# Deploy Staging + +Push to the staging environment. +~~~~ + +## Limitations + +Checks that the skill name field uses kebab-case format. Does not validate whether the name describes the skill's purpose. diff --git a/framework/rules/core/skill-directory-kebab-case/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/core/skill-directory-kebab-case/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..2d5f36ff --- /dev/null +++ b/framework/rules/core/skill-directory-kebab-case/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +Name skill directories in kebab-case (e.g., extract-claims, admit-source) diff --git a/framework/rules/core/skill-directory-kebab-case/vocab.yml b/framework/rules/core/skill-directory-kebab-case/vocab.yml new file mode 100644 index 00000000..30c3e716 --- /dev/null +++ b/framework/rules/core/skill-directory-kebab-case/vocab.yml @@ -0,0 +1,7 @@ +skill_directory_kebab_case: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/skill-entry-point-present/checks.yml b/framework/rules/core/skill-entry-point-present/checks.yml new file mode 100644 index 00000000..a8490f58 --- /dev/null +++ b/framework/rules/core/skill-entry-point-present/checks.yml @@ -0,0 +1,12 @@ +checks: +- id: CORE.S.0015.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0015.content_check + type: content_query + query: has_named_tokens_matching + args: + tokens: + - SKILL.md + message: Missing skill entry point — each skill directory must contain a SKILL.md + expect: present diff --git a/framework/rules/core/skill-entry-point-present/rule.md b/framework/rules/core/skill-entry-point-present/rule.md new file mode 100644 index 00000000..03fca360 --- /dev/null +++ b/framework/rules/core/skill-entry-point-present/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:S:0015 +slug: skill-entry-point-present +title: "Skill Entry Point Present" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {type: skill} +--- +# Skill Entry Point Present + +Each skill file must reference or contain a `SKILL.md` entry point. The entry point is the standard discovery mechanism that agents use to find and invoke skills. + +## Antipatterns + +- Naming the skill entry point `README.md` or `index.md` instead of `SKILL.md`. The check looks for the specific token `SKILL.md` in the file content. +- Creating a skill directory with workflow files but no `SKILL.md` reference. Without the entry point token, the skill is not discoverable. +- Referencing a different filename like `skill.md` (lowercase). The check matches the exact token `SKILL.md`. + +## Pass / Fail + +### Pass + +~~~~markdown +# Check Skill + +Entry point: SKILL.md + +## Process +1. Run `uv run ails check .` +2. Report results. +~~~~ + +### Fail + +~~~~markdown +# Check Skill + +## Process +1. Run the linter. +2. Report results. +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/skill-entry-point-present/tests/fail/.claude/skills/example/SKILL.md b/framework/rules/core/skill-entry-point-present/tests/fail/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..16a11147 --- /dev/null +++ b/framework/rules/core/skill-entry-point-present/tests/fail/.claude/skills/example/SKILL.md @@ -0,0 +1 @@ +# Instruction file content diff --git a/framework/rules/core/skill-entry-point-present/tests/pass/.claude/skills/example/SKILL.md b/framework/rules/core/skill-entry-point-present/tests/pass/.claude/skills/example/SKILL.md new file mode 100644 index 00000000..b67bf802 --- /dev/null +++ b/framework/rules/core/skill-entry-point-present/tests/pass/.claude/skills/example/SKILL.md @@ -0,0 +1,11 @@ +## Name + +commit + +## Description + +Create a git commit + +## Process + +1. Stage files diff --git a/framework/rules/core/skill-entry-point-present/vocab.yml b/framework/rules/core/skill-entry-point-present/vocab.yml new file mode 100644 index 00000000..68445cfc --- /dev/null +++ b/framework/rules/core/skill-entry-point-present/vocab.yml @@ -0,0 +1,7 @@ +skill_entry_point_present: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/specificity-gap/rule.md b/framework/rules/core/specificity-gap/rule.md new file mode 100644 index 00000000..d9bfc883 --- /dev/null +++ b/framework/rules/core/specificity-gap/rule.md @@ -0,0 +1,50 @@ +--- +id: CORE:C:0042 +slug: specificity-gap +title: "Specificity Gap" +category: coherence +type: mechanical +execution: server +severity: critical +match: {} +--- + +# Specificity Gap + +Instructions must name concrete constructs -- backtick-wrapped tokens, file paths, function names, CLI commands -- instead of abstract concepts. Abstract instructions are dramatically less effective because the model cannot distinguish them from general knowledge. + +## Antipatterns + +- Writing "Follow the coding style" instead of naming the specific tool (`ruff format`, 4-space indent, `snake_case`). The model interprets abstract style references using its own defaults. +- Using category names like "mocking libraries" instead of specific imports like `unittest.mock`, `MagicMock`, `patch()`. Category names are as vague as abstract concepts. +- Stating "Run the tests" without specifying the command (`uv run pytest tests/ -v`). The model guesses which test runner to use. + +## Pass / Fail + +### Pass + +~~~~markdown +Use `ruff format` with 4-space indent and `snake_case` +for all functions in `src/reporails_cli/`. +Run `uv run pytest tests/ -v` before committing. +~~~~ + +### Fail + +~~~~markdown +Follow the project's coding style. +Run the tests before committing. +Use appropriate mocking libraries. +~~~~ + +## Fix + +Replace "Don't use mocking" with "Don't use `unittest.mock`, +`MagicMock`, `patch()`". Replace "Follow the coding style" with "Use `ruff format`, +4-space indent, `snake_case` for functions". Name the exact tools, functions, files, +patterns, and libraries. Category names ("mocking libraries") are as vague as +abstract concepts — the model needs the import path, not the category. + +## Limitations + +Measures whether instructions contain named constructs (backtick-wrapped tokens, file paths, function names). Cannot evaluate whether the named constructs are the right ones for the project. diff --git a/framework/rules/core/specificity-shields/rule.md b/framework/rules/core/specificity-shields/rule.md new file mode 100644 index 00000000..87bdb35f --- /dev/null +++ b/framework/rules/core/specificity-shields/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:C:0050 +slug: specificity-shields +title: "Specificity Shields Against Competition" +category: coherence +type: mechanical +execution: server +severity: medium +match: {} +--- + +# Specificity Shields Against Competition + +Instructions in prose-heavy files must name specific constructs to resist topic competition. Vague instructions surrounded by prose on the same topic degrade severely, while named instructions maintain compliance. + +## Antipatterns + +- Writing a generic directive in a file with extensive explanatory prose. "Use the formatter" in a file with paragraphs about formatting conventions gets overwhelmed by the surrounding content. +- Adding context paragraphs around a constraint without naming constructs in the constraint itself. The prose competes with the vague instruction and wins. +- Keeping instructions abstract in files that also contain documentation. Prose-heavy files demand more specific instructions, not less. + +## Pass / Fail + +### Pass + +~~~~markdown +Code formatting uses `ruff format` with the config +in `pyproject.toml`. NEVER run `black` or `autopep8`. +~~~~ + +### Fail + +~~~~markdown +We use a consistent code formatting approach across +the project. Follow the standard formatting rules. +Always format your code before committing. +~~~~ + +## Fix + +In files with substantial prose, naming specific constructs is even more critical. Vague instructions in prose-heavy files get hit twice: once by being vague, again by being vulnerable to competition. Priority: name constructs first, then reduce surrounding prose. + +## Limitations + +Combines specificity measurement with prose density. May flag files where prose is intentionally kept for human readers — the diagnostic applies to model compliance, not human readability. diff --git a/framework/rules/core/subdirectory-instruction-files/checks.yml b/framework/rules/core/subdirectory-instruction-files/checks.yml new file mode 100644 index 00000000..8c34089d --- /dev/null +++ b/framework/rules/core/subdirectory-instruction-files/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0037.check + type: mechanical + check: file_exists +- id: CORE.S.0037.content_check + type: content_query + query: has_directive_atoms + args: + message: Subdirectory instruction file lacks instructional content — add directives or constraints + expect: present diff --git a/framework/rules/core/subdirectory-instruction-files/rule.md b/framework/rules/core/subdirectory-instruction-files/rule.md new file mode 100644 index 00000000..1283ef60 --- /dev/null +++ b/framework/rules/core/subdirectory-instruction-files/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:S:0037 +slug: subdirectory-instruction-files +title: "Subdirectory Instruction Files" +category: structure +type: mechanical +severity: medium +backed_by: [] +match: {cardinality: hierarchical} +--- + +# Subdirectory Instruction Files + +Subdirectory instruction files must contain directive content -- actionable instructions the agent can follow. Files without directives or constraints waste the agent's context window without providing guidance. + +## Antipatterns + +- Creating a subdirectory instruction file that only contains a title heading and no directives. The check requires at least one directive atom (an instruction the agent can act on). +- Filling the file with passive descriptions ("This directory contains utility functions") without any imperatives. Descriptions are not directives. +- Copying boilerplate from the root instruction file without adding subdirectory-specific guidance. The file must contain its own directive content. + +## Pass / Fail + +### Pass + +~~~~markdown +# Utils + +Use `snake_case` for all function names in this directory. +NEVER import from `src/reporails_cli/interfaces/` -- utils must not depend on interface code. +~~~~ + +### Fail + +~~~~markdown +# Utils + +This directory contains utility functions for the project. +~~~~ + +## Limitations + +Checks that subdirectory instruction files contain directive content. Does not evaluate directive quality or completeness. diff --git a/framework/rules/core/subdirectory-instruction-files/tests/fail/.gitkeep b/framework/rules/core/subdirectory-instruction-files/tests/fail/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/framework/rules/core/subdirectory-instruction-files/vocab.yml b/framework/rules/core/subdirectory-instruction-files/vocab.yml new file mode 100644 index 00000000..5968be26 --- /dev/null +++ b/framework/rules/core/subdirectory-instruction-files/vocab.yml @@ -0,0 +1,7 @@ +subdirectory_instruction_files: + - scope + - path + - local + - global + - boundary + - scope declaration diff --git a/framework/rules/core/tech-stack-declared/checks.yml b/framework/rules/core/tech-stack-declared/checks.yml new file mode 100644 index 00000000..893d2787 --- /dev/null +++ b/framework/rules/core/tech-stack-declared/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0034.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0034.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Stack + - Tech + - Language + - Runtime + - Framework + message: Missing tech stack declaration — list languages, frameworks, and runtimes + expect: present diff --git a/framework/rules/core/tech-stack-declared/rule.md b/framework/rules/core/tech-stack-declared/rule.md new file mode 100644 index 00000000..2edaca23 --- /dev/null +++ b/framework/rules/core/tech-stack-declared/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0034 +slug: tech-stack-declared +title: "Tech Stack Declared" +category: coherence +type: mechanical +severity: high +backed_by: [] +match: {type: main} +--- +# Tech Stack Declared + +The root instruction file must contain a heading matching technology terms (Stack, Tech, Language, Runtime, or Framework). Declaring the tech stack prevents the agent from guessing or suggesting incompatible technologies. + +## Antipatterns + +- Mentioning technologies only in prose without a dedicated heading. The check requires a heading containing one of the matching terms, not inline references. +- Using a heading like "## Dependencies" or "## Tools" that describes related content but does not match the expected terms (Stack, Tech, Language, Runtime, Framework). +- Documenting the tech stack only in `package.json` or `pyproject.toml` without a corresponding heading in the root instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## Tech Stack + +- Python 3.12, `uv` for dependency management +- `ruff` for linting, `pytest` for testing +~~~~ + +### Fail + +~~~~markdown +# Project + +## Getting Started + +Install the dependencies and run the project. +We use Python and some testing tools. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/testing-framework-documented/checks.yml b/framework/rules/core/testing-framework-documented/checks.yml new file mode 100644 index 00000000..43ec1eae --- /dev/null +++ b/framework/rules/core/testing-framework-documented/checks.yml @@ -0,0 +1,15 @@ +checks: +- id: CORE.C.0005.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0005.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Testing + - Tests + - Test + message: Missing testing documentation — describe the test framework and how to + run tests + expect: present diff --git a/framework/rules/core/testing-framework-documented/rule.md b/framework/rules/core/testing-framework-documented/rule.md new file mode 100644 index 00000000..51343216 --- /dev/null +++ b/framework/rules/core/testing-framework-documented/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0005 +slug: testing-framework-documented +title: "Testing Framework Documented" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Testing Framework Documented + +The instruction file must contain a heading matching testing terms (Testing, Tests, or Test). Documenting the testing framework tells the agent which tool to use, where tests live, and how to run them. + +## Antipatterns + +- Embedding test commands inline without a heading. Writing `uv run pytest` in a "Commands" section does not satisfy the check -- there must be a heading containing "Testing", "Tests", or "Test". +- Using a heading like "## Quality" or "## Validation" that covers testing but does not match the expected terms. +- Relying on the test runner's own documentation instead of including a testing section in the instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## Testing + +Run `uv run pytest tests/ -v` for the full suite. +Test files use `test_` prefix with `pytest` fixtures. +~~~~ + +### Fail + +~~~~markdown +# Project + +## Commands + +Run the linter and quality checks. +Make sure everything passes before committing. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/topic-scatter/rule.md b/framework/rules/core/topic-scatter/rule.md new file mode 100644 index 00000000..d3cf2742 --- /dev/null +++ b/framework/rules/core/topic-scatter/rule.md @@ -0,0 +1,48 @@ +--- +id: CORE:C:0044 +slug: topic-scatter +title: "Topic Scatter" +category: coherence +type: mechanical +execution: server +severity: critical +match: {} +--- + +# Topic Scatter + +Instruction files must focus on 1-2 topics. Instructions spanning multiple unrelated topics compete for attention, degrading compliance on all topics -- while same-topic instructions reinforce each other. + +## Antipatterns + +- Putting testing instructions, deployment steps, formatting rules, and security constraints all in one scoped rule file. Each unrelated topic reduces compliance on every other topic. +- Adding "one more thing" to an existing file rather than creating a new file. Incremental topic additions cause gradual scatter that is hard to notice. +- Using a single `.claude/rules/everything.md` file instead of splitting into focused files like `testing.md`, `formatting.md`, and `security.md`. + +## Pass / Fail + +### Pass + +~~~~markdown + +# Testing +Run `uv run pytest tests/ -v` before committing. +Use `@pytest.mark.parametrize` for multi-case tests. +~~~~ + +### Fail + +~~~~markdown + +# Guidelines +Run pytest before committing. Use ruff for formatting. +NEVER push to main. Deploy with docker compose up. +~~~~ + +## Fix + +Keep instruction files to 1-2 topics maximum. As topic count grows, individual instruction compliance drops sharply. Split multi-topic files into single-topic files. For critical directives, add same-topic reinforcement. + +## Limitations + +Counts competing topics using embedding-based clustering. Intentionally broad files (like a main `CLAUDE.md` that covers multiple concerns) will be flagged — the diagnostic applies to all files equally. diff --git a/framework/rules/core/total-instruction-size-limit/checks.yml b/framework/rules/core/total-instruction-size-limit/checks.yml new file mode 100644 index 00000000..11377996 --- /dev/null +++ b/framework/rules/core/total-instruction-size-limit/checks.yml @@ -0,0 +1,6 @@ +checks: +- id: CORE.E.0001.check + type: mechanical + check: aggregate_byte_size + args: + max: 102400 diff --git a/framework/rules/core/total-instruction-size-limit/rule.md b/framework/rules/core/total-instruction-size-limit/rule.md new file mode 100644 index 00000000..c8318bc0 --- /dev/null +++ b/framework/rules/core/total-instruction-size-limit/rule.md @@ -0,0 +1,43 @@ +--- +id: CORE:E:0001 +slug: total-instruction-size-limit +title: "Total Instruction Size Limit" +category: efficiency +type: mechanical +severity: high +backed_by: [] +match: {format: freeform} +--- + +# Total Instruction Size Limit + +The aggregate size of all instruction files in the project must not exceed 100 KB (102,400 bytes). Exceeding this limit wastes context budget and dilutes the effectiveness of individual instructions. + +## Antipatterns + +- Adding extensive documentation and examples to instruction files instead of keeping them concise. Instruction files should contain directives, not documentation. +- Duplicating instructions across multiple files. Each copy adds to the aggregate size without adding value. +- Including large code blocks or data tables in instruction files. Reference external files instead of embedding bulky content. +- Not monitoring total size as the project grows. Individual files may be small, but the aggregate can silently exceed the limit. + +## Pass / Fail + +### Pass + +~~~~markdown +Project with 5 instruction files totaling 40 KB: + CLAUDE.md (15 KB) + 4 scoped rules (6 KB each) +Total: 39 KB -- well under the 100 KB limit. +~~~~ + +### Fail + +~~~~markdown +Project with 20 instruction files totaling 120 KB: + CLAUDE.md (30 KB) + 19 scoped rules (5 KB each) +Total: 125 KB -- exceeds the 100 KB limit. +~~~~ + +## Limitations + +Structural check with limited semantic understanding. diff --git a/framework/rules/core/valid-markdown-syntax/checks.yml b/framework/rules/core/valid-markdown-syntax/checks.yml new file mode 100644 index 00000000..e5c90939 --- /dev/null +++ b/framework/rules/core/valid-markdown-syntax/checks.yml @@ -0,0 +1,10 @@ +checks: +- id: CORE.S.0009.file_in_scope + type: mechanical + check: file_exists +- id: CORE.S.0009.content_check + type: content_query + query: has_valid_markdown + args: + message: Invalid or empty markdown — file must contain valid markdown structure + expect: present diff --git a/framework/rules/core/valid-markdown-syntax/rule.md b/framework/rules/core/valid-markdown-syntax/rule.md new file mode 100644 index 00000000..34a33b9a --- /dev/null +++ b/framework/rules/core/valid-markdown-syntax/rule.md @@ -0,0 +1,45 @@ +--- +id: CORE:S:0009 +slug: valid-markdown-syntax +title: "Valid Markdown Syntax" +category: structure +type: mechanical +severity: critical +backed_by: [] +match: {format: freeform} +--- +# Valid Markdown Syntax + +Instruction files must contain valid markdown structure. Broken or empty markdown prevents agents from parsing content correctly and can cause instructions to be silently ignored. + +## Antipatterns + +- Leaving unclosed fenced code blocks (starting ``` without a closing ```). The parser treats everything after the opening fence as code, hiding subsequent instructions. +- Creating a file with only frontmatter and no body content. An empty body produces no valid markdown structure for the agent to parse. +- Using malformed heading syntax (`##No space after hashes`). Missing the space after `#` characters prevents heading detection. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project Setup + +Use `uv sync` to install dependencies. + +```bash +uv run pytest tests/ +``` +~~~~ + +### Fail + +~~~~markdown +--- +title: Project +--- +~~~~ + +## Limitations + +Uses content analysis on mapped instruction atoms. Results depend on mapper quality and may miss edge cases. diff --git a/framework/rules/core/valid-markdown-syntax/vocab.yml b/framework/rules/core/valid-markdown-syntax/vocab.yml new file mode 100644 index 00000000..066b4f08 --- /dev/null +++ b/framework/rules/core/valid-markdown-syntax/vocab.yml @@ -0,0 +1,6 @@ +valid_markdown_syntax: + - file reference + - import path + - include directive + - frontmatter + - description field diff --git a/framework/rules/core/validation-commands-present/checks.yml b/framework/rules/core/validation-commands-present/checks.yml new file mode 100644 index 00000000..80951904 --- /dev/null +++ b/framework/rules/core/validation-commands-present/checks.yml @@ -0,0 +1,17 @@ +checks: +- id: CORE.C.0008.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0008.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Validation + - Verify + - QA + - Lint + - Check + message: Missing validation commands — show how to lint, type-check, or validate + the project + expect: present diff --git a/framework/rules/core/validation-commands-present/rule.md b/framework/rules/core/validation-commands-present/rule.md new file mode 100644 index 00000000..57fca41c --- /dev/null +++ b/framework/rules/core/validation-commands-present/rule.md @@ -0,0 +1,46 @@ +--- +id: CORE:C:0008 +slug: validation-commands-present +title: "Validation Commands Present" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Validation Commands Present + +The instruction file must contain a heading matching validation terms (Validation, Verify, QA, Lint, or Check). Documenting validation commands tells the agent which quality gates to run before committing. + +## Antipatterns + +- Embedding lint commands in a "Commands" section without using a heading that matches validation terms. The check looks for headings containing Validation, Verify, QA, Lint, or Check. +- Using a heading like "## Build" that includes validation steps but does not match the expected terms. +- Listing validation tools in `pyproject.toml` without a corresponding section in the instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## QA + +Run `uv run poe qa_fast` for lint + type check. +Run `uv run poe qa` for the full suite. +~~~~ + +### Fail + +~~~~markdown +# Project + +## Setup + +Install dependencies with `uv sync`. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/core/vcs-tracked/checks.yml b/framework/rules/core/vcs-tracked/checks.yml new file mode 100644 index 00000000..b2e179db --- /dev/null +++ b/framework/rules/core/vcs-tracked/checks.yml @@ -0,0 +1,4 @@ +checks: +- id: CORE.G.0001.check + type: mechanical + check: git_tracked diff --git a/framework/rules/core/vcs-tracked/rule.md b/framework/rules/core/vcs-tracked/rule.md new file mode 100644 index 00000000..abc62651 --- /dev/null +++ b/framework/rules/core/vcs-tracked/rule.md @@ -0,0 +1,41 @@ +--- +id: CORE:G:0001 +slug: vcs-tracked +title: "Vcs Tracked" +category: governance +type: mechanical +severity: high +backed_by: [] +match: {format: [freeform, frontmatter, schema_validated]} +--- + +# Vcs Tracked + +All instruction files must be tracked in git. Untracked instruction files create divergence between collaborators -- each developer's agent sees different instructions. + +## Antipatterns + +- Adding an instruction file but forgetting to `git add` it. The file works locally but is invisible to other contributors. +- Adding instruction file paths to `.gitignore` (e.g., ignoring all `.md` files in `.claude/`). Instruction files must be committed, not ignored. +- Creating instruction files in directories outside the repository root. Files outside the repo boundary cannot be git-tracked. + +## Pass / Fail + +### Pass + +~~~~markdown +$ git ls-files .claude/rules/testing.md +.claude/rules/testing.md +(file is tracked) +~~~~ + +### Fail + +~~~~markdown +$ git ls-files .claude/rules/testing.md +(no output -- file is not tracked) +~~~~ + +## Limitations + +Structural check with limited semantic understanding. diff --git a/framework/rules/core/workflow-definitions/checks.yml b/framework/rules/core/workflow-definitions/checks.yml new file mode 100644 index 00000000..109dd08f --- /dev/null +++ b/framework/rules/core/workflow-definitions/checks.yml @@ -0,0 +1,16 @@ +checks: +- id: CORE.C.0007.file_in_scope + type: mechanical + check: file_exists +- id: CORE.C.0007.content_check + type: content_query + query: has_heading_matching + args: + terms: + - Workflow + - Process + - Pipeline + - Steps + message: Missing workflow definitions — describe repeatable processes with ordered + steps + expect: present diff --git a/framework/rules/core/workflow-definitions/rule.md b/framework/rules/core/workflow-definitions/rule.md new file mode 100644 index 00000000..9884dca0 --- /dev/null +++ b/framework/rules/core/workflow-definitions/rule.md @@ -0,0 +1,47 @@ +--- +id: CORE:C:0007 +slug: workflow-definitions +title: "Workflow Definitions" +category: coherence +type: mechanical +severity: medium +backed_by: [] +match: {format: freeform} +--- +# Workflow Definitions + +The instruction file must contain a heading matching workflow terms (Workflow, Process, Pipeline, or Steps). Defining repeatable workflows with ordered steps helps the agent follow consistent processes for common tasks. + +## Antipatterns + +- Listing commands without a heading that matches workflow terms. The check requires a heading containing Workflow, Process, Pipeline, or Steps -- not just numbered lists of commands. +- Using a heading like "## How To" or "## Procedures" that describes workflow content but does not match the expected terms. +- Documenting workflows only in external runbooks or CI configuration without a corresponding section in the instruction file. + +## Pass / Fail + +### Pass + +~~~~markdown +# Project + +## Workflow + +1. Run `uv run poe qa_fast` for pre-commit checks. +2. Create a PR with `gh pr create`. +3. Wait for CI to pass before merging. +~~~~ + +### Fail + +~~~~markdown +# Project + +## Setup + +Install with `uv sync` and start developing. +~~~~ + +## Limitations + +Checks for headings matching topic keywords. Does not evaluate the quality or completeness of the content under those headings. diff --git a/framework/rules/cursor/config.yml b/framework/rules/cursor/config.yml new file mode 100644 index 00000000..2a80eb6c --- /dev/null +++ b/framework/rules/cursor/config.yml @@ -0,0 +1,163 @@ +# Cursor Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 + +agent: cursor +version: "0.5.0" +prefix: CURSOR +name: Cursor + +file_types: + main: + # AGENTS.md is Cursor's freeform root instruction (no frontmatter) + format: freeform + scope: global + cardinality: chain + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/AGENTS.md"] + precedence: project + vcs: committed + maintainer: human + + rules: + # .cursor/rules/ with MDC frontmatter — 4 activation modes: + # alwaysApply (global), intelligent (agent decides), globs (path-scoped), manual (@mention) + format: [frontmatter, freeform] + scope: global + cardinality: collection + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".cursor/rules/**/*.mdc", ".cursor/rules/**/*.md"] + precedence: project + vcs: committed + maintainer: human + + legacy_cursorrules: + deprecated: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".cursorrules"] + precedence: project + vcs: committed + maintainer: human + + skills: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".cursor/skills/**/SKILL.md", ".claude/skills/**/SKILL.md", ".codex/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.cursor/skills/**/SKILL.md", "~/.claude/skills/**/SKILL.md", "~/.codex/skills/**/SKILL.md"] + precedence: user + vcs: external + maintainer: human + + agents: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".cursor/agents/*.md", ".claude/agents/*.md", ".codex/agents/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.cursor/agents/*.md", "~/.claude/agents/*.md", "~/.codex/agents/*.md"] + precedence: user + vcs: external + maintainer: human + + hooks: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".cursor/hooks.json"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.cursor/hooks.json"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/Library/Application Support/Cursor/hooks.json" + - "/etc/cursor/hooks.json" + - "C:/ProgramData/Cursor/hooks.json" + precedence: managed + vcs: external + maintainer: system + + mcp: + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".cursor/mcp.json"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.cursor/mcp.json"] + precedence: user + vcs: external + maintainer: human + + managed_policy: + # MDM on macOS (.mobileconfig), Group Policy on Windows (ADMX/ADML), policy.json on Linux + format: schema_validated + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + managed: + patterns: ["~/.cursor/policy.json"] + precedence: managed + vcs: external + maintainer: system + + bugbot_rules: + # Auto-generated from PR feedback and reviewer comments, managed by Bugbot + format: frontmatter + scope: path_scoped + cardinality: collection + lifecycle: mutable + loading: on_demand + scopes: + auto: + patterns: [".cursor/rules/**/*.mdc"] + precedence: project + vcs: committed + maintainer: agent + +excludes: + - CLAUDE:* + - CODEX:* diff --git a/framework/rules/gemini/config.yml b/framework/rules/gemini/config.yml new file mode 100644 index 00000000..6d828c20 --- /dev/null +++ b/framework/rules/gemini/config.yml @@ -0,0 +1,186 @@ +# Google Gemini Agent Configuration +# Schema: schemas/agent.schema.yml v0.5.0 + +agent: gemini +version: "0.5.0" +prefix: GEMINI +name: Google Gemini + +file_types: + main: + required: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/GEMINI.md", "**/AGENTS.md"] + precedence: project + vcs: committed + maintainer: human + user: + # Also serves as memory surface (agent appends to ## Gemini Added Memories) + patterns: ["~/.gemini/GEMINI.md"] + precedence: user + vcs: external + maintainer: hybrid + lifecycle: mutable + + nested_context: + format: freeform + scope: path_scoped + cardinality: hierarchical + lifecycle: static + loading: on_demand + scopes: + project: + patterns: ["**/GEMINI.md"] + precedence: project + vcs: committed + maintainer: human + + 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: + 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: + 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 in settings.json hooks key + 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: + 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 + system_policies: + patterns: + - "/etc/gemini-cli/policies" + - "/Library/Application Support/GeminiCli/policies" + precedence: managed + vcs: external + maintainer: system + cardinality: collection + + 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: + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: [".geminiignore"] + precedence: project + vcs: committed + maintainer: human + +excludes: + - CLAUDE:* + - CODEX:* diff --git a/framework/schemas/agent.schema.yml b/framework/schemas/agent.schema.yml new file mode 100644 index 00000000..2b5acbbd --- /dev/null +++ b/framework/schemas/agent.schema.yml @@ -0,0 +1,229 @@ +# Agent configuration schema v0.5.0 +# +# Foundational assumption: coding agents are more alike than different. +# All agents occupy the same M1 (governance) × M2 (loading) tensor space. +# An instruction surface has the same tensor coordinate regardless of which +# agent reads it — the agent determines the file path, the tensor determines +# the quality criteria. +# +# Each file_type is a CAPABILITY (skills, rules, agents, etc.) with shared +# properties and one or more SCOPES (project, user, managed, auto, local). +# Scopes determine where instances live and carry scope-specific properties +# (patterns, precedence, vcs, maintainer). Shared properties (format, +# cardinality, lifecycle, loading) are declared once at capability level. +version: "0.5.0" + +fields: + agent: + required: true + type: string + pattern: "^[a-z]+$" + description: "Agent identifier. Lowercase, used as config directory name." + + version: + required: true + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" + description: "Semver version of this agent configuration" + + prefix: + required: false + type: string + pattern: "^[A-Z]+$" + description: "Uppercase agent prefix for coordinate namespacing (e.g., CLAUDE, CODEX)" + + name: + required: false + type: string + description: "Human-readable display name for the agent" + + core: + required: false + type: boolean + description: "If true, this config represents CORE itself" + + file_types: + required: true + type: object + description: "Capability declarations. Each key is a recognized capability name." + additionalProperties: + type: object + description: "A capability with shared properties and scoped pattern sets." + properties: + # Capability-level (shared across all scopes) + required: + type: boolean + default: false + description: "Whether at least one instance of this capability must exist" + deprecated: + type: boolean + default: false + description: "Whether this capability is deprecated by the agent vendor" + format: + type: [enum, array] + values: [freeform, frontmatter, schema_validated] + description: "File content format(s). Shared across scopes." + scope: + type: enum + values: [global, path_scoped, task_scoped] + description: "Effect scope — what the surface applies to. Distinct from location scope (the scopes key)." + cardinality: + type: enum + values: [singleton, optional, collection, hierarchical, chain] + description: "How many instances of this capability exist per scope" + lifecycle: + type: enum + values: [static, mutable, transient] + description: "How the file changes over time" + loading: + type: enum + values: [session_start, on_demand, on_invocation] + description: "When the agent loads the file" + + # Scoped pattern sets + scopes: + type: object + description: "Location scopes. Each key is a scope name (project, user, managed, auto, local)." + additionalProperties: + type: object + properties: + patterns: + required: true + type: array + items: { type: string } + description: "Glob patterns for file discovery in this scope" + precedence: + required: true + type: enum + values: [managed, project, user, auto] + description: "Priority in conflict resolution" + vcs: + required: true + type: enum + values: [committed, gitignored, external] + description: "Version control status" + maintainer: + required: true + type: enum + values: [human, agent, system, hybrid] + description: "Who maintains the file" + # Any capability-level property can be overridden per scope + format: { type: [enum, array], values: [freeform, frontmatter, schema_validated] } + scope: { type: enum, values: [global, path_scoped, task_scoped] } + cardinality: { type: enum, values: [singleton, optional, collection, hierarchical, chain] } + lifecycle: { type: enum, values: [static, mutable, transient] } + loading: { type: enum, values: [session_start, on_demand, on_invocation] } + + excludes: + required: false + type: array + description: "Rule coordinates or glob patterns to exclude from resolution" + items: + type: string + pattern: "^[A-Z_]+:[SCDEGM*]:[0-9]{4}$|^[A-Z_]+:\\*$" + + overrides: + required: false + type: object + description: "Per-rule severity overrides keyed by coordinate (e.g., RRAILS:C:0008)" + additionalProperties: + type: object + properties: + severity: + type: enum + values: [critical, high, medium, low] + +validation: + - rule: "agent uppercase must be in reserved_agent_prefixes (see rule.schema.yml)" + - rule: "scope keys should be one of: project, user, managed, auto, local" + +examples: + claude: | + agent: claude + version: "0.5.0" + prefix: CLAUDE + name: Claude Code + file_types: + main: + required: true + format: freeform + scope: global + cardinality: singleton + lifecycle: static + loading: session_start + scopes: + project: + patterns: ["**/CLAUDE.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/CLAUDE.md"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: + - "/etc/claude-code/CLAUDE.md" + - "/Library/Application Support/ClaudeCode/CLAUDE.md" + precedence: managed + vcs: external + maintainer: system + rules: + format: [frontmatter, freeform] + scope: path_scoped + cardinality: collection + lifecycle: static + loading: on_demand + scopes: + project: + patterns: [".claude/rules/**/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/rules/*.md"] + precedence: user + vcs: external + maintainer: human + skills: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: hierarchical + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/skills/**/SKILL.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/skills/**/SKILL.md"] + precedence: user + vcs: external + maintainer: human + managed: + patterns: ["/etc/claude-code/skills/**/SKILL.md"] + precedence: managed + vcs: external + maintainer: system + agents: + format: [frontmatter, freeform] + scope: task_scoped + cardinality: collection + lifecycle: static + loading: on_invocation + scopes: + project: + patterns: [".claude/agents/**/*.md"] + precedence: project + vcs: committed + maintainer: human + user: + patterns: ["~/.claude/agents/**/*.md"] + precedence: user + vcs: external + maintainer: human + excludes: + - CORE:C:0026 diff --git a/framework/schemas/capability.schema.yml b/framework/schemas/capability.schema.yml new file mode 100644 index 00000000..7d6b8263 --- /dev/null +++ b/framework/schemas/capability.schema.yml @@ -0,0 +1,28 @@ +# Capability taxonomy schema +version: "0.1.0" + +fields: + id: + required: true + type: string + pattern: "^[a-z][a-z0-9_]*$" + description: "Unique capability identifier (snake_case)" + + level: + required: true + type: enum + values: [L1, L2, L3, L4, L5, L6] + description: "Which level this capability belongs to" + + description: + required: true + type: string + description: "What this capability means and how to recognize it" + + threshold: + required: false + type: object + description: "Detection parameters (e.g., min_lines, min_sections, has_any_of)" + +validation: + - rule: "id must be unique across all capabilities" \ No newline at end of file diff --git a/framework/schemas/levels.schema.yml b/framework/schemas/levels.schema.yml new file mode 100644 index 00000000..35aebabe --- /dev/null +++ b/framework/schemas/levels.schema.yml @@ -0,0 +1,61 @@ +# Levels schema +version: "0.1.0" + +fields: + levels: + required: true + type: object + description: "Map of level IDs (L0-L6) to level definitions" + pattern_key: "^L[0-6]$" + properties: + name: + required: true + type: string + description: "Level display name (e.g., Basic, Scoped, Structured)" + description: + required: true + type: string + description: "What this level represents" + capabilities: + required: true + type: array + description: "Capability IDs required at this level (must exist in registry/capabilities.yml)" + items: + type: string + pattern: "^[a-z][a-z0-9_]*$" + +validation: + - rule: "Each capability ID must appear in exactly one level" + - rule: "Capability IDs must exist in registry/capabilities.yml" + +examples: + full: | + levels: + L0: + name: Absent + description: "No instruction file exists" + capabilities: [] + L1: + name: Basic + description: "A reviewed, non-trivial instruction file exists" + capabilities: [instruction_file, baseline_quality] + L2: + name: Scoped + description: "Instruction file has structural organization and controlled size" + capabilities: [structural_scaffolding, size_controlled] + L3: + name: Structured + description: "Substantive content fills the scaffolding; guidance is modular" + capabilities: [content_maturity, external_references, multiple_files] + L4: + name: Abstracted + description: "Instructions adapt based on code location" + capabilities: [path_scoping] + L5: + name: Maintained + description: "Structurally sound, governed, and navigable" + capabilities: [structural_integrity, org_policy, navigation] + L6: + name: Adaptive + description: "Dynamic context discovery and extensibility" + capabilities: [dynamic_context, extensibility, state_persistence] diff --git a/framework/schemas/package.schema.yml b/framework/schemas/package.schema.yml new file mode 100644 index 00000000..6ef32a22 --- /dev/null +++ b/framework/schemas/package.schema.yml @@ -0,0 +1,142 @@ +# Package configuration schema +version: "0.1.0" + +# Updated from v0.0.1: AILS→RRAILS, coordinate format, +# removed provides.levels (rules self-declare level). + +fields: + package: + required: true + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: "Package identifier (lowercase, hyphens allowed)" + + prefix: + required: true + type: string + pattern: "^[A-Z]{3,}$" + description: "Rule ID prefix for package rules. Must be in reserved_package_prefixes (see rule.schema.yml)" + + name: + required: true + type: string + max_length: 64 + description: "Human-readable package name (max 64 chars)" + + description: + required: false + type: string + description: "One-line purpose of the package" + + version: + required: true + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" + description: "Semver version of the package" + + depends_on: + required: true + type: object + description: "Schema dependency declaration" + properties: + schema: + required: true + type: string + description: "Schema repository identifier (e.g., reporails/cli)" + min_version: + required: true + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" + description: "Minimum rule schema version required" + source: + required: true + type: string + format: url + description: "Git repository URL for schema resolution (cloned by tag at validation time)" + + provides: + required: true + type: object + description: "What the package contributes" + properties: + rules: + required: true + type: boolean + description: "Must be true — every package provides rules" + sources: + required: false + type: boolean + description: "Whether the package provides docs/sources.yml (scoped to own rules)" + agent_rules: + required: false + type: array + items: + type: object + properties: + agent: + required: true + type: string + description: "Agent identifier (must match an agent in reserved_agent_prefixes)" + prefix: + required: true + type: string + pattern: "^[A-Z]{3,}_[A-Z]+$" + description: "Agent rules prefix within package namespace (e.g., RRAILS_CLAUDE)" + description: "Agent-specific rules provided by this package" + + excludes: + required: false + type: array + items: + type: string + pattern: "^CORE:[SCDEGM]:[0-9]{4}$" + description: "Core rule IDs this package opts out of" + + overrides: + required: false + type: object + description: "Per-check severity adjustments on core rules (key = check ID)" + additionalProperties: + type: object + properties: + severity: + type: enum + values: [critical, high, medium, low] + description: "Override default severity" + +validation: + - rule: "prefix must be in reserved_package_prefixes" + - rule: "prefix must NOT be in reserved_agent_prefixes" + - rule: "provides.rules is required and must be true" + - rule: "provides.agent_rules[].prefix must start with the package prefix" + - rule: "excludes items must be valid core rule coordinates" + +examples: + recommended: | + package: recommended + prefix: RRAILS + name: Reporails Recommended Rules + description: Opinionated best practices and governance + version: "0.2.2" + depends_on: + schema: "reporails/cli" + min_version: "0.5.0" + source: "https://github.com/reporails/cli.git" + provides: + rules: true + sources: true + agent_rules: + - agent: claude + prefix: RRAILS_CLAUDE + + minimal: | + package: my-rules + prefix: MYR + name: My Custom Rules + version: "1.0.0" + depends_on: + schema: "reporails/cli" + min_version: "0.5.0" + source: "https://github.com/reporails/cli.git" + provides: + rules: true diff --git a/framework/schemas/project.schema.yml b/framework/schemas/project.schema.yml new file mode 100644 index 00000000..81b8938f --- /dev/null +++ b/framework/schemas/project.schema.yml @@ -0,0 +1,142 @@ +# Project configuration schema (.ails/config.yml) +version: "0.1.0" + +# Removed instruction_files (moved to agent config). +# Updated rules.disabled/overrides for coordinate format. + +fields: + schema_version: + required: true + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" + default: "0.1.0" + description: "Config schema version (semver). CLI validates against supported range." + + agent: + required: false + type: string + default: "claude" + description: "Default agent for this project (loads agent config from rules repo)" + + components: + required: false + type: array + description: "Monorepo component definitions for multi-path scoring" + items: + type: object + properties: + path: + required: true + type: string + description: "Relative path to component root" + main: + type: string + default: "CLAUDE.md" + description: "Component's primary instruction file" + expected_level: + type: string + pattern: "^L[1-6]$" + description: "Target capability level for this component" + role: + type: enum + values: [coordinator, component, library, docs] + default: component + description: "Component role (affects which rules apply)" + + rules: + required: false + type: object + description: "Project-level rule overrides and exclusions" + properties: + disabled: + type: array + description: "Rule coordinates to skip entirely" + items: + type: string + pattern: "^[A-Z_]+:[SCDEGM]:[0-9]{4}$" + overrides: + type: object + description: "Per-check severity adjustments (key = check coordinate)" + additionalProperties: + type: object + properties: + severity: + type: enum + values: [critical, high, medium, low] + description: "Override default severity for this check" + disabled: + type: boolean + description: "Disable this specific check" + + scoring: + required: false + type: object + description: "Scoring configuration for CLI output" + properties: + scope: + type: enum + values: [main, all, aggregate] + default: main + description: "What to score: main file only, all files, or aggregate across components" + + tiers: + required: false + type: object + description: "Orthogonal to levels: evidence confidence vs concern level" + properties: + core: + type: boolean + default: true + description: "Enforce rules backed by official/research sources (weight >= 0.8)" + experimental: + type: boolean + default: true + description: "Enforce rules backed by community sources only (weight < 0.8)" + +defaults: + schema_version: "0.1.0" + agent: "claude" + scoring: + scope: main + tiers: + core: true + experimental: true + +validation: + - rule: "schema_version must be supported (currently: 0.1.0)" + - rule: "Unknown fields should warn, not error" + - rule: "If tiers.core is false, no rules are enforced" + - rule: "rules.disabled items must be valid rule coordinates" + - rule: "rules.overrides keys must be valid check coordinates" + +examples: + minimal: | + schema_version: "0.1.0" + + monorepo: | + schema_version: "0.1.0" + agent: claude + components: + - path: "packages/api" + role: component + expected_level: L3 + - path: "packages/shared" + role: library + expected_level: L2 + scoring: + scope: aggregate + + with_overrides: | + schema_version: "0.1.0" + agent: claude + rules: + disabled: ["CORE:E:0002"] + overrides: + "CORE.S.0001.file-exists": + severity: medium + + strict: | + schema_version: "0.1.0" + tiers: + core: true + experimental: false diff --git a/framework/schemas/rule.schema.yml b/framework/schemas/rule.schema.yml new file mode 100644 index 00000000..1183dd29 --- /dev/null +++ b/framework/schemas/rule.schema.yml @@ -0,0 +1,333 @@ +# Rule frontmatter schema +version: "0.7.0" + +types: + - mechanical + - deterministic + +categories: + - structure + - coherence + - direction + - efficiency + - maintenance + - governance + +severities: + - critical + - high + - medium + - low + +reserved_agent_prefixes: + - CLAUDE + - COPILOT + - CODEX + - CURSOR + - WINDSURF + - CLINE + - AIDER + - CONTINUE + - ROO + - GEMINI + +reserved_package_prefixes: + - RRAILS + +fields: + id: + required: true + type: string + pattern: + core: "^CORE:[SCDEGM]:[0-9]{4}$" + agent: "^[A-Z]+:[SCDEGM]:[0-9]{4}$" + package: "^[A-Z]{3,}:[SCDEGM]:[0-9]{4}$" + package_agent: "^[A-Z]{3,}_[A-Z]+:[SCDEGM]:[0-9]{4}$" + custom: "^[A-Z]{3,}:[SCDEGM]:[0-9]{4}$" + description: "Coordinate: {REGISTRY}:{CATEGORY}:{SLOT}. Immutable, never reused." + + slug: + required: true + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: "Mutable display name, used as directory name" + + title: + required: true + type: string + max_length: 64 + description: "Human-readable rule name (max 64 chars)" + + category: + required: true + type: enum + values: [structure, coherence, direction, efficiency, maintenance, governance] + description: "Rule concern area. Maps to category letter in coordinate: S/C/D/E/M/G" + + type: + required: true + type: enum + values: [mechanical, deterministic] + description: "Local check dispatch. Determines which engine runs checks.yml entries." + + execution: + required: false + type: enum + values: [local, server] + default: local + description: "Where the rule executes. local = checks.yml on client. server = diagnostic from API, no local checks." + + severity: + required: true + type: enum + values: [critical, high, medium, low] + description: "Impact weight for scoring. One per rule. Critical=5.5, High=4.0, Medium=2.5, Low=1.0" + + match: + required: true + type: object + description: "Property-based file targeting. Unspecified properties are wildcards." + properties: + type: + type: string + description: "Named file type (main, scoped_rule, skill, config, override, memory, mcp, managed)" + scope: + type: enum + values: [global, path_scoped, task_scoped, user, system] + format: + type: [enum, array] + values: [freeform, frontmatter, schema_validated] + description: "Container format. Single value or list for OR-matching (e.g., [freeform, frontmatter])" + content_format: + required_if: "format includes freeform" + forbidden_if: "format excludes freeform" + type: array + items: + type: enum + values: [prose, heading, code_block, data_block, table, list] + description: > + Content region format within the file. Fine structure of the format axis. + prose: natural language paragraphs. + heading: markdown section headers. + code_block: fenced code blocks (executable/example code). + data_block: structured visualization/data blocks (mermaid, embedded yaml/json). + table: markdown tables. + list: ordered/unordered lists. + cardinality: + type: enum + values: [singleton, optional, collection, hierarchical, chain] + lifecycle: + type: enum + values: [static, mutable, transient] + maintainer: + type: enum + values: [human, agent, system, hybrid] + vcs: + type: enum + values: [committed, gitignored, external] + loading: + type: enum + values: [session_start, on_demand, on_invocation] + precedence: + type: enum + values: [managed, project, user, auto] + + backed_by: + required: false + type: array + description: "Evidence sources backing this rule. References source IDs from docs/sources.yml." + items: + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: "Source ID (matches id field in docs/sources.yml)" + + concern: + required: false + type: enum + values: [identity, directive, knowledge, constraint] + description: "Content concern classification. Null for formal/structural rules." + + checks: + required: false + type: array + description: "Check stubs in rule.md (optional). Full definitions in checks.yml." + items: + id: + required: true + type: string + pattern: "^[A-Z_]+\\.[SCDEGM]\\.[0-9]{4}\\.[a-z][a-z0-9-]*$" + description: "Check coordinate: {NAMESPACE}.{CATEGORY}.{SLOT}.{descriptive-name}" + type: + required: true + type: enum + values: [mechanical, deterministic, content_query] + description: "Gate type for this check step" + # mechanical + check: + required_if: "type == mechanical" + type: string + description: "Mechanical check function name (see values for available checks)" + values: + - file_exists + - directory_exists + - directory_contains + - git_tracked + - frontmatter_key + - file_count + - line_count + - byte_size + - path_resolves + - extract_imports + - aggregate_byte_size + - import_depth + - directory_file_types + - frontmatter_valid_glob + - content_absent + args: + required: false + type: object + description: "Check-specific arguments (e.g., path, min, max)" + # deterministic + pattern: + required_if: "type == deterministic" + type: string + description: "Regex pattern to detect evidence" + expect: + required: false + type: enum + values: [present, absent] + default: present + description: "What the pattern tests for. present (default): pattern matches expected evidence — no match = violation. absent: pattern matches forbidden content — match = violation." + message: + required_if: "type == deterministic" + type: string + description: "Human-readable message describing what was detected or missing" + cross_file: + required: false + type: boolean + default: false + description: "Whether pattern operates across the full target set (not per-file)" + # content_query + query: + required_if: "type == content_query" + type: string + description: "Content query function name from content_queries.QUERY_REGISTRY" + + supersedes: + required: false + type: string + pattern: "^[A-Z_]+:[SCDEGM]:[0-9]{4}$" + description: "Coordinate of replaced rule. 1:1 only." + + see_also: + required: false + type: array + description: "Related rule coordinates for cross-referencing" + items: + type: string + pattern: "^[A-Z_]+:[SCDEGM]:[0-9]{4}$" + + conflicts: + required: false + type: array + description: "Known conflicts with other rules, with resolution status" + items: + conflict_id: + required: true + type: string + description: "Reference to a resolution in docs/resolutions/" + resolution_honored: + required: true + type: boolean + description: "Whether this rule follows the published resolution" + scoped_to: + required: false + type: string + description: "Context scope where this conflict applies (e.g., monorepo, single-root)" + +checks_file: + filename: "checks.yml" + top_level_key: "checks" + description: "Consolidated check definitions. Each entry has id, type, and type-specific fields." + +validation: + - rule: "Every checks[].id in rule.md must match a corresponding entry in checks.yml" + - rule: "checks[].id format: {NAMESPACE}.{CATEGORY}.{SLOT}.{descriptive-name}" + - rule: "mechanical type: checks may only contain mechanical + content_query" + - rule: "deterministic type: checks may contain mechanical + deterministic + content_query" + - rule: "execution: server rules have no checks.yml — diagnostics from API" + - rule: "supersedes target must be a valid rule coordinate" + - rule: "No circular supersessions" + - rule: "Each rule may be superseded by at most one other rule" + - rule: "Severity is a rule-level field (one per rule, not per check)" + - rule: "Slots are immutable — deleted rules are tombstoned, never reused" + - rule: "Tier derived from backed_by: core (weight >= 0.8), experimental (< 0.8 or empty)" + +examples: + core_mechanical: | + --- + id: CORE:M:0001 + slug: version-controlled + title: Version Controlled + category: maintenance + type: mechanical + severity: critical + backed_by: + - claude-code-best-practices + match: {} + see_also: [CLAUDE:M:0001] + --- + + core_deterministic: | + --- + id: CORE:S:0001 + slug: size-limits + title: Size Limits + category: structure + type: deterministic + severity: critical + backed_by: + - claude-code-best-practices + match: {type: main} + see_also: [CORE:S:0002, CORE:C:0001] + --- + + server_rule: | + --- + id: CORE:C:0040 + slug: cross-file-conflict + title: No Conflicting Instructions + category: coherence + type: mechanical + execution: server + severity: critical + match: {} + --- + + agent_specific: | + --- + id: CLAUDE:S:0001 + slug: hierarchical-memory + title: Hierarchical Memory + category: structure + type: mechanical + severity: medium + backed_by: + - claude-code-memory + match: {type: scoped_rule} + see_also: [CLAUDE:S:0002, CORE:S:0002] + --- + + with_supersession: | + --- + id: CORE:C:0002 + slug: single-source-of-truth + title: Single Source of Truth + category: coherence + type: mechanical + severity: high + supersedes: CORE:C:0001 + backed_by: + - claude-code-best-practices + match: {} + --- diff --git a/framework/schemas/sources.schema.yml b/framework/schemas/sources.schema.yml new file mode 100644 index 00000000..7018fd15 --- /dev/null +++ b/framework/schemas/sources.schema.yml @@ -0,0 +1,52 @@ +# Public source registry schema +# Format: flat YAML list of source entries +# Location: docs/sources.yml (in each package repo) +version: "0.1.0" + +format: list + +fields: + id: + required: true + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: "Source slug — unique identifier" + + url: + required: true + type: string + format: url + description: "Canonical URL to the source" + + type: + required: true + type: enum + values: [official, research, community, internal] + description: | + Source origin type. Determines base weight: + official: 1.0 + research: 0.8 + community: 0.6 + internal: 0.4 + + weight: + required: true + type: number + min: 0.4 + max: 1.0 + description: "Authority weight for tier derivation" + + title: + required: false + type: string + description: "Human-readable source name" + +tier_derivation: + description: "Computed by CLI, not stored in sources.yml" + core: "max(backing_source_weights) >= 0.8" + experimental: "max(backing_source_weights) < 0.8 or no backing" + +validation: + - rule: "Every backed_by reference in rule.md must match an id in sources.yml" + - rule: "Flat list format — no scoped dicts, no nested claims" + - rule: "Weights are authoritative" diff --git a/framework/schemas/user.schema.yml b/framework/schemas/user.schema.yml new file mode 100644 index 00000000..0e86c705 --- /dev/null +++ b/framework/schemas/user.schema.yml @@ -0,0 +1,86 @@ +# User configuration schema (~/.reporails/config.yml) +version: "0.1.0" + +fields: + schema_version: + required: true + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" + default: "0.1.0" + description: "User config schema version (semver)" + + defaults: + required: false + type: object + description: "Default values applied when project config is missing fields" + properties: + agent: + type: string + default: "claude" + description: "Default agent when project doesn't specify" + + display: + required: false + type: object + description: "Output display preferences" + properties: + ascii: + type: boolean + default: false + description: "Use ASCII box drawing instead of Unicode" + color: + type: boolean + default: true + description: "Enable colored terminal output" + compact: + type: boolean + default: false + description: "Use compact output format by default" + + framework_path: + required: false + type: string + description: "Local framework path — for framework developers only" + + analytics: + required: false + type: object + description: "Anonymous usage analytics" + properties: + enabled: + type: boolean + default: true + description: "Track scan history in ~/.reporails/analytics/" + +defaults: + schema_version: "0.1.0" + defaults: + agent: "claude" + display: + ascii: false + color: true + compact: false + analytics: + enabled: true + +validation: + - rule: "schema_version must be supported (currently: 0.1.0)" + - rule: "Unknown fields should warn, not error" + - rule: "Invalid patterns should error with clear message" + +examples: + default: | + schema_version: "0.1.0" + display: + color: true + + developer: | + schema_version: "0.1.0" + framework_path: /home/dev/reporails/framework + + ci: | + schema_version: "0.1.0" + display: + ascii: true + color: false + compact: true diff --git a/framework/sources.yml b/framework/sources.yml new file mode 100644 index 00000000..1c5d4d80 --- /dev/null +++ b/framework/sources.yml @@ -0,0 +1,283 @@ +# Source Registry — Core Package +# Schema: schemas/sources.schema.yml +- id: advanced-context-engineering + url: + https://raw.githubusercontent.com/humanlayer/advanced-context-engineering-for-coding-agents/main/ace-fca.md + type: research + weight: 0.8 + title: Advanced Context Engineering for Coding Agents +- id: agent-readmes-empirical-study + url: https://arxiv.org/html/2511.12884 + type: research + weight: 0.8 + title: 'Agent READMEs: An Empirical Study of Context Files for Agentic Coding' +- id: agentic-coding-adoption-github + url: https://arxiv.org/html/2601.18341 + type: research + weight: 0.8 + title: Agentic Coding Adoption on GitHub +- id: agents-md-impact-efficiency + url: https://arxiv.org/html/2601.20404 + type: research + weight: 0.8 + title: The Impact of AGENTS.md Files on Coding Agent Efficiency +- id: agents-md-spec + url: https://agents.md/ + type: official + weight: 1.0 + title: Agents.md Specification +- id: awesome-copilot-meta-instructions + url: + https://raw.githubusercontent.com/github/awesome-copilot/main/instructions/instructions.instructions.md + type: community + weight: 0.6 + title: Awesome Copilot Meta-Instructions +- id: builder-ai-instruction-best-practices + url: https://www.builder.io/c/docs/ai-instruction-best-practices + type: community + weight: 0.6 +- id: building-skills-for-claude + url: + https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?hsLang=en + type: official + weight: 1.0 + title: The Complete Guide to Building Skills for Claude +- id: claude-4-best-practices + url: + https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/claude-4-best-practices + type: official + weight: 1.0 + title: Claude 4 Best Practices +- id: claude-code-hooks + url: https://docs.anthropic.com/en/docs/claude-code/hooks + type: official + weight: 1.0 + title: Claude Code Hooks Guide +- id: claude-code-issue-13579 + url: https://github.com/anthropics/claude-code/issues/13579 + type: community + weight: 0.6 + title: 'Claude Code Issue #13579 - Token Waste' +- id: claude-code-memory + url: https://code.claude.com/docs/en/memory + type: official + weight: 1.0 + title: Claude Code Memory Documentation +- id: claude-code-settings + url: https://code.claude.com/docs/en/settings + type: official + weight: 1.0 + title: Claude Code Settings Documentation +- id: claude-context-windows + url: https://docs.anthropic.com/en/docs/build-with-claude/context-windows + type: official + weight: 1.0 + title: Claude Context Windows +- id: claude-md-guide + url: https://www.builder.io/blog/claude-md-guide + type: community + weight: 0.6 + title: Claude.md Guide - Builder.io +- id: claude-md-optimization-study + url: + https://arize.com/blog/claude-md-best-practices-learned-from-optimizing-claude-code-with-prompt-learning/ + type: research + weight: 0.8 + title: Claude.md Best Practices - Arize +- id: claudemd-best-practices-backbone-yml-pattern + url: + https://dev.to/cleverhoods/claudemd-best-practices-the-backboneyml-pattern-30fi + type: internal + weight: 0.6 + title: 'CLAUDE.md Best Practices: The backbone.yml Pattern' +- id: claudemd-best-practices-mermaid-for-workflows + url: + https://dev.to/cleverhoods/claudemd-best-practices-mermaid-for-workflows-khb + type: internal + weight: 0.6 + title: 'CLAUDE.md Best Practices: Mermaid for Workflows' +- id: codex-agent-loop + url: https://openai.com/index/unrolling-the-codex-agent-loop/ + type: official + weight: 1.0 + title: Unrolling the Codex Agent Loop +- id: codex-agents-md + url: https://developers.openai.com/codex/guides/agents-md + type: official + weight: 1.0 + title: Codex Agents.md Guide +- id: codex-developers-2025 + url: https://developers.openai.com/blog/openai-for-developers-2025/ + type: official + weight: 1.0 +- id: codex-eval-skills + url: https://developers.openai.com/blog/eval-skills/ + type: official + weight: 1.0 +- id: codex-exec-plans + url: https://developers.openai.com/cookbook/articles/codex_exec_plans + type: official + weight: 1.0 +- id: codex-introducing + url: https://openai.com/index/introducing-codex/ + type: official + weight: 1.0 +- id: codex-prompting-guide + url: + https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide/ + type: official + weight: 1.0 +- id: codex-skills-guide + url: https://developers.openai.com/codex/skills/ + type: official + weight: 1.0 +- id: codex-skills-shell-compaction + url: https://developers.openai.com/blog/skills-shell-tips/ + type: official + weight: 1.0 +- id: copilot-about-coding-agent + url: + https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-coding-agent + type: official + weight: 0.9 +- id: copilot-ai-best-practices-vscode + url: https://code.visualstudio.com/docs/copilot/copilot-tips-and-tricks + type: official + weight: 0.9 +- id: copilot-cli-best-practices + url: https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-best-practices + type: official + weight: 1.0 + title: Copilot CLI Best Practices +- id: copilot-coding-agent-best-practices + url: + https://docs.github.com/copilot/how-tos/agents/copilot-coding-agent/best-practices-for-using-copilot-to-work-on-tasks + type: official + weight: 1.0 + title: Best Practices for Using Copilot Coding Agent +- id: copilot-coding-agent-results + url: + https://docs.github.com/en/copilot/tutorials/coding-agent/get-the-best-results + type: official + weight: 1.0 + title: Get the Best Results from Copilot Coding Agent +- id: copilot-coding-agent-tasks + url: + https://docs.github.com/en/copilot/how-tos/agents/copilot-coding-agent/best-practices-for-using-copilot-to-work-on-tasks + type: official + weight: 0.9 +- id: copilot-custom-instructions + url: + https://docs.github.com/copilot/customizing-copilot/adding-custom-instructions-for-github-copilot + type: official + weight: 1.0 + title: Adding Repository Custom Instructions for GitHub Copilot +- id: copilot-custom-instructions-vscode + url: + https://code.visualstudio.com/docs/copilot/customization/custom-instructions + type: official + weight: 0.9 +- id: developer-context-cursor-study + url: https://arxiv.org/html/2512.18925 + type: research + weight: 0.8 + title: 'Developer Context in AI-Assisted Coding: A Cursor Study' +- id: dometrain-claude-md-guide + url: https://dometrain.com/blog/creating-the-perfect-claudemd-for-claude-code/ + type: community + weight: 0.6 + title: Creating the Perfect CLAUDE.md - Dometrain +- id: enterprise-claude-usage + url: https://blog.sshh.io/p/how-i-use-every-claude-code-feature + type: community + weight: 0.6 + title: How I Use Every Claude Code Feature +- id: evaluating-agents-md + url: https://arxiv.org/html/2602.11988 + type: research + weight: 0.8 + title: 'Evaluating AGENTS.md: Impact on Coding Agent Quality' +- id: flowbench-workflow-format-benchmark + url: https://arxiv.org/html/2406.14884v1 + type: research + weight: 0.8 + title: 'FlowBench: Revisiting and Benchmarking Workflow-Guided Planning for LLM-based + Agents' +- id: fowler-assessing-quality-agents + url: https://martinfowler.com/articles/exploring-gen-ai/ccmenu-quality.html + type: community + weight: 0.6 + title: Assessing Quality of AI Coding Agents - Martin Fowler +- id: fowler-context-engineering-agents + url: + https://martinfowler.com/articles/exploring-gen-ai/context-engineering-coding-agents.html + type: community + weight: 0.6 + title: Context Engineering for Coding Agents - Martin Fowler +- id: fowler-pushing-ai-autonomy + url: https://martinfowler.com/articles/pushing-ai-autonomy.html + type: community + weight: 0.6 + title: Pushing AI Autonomy - Martin Fowler +- id: instruction-limits-principles + url: https://www.humanlayer.dev/blog/writing-a-good-claude-md + type: community + weight: 0.6 + title: Writing a Good Claude.md - HumanLayer +- id: lost-in-the-middle-long-contexts + url: https://arxiv.org/html/2307.03172v1 + type: research + weight: 0.8 + title: 'Lost in the Middle: How Language Models Use Long Contexts' +- id: microsoft-awesome-copilot-blog + url: + https://developer.microsoft.com/blog/introducing-awesome-github-copilot-customizations-repo + type: community + weight: 0.6 +- id: monorepo-claude-md-organization + url: + https://dev.to/anvodev/how-i-organized-my-claudemd-in-a-monorepo-with-too-many-contexts-37k7 + type: community + weight: 0.6 + title: Monorepo Claude.md Organization +- id: openai-codex-own-agents-md + url: https://developers.openai.com/codex/guides/agents-md/ + type: official + weight: 1.0 +- id: openai-community-agents-md-optimization + url: https://community.openai.com/t/agents-md-file-optimization/1369152 + type: community + weight: 0.6 +- id: osmani-ai-coding-workflow + url: https://addyosmani.com/blog/ai-coding-workflow/ + type: research + weight: 0.8 + title: AI-Assisted Coding Workflow - Addy Osmani +- id: prompthub-cursor-rules-analysis + url: https://www.prompthub.us/blog/top-cursor-rules-for-coding-agents + type: community + weight: 0.6 + title: Top Cursor Rules for Coding Agents - PromptHub +- id: rules-directory-mechanics + url: https://claudefa.st/blog/guide/mechanics/rules-directory + type: community + weight: 0.6 + title: Rules Directory Mechanics - ClaudeFast +- id: sewell-agents-md-tips + url: https://www.builder.io/blog/agents-md + type: community + weight: 0.6 +- id: sewell-codex-vs-claude + url: https://www.builder.io/blog/codex-vs-claude-code + type: community + weight: 0.6 +- id: spec-writing-for-agents + url: https://addyosmani.com/blog/good-spec/ + type: research + weight: 0.8 + title: Writing Good Specs for AI Agents - Addy Osmani +- id: using-claude-md-files + url: https://claude.com/blog/using-claude-md-files + type: official + weight: 1.0 + title: Using CLAUDE.md Files - Anthropic diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..d82c191d --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,64 @@ +"""Hatch build hook to bundle framework content, excluding test fixtures.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +# Framework content to bundle into the wheel. +# Source paths are relative to repo root; destinations are inside the wheel. +FRAMEWORK_INCLUDES = { + "framework/rules": "reporails_cli/rules", + "framework/registry/levels.yml": "reporails_cli/registry/levels.yml", + "framework/sources.yml": "reporails_cli/sources.yml", +} + +# Bundled model (gitignored, but must be in wheel). +# Populated by scripts/fetch_bundled_model.py before build. +BUNDLED_MODEL = "src/reporails_cli/bundled/models" +BUNDLED_MODEL_DEST = "reporails_cli/bundled/models" + +# Directory names to skip when bundling (rule test fixtures are dev-only) +SKIP_DIRS = {"tests"} + + +class CustomBuildHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + root = Path(self.root) + force_include = build_data["force_include"] + + for src_rel, dest_rel in FRAMEWORK_INCLUDES.items(): + src = root / src_rel + if src.is_file(): + force_include[str(src)] = dest_rel + elif src.is_dir(): + for path in src.rglob("*"): + if path.is_file() and not _in_skip_dir(path, src): + rel = path.relative_to(src) + force_include[str(path)] = f"{dest_rel}/{rel}" + + # Bundle ONNX model (gitignored but required at runtime) + model_dir = root / BUNDLED_MODEL + if model_dir.is_dir(): + for path in model_dir.rglob("*"): + if not path.is_file(): + continue + # Skip HF download cache metadata + if ".cache" in path.parts: + continue + rel = path.relative_to(root / "src") + force_include[str(path)] = str(rel) + + # Remove the pyproject.toml force-include entries (they'd double-include) + # — handled by clearing them from config before this hook, or by + # removing them from pyproject.toml entirely. + + +def _in_skip_dir(path: Path, base: Path) -> bool: + """Check if any parent directory between base and path is in SKIP_DIRS.""" + rel = path.relative_to(base) + return any(part in SKIP_DIRS for part in rel.parts) diff --git a/packages/npm/README.md b/packages/npm/README.md index b8edc07f..b5909522 100644 --- a/packages/npm/README.md +++ b/packages/npm/README.md @@ -1,164 +1,116 @@ # @reporails/cli -Score your CLAUDE.md files. See what's missing. Improve your AI coding setup. +AI instruction diagnostics for coding agents. Validates instruction files for Claude, Codex, Copilot, Gemini, and Cursor against 90+ deterministic rules. + +### Beta — limited 100 spots, free until GA. ## Quick Start ```bash -npx @reporails/cli install +npx @reporails/cli check ``` -This detects agents in your project and writes the MCP config. Then ask Claude: +That's it. Score and actionable findings — no install, no account. ``` -> What ails claude? -``` +Reporails — Diagnostics -### CLI path (only deterministic rules) + ┌─ Main (1) + │ CLAUDE.md 12 dir / 5 con · 60% prose + │ ⚠ L1 No NEVER or AVOID statements found CORE:C:0003 + │ ○ L1 No version or date marker found CORE:C:0012 + │ + └─ 3 findings -```bash -npx @reporails/cli check -``` + ── Summary ────────────────────────────────────────────── -That's it. You'll get a score, capability level, and actionable violations. -``` -╔══════════════════════════════════════════════════════════════╗ -║ SCORE: 8.1 / 10 (awaiting semantic) | CAPABILITY: Maintained (L5) ║ -║ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ ║ -╚══════════════════════════════════════════════════════════════╝ - -Violations: - CLAUDE.md (7 issues) - ○ :1 No NEVER or AVOID statements found RRAILS:C:0003 - · :1 No version or date marker found CORE:C:0012 - ... -``` + Score: 7.2 / 10 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ (0.3s) + Agent: Claude -Fix the issues, run again, watch your score and your experience improve. + 3 findings · 0 errors · 2 warnings · 1 info +``` ## Install ```bash -# Ephemeral (no install, always latest) +# Zero install — always latest npx @reporails/cli check -# Persistent (adds `ails` to PATH) +# Or install globally npm install -g @reporails/cli ``` -Once installed, all commands use `ails` directly. +Once installed globally, use `ails` directly: + +```bash +ails check +ails auth login # Unlock Pro diagnostics (GitHub sign-in) +ails check # Now with cross-file analysis + compliance scoring +``` + +## Supported Agents + +| Agent | Instruction files | +|-------|-------------------| +| Claude | `CLAUDE.md`, `.claude/rules/*.md`, `.claude/skills/*/SKILL.md` | +| Codex | `AGENTS.md`, `CODEX.md`, `agents/*.md` | +| Copilot | `copilot-instructions.md`, `.github/copilot-instructions.md` | +| Gemini | `GEMINI.md`, `.gemini/rules/*.md` | +| Cursor | `.cursorrules`, `.cursor/rules/*.md` | + +## Commands + +| Command | Description | +|---------|-------------| +| `check [PATH]` | Validate instruction files (90+ rules) | +| `explain RULE_ID` | Show rule details and fix guidance | +| `heal` | Interactive auto-fix for violations | +| `auth login` | Sign in with GitHub (Pro enrollment) | +| `auth status` | Check auth state | +| `auth logout` | Remove stored credentials | +| `install [PATH]` | Install MCP server for detected agents | +| `version` | Show version info | ## What It Checks -- **Structure** — File organization, size limits -- **Content** — Clarity, completeness, anti-patterns -- **Efficiency** — Token usage, context management -- **Maintenance** — Versioning, review processes -- **Governance** — Ownership, security policies +90+ rules across six categories: -## Capability Levels +- **Structure** — File organization, size limits, modularity, imports +- **Content** — Specificity, reinforcement, topic clustering, anti-patterns +- **Context Quality** — Tech stack, project description, domain terminology +- **Efficiency** — Token usage, import depth, elaboration +- **Maintenance** — Versioning, review processes +- **Governance** — Security policies, credential protection -Capability levels describe what your AI instruction setup enables — not how "mature" it is. Different projects need different capabilities. +## Unauthenticated vs Authenticated -| Level | Name | What It Enables | -|-------|------|-----------------| -| L0 | Absent | No instruction file — nothing to evaluate | -| L1 | Basic | Reviewed, tracked instruction file | -| L2 | Scoped | Project-specific constraints, size control | -| L3 | Structured | External references, multiple files | -| L4 | Abstracted | Path-scoped rules, context-aware loading | -| L5 | Maintained | Structural integrity, governance, navigation | -| L6 | Adaptive | Dynamic context, extensibility, persistence | +| Feature | Unauthenticated | Authenticated | +|---------|-----------------|---------------| +| Mechanical rules | 70+ | 70+ | +| Deterministic rules | 20+ | 20+ | +| Cross-file analysis | — | Conflicts, repetition | +| Reinforcement detection | — | Orphan instructions, topic clustering | +| Compliance scoring | — | Per-instruction strength | ## GitHub Actions -Add `ails check` as a CI gate with inline PR annotations: - ```yaml -# .github/workflows/reporails.yml name: Reporails on: pull_request: - paths: ['CLAUDE.md', '.claude/**'] + paths: ['CLAUDE.md', '.claude/**', 'AGENTS.md', '.cursorrules'] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: reporails/cli/action@v1 - with: - min-score: '6.0' + - run: npx @reporails/cli check --format github --strict ``` -See the [main README](https://github.com/reporails/cli#github-actions) for full action inputs/outputs. - -## Commands - -| Command | Description | -|---------|-------------| -| `install [PATH]` | Install MCP server for detected agents | -| `check [PATH]` | Validate instruction files | -| `heal [PATH]` | Interactive auto-fix + semantic evaluation | -| `explain RULE_ID` | Show rule details | -| `map [PATH]` | Discover project structure | -| `update` | Update rules framework + recommended | -| `config set KEY VALUE` | Set a project config value | -| `config set --global KEY VALUE` | Set a global default | -| `config get KEY` | Show a config value | -| `config list` | Show all config (project + global) | -| `dismiss RULE_ID` | Dismiss a semantic finding | -| `judge PATH VERDICTS` | Cache semantic verdicts | -| `version` | Show version info | - -See the [main README](https://github.com/reporails/cli#commands) for full flag reference. - -## Updating - -```bash -ails update # Update rules framework + recommended to latest -ails update --check # Check for updates without installing -ails update --recommended # Update recommended rules only -ails update --force # Force reinstall even if current -ails update --cli # Upgrade the CLI package itself -``` - -Before each scan, the CLI prompts when updates are available. Use `--no-update-check` to skip. - -The **CLI itself** updates automatically — `npx @reporails/cli` always fetches the latest version. -Persistent installs: `npm install -g @reporails/cli@latest` - -## Recommended Rules - -[Recommended rules](https://github.com/reporails/recommended) (AILS_ namespace) are included by default and auto-downloaded on first run. To opt out: - -```bash -ails config set recommended false # This project only -ails config set --global recommended false # All projects -``` - -To update recommended rules independently: - -```bash -ails update --recommended -``` - -## Prerequisites - -- **Node.js >= 18** -- **uv** — auto-installed if missing ([manual install](https://docs.astral.sh/uv/)) -- **No additional dependencies** — `install` writes config files directly - ## How It Works -This is a thin Node.js wrapper around the [reporails-cli](https://pypi.org/project/reporails-cli/) Python package. Commands are proxied via `uvx` — no Python installation required. - -## Rules - -Core rules are maintained at [reporails/rules](https://github.com/reporails/rules). -Recommended rules at [reporails/recommended](https://github.com/reporails/recommended). - -Want to add or improve rules? Please follow [Contribute](https://github.com/reporails/rules/blob/main/CONTRIBUTING.md) guide in the [Core repo](https://github.com/reporails/rules). +Thin Node.js wrapper around the [reporails-cli](https://pypi.org/project/reporails-cli/) Python package. Commands are proxied via `uvx` — no Python install required. Node.js >= 18 needed. `uv` is auto-installed if missing. ## License -BUSL 1.1 — converts to Apache 2.0 on 2029-02-20 or at 1.0, whichever comes first. +[BUSL 1.1](https://github.com/reporails/cli/blob/main/LICENSE) — converts to Apache 2.0 three years after each release. diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index 2e4ab674..9e349a95 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -8,26 +8,23 @@ const PYPI_PACKAGE = "reporails-cli"; const CLI_COMMAND = "ails"; const HELP = ` -reporails — Score your CLAUDE.md files +ails — Validate and score AI instruction files Usage: - reporails install [PATH] Install MCP server for detected agents - reporails check [PATH] [OPTIONS] Validate instruction files - reporails explain RULE_ID Show rule details - reporails map [PATH] [--save] Discover project structure - reporails update Update rules framework + recommended - reporails update --check Check for updates without installing - reporails update --recommended Update recommended rules only - reporails update --cli Upgrade CLI package itself - reporails dismiss RULE_ID Dismiss a semantic finding - reporails version Show version info - reporails [args...] Proxy any command to ails CLI + ails check [PATH] [OPTIONS] Validate instruction files + ails explain RULE_ID Show rule details + ails install [PATH] Install MCP server for detected agents + ails update Update rules framework + ails update --cli Upgrade CLI package itself + ails version Show version info Examples: - npx @reporails/cli install # Install MCP server - npx @reporails/cli check # Score your setup + npx @reporails/cli check # Validate your setup + npx @reporails/cli install # Install MCP server npx @reporails/cli explain CORE:S:0001 # Explain a rule - npx @reporails/cli update # Update rules + recommended + +Aliases: + ails, reporails — both work after global install Prerequisites: Node.js >= 18 (uv is auto-installed if missing) diff --git a/packages/npm/package.json b/packages/npm/package.json index 6f238bc5..ff82afde 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,9 +1,10 @@ { "name": "@reporails/cli", - "version": "0.4.0", - "description": "Score your CLAUDE.md files. See what's missing. Improve your AI coding setup.", + "version": "0.5.0", + "description": "AI instruction diagnostics for coding agents", "type": "module", "bin": { + "ails": "bin/reporails.mjs", "reporails": "bin/reporails.mjs" }, "engines": { @@ -14,12 +15,15 @@ "README.md" ], "keywords": [ + "ai-instructions", "claude", - "claude-code", - "mcp", + "codex", + "copilot", + "cursor", + "gemini", + "diagnostics", "validation", - "ai-instructions", - "claude-md" + "mcp" ], "repository": { "type": "git", diff --git a/pyproject.toml b/pyproject.toml index 74c3e38e..725dd9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "reporails-cli" -version = "0.4.0" -description = "Validate and score CLAUDE.md files — MCP-first AI context governance" +version = "0.5.0" +description = "AI instruction diagnostics for coding agents" readme = "README.md" license = { text = "BUSL-1.1" } requires-python = ">=3.12" authors = [{ name = "Reporails Team" }] -keywords = ["claude", "ai", "context", "validation", "mcp", "claude-code"] +keywords = ["ai-instructions", "claude", "codex", "copilot", "cursor", "gemini", "diagnostics", "validation", "mcp"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", @@ -24,6 +24,18 @@ dependencies = [ "httpx>=0.27.0", "mcp>=1.0.0", "packaging>=23.0", + "markdown-it-py>=3.0.0", + # Embedding stack — ONNX Runtime + HF tokenizers on bundled MiniLM-L6-v2. + # No torch, no sentence-transformers. See core/_torch_blocker.py and + # core/mapper/onnx_embedder.py for the reason and the wiring. + "onnxruntime>=1.18,<2", + "tokenizers>=0.19,<1", + "numpy>=1.26,<3", + # spaCy drives Phase 3 imperative classification in the mapper. It's a + # hard requirement — the fallback verb lexicon drops charge accuracy + # from 96% to ~78%. With the torch blocker active, spaCy loads in ~1s. + "spacy>=3.8.11,<4", + "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", ] [project.optional-dependencies] @@ -46,12 +58,22 @@ dev = [ "ruff>=0.3.0", "pylint>=3.0.0", "poethepoet>=0.25.0", + # huggingface_hub: only needed by scripts/fetch_bundled_model.py on + # dev machines and in CI. Not a runtime dep — end users get the + # pre-bundled wheel with the ONNX model already inside. + "huggingface_hub>=0.23", + # Research / test extras (not on the CLI critical path) + "scipy>=1.17.1", + "networkx>=3.6.1", + "matplotlib>=3.10.8", + "sympy>=1.14.0", + "scikit-learn>=1.8.0", + "anthropic>=0.40.0", ] [project.scripts] ails = "reporails_cli.interfaces.cli.main:app" reporails-mcp = "reporails_cli.interfaces.mcp.server:main" -ails-mcp = "reporails_cli.interfaces.mcp.server:main" # deprecated, remove ~0.5.0 [project.urls] Homepage = "https://github.com/reporails/cli" @@ -63,10 +85,18 @@ Issues = "https://github.com/reporails/cli/issues" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +# Required because we pin en_core_web_sm via a direct URL (spaCy models +# are distributed as GitHub release wheels, not via PyPI). +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["src/reporails_cli"] -only-include = ["src/reporails_cli"] -artifacts = ["src/reporails_cli/bundled/*.yml"] +artifacts = [ + "src/reporails_cli/bundled/*.yml", +] + +[tool.hatch.build.targets.wheel.hooks.custom] [tool.poe.tasks] fmt = "ruff format src/ tests/" @@ -80,6 +110,7 @@ test_smoke = "pytest tests/smoke/ -v -m e2e" qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_unit"] qa = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_unit", "test_integration", "test_smoke"] mcp_dev = "python -m reporails_cli.interfaces.mcp.server" +fetch_bundled_model = "python scripts/fetch_bundled_model.py" [tool.ruff] target-version = "py312" @@ -109,6 +140,10 @@ packages = ["reporails_cli"] explicit_package_bases = true ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "reporails_cli._archived.*" +ignore_errors = true + [[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false @@ -126,6 +161,7 @@ max-locals = 15 [tool.pylint.format] max-line-length = 120 +max-module-lines = 600 [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/fetch_bundled_model.py b/scripts/fetch_bundled_model.py new file mode 100644 index 00000000..de38a045 --- /dev/null +++ b/scripts/fetch_bundled_model.py @@ -0,0 +1,102 @@ +"""Fetch the bundled ONNX embedding model from Hugging Face Hub. + +This is a **dev-only** helper. End users never run it — they install the +pre-built wheel which already contains the ONNX files. + +Runs at clone time (via ``uv run poe fetch_bundled_model``) and in CI +before ``hatch build``. Idempotent: skips the download if all expected +files are already present. + +Source: ``Xenova/all-MiniLM-L6-v2`` — a pre-exported ONNX build of +``sentence-transformers/all-MiniLM-L6-v2``. fp32 is bit-identical to +the PyTorch reference. Total download: ~87 MB. + +Target: ``src/reporails_cli/bundled/models/minilm-l6-v2/`` + +The target directory is ``.gitignore``d so the binaries are never +committed. The wheel builder picks them up via ``artifacts`` in +``pyproject.toml``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Files we need from the Xenova repo +_ALLOW_PATTERNS: list[str] = [ + "onnx/model.onnx", + "tokenizer.json", + "config.json", + "tokenizer_config.json", + "special_tokens_map.json", +] + +# Required files (checked by the idempotency guard) +_REQUIRED: list[str] = _ALLOW_PATTERNS[:] + +# Repo-relative bundle target +_BUNDLE_RELPATH = Path("src/reporails_cli/bundled/models/minilm-l6-v2") +_HF_REPO_ID = "Xenova/all-MiniLM-L6-v2" + + +def _repo_root() -> Path: + """Return the repository root, assuming this script lives in ``scripts/``.""" + return Path(__file__).resolve().parent.parent + + +def _target_dir() -> Path: + return _repo_root() / _BUNDLE_RELPATH + + +def _already_present(target: Path) -> bool: + """True when every required file exists and is non-empty.""" + for rel in _REQUIRED: + f = target / rel + if not f.exists() or f.stat().st_size == 0: + return False + return True + + +def main() -> int: + target = _target_dir() + if _already_present(target): + size_mb = sum((target / r).stat().st_size for r in _REQUIRED) / 1e6 + print(f"✓ bundled model already present at {target} ({size_mb:.1f} MB)") + return 0 + + print(f"downloading {_HF_REPO_ID} to {target} ...") + + try: + from huggingface_hub import snapshot_download + except ImportError: + print( + "ERROR: huggingface_hub is required for the dev fetch script.\n" + "Install with: uv add --dev huggingface_hub", + file=sys.stderr, + ) + return 1 + + target.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id=_HF_REPO_ID, + allow_patterns=_ALLOW_PATTERNS, + local_dir=str(target), + ) + + if not _already_present(target): + missing = [r for r in _REQUIRED if not (target / r).exists()] + print(f"ERROR: download completed but missing files: {missing}", file=sys.stderr) + return 1 + + size_mb = sum((target / r).stat().st_size for r in _REQUIRED) / 1e6 + print(f"✓ fetched {len(_REQUIRED)} files to {target} ({size_mb:.1f} MB)") + print(" contents:") + for rel in _REQUIRED: + f = target / rel + print(f" {rel} {f.stat().st_size / 1e6:.2f} MB") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/reporails_cli/__init__.py b/src/reporails_cli/__init__.py index d65541e5..86f7bc91 100644 --- a/src/reporails_cli/__init__.py +++ b/src/reporails_cli/__init__.py @@ -2,10 +2,6 @@ from __future__ import annotations -from importlib.metadata import version - -__version__ = version("reporails-cli") - from reporails_cli.core.models import ( Category, Level, @@ -24,3 +20,11 @@ "Violation", "__version__", ] + + +def __getattr__(name: str) -> str: + if name == "__version__": + from importlib.metadata import version + + return version("reporails-cli") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/reporails_cli/_archived/formatters/box.py b/src/reporails_cli/_archived/formatters/box.py new file mode 100644 index 00000000..d246b61a --- /dev/null +++ b/src/reporails_cli/_archived/formatters/box.py @@ -0,0 +1,296 @@ +"""Assessment box formatting — main score/capability box in output.""" +# pylint: disable=too-many-locals,too-many-statements + +from __future__ import annotations + +from typing import Any + +from reporails_cli.core.levels import get_level_labels +from reporails_cli.core.models import ScanDelta +from reporails_cli.formatters.text.chars import ASCII_MODE, get_chars +from reporails_cli.formatters.text.components import ( + _category_result_color, + _friction_color, + _level_color, + _score_color, + _violations_count_color, + build_score_bar, + format_level_delta, + format_score_delta, + format_violations_delta, + pad_line, +) +from reporails_cli.templates import render + + +def _build_category_bar( + passed: int, total: int, chars: dict[str, str], bar_width: int, colored: bool +) -> tuple[str, int]: + """Build a mini score bar for a category. Returns (bar_string, markup_extra).""" + ratio = passed / total if total else 0 + filled = int(ratio * bar_width) + filled = min(filled, bar_width) + filled_str = chars["filled"] * filled + empty_str = chars["empty"] * (bar_width - filled) + if colored and filled_str: + color = _category_result_color(passed, total) + rich = f"[{color}]{filled_str}[/{color}][dim]{empty_str}[/dim]" + plain_len = len(filled_str) + len(empty_str) + return (rich, len(rich) - plain_len) + return (filled_str + empty_str, 0) + + +def _format_category_table( + cat_summary: list[dict[str, Any]], + chars: dict[str, str], + box_width: int, + empty_line: str, + colored: bool, +) -> str: + """Format category bars inside the assessment box.""" + active = [cs for cs in cat_summary if cs["total"] > 0] + if not active: + return empty_line + + # Category section spans full content width (box_width - 6 = 56) + # name(14) + bar(28) + gap(2) + stat(7) + gap(2) + sev(3) = 56 + content_width = box_width - 6 + name_col = 14 + bar_width = 28 + stat_col = 7 + sev_col = 3 + sev_key_map = {"critical": "crit", "high": "high", "medium": "med", "low": "low"} + cat_lines = [] + header = f"{'Category':<{name_col}}{'Result':<{bar_width}} {'Checks':^{stat_col}} {'Sev':^{sev_col}}" + cat_lines.append(pad_line(header, box_width, chars["v"])) + cat_lines.append(pad_line(chars["sep"] * content_width, box_width, chars["v"])) + cat_lines.append(empty_line) + for cs in active: + name = cs["name"].title() + stat_plain = f"{cs['passed']}/{cs['total']}" + bar, bar_extra = _build_category_bar(cs["passed"], cs["total"], chars, bar_width, colored) + + # Status icon: ✓ for all-pass, severity icon for failures + if cs["passed"] == cs["total"]: + icon_plain = chars["check"] + icon = f"[green]{icon_plain}[/green]" if colored else icon_plain + else: + sev = cs.get("worst_severity", "") + icon_plain = chars.get(sev_key_map.get(sev, ""), "") + sc = _category_result_color(cs["passed"], cs["total"]) if colored else "" + icon = f"[{sc}]{icon_plain}[/{sc}]" if sc else icon_plain + icon_extra = len(icon) - len(icon_plain) + + if colored: + rc = _category_result_color(cs["passed"], cs["total"]) + stat_rich = f"[{rc}]{stat_plain}[/{rc}]" + stat_extra = len(stat_rich) - len(stat_plain) + else: + stat_rich = stat_plain + stat_extra = 0 + + icon_left = (sev_col - len(icon_plain)) // 2 + icon_right = sev_col - len(icon_plain) - icon_left + icon_str = f"{' ' * icon_left}{icon}{' ' * icon_right}" + row = f"{name:<{name_col}}{bar} {stat_rich:^{stat_col + stat_extra}} {icon_str}" + row_extra = bar_extra + stat_extra + icon_extra + cat_lines.append(pad_line(row, box_width + row_extra, chars["v"])) + cat_lines.append(empty_line) + return "\n".join(cat_lines) + + +def _format_summary_line( + violations: list[dict[str, Any]], + rules_checked: int, + viol_delta_plain: str, + viol_delta_rich: str, + colored: bool, + box_width: int, + chars: dict[str, str], +) -> str: + """Format the violations summary line inside the box.""" + seen: set[tuple[str, str]] = set() + for v in violations: + location = v.get("location", "") + file_path = location.rsplit(":", 1)[0] if ":" in location else location + seen.add((file_path, v.get("rule_id", ""))) + violation_count = len(seen) + if violation_count == 0: + summary_plain = f"No violations \u00b7 {rules_checked} rules checked" + if colored: + vc = _violations_count_color(violation_count) + summary_rich = f"[{vc}]No violations[/{vc}] \u00b7 {rules_checked} rules checked" + summary_extra = len(summary_rich) - len(summary_plain) + else: + summary_rich = summary_plain + summary_extra = 0 + else: + count_str = f"{violation_count}" + summary_plain = f"{count_str} violation(s){viol_delta_plain} \u00b7 {rules_checked} rules checked" + if colored: + vc = _violations_count_color(violation_count) + summary_rich = ( + f"[{vc}]{count_str}[/{vc}] violation(s){viol_delta_rich} \u00b7 {rules_checked} rules checked" + ) + summary_extra = len(summary_rich) - len(summary_plain) + else: + summary_rich = summary_plain + summary_extra = 0 + return pad_line(summary_rich, box_width + summary_extra, chars["v"]) + + +def _format_friction_line( + data: dict[str, Any], + colored: bool, + box_width: int, + chars: dict[str, str], + empty_line: str, +) -> str: + """Format the friction line inside the box.""" + friction_raw = data.get("friction", "none") + friction_level = friction_raw if isinstance(friction_raw, str) else friction_raw.get("level", "none") + if friction_level == "none": + return empty_line + friction_plain = f"Friction: {friction_level.title()}" + if colored: + fc = _friction_color(friction_level) + friction_rich = f"Friction: [bold {fc}]{friction_level.title()}[/bold {fc}]" + else: + friction_rich = f"Friction: [underline]{friction_level.title()}[/underline]" + markup_extra = len(friction_rich) - len(friction_plain) + return pad_line(friction_rich, box_width + markup_extra, chars["v"]) + + +def _format_agent_line( + surface: dict[str, Any] | None, + box_width: int, + chars: dict[str, str], + empty_line: str, +) -> str: + """Format the detected-agent line inside the box. Empty if only generic.""" + if not surface: + return empty_line + non_generic = [a for a in surface.get("detected_agents", []) if a != "generic"] + if not non_generic: + return empty_line + effective = surface.get("effective_agent", "generic") + if effective != "generic" and effective in non_generic: + suffix = " (assumed)" if surface.get("assumed") else "" + text = f"Agent: {effective}{suffix}" + else: + text = f"Agents: {', '.join(non_generic)}" + return pad_line(text, box_width, chars["v"]) + + +def _format_scope_line(surface: dict[str, Any] | None, box_width: int, chars: dict[str, str]) -> str: + """Format the scope line inside the box.""" + if not surface or not surface.get("main"): + return pad_line("Scope: (none detected)", box_width, chars["v"]) + main = surface["main"] + counts: dict[str, int] = surface.get("counts", {}) + if not counts: + return pad_line(f"Scope: {main}", box_width, chars["v"]) + parts = [f"{ct} {lb if ct != 1 else lb.rstrip('s')}" for lb, ct in counts.items()] + return pad_line(f"Scope: {main} + {', '.join(parts)}", box_width, chars["v"]) + + +def format_assessment_box( + data: dict[str, Any], + ascii_mode: bool | None = None, + delta: ScanDelta | None = None, + elapsed_ms: float | None = None, + surface: dict[str, Any] | None = None, +) -> str: + """Format the visual assessment box using templates.""" + chars = get_chars(ascii_mode) + box_width = 62 + + # Determine coloring: enabled unless ASCII mode + use_ascii = ascii_mode if ascii_mode is not None else ASCII_MODE + colored = not use_ascii + + score = data.get("score", 0.0) + level = data.get("level", "L1") + summary_info = data.get("summary", {}) + rules_checked = summary_info.get("rules_checked", 0) + violations = data.get("violations", []) + + level_label = get_level_labels().get(level, level) + + # Plain delta strings (always uncolored, for width calculations) + score_delta_plain, _ = format_score_delta(delta, ascii_mode, colored=False) + level_delta_plain, _ = format_level_delta(delta, ascii_mode, colored=False) + viol_delta_plain, _ = format_violations_delta(delta, ascii_mode, colored=False) + + # Colored delta strings (may contain Rich markup) + score_delta_rich, _ = format_score_delta(delta, ascii_mode, colored) + level_delta_rich, _ = format_level_delta(delta, ascii_mode, colored) + viol_delta_rich, _ = format_violations_delta(delta, ascii_mode, colored) + + level_display = level + + # Build individual lines + top_border = chars["tl"] + chars["h"] * box_width + chars["tr"] + bottom_border = chars["bl"] + chars["h"] * box_width + chars["br"] + empty_line = chars["v"] + " " * box_width + chars["v"] + + # Score line: left = score + delta, right = elapsed time (dim) + left_plain = f"SCORE: {score:.1f} / 10{score_delta_plain}" + elapsed_plain = f"{elapsed_ms:.0f}ms" if elapsed_ms is not None else "" + # Align elapsed to right edge of content area (3 left + 3 right margin) + content_width = box_width - 6 + score_gap = max(content_width - len(left_plain) - len(elapsed_plain), 1) + + if colored: + sc = _score_color(score) + left_rich = f"SCORE: [bold {sc}]{score:.1f}[/bold {sc}] / 10{score_delta_rich}" + elapsed_rich = f"[dim]{elapsed_plain}[/dim]" if elapsed_plain else "" + score_text = f"{left_rich}{' ' * score_gap}{elapsed_rich}" + score_extra = len(score_text) - len(left_plain) - score_gap - len(elapsed_plain) + else: + score_text = f"{left_plain}{' ' * score_gap}{elapsed_plain}" + score_extra = 0 + score_line = pad_line(score_text, box_width + score_extra, chars["v"]) + + # Capability line: LEVEL: label (Lx) + level delta + cap_plain = f"LEVEL: {level_label} ({level_display}){level_delta_plain}" + if colored: + lc = _level_color(level) + cap_rich = f"LEVEL: [{lc}]{level_label} ({level_display})[/{lc}]{level_delta_rich}" + cap_extra = len(cap_rich) - len(cap_plain) + else: + cap_rich = cap_plain + cap_extra = 0 + capability_line = pad_line(cap_rich, box_width + cap_extra, chars["v"]) + + # Progress bar + bar, bar_extra = build_score_bar(score, ascii_mode, colored) + bar_line = pad_line(bar, box_width + bar_extra, chars["v"]) + + # Agent + scope lines + agent_line = _format_agent_line(surface, box_width, chars, empty_line) + surface_line = _format_scope_line(surface, box_width, chars) + + summary_line = _format_summary_line( + violations, rules_checked, viol_delta_plain, viol_delta_rich, colored, box_width, chars + ) + friction_line = _format_friction_line(data, colored, box_width, chars, empty_line) + + # Category table inside box + cat_summary = data.get("category_summary", []) + category_section = _format_category_table(cat_summary, chars, box_width, empty_line, colored) + + return render( + "cli_box.txt", + top_border=top_border, + bottom_border=bottom_border, + empty_line=empty_line, + score_line=score_line, + capability_line=capability_line, + bar_line=bar_line, + agent_line=agent_line, + surface_line=surface_line, + summary_line=summary_line, + friction_line=friction_line, + category_section=category_section, + ) diff --git a/src/reporails_cli/_archived/formatters/compact.py b/src/reporails_cli/_archived/formatters/compact.py new file mode 100644 index 00000000..cd65625b --- /dev/null +++ b/src/reporails_cli/_archived/formatters/compact.py @@ -0,0 +1,130 @@ +"""Compact terminal output formatter. + +Provides minimal output for non-TTY contexts and quick checks. +""" +# pylint: disable=too-many-locals,too-many-statements + +from __future__ import annotations + +from typing import Any + +from reporails_cli.core.levels import get_level_labels +from reporails_cli.core.models import Level, ScanDelta, ValidationResult +from reporails_cli.formatters import json as json_formatter +from reporails_cli.formatters.text.chars import get_chars +from reporails_cli.formatters.text.components import ( + format_level_delta, + format_score_delta, + format_violations_delta, + get_severity_icons, + normalize_path, +) + + +def format_compact( + result: ValidationResult, + ascii_mode: bool | None = None, + _show_legend: bool = True, + delta: ScanDelta | None = None, +) -> str: + """Format validation result in compact form for Claude Code / non-TTY.""" + data = json_formatter.format_result(result, delta) + chars = get_chars(ascii_mode) + lines = [] + + score = data.get("score", 0.0) + level = data.get("level", "L1") + level_label = get_level_labels().get(result.level, "Unknown") + + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + return f"{level_label} ({level}) — no instruction files found" + + violations = data.get("violations", []) + is_partial = data.get("is_partial", True) + + # Group and dedupe violations + grouped: dict[str, list[dict[str, Any]]] = {} + for v in violations: + location = v.get("location", "") + file_path = location.rsplit(":", 1)[0] if ":" in location else location + if file_path not in grouped: + grouped[file_path] = [] + grouped[file_path].append(v) + + deduped_count = 0 + deduped_grouped: dict[str, list[dict[str, Any]]] = {} + for file_path, file_violations in grouped.items(): + seen: set[str] = set() + unique = [ + v + for v in file_violations + if not (v.get("rule_id", "") in seen or seen.add(v.get("rule_id", ""))) # type: ignore[func-returns-value] + ] + deduped_grouped[file_path] = unique + deduped_count += len(unique) + + # Header line with delta indicators (compact never uses colors) + score_delta_str, _ = format_score_delta(delta, ascii_mode) + level_delta_str, _ = format_level_delta(delta, ascii_mode) + violations_delta_str, _ = format_violations_delta(delta, ascii_mode) if deduped_count > 0 else ("", 0) + partial_marker = " (awaiting semantic)" if is_partial else "" + header_base = f"Score: {score:.1f}/10{score_delta_str} ({level_label} ({level}){level_delta_str}){partial_marker}" + if deduped_count > 0: + lines.append(f"{header_base} - {deduped_count} violations{violations_delta_str}") + else: + lines.append(f"{header_base} {chars['check']} clean") + return "\n".join(lines) + + lines.append("") + + severity_icons = get_severity_icons(chars) + + for file_path, unique in deduped_grouped.items(): + display_path = normalize_path(file_path) + lines.append(f"{display_path}:") + for v in unique: + sev = v.get("severity", "medium") + icon = severity_icons.get(sev, "?") + rule_id = v.get("rule_id", "?") + msg = v.get("message", "") + location = v.get("location", "") + line_num = location.rsplit(":", 1)[-1] if ":" in location else "?" + if len(msg) > 45: + msg = msg[:42] + "..." + lines.append(f" {icon} :{line_num} {msg:<47} {rule_id}") + lines.append("") + + # Pending semantic + if result.pending_semantic and result.is_partial: + ps = result.pending_semantic + lines.append(f"Pending: {ps.rule_count} semantic rules ({', '.join(ps.rules)})") + lines.append("") + + # Friction + friction = data.get("friction", "none") + friction_level = friction if isinstance(friction, str) else friction.get("level", "none") + if friction_level != "none": + lines.append(f"Friction: {friction_level.title()}") + lines.append("") + + return "\n".join(lines).rstrip() + + +def format_score(result: ValidationResult, _ascii_mode: bool | None = None) -> str: + """Format quick score summary for terminal.""" + level_label = get_level_labels().get(result.level, "Unknown") + + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + return f"ails: {level_label} ({result.level.value}) — no instruction files found" + + violation_count = len(result.violations) + partial = " (awaiting semantic)" if result.is_partial else "" + + if violation_count == 0: + return f"ails: {result.score:.1f}/10 {level_label} ({result.level.value}){partial}" + else: + return ( + f"ails: {result.score:.1f}/10 {level_label} ({result.level.value}){partial} - {violation_count} violations" + ) diff --git a/src/reporails_cli/_archived/formatters/full.py b/src/reporails_cli/_archived/formatters/full.py new file mode 100644 index 00000000..9398e510 --- /dev/null +++ b/src/reporails_cli/_archived/formatters/full.py @@ -0,0 +1,90 @@ +"""Full terminal output formatter. + +Provides rich, detailed output for interactive terminal use. +""" +# pylint: disable=too-many-locals + +from __future__ import annotations + +from reporails_cli.core.levels import get_level_labels +from reporails_cli.core.models import Level, ScanDelta, ValidationResult +from reporails_cli.formatters import json as json_formatter +from reporails_cli.formatters.text.box import format_assessment_box +from reporails_cli.formatters.text.violations import format_violations_section + + +def _format_semantic_cta( + result: ValidationResult, +) -> str: + """Format semantic CTA for installed users with partial results.""" + if not result.is_partial: + return "" + return "[dim]For full semantic analysis: ails install[/dim]" + + +def _format_install_cta() -> str: + """CTA for ephemeral (npx/uvx) users to install permanently.""" + from reporails_cli.core.self_update import is_ephemeral_install + + if not is_ephemeral_install(): + return "" + return "[dim]Run ails install to enable MCP scoring and faster checks.[/dim]" + + +def format_result( + result: ValidationResult, + ascii_mode: bool | None = None, + quiet_semantic: bool = False, + _show_legend: bool = True, + delta: ScanDelta | None = None, + show_mcp_cta: bool = True, + elapsed_ms: float | None = None, + surface: dict[str, object] | None = None, +) -> str: + """Format validation result for terminal output.""" + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + level_label = get_level_labels().get(result.level, "Unknown") + return f"{level_label} ({result.level.value}) — no instruction files found" + + data = json_formatter.format_result(result, delta) + + # Copy violations so injections don't affect scorecard counts + violations = list(data.get("violations", [])) + + # Inject pending semantic checks as inline violations + if not quiet_semantic: + violations.extend( + { + "rule_id": jr.rule_id, + "location": jr.location, + "message": jr.rule_title, + "severity": "pending", + } + for jr in result.judgment_requests + ) + + sections = [] + + # Violations first + sections.append(format_violations_section(violations, ascii_mode)) + + # Spacer before scorecard + sections.append("") + + # Assessment box (scorecard at bottom) + sections.append(format_assessment_box(data, ascii_mode, delta, elapsed_ms=elapsed_ms, surface=surface)) + + # CTA: ephemeral install CTA takes priority over semantic CTA + if show_mcp_cta: + install_cta = _format_install_cta() + if install_cta: + sections.append("") + sections.append(install_cta) + elif result.is_partial and not quiet_semantic: + cta = _format_semantic_cta(result) + if cta: + sections.append("") + sections.append(cta) + + return "\n".join(sections) diff --git a/src/reporails_cli/_archived/formatters/violations.py b/src/reporails_cli/_archived/formatters/violations.py new file mode 100644 index 00000000..a2939c57 --- /dev/null +++ b/src/reporails_cli/_archived/formatters/violations.py @@ -0,0 +1,152 @@ +"""Violations section formatting. + +Handles grouping, sorting, and rendering of violations. +""" +# pylint: disable=too-many-locals,too-many-statements + +from __future__ import annotations + +from typing import Any + +from reporails_cli.formatters.text.chars import ASCII_MODE, get_chars +from reporails_cli.formatters.text.components import ( + format_legend, + get_severity_icons, + normalize_path, +) +from reporails_cli.templates import render + +# Severities that are informational (not real issues) +_INFO_SEVERITIES = {"pending"} + +_SEVERITY_ORDER = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + "pending": 4, +} + + +def _format_file_header( + display_path: str, + unique_violations: list[dict[str, Any]], +) -> str: + """Format file header with issue and info-severity counts.""" + real_count = sum(1 for v in unique_violations if v.get("severity") not in _INFO_SEVERITIES) + pending_count = sum(1 for v in unique_violations if v.get("severity") == "pending") + issue_word = "issue" if real_count == 1 else "issues" + + if pending_count > 0: + return f" {display_path} ({real_count} {issue_word}, {pending_count} awaiting semantic)" + return render( + "cli_file_header.txt", + filepath=display_path, + count=real_count, + issue_word=issue_word, + ) + + +def format_violations_section( + violations: list[dict[str, Any]], + ascii_mode: bool | None = None, +) -> str: + """Format violations section grouped by file.""" + if not violations: + return "No violations found." + + chars = get_chars(ascii_mode) + use_ascii = ascii_mode if ascii_mode is not None else ASCII_MODE + colored = not use_ascii + legend = format_legend(ascii_mode, colored) + plain_legend = format_legend(ascii_mode, colored=False) + line_width = 64 + separator = "-" * line_width + gap = line_width - len("Violations") - len(plain_legend) + header = f"Violations{' ' * max(gap, 1)}{legend}" + lines = ["", separator, header, separator] + + # Fixed column widths: " " (4) + icon (1) + " " (2) + line (4) + " " (2) = 13 prefix + # suffix: " " (2) + rule_id + line_col_w = 4 + prefix_w = 4 + 1 + 2 + line_col_w + 2 # 13 + suffix_gap = 2 + rule_col_w = 14 + issue_w = line_width - prefix_w - suffix_gap - rule_col_w + col_labels = f" {'':1} {'line':<{line_col_w}} {'issue':<{issue_w}} {'rule':>{rule_col_w}}" + + # Group by file + grouped: dict[str, list[dict[str, Any]]] = {} + for v in violations: + location = v.get("location", "") + file_path = location.rsplit(":", 1)[0] if ":" in location else location + if file_path not in grouped: + grouped[file_path] = [] + grouped[file_path].append(v) + + # Sort files by worst severity + def file_sort_key(item: tuple[str, list[dict[str, Any]]]) -> tuple[int, int]: + worst = min(_SEVERITY_ORDER.get(v.get("severity", "low"), 3) for v in item[1]) + return (worst, -len(item[1])) + + sorted_files = sorted(grouped.items(), key=file_sort_key) + severity_icons = get_severity_icons(chars, colored) + + for file_path, file_violations in sorted_files: + display_path = normalize_path(file_path) + + # Deduplicate by rule_id within file + seen_rules: set[str] = set() + unique_violations: list[dict[str, Any]] = [] + for v in file_violations: + rule_id = v.get("rule_id", "") + if rule_id not in seen_rules: + seen_rules.add(rule_id) + unique_violations.append(v) + + lines.append(_format_file_header(display_path, unique_violations)) + lines.append(separator) + lines.append(col_labels) + + sorted_violations = sorted( + unique_violations, + key=lambda v: (_SEVERITY_ORDER.get(v.get("severity", ""), 9), v.get("location", "")), + ) + + for v in sorted_violations: + sev = v.get("severity", "medium") + icon = severity_icons.get(sev, "?") + location = v.get("location", "") + raw_line = location.rsplit(":", 1)[-1] if ":" in location else "" + msg = v.get("message", "") + rule_id = v.get("rule_id", "") + + # Line number: show :N for line-specific, blank for file-wide (line 1) + line_str = f":{raw_line}" if raw_line and raw_line != "1" else "" + line_field = line_str.ljust(line_col_w) + + # Fit message within remaining space with word-boundary truncation + max_msg_len = line_width - prefix_w - suffix_gap - len(rule_id) + max_msg_len = max(max_msg_len, 10) + if len(msg) > max_msg_len: + truncated = msg[: max_msg_len - 3] + last_space = truncated.rfind(" ") + if last_space > (max_msg_len - 3) // 2: + truncated = truncated[:last_space] + msg = truncated.rstrip() + "..." + msg = msg.ljust(max_msg_len) + + lines.append( + render( + "cli_violation.txt", + icon=icon, + line=line_field, + rule_id=rule_id, + message=msg, + ) + ) + + lines.append(separator) + lines.append("") + + return "\n".join(lines) diff --git a/tests/unit/test_delta.py b/src/reporails_cli/_archived/tests/test_delta.py similarity index 100% rename from tests/unit/test_delta.py rename to src/reporails_cli/_archived/tests/test_delta.py diff --git a/tests/unit/test_templates.py b/src/reporails_cli/_archived/tests/test_templates.py similarity index 100% rename from tests/unit/test_templates.py rename to src/reporails_cli/_archived/tests/test_templates.py diff --git a/src/reporails_cli/bundled/__init__.py b/src/reporails_cli/bundled/__init__.py index c2e4086c..d6952f23 100644 --- a/src/reporails_cli/bundled/__init__.py +++ b/src/reporails_cli/bundled/__init__.py @@ -1,7 +1,9 @@ """Bundled configuration files for reporails CLI. This package contains CLI-owned configuration: -- capability-patterns.yml: Regex patterns for capability detection +- project-types.yml: Project type detection data for backbone discovery +- models/: Bundled ONNX embedding model (populated by + scripts/fetch_bundled_model.py, not committed to git) """ from __future__ import annotations @@ -14,6 +16,16 @@ def get_bundled_path() -> Path: return Path(__file__).parent -def get_capability_patterns_path() -> Path: - """Get path to bundled capability-patterns.yml.""" - return get_bundled_path() / "capability-patterns.yml" +def get_project_types_path() -> Path: + """Get path to bundled project-types.yml.""" + return get_bundled_path() / "project-types.yml" + + +def get_models_path() -> Path: + """Get path to bundled ML models directory. + + The directory is populated at build time by + ``scripts/fetch_bundled_model.py`` and shipped inside the wheel. + See ``core/mapper/onnx_embedder.py`` for the consumer. + """ + return get_bundled_path() / "models" diff --git a/src/reporails_cli/bundled/capability-patterns.yml b/src/reporails_cli/bundled/capability-patterns.yml deleted file mode 100644 index fbdc9e4d..00000000 --- a/src/reporails_cli/bundled/capability-patterns.yml +++ /dev/null @@ -1,63 +0,0 @@ -# Regex patterns for capability detection -# CLI-owned configuration for content analysis (Phase 2) - -rules: - - id: capability.has-sections - message: "Has markdown sections (H2+)" - languages: [generic] - severity: INFO - pattern-regex: "^##+ " - paths: - include: - - "*.md" - - "**/CLAUDE.md" - - "**/*.md" - - - id: capability.has-imports - message: "Has @imports or file references" - languages: [generic] - severity: INFO - pattern-either: - - pattern-regex: "@import" - - pattern-regex: "@docs/" - - pattern-regex: "@\\.shared/" - - pattern-regex: "Read `[^`]+`" - paths: - include: - - "*.md" - - "**/CLAUDE.md" - - "**/*.md" - - - id: capability.has-explicit-constraints - message: "Has explicit constraints (RFC 2119, structural, or marker)" - languages: [generic] - severity: INFO - pattern-either: - # Strong: RFC 2119 all-caps keywords - - pattern-regex: "\\bMUST\\b" - - pattern-regex: "\\bMUST NOT\\b" - - pattern-regex: "\\bNEVER\\b" - # Medium: list-item + constraint verb (structural context prevents false positives) - - pattern-regex: "^- .*Never" - - pattern-regex: "^- .*Avoid" - - pattern-regex: "^- .*Always" - - pattern-regex: "^- .*must" - # Medium: explicit negation - - pattern-regex: "Do NOT" - - pattern-regex: "do NOT" - # Medium: anti-pattern markers - - pattern-regex: "❌" - paths: - include: - - "**/AGENTS.md" - - "**/CLAUDE.md" - - - id: capability.has-path-scoped-rules - message: "Has path-scoped rules (paths: in frontmatter)" - languages: [generic] - severity: INFO - pattern-regex: "^paths:\\s*$" - paths: - include: - - ".claude/rules/*.md" - - ".ai/rules/*.md" diff --git a/src/reporails_cli/bundled/project-types.yml b/src/reporails_cli/bundled/project-types.yml new file mode 100644 index 00000000..a64b3083 --- /dev/null +++ b/src/reporails_cli/bundled/project-types.yml @@ -0,0 +1,509 @@ +# Project type detection data for backbone v3 discovery engine. +# Code reads this file; add entries here to support new languages/tools. +# +# Supported manifest formats: toml, json, yaml. +# Manifests with format "text" or "custom" are detected by file existence +# only — dependency_paths, version_path, etc. are not traversed. + +manifests: + # Priority order — first match wins for primary manifest. + # Parseable formats (toml, json, yaml) support dotpath traversal. + # Non-parseable formats (text, custom) only signal language via file existence. + + # --- Python --- + - file: pyproject.toml + format: toml + language: python + runtime: cpython + dependency_paths: ["project.dependencies"] + version_path: project.version + cli_entry_path: project.scripts + workspace_path: tool.uv.workspace + + # --- JavaScript / TypeScript --- + - file: package.json + format: json + language: javascript + typescript_marker: tsconfig.json + dependency_paths: ["dependencies", "devDependencies"] + version_path: version + cli_entry_path: bin + workspace_path: workspaces + runtime_lockfiles: + bun: [bun.lockb, bun.lock] + deno: [deno.lock] + node: [] # default when no lockfile matches + + # --- Rust --- + - file: Cargo.toml + format: toml + language: rust + dependency_paths: ["dependencies"] + version_path: package.version + workspace_path: workspace + + # --- Go --- + - file: go.mod + format: text + language: go + + # --- PHP --- + - file: composer.json + format: json + language: php + dependency_paths: ["require", "require-dev"] + version_path: version + cli_entry_path: bin + + # --- Dart / Flutter --- + - file: pubspec.yaml + format: yaml + language: dart + dependency_paths: ["dependencies", "dev_dependencies"] + version_path: version + workspace_path: workspace + + # --- Ruby --- + - file: Gemfile + format: custom + language: ruby + + # --- Java (Maven) --- + - file: pom.xml + format: xml + language: java + + # --- Java / Kotlin (Gradle) --- + - file: build.gradle.kts + format: custom + language: kotlin + + - file: build.gradle + format: custom + language: java + + # --- C# / .NET --- + - file: "*.sln" + format: custom + language: csharp + glob: true # match via glob, not exact filename + + - file: "*.csproj" + format: custom + language: csharp + glob: true + + # --- Elixir --- + - file: mix.exs + format: custom + language: elixir + + # --- Scala --- + - file: build.sbt + format: custom + language: scala + + # --- Swift --- + - file: Package.swift + format: custom + language: swift + + # --- Zig --- + - file: build.zig.zon + format: custom + language: zig + + # --- Lua --- + - file: "*.rockspec" + format: custom + language: lua + glob: true + + +frameworks: + # dep_contains → framework_name (first match wins per dependency list). + # Ordered: more specific matches first to avoid false positives. + + # Python + - match: fastapi + name: fastapi + - match: django + name: django + - match: flask + name: flask + - match: starlette + name: starlette + - match: litestar + name: litestar + - match: sanic + name: sanic + + # JavaScript / TypeScript + - match: "@angular/core" + name: angular + - match: "@nestjs/core" + name: nestjs + - match: next + name: nextjs + - match: nuxt + name: nuxtjs + - match: svelte + name: sveltekit + - match: remix + name: remix + - match: astro + name: astro + - match: express + name: express + - match: fastify + name: fastify + - match: hono + name: hono + - match: koa + name: koa + - match: "@hapi/hapi" + name: hapi + + # Ruby + - match: rails + name: rails + - match: sinatra + name: sinatra + - match: hanami + name: hanami + - match: grape + name: grape + + # PHP + - match: laravel + name: laravel + - match: symfony/framework-bundle + name: symfony + - match: slim/slim + name: slim + - match: codeigniter4/framework + name: codeigniter + + # Rust + - match: actix + name: actix + - match: axum + name: axum + - match: rocket + name: rocket + - match: warp + name: warp + - match: leptos + name: leptos + + # Go + - match: gin-gonic + name: gin + - match: fiber + name: fiber + - match: labstack/echo + name: echo + - match: go-chi/chi + name: chi + - match: gorilla/mux + name: gorilla + + # Java / Kotlin + - match: spring-boot + name: spring-boot + - match: quarkus + name: quarkus + - match: micronaut + name: micronaut + - match: ktor-server + name: ktor + + # Dart / Flutter + - match: flutter + name: flutter + - match: serverpod + name: serverpod + + # Elixir + - match: phoenix + name: phoenix + + # Scala + - match: http4s + name: http4s + - match: akka-http + name: akka-http + - match: zio-http + name: zio-http + + # Swift + - match: vapor + name: vapor + + +task_runners: + # Checked against pyproject.toml structure (toml-based runners only). + - name: poe + config_path: tool.poe.tasks + command_prefix: "poe" + test_aliases: [qa] + - name: taskipy + config_path: tool.taskipy.tasks + command_prefix: "task" + + +script_sources: + # Checked in order after task runners. + - file: package.json + scripts_path: scripts + command_prefix: "npm run" + - file: Makefile + type: makefile + command_prefix: "make" + - file: Justfile + type: makefile # same colon-target parsing + command_prefix: "just" + - file: Rakefile + type: makefile + command_prefix: "rake" + + +tool_commands: + # Inferred from tool config presence (last resort). + # Grouped by command key; probes checked in order, first match wins. + + test: + # Python + - config_paths: ["tool.pytest"] + config_file: pyproject.toml + command: pytest + - file: pytest.ini + command: pytest + # JavaScript + - file: vitest.config.ts + command: vitest + - file: vitest.config.js + command: vitest + - file: jest.config.js + command: jest + - file: jest.config.ts + command: jest + - file: jest.config.mjs + command: jest + - file: playwright.config.ts + command: "playwright test" + - file: playwright.config.js + command: "playwright test" + # Ruby + - file: .rspec + command: rspec + # PHP + - file: phpunit.xml + command: "./vendor/bin/phpunit" + - file: phpunit.xml.dist + command: "./vendor/bin/phpunit" + # Rust + - file: Cargo.toml + command: "cargo test" + # Go + - file: go.mod + command: "go test ./..." + # Elixir + - file: mix.exs + command: "mix test" + # Dart + - file: pubspec.yaml + command: "dart test" + # Zig + - file: build.zig + command: "zig build test" + + lint: + # Python + - file: ruff.toml + command: "ruff check" + - config_paths: ["tool.ruff"] + config_file: pyproject.toml + command: "ruff check" + - config_paths: ["tool.mypy"] + config_file: pyproject.toml + command: "mypy ." + - file: mypy.ini + command: "mypy ." + # JavaScript + - file: eslint.config.js + command: "eslint ." + - file: eslint.config.mjs + command: "eslint ." + - file: .eslintrc.json + command: "eslint ." + - file: biome.json + command: "biome check ." + - file: biome.jsonc + command: "biome check ." + # Rust + - file: clippy.toml + command: "cargo clippy" + - file: .clippy.toml + command: "cargo clippy" + # Go + - file: .golangci.yml + command: "golangci-lint run" + - file: .golangci.yaml + command: "golangci-lint run" + # Ruby + - file: .rubocop.yml + command: rubocop + # PHP + - file: phpstan.neon + command: "phpstan analyse" + - file: phpstan.neon.dist + command: "phpstan analyse" + # Elixir + - file: .credo.exs + command: "mix credo" + # Dart + - file: analysis_options.yaml + command: "dart analyze" + # Scala + - file: .scalafix.conf + command: "sbt scalafix" + # Lua + - file: .luacheckrc + command: "luacheck ." + # Swift + - file: .swiftlint.yml + command: swiftlint + + format: + # Python + - file: ruff.toml + command: "ruff format" + - config_paths: ["tool.ruff"] + config_file: pyproject.toml + command: "ruff format" + - config_paths: ["tool.black"] + config_file: pyproject.toml + command: "black ." + # JavaScript + - file: .prettierrc + command: "prettier --write ." + - file: .prettierrc.json + command: "prettier --write ." + - file: biome.json + command: "biome format --write ." + # Rust + - file: rustfmt.toml + command: "cargo fmt" + - file: .rustfmt.toml + command: "cargo fmt" + # Go + - file: go.mod + command: "gofmt -w ." + # Ruby + - file: .rubocop.yml + command: "rubocop -a" + # PHP + - file: .php-cs-fixer.php + command: "php-cs-fixer fix" + - file: .php-cs-fixer.dist.php + command: "php-cs-fixer fix" + # C# / .NET + - file: .editorconfig + command: "dotnet format" + # Elixir + - file: .formatter.exs + command: "mix format" + # Dart + - file: analysis_options.yaml + command: "dart format ." + # Scala + - file: .scalafmt.conf + command: "sbt scalafmtAll" + # Lua + - file: .stylua.toml + command: "stylua ." + # Swift + - file: .swiftformat + command: "swiftformat ." + + build: + # JavaScript / TypeScript + - file: vite.config.ts + command: "vite build" + - file: vite.config.js + command: "vite build" + - file: webpack.config.js + command: webpack + - file: tsconfig.json + command: tsc + # Rust + - file: Cargo.toml + command: "cargo build" + # Go + - file: go.mod + command: "go build ./..." + # Java / Kotlin + - file: pom.xml + command: "mvn package" + - file: build.gradle.kts + command: "gradle build" + - file: build.gradle + command: "gradle build" + # C# / .NET + - file: "*.sln" + command: "dotnet build" + # Zig + - file: build.zig + command: "zig build" + # Swift + - file: Package.swift + command: "swift build" + + +meta: + version_files: [VERSION, version.txt] + changelogs: [CHANGELOG.md, UNRELEASED.md, CHANGES.md, HISTORY.md, NEWS.md] + ci_patterns: + - path: .github/workflows + type: dir + display: ".github/workflows/" + - path: .gitlab-ci.yml + type: file + display: ".gitlab-ci.yml" + - path: .circleci + type: dir + display: ".circleci/" + - path: Jenkinsfile + type: file + display: Jenkinsfile + - path: azure-pipelines.yml + type: file + display: azure-pipelines.yml + - path: .travis.yml + type: file + display: ".travis.yml" + - path: bitbucket-pipelines.yml + type: file + display: bitbucket-pipelines.yml + - path: .buildkite + type: dir + display: ".buildkite/" + - path: .drone.yml + type: file + display: ".drone.yml" + - path: .woodpecker.yml + type: file + display: ".woodpecker.yml" + - path: .teamcity + type: dir + display: ".teamcity/" + + +paths: + # (key, [candidate_dirs]) — first existing dir wins + src: [src, lib, app] + tests: [tests, test, __tests__, spec] + docs: [docs, documentation, doc] + scripts: [scripts, bin] + config: [.config, config] diff --git a/src/reporails_cli/core/__init__.py b/src/reporails_cli/core/__init__.py index 1d65d48e..d2be8fb3 100644 --- a/src/reporails_cli/core/__init__.py +++ b/src/reporails_cli/core/__init__.py @@ -14,10 +14,6 @@ ValidationResult, Violation, ) -from reporails_cli.core.scorer import ( - calculate_score, - estimate_friction, -) __all__ = [ "Category", @@ -30,6 +26,4 @@ "Severity", "ValidationResult", "Violation", - "calculate_score", - "estimate_friction", ] diff --git a/src/reporails_cli/core/_torch_blocker.py b/src/reporails_cli/core/_torch_blocker.py new file mode 100644 index 00000000..02694698 --- /dev/null +++ b/src/reporails_cli/core/_torch_blocker.py @@ -0,0 +1,98 @@ +"""Block ``import torch`` at every CLI / daemon / MCP entry point. + +Why this exists +--------------- + +Importing ``torch`` on CPU typically takes **15-25 seconds** because of the +CUDA probe, the large C++ extension load, and the initialisation of many +submodules. The ``reporails`` pipeline does not need torch at runtime (the +ONNX Runtime embedder handles inference directly), but two transitive +import paths drag it in anyway: + +1. ``sentence_transformers`` → ``transformers`` → ``torch`` — the obvious + path. We removed ``sentence-transformers`` from dependencies entirely. + +2. ``spacy`` → ``thinc`` → ``thinc/compat.py`` → ``try: import torch`` as a + side-effect to set a ``has_torch = True`` flag that spaCy itself never + uses in our code path. This is the silent killer: **every** ``import + spacy`` was costing 15-25 s of torch import. + +Installing a ``sys.meta_path`` finder that raises ``ImportError`` for any +``torch`` / ``torch.*`` module forces thinc's ``try: import torch`` into +its ``except ImportError: has_torch = False`` branch. spaCy then loads +cleanly in **<1 s** and torch never enters ``sys.modules``. + +Install points +-------------- + +``install()`` must be called **before** any reporails module import that +transitively reaches ``thinc`` or ``sentence_transformers``. Concretely, +the very first non-stdlib import in each entry point: + +- ``src/reporails_cli/interfaces/cli/main.py`` — the ``ails`` CLI +- ``src/reporails_cli/interfaces/mcp/server.py`` — the MCP server +- ``src/reporails_cli/core/mapper/daemon.py`` — inside ``_daemon_main`` + (the forked daemon child re-enters Python-land and must reinstall) + +Calling ``install()`` twice is safe (idempotent). + +Guarantees +---------- + +After ``install()`` returns: + +- ``"torch" not in sys.modules`` (any stale torch reference is cleared) +- Any subsequent ``import torch`` raises ``ImportError`` with a clear + reporails-flavoured message +- spaCy / thinc / onnxruntime / tokenizers are unaffected — none of them + require torch at runtime on our code path +""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence +from importlib.machinery import ModuleSpec + + +class _TorchBlocker: + """``sys.meta_path`` finder that rejects any ``torch*`` import. + + Returning ``None`` from ``find_spec`` means "not my problem, try the + next finder." Raising ``ImportError`` means "this import cannot + succeed." We raise, because a silent skip would let a later finder + successfully import torch. + """ + + def find_spec( + self, + fullname: str, + path: Sequence[str] | None = None, # noqa: ARG002 (MetaPathFinder protocol) + target: object | None = None, # noqa: ARG002 (MetaPathFinder protocol) + ) -> ModuleSpec | None: + if fullname == "torch" or fullname.startswith("torch."): + raise ImportError( + f"torch import blocked by ails entry-point hook: {fullname}. " + "The reporails pipeline runs on ONNX Runtime; torch is not a " + "runtime dependency." + ) + return None + + +def install() -> None: + """Install the torch blocker and drop any stale torch references. + + Idempotent. Safe to call multiple times — only the first call + actually mutates ``sys.meta_path``. + """ + if not any(isinstance(f, _TorchBlocker) for f in sys.meta_path): + sys.meta_path.insert(0, _TorchBlocker()) + + # Defensive cleanup: if anything already imported torch before we + # got a chance to install the blocker (e.g. a test runner or a + # recently-cached module), drop those references so the next + # ``import torch`` triggers the blocker instead of reusing the + # cached module. + for name in list(sys.modules): + if name == "torch" or name.startswith("torch."): + del sys.modules[name] diff --git a/src/reporails_cli/core/agents.py b/src/reporails_cli/core/agents.py index 975ddcad..127409ce 100644 --- a/src/reporails_cli/core/agents.py +++ b/src/reporails_cli/core/agents.py @@ -1,18 +1,62 @@ -"""Agent definitions - coding agent agnostic discovery. - -Supports multiple AI coding assistants: -- Claude (Anthropic) -- Cursor -- Windsurf -- GitHub Copilot -- Aider -- Generic/custom +"""Agent definitions - coding agent agnostic discovery. # pylint: disable=too-many-lines + +File discovery is driven by agent config.yml (file_types section) bundled +in framework/rules/*/config.yml. The agent registry is built at first +access from these configs — no hardcoded agent list. """ from __future__ import annotations +import logging +import os from dataclasses import dataclass, field from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _extract_patterns(spec: dict[str, Any]) -> list[str]: + """Extract all file patterns from a file type spec. + + Supports both v0.3.0 (patterns at top level) and v0.5.0 (patterns + inside scopes). Returns a flat list of all patterns across all scopes. + """ + # v0.3.0: patterns at top level + patterns = spec.get("patterns", []) + if isinstance(patterns, str): + patterns = [patterns] + if patterns: + return list(patterns) + + # v0.5.0: patterns inside scopes + scopes = spec.get("scopes", {}) + if not isinstance(scopes, dict): + return [] + all_patterns: list[str] = [] + for scope_spec in scopes.values(): + if not isinstance(scope_spec, dict): + continue + scope_patterns = scope_spec.get("patterns", []) + if isinstance(scope_patterns, str): + scope_patterns = [scope_patterns] + all_patterns.extend(scope_patterns) + return all_patterns + + +def _extract_properties(spec: dict[str, Any]) -> dict[str, Any]: + """Extract properties from a file type spec. + + Supports both v0.3.0 (properties nested) and v0.5.0 (flattened). + """ + # v0.3.0: properties in a nested dict + props = spec.get("properties") + if isinstance(props, dict): + return props + + # v0.5.0: properties flattened at file type level + prop_keys = {"format", "scope", "cardinality", "lifecycle", "maintainer", "vcs", "loading", "precedence"} + return {k: v for k, v in spec.items() if k in prop_keys and v is not None} @dataclass(frozen=True) @@ -21,70 +65,103 @@ class AgentType: id: str name: str - instruction_patterns: tuple[str, ...] # Glob patterns for instruction files + instruction_patterns: tuple[str, ...] # Root-level marker patterns for fast detection config_patterns: tuple[str, ...] # Glob patterns for config files rule_patterns: tuple[str, ...] # Glob patterns for rule/snippet files directory_patterns: tuple[tuple[str, str], ...] = () # (label, dir_path) pairs -# Known coding agents and their conventions -KNOWN_AGENTS: dict[str, AgentType] = { - "claude": AgentType( - id="claude", - name="Claude (Anthropic)", - instruction_patterns=("CLAUDE.md",), - config_patterns=(".claude/settings.json", ".claude/mcp.json"), - rule_patterns=(".claude/rules/*.md", ".claude/skills/**/*.md"), - directory_patterns=( - ("rules", ".claude/rules"), - ("skills", ".claude/skills"), - ("tasks", ".claude/tasks"), - ), - ), - "cursor": AgentType( - id="cursor", - name="Cursor", - instruction_patterns=(".cursorrules", ".cursor/rules/*.md"), - config_patterns=(".cursor/settings.json",), - rule_patterns=(".cursor/rules/*.md",), - directory_patterns=(("rules", ".cursor/rules"),), - ), - "windsurf": AgentType( - id="windsurf", - name="Windsurf", - instruction_patterns=(".windsurfrules",), - config_patterns=(), - rule_patterns=(), - ), - "copilot": AgentType( - id="copilot", - name="GitHub Copilot", - instruction_patterns=(".github/copilot-instructions.md",), - config_patterns=(), - rule_patterns=(), - ), - "aider": AgentType( - id="aider", - name="Aider", - instruction_patterns=(".aider.conf.yml", "CONVENTIONS.md"), - config_patterns=(".aider.conf.yml",), - rule_patterns=(), - ), - "codex": AgentType( - id="codex", - name="OpenAI Codex", - instruction_patterns=("AGENTS.md",), - config_patterns=(), - rule_patterns=(), - ), - "generic": AgentType( - id="generic", - name="Generic AI Instructions", - instruction_patterns=("AGENTS.md", ".ai/instructions.md"), - config_patterns=(), - rule_patterns=(".ai/rules/*.md",), - ), -} +def _dir_prefix_from_glob(pattern: str) -> tuple[str, str] | None: + """Extract (label, dir_path) from a glob pattern like '.cursor/rules/**/*.mdc'.""" + parts = Path(pattern).parts + dir_parts = [] + for part in parts: + if "*" in part: + break + dir_parts.append(part) + if not dir_parts: + return None + return dir_parts[-1], str(Path(*dir_parts)) + + +def _parse_agent_config(data: dict[str, Any]) -> AgentType | None: + """Parse a single agent config.yml into an AgentType.""" + agent_id = data.get("agent") + agent_name: str = data.get("name", agent_id) or agent_id or "" + file_types = data.get("file_types") + if not agent_id or not file_types or not isinstance(file_types, dict): + return None + + instr_patterns: list[str] = [] + cfg_patterns: list[str] = [] + rule_pats: list[str] = [] + dir_patterns: list[tuple[str, str]] = [] + + for spec in file_types.values(): + if not isinstance(spec, dict): + continue + patterns = _extract_patterns(spec) + properties = _extract_properties(spec) + + bucket = _categorize_file_type(patterns, properties) + + if bucket == "instruction": + instr_patterns.extend( + root_p for p in patterns if (root_p := p.lstrip("*").lstrip("/")) and not root_p.startswith(("/", "~")) + ) + elif bucket == "rule": + rule_pats.extend(patterns) + for p in patterns: + pair = _dir_prefix_from_glob(p) + if pair and pair not in dir_patterns: + dir_patterns.append(pair) + elif bucket == "config": + cfg_patterns.extend(patterns) + instr_patterns.extend(p for p in patterns if not p.startswith(("/", "~", "*"))) + + return AgentType( + id=agent_id, + name=agent_name, + instruction_patterns=tuple(instr_patterns), + config_patterns=tuple(cfg_patterns), + rule_patterns=tuple(rule_pats), + directory_patterns=tuple(dir_patterns), + ) + + +def _build_agent_registry() -> dict[str, AgentType]: + """Build agent registry from bundled framework/rules/*/config.yml files.""" + from reporails_cli.core.bootstrap import get_rules_path + from reporails_cli.core.utils import load_yaml_file + + registry: dict[str, AgentType] = {} + rules_path = get_rules_path() + if not rules_path or not rules_path.is_dir(): + return registry + + for config_path in sorted(rules_path.glob("*/config.yml")): + try: + data = load_yaml_file(config_path) + except Exception: + continue + if not data or not isinstance(data, dict): + continue + agent_type = _parse_agent_config(data) + if agent_type: + registry[agent_type.id] = agent_type + + return registry + + +_agent_registry: dict[str, AgentType] | None = None + + +def get_known_agents() -> dict[str, AgentType]: + """Get the agent registry, building from config.yml on first access.""" + global _agent_registry + if _agent_registry is None: + _agent_registry = _build_agent_registry() + return _agent_registry @dataclass @@ -108,58 +185,451 @@ def clear_agent_cache() -> None: _agent_cache.clear() -def detect_agents(target: Path) -> list[DetectedAgent]: - """Detect which coding agents are configured in the target directory. +def _ci_glob(target: Path, pattern: str) -> list[Path]: + """Case-insensitive glob for root-level filenames, standard glob for nested.""" + parts = Path(pattern).parts + if len(parts) == 1 and "*" not in pattern: + # Root-level exact filename — match case-insensitively + lower = pattern.lower() + try: + return [p for p in target.iterdir() if p.name.lower() == lower and not p.is_dir()] + except OSError: + return [] + return list(target.glob(pattern)) - Scans for known file patterns. Results cached per target path. + +# ─── Config-driven discovery ──────────────────────────────────────────── + + +def _load_config_file_types( + agent_id: str, + rules_paths: list[Path] | None = None, +) -> dict[str, Any] | None: + """Load file_types section from agent config.yml. + + Searches rules_paths first, then falls back to the default config path. + Returns the file_types dict or None if not found. + """ + + from reporails_cli.core.bootstrap import get_agent_config_path + + candidates: list[Path] = [] + if rules_paths: + candidates.extend(rp / agent_id / "config.yml" for rp in rules_paths) + candidates.append(get_agent_config_path(agent_id)) + + for path in candidates: + if not path.exists(): + continue + try: + from reporails_cli.core.utils import load_yaml_file + + data = load_yaml_file(path) + if not data: + logger.warning("Agent config is empty: %s", path) + continue + ft = data.get("file_types") + if ft and isinstance(ft, dict): + return dict(ft) + except Exception: + logger.debug("Failed to load agent config %s", path, exc_info=True) + continue + return None + + +def _categorize_file_type(patterns: list[str], properties: dict[str, str]) -> str: + """Categorize a file_type entry as instruction/rule/config/skip. + + Uses file_type properties from config.yml: + - format: schema_validated → config + - scope: path_scoped → rule (scoped rule files) + - directory-only or system paths → skip + - everything else → instruction + """ + # Skip directory-only patterns (e.g., ".claude/memory/") + if all(p.endswith("/") for p in patterns): + return "skip" + # Skip absolute system paths (managed configs) + if all(p.startswith(("/", "C:")) for p in patterns): + return "skip" + # Schema-validated files → config bucket + if properties.get("format") == "schema_validated": + return "config" + # Path-scoped markdown → rule files bucket + if properties.get("scope") == "path_scoped": + return "rule" + # Everything else (main, skill, override) → instruction bucket + return "instruction" + + +def _is_excluded(path: Path, target: Path, exclude_dirs: frozenset[str]) -> bool: + """Check if any path component is in the exclusion set.""" + if not exclude_dirs: + return False + try: + rel = path.relative_to(target) + except ValueError: + return False + return bool(exclude_dirs & set(rel.parts)) + + +# Directories that never contain instruction files — skipped unconditionally. +# Project-specific exclusions come from .ails/config.yml exclude_dirs. +_ALWAYS_SKIP = frozenset({".git", "__pycache__", "node_modules"}) + + +def _walk_glob(root: Path, filename: str, exclude_dirs: frozenset[str]) -> list[Path]: + """Walk directory tree matching a filename, skipping excluded dirs. + + Much faster than Path.glob("**/name") because it prunes excluded + subtrees during traversal instead of filtering afterwards. + Uses os.scandir for efficient directory traversal. + """ + skip = exclude_dirs | _ALWAYS_SKIP + lower_name = filename.lower() + results: list[Path] = [] + stack = [str(root)] + while stack: + current = stack.pop() + try: + scanner = os.scandir(current) + except OSError: + continue + with scanner: + for entry in scanner: + name = entry.name + if name.lower() == lower_name: + try: + is_match = entry.is_file(follow_symlinks=True) + except OSError: + # Broken/circular symlink — include it so downstream + # code can report the error properly + is_match = entry.is_symlink() + if is_match: + results.append(Path(entry.path)) + elif entry.is_dir(follow_symlinks=False) and name not in skip: + stack.append(entry.path) + return results + + +def _glob_file_type_patterns( + target: Path, + patterns: list[str], + exclude_dirs: frozenset[str] = frozenset(), +) -> list[Path]: + """Glob file_type patterns against target directory. + + For recursive (**/) patterns with a literal filename (e.g., **/CLAUDE.md), + uses pruning walk to avoid traversing excluded directory trees. + Falls back to Path.glob for wildcard filenames (e.g., **/*.md). + + External paths (~/... and /absolute/...) are resolved outside the project + directory. These are part of the instruction surface (user-level config, + managed policies, auto-memory) even though they live outside the repo. + """ + found: list[Path] = [] + for pattern in patterns: + # Skip directory-only patterns + if pattern.endswith("/"): + continue + + # External paths: ~/... or /absolute/... or C:/... + if pattern.startswith("~") or pattern.startswith("/") or (len(pattern) > 1 and pattern[1] == ":"): + expanded = Path(pattern).expanduser() + if "*" in pattern: + # Glob external pattern (e.g., ~/.claude/projects/*/memory/MEMORY.md) + # For project-scoped patterns (containing */), replace * with + # the current project's directory key to avoid matching other projects. + expanded_str = str(expanded) + if "/projects/*/" in expanded_str: + project_key = str(target.resolve()).replace("/", "-") + expanded_str = expanded_str.replace("/projects/*/", f"/projects/{project_key}/") + import glob as _glob + + found.extend(Path(p) for p in _glob.glob(expanded_str) if Path(p).is_file()) + elif expanded.is_file(): + found.append(expanded) + continue + + parts = Path(pattern).parts + filename = parts[-1] if parts else "" + + # Use pruning walk only for recursive patterns with literal filenames + if "**" in pattern and "*" not in filename: + # Extract prefix before ** (e.g., ".claude/skills/**/SKILL.md" → ".claude/skills") + prefix_parts = [] + for p in parts: + if "**" in p: + break + prefix_parts.append(p) + walk_root = target / Path(*prefix_parts) if prefix_parts else target + if walk_root.is_dir(): + found.extend( + m + for m in _walk_glob(walk_root, filename, exclude_dirs) + if not _is_excluded(m, target, exclude_dirs) + ) + else: + # Non-recursive or wildcard filename — use standard glob + found.extend(m for m in _ci_glob(target, pattern) if not _is_excluded(m, target, exclude_dirs)) + return found + + +def _discover_from_config( + target: Path, + agent_id: str, + rules_paths: list[Path] | None = None, + extra_exclude_dirs: frozenset[str] = frozenset(), +) -> tuple[list[Path], list[Path], list[Path]] | None: + """Discover files using config.yml file_types. + + Returns (instruction_files, rule_files, config_files) or None if + no config.yml is available for this agent. + """ + file_types = _load_config_file_types(agent_id, rules_paths) + if file_types is None: + return None + + instruction_files: list[Path] = [] + rule_files: list[Path] = [] + config_files: list[Path] = [] + + for spec in file_types.values(): + if not isinstance(spec, dict): + continue + patterns = _extract_patterns(spec) + properties = _extract_properties(spec) + + bucket = _categorize_file_type(patterns, properties) + if bucket == "skip": + continue + + found = _glob_file_type_patterns(target, patterns, extra_exclude_dirs) + + if bucket == "instruction": + instruction_files.extend(found) + elif bucket == "rule": + rule_files.extend(found) + elif bucket == "config": + config_files.extend(found) + + return ( + sorted(set(instruction_files)), + sorted(set(rule_files)), + sorted(set(config_files)), + ) + + +# ─── Public API ───────────────────────────────────────────────────────── + + +def _load_project_exclude_dirs(target: Path) -> frozenset[str]: + """Load exclude_dirs from .ails/config.yml if it exists.""" + config_path = target / ".ails" / "config.yml" + if not config_path.exists(): + return frozenset() + try: + from reporails_cli.core.utils import load_yaml_file + + data = load_yaml_file(config_path) + if not data: + logger.warning("Project config is empty: %s", config_path) + return frozenset() + dirs = data.get("exclude_dirs", []) + if isinstance(dirs, list): + return frozenset(str(d) for d in dirs) + except Exception: + logger.warning("Failed to load project config %s", config_path, exc_info=True) + return frozenset() + + +def _agent_has_marker(target: Path, agent_type: AgentType) -> bool: + """Fast existence check — does this agent likely exist in the project? + + Checks for root-level instruction files or agent-specific directories. + Returns False only when we're certain the agent is absent (a few stat() calls). + Uses os.path.lexists to detect symlinks (even broken/circular ones). + + Root-level files are matched case-insensitively (claude.md == CLAUDE.md) + because repos in the wild use both conventions. + """ + # Build lowercase index of root files once per call (cheap — root only) + try: + root_lower = { + entry.name.lower(): entry.name + for entry in os.scandir(target) + if entry.is_file(follow_symlinks=False) or entry.is_symlink() + } + except OSError: + root_lower = {} + + for pattern in agent_type.instruction_patterns: + # Patterns with path separators or globs — check exact path + if "/" in pattern or "*" in pattern: + if os.path.lexists(target / pattern): + return True + else: + # Root-level file — case-insensitive match + if pattern.lower() in root_lower: + return True + return any((target / dir_path).is_dir() for _, dir_path in agent_type.directory_patterns) + + +def detect_agents( # pylint: disable=too-many-locals + target: Path, + rules_paths: list[Path] | None = None, +) -> list[DetectedAgent]: + """Detect coding agents in the target directory. + + Uses config.yml file_types from bundled framework for discovery. + Cached per target path. """ cache_key = str(target) cached = _agent_cache.get(cache_key) if cached is not None: return cached - detected: list[DetectedAgent] = [] + # Load project exclude_dirs early so discovery skips noise directories + project_excludes = _load_project_exclude_dirs(target) - for agent_type in KNOWN_AGENTS.values(): - instruction_files: list[Path] = [] - config_files: list[Path] = [] - rule_files: list[Path] = [] + detected: list[DetectedAgent] = [] - # Find instruction files - for pattern in agent_type.instruction_patterns: - instruction_files.extend(target.glob(pattern)) + for agent_id, agent_type in get_known_agents().items(): + # Fast marker check — skip agents with no footprint (avoids tree walks) + if not _agent_has_marker(target, agent_type): + continue - # Find config files - for pattern in agent_type.config_patterns: - config_files.extend(target.glob(pattern)) + # Config-driven discovery from bundled config.yml + config_result = _discover_from_config(target, agent_id, rules_paths, project_excludes) + if config_result is None: + continue - # Find rule files - for pattern in agent_type.rule_patterns: - rule_files.extend(target.glob(pattern)) + instruction_files, rule_files, config_files = config_result - # Detect directories + # Detect directories (derived from config.yml patterns) detected_dirs: dict[str, str] = {} for label, dir_path in agent_type.directory_patterns: full_path = target / dir_path if full_path.is_dir() and any(full_path.iterdir()): detected_dirs[label] = dir_path + "/" - # Only include if we found at least one instruction file - if instruction_files: + # Include if we found any scannable files (instruction or rule) + if instruction_files or rule_files: detected.append( DetectedAgent( agent_type=agent_type, - instruction_files=sorted(set(instruction_files)), - config_files=sorted(set(config_files)), - rule_files=sorted(set(rule_files)), + instruction_files=instruction_files, + config_files=config_files, + rule_files=rule_files, detected_directories=detected_dirs, ) ) + detected = _disambiguate_codex_generic(detected, target) + detected = _disambiguate_shared_files(detected) + _agent_cache[cache_key] = detected return detected +def detect_single_agent( + target: Path, + agent_id: str, + rules_paths: list[Path] | None = None, +) -> DetectedAgent | None: + """Detect a single agent by ID, bypassing disambiguation.""" + agent_type = get_known_agents().get(agent_id) + if not agent_type: + return None + + config_result = _discover_from_config(target, agent_id, rules_paths) + if config_result is None: + return None + instruction_files, rule_files, config_files = config_result + + if not instruction_files: + return None + return DetectedAgent( + agent_type=agent_type, + instruction_files=instruction_files, + config_files=config_files, + rule_files=rule_files, + ) + + +def _disambiguate_codex_generic(detected: list[DetectedAgent], target: Path) -> list[DetectedAgent]: + """Resolve codex/generic ambiguity when both match on AGENTS.md. + + Three tiers: (1) AGENTS.override.md in project, (2) .codex/config.toml + in project, (3) ~/.codex/config.toml + codex patterns in .gitignore. + When codex confirmed → drop generic. Otherwise → drop codex. + """ + codex = next((a for a in detected if a.agent_type.id == "codex"), None) + generic = next((a for a in detected if a.agent_type.id == "generic"), None) + if codex is None or generic is None: + return detected + + codex_confirmed = ( + any(f.name == "AGENTS.override.md" for f in codex.instruction_files) # Tier 1 + or bool(codex.config_files) # Tier 2 + or _codex_global_heuristic(target) # Tier 3 + ) + drop = "generic" if codex_confirmed else "codex" + return [a for a in detected if a.agent_type.id != drop] + + +def _codex_global_heuristic(target: Path) -> bool: + """Tier 3: ~/.codex/config.toml exists AND .gitignore mentions codex patterns.""" + if not (Path.home() / ".codex" / "config.toml").exists(): + return False + gitignore = target / ".gitignore" + if not gitignore.exists(): + return False + try: + content = gitignore.read_text(encoding="utf-8") + except OSError: + return False + return ".codex" in content or "AGENTS.override" in content + + +def _disambiguate_shared_files(detected: list[DetectedAgent]) -> list[DetectedAgent]: + """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 + 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). + """ + if len(detected) <= 1: + return detected + + # Count how many agents claim each file + from collections import Counter + + file_claim_count: Counter[Path] = Counter() + for a in detected: + for f in a.instruction_files: + file_claim_count[f] += 1 + + # Shared files = claimed by 2+ agents + shared = {f for f, count in file_claim_count.items() if count >= 2} + if not shared: + return detected + + result: list[DetectedAgent] = [] + for a in detected: + # Generic always stays — it's the catch-all for cross-agent files + if a.agent_type.id == "generic": + result.append(a) + continue + # Keep agent if it has at least one non-shared instruction file + has_distinctive = any(f not in shared for f in a.instruction_files) + # Also keep if it has agent-specific rule files or config files + if has_distinctive or a.rule_files or a.config_files: + result.append(a) + return result + + def _distinctive_agents(detected_agents: list[DetectedAgent]) -> list[DetectedAgent]: """Return agents that are genuinely distinctive (not just generic aliases). @@ -179,11 +649,7 @@ def _distinctive_agents(detected_agents: list[DetectedAgent]) -> list[DetectedAg def auto_detect_agent(detected_agents: list[DetectedAgent]) -> str: - """Pick agent when exactly one distinctive agent is detected. - - Returns agent ID if unambiguous, empty string if zero or multiple. - Conservative: better to fall back to generic than guess wrong. - """ + """Pick agent when exactly one distinctive agent is detected.""" distinctive = _distinctive_agents(detected_agents) if len(distinctive) == 1: return distinctive[0].agent_type.id @@ -191,11 +657,7 @@ def auto_detect_agent(detected_agents: list[DetectedAgent]) -> str: def resolve_agent(agent: str, detected_agents: list[DetectedAgent]) -> tuple[str, bool, bool]: - """Apply auto-detect step in the agent resolution chain. - - Called after CLI flag and config have been checked. - Returns (agent, assumed, mixed_signals). - """ + """Auto-detect step in agent resolution. Returns (agent, assumed, mixed).""" if agent: return agent, False, False auto = auto_detect_agent(detected_agents) @@ -224,12 +686,7 @@ def filter_agents_by_exclude_dirs( target: Path, exclude_dirs: list[str] | None, ) -> list[DetectedAgent]: - """Remove files in excluded directories from detected agents. - - Applied after detect_agents() so the cache is unaffected. Filters - instruction_files and rule_files; drops agents with no remaining - instruction files. - """ + """Remove files in excluded directories. Drops agents with no remaining files.""" if not exclude_dirs: return agents exclude_set = set(exclude_dirs) diff --git a/src/reporails_cli/core/api_client.py b/src/reporails_cli/core/api_client.py new file mode 100644 index 00000000..ab15e72d --- /dev/null +++ b/src/reporails_cli/core/api_client.py @@ -0,0 +1,525 @@ +"""API client for reporails diagnostic server. + +Sends text-stripped RulesetMap to the diagnostic API (default: api.reporails.com) +and deserializes the response. AILS_SERVER_URL overrides the default endpoint. + +Response dataclasses define the wire format — shared contract between CLI and API. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +from reporails_cli.core.mapper.mapper import RulesetMap + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────── +# RESPONSE DATACLASSES +# ────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Diagnostic: + """A single diagnostic from the server.""" + + file: str + line: int + severity: str # "error" | "warning" | "info" + rule: str # theory-native label + message: str + fix: str = "" + line_2: int = 0 # secondary line (conflict pairs) + + +@dataclass(frozen=True) +class Hint: + """An interaction diagnostic hint (free tier). + + Surfaces that a problem exists without line-level detail or fix suggestions. + The detection is the gift; the fix is the product. + Severity is preserved so the free tier can show honest error/warning counts. + """ + + file: str + diagnostic_type: str # "c(N)", "conflict", etc. + count: int + summary: str + severity: str = "warning" # worst severity of the gated diagnostics + error_count: int = 0 # how many of the gated diagnostics were errors + warning_count: int = 0 # how many were warnings + + +@dataclass(frozen=True) +class CrossFileFinding: + """A cross-file conflict or repetition.""" + + file_1: str + file_2: str + line_1: int + line_2: int + charge_1: int + charge_2: int + distance: float + finding_type: str # "conflict" | "repetition" + + +@dataclass(frozen=True) +class TargetScore: + """Per-instruction compliance breakdown (Pro tier).""" + + line: int + file_path: str + compliance_band: str # "HIGH" | "MODERATE" | "LOW" + impact_rank: int + n_eff: float + diagnostics: tuple[Diagnostic, ...] = () + + +@dataclass(frozen=True) +class ContextResult: + """Results for a loading context.""" + + context_name: str + files: tuple[str, ...] = () + compliance_band: str = "" + n_charged: int = 0 + n_atoms: int = 0 + per_target: tuple[TargetScore, ...] = () + + +@dataclass(frozen=True) +class QualityResult: + """Aggregate quality assessment.""" + + contexts: tuple[ContextResult, ...] = () + compliance_band: str = "" # aggregate + weakest_context: str | None = None + strongest_context: str | None = None + + +@dataclass(frozen=True) +class FileAnalysis: + """Per-file server analysis.""" + + file: str + diagnostics: tuple[Diagnostic, ...] = () + compliance_band: str = "" + stats: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RulesetReport: + """Full server analysis report.""" + + per_file: tuple[FileAnalysis, ...] = () + cross_file: tuple[CrossFileFinding, ...] = () + quality: QualityResult = field(default_factory=QualityResult) + stats: dict[str, int] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LintResult: + """Result from lint() — wraps report + hints for tier gating.""" + + report: RulesetReport + hints: tuple[Hint, ...] = () + tier: str = "free" + + +# ────────────────────────────────────────────────────────────────── +# CLIENT +# ────────────────────────────────────────────────────────────────── + + +def _tier_from_config() -> str: + """Read tier from global config (~/.reporails/config.yml).""" + try: + from reporails_cli.core.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) + return "" + + +def _api_key_from_credentials() -> str: + """Read API key from ~/.reporails/credentials.yml (set by `ails auth login`).""" + 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 "" + except (OSError, yaml.YAMLError) as exc: + logger.debug("Could not read credentials file: %s", exc) + return "" + + +class AilsClient: + """Equation client — HTTP to diagnostic API, local fallback. + + Sends a text-stripped RulesetMap to the diagnostic API (default: + api.reporails.com) via POST /diagnose. AILS_SERVER_URL overrides + the endpoint. Returns None when the server is unreachable. + """ + + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + tier: str | None = None, + 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.timeout = timeout + + def lint(self, ruleset_map: RulesetMap) -> LintResult | None: + """Run diagnostics on a ruleset map via the API. + + Returns LintResult with report + hints (tier-gated), or None on failure. + Requires network — diagnostics run server-side only. + """ + if not self.base_url: + logger.debug("No server URL configured — diagnostics unavailable offline") + return None + return self._lint_remote(ruleset_map) + + def _lint_remote(self, ruleset_map: RulesetMap) -> LintResult | None: + """POST text-stripped RulesetMap to diagnostic API. + + In production: CLI → Worker (/v1/diagnose, Bearer rr_*) → FastAPI. + In local dev (AILS_DEV_MODE): CLI → FastAPI directly (/diagnose, X-Tier header). + """ + try: + import httpx + except ImportError: + logger.debug("httpx not installed — cannot use remote diagnostics") + return None + + try: + payload = _strip_and_serialize(ruleset_map) + dev_mode = os.environ.get("AILS_DEV_MODE", "").lower() in ("true", "1") + + if dev_mode: + # Direct to FastAPI — no Worker, no Bearer auth + url = f"{self.base_url.rstrip('/')}/diagnose" + headers: dict[str, str] = {"X-Tier": self.tier} + else: + # Through Worker — Bearer auth, /v1/diagnose path + url = f"{self.base_url.rstrip('/')}/v1/diagnose" + headers = {} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + resp = httpx.post(url, json=payload, headers=headers, timeout=self.timeout) + resp.raise_for_status() + return _deserialize_lint_result(resp.json()) + except httpx.TimeoutException: + logger.debug("Remote diagnostic request timed out after %.1fs", self.timeout) + return None + except httpx.HTTPStatusError as exc: + logger.debug("Remote diagnostic returned HTTP %d: %s", exc.response.status_code, exc) + return None + except httpx.HTTPError as exc: + logger.debug("Remote diagnostic network error: %s", exc) + return None + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: + logger.debug("Remote diagnostic response malformed: %s", exc) + return None + + + +# ────────────────────────────────────────────────────────────────── +# WIRE FORMAT — serialization for API transport (v2, obfuscated) +# ────────────────────────────────────────────────────────────────── + +# Encoding tables — map semantic names to wire-format short codes. +# These exist ONLY in source code; they never appear in output. +_CHARGE_ENC = {"CONSTRAINT": 0, "DIRECTIVE": 1, "IMPERATIVE": 2, "NEUTRAL": 3, "AMBIGUOUS": 4} +_MODALITY_ENC = {"imperative": 0, "direct": 1, "absolute": 2, "hedged": 3, "none": 4} +_SPECIFICITY_ENC = {"named": 0, "abstract": 1} +_FORMAT_ENC = { + "prose": 0, + "heading": 1, + "list": 2, + "numbered": 3, + "table": 4, + "blockquote": 5, + "code_block": 6, + "data_block": 7, +} +_KIND_ENC = {"heading": 0, "excitation": 1} +_STYLE_ENC = {"backtick": 0, "italic": 1, "bold": 2, "none": 3} + + +def _strip_and_serialize(ruleset_map: RulesetMap) -> dict[str, Any]: # noqa: C901 + """Serialize RulesetMap to v2 wire format (obfuscated field names). + + Strips text, plain_text, rule, role, topics from atoms. + Replaces semantic field names with short codes and enum strings with integers. + Instruction content never leaves the client. + """ + import numpy as np + + # File index lookup — atoms reference files by position, not path string + file_idx = {f.path: i for i, f in enumerate(ruleset_map.files)} + + atoms_out: list[dict[str, Any]] = [] + for a in ruleset_map.atoms: + d: dict[str, Any] = { + "line": a.line, + "t": _KIND_ENC.get(a.kind, 1), + "c": _CHARGE_ENC.get(a.charge, 3), + "cv": a.charge_value, + "m": _MODALITY_ENC.get(a.modality, 4), + "s": _SPECIFICITY_ENC.get(a.specificity, 1), + "sc": a.scope_conditional, + "f": _FORMAT_ENC.get(a.format, 0), + "pi": a.position_index, + "tc": a.token_count, + "fi": file_idx.get(a.file_path, -1), + "k": a.cluster_id, + } + il = [ + *[{"term": tok, "s": 0} for tok in a.named_tokens], + *[{"term": tok, "s": 1} for tok in a.italic_tokens], + *[{"term": tok, "s": 2} for tok in a.bold_tokens], + *[{"term": tok, "s": 3} for tok in a.unformatted_code], + ] + if il: + d["il"] = il + if a.embedding_int8 is not None: + raw = bytes(v & 0xFF for v in a.embedding_int8) + d["e"] = base64.b64encode(raw).decode("ascii") + if a.heading_context: + d["hc"] = a.heading_context + if a.depth is not None: + d["d"] = a.depth + if a.ambiguous: + d["a"] = True + if a.embedded_charge_markers: + d["ecm"] = list(a.embedded_charge_markers) + atoms_out.append(d) + + files_out = [] + for f in ruleset_map.files: + fd: dict[str, Any] = { + "path": f.path, + "content_hash": f.content_hash, + "loading": f.loading, + "scope": f.scope, + "agent": f.agent, + } + if f.globs: + fd["globs"] = list(f.globs) + if f.description: + fd["description"] = f.description + if f.description_embedding: + raw = np.asarray(f.description_embedding, dtype=np.int8).tobytes() + fd["description_embedding_b64"] = base64.b64encode(raw).decode("ascii") + files_out.append(fd) + + clusters_out = [] + for c in ruleset_map.clusters: + cd: dict[str, Any] = { + "id": c.id, + "n_atoms": c.n_atoms, + "n_charged": c.n_charged, + "n_neutral": c.n_neutral, + } + if c.centroid: + raw = np.asarray(c.centroid, dtype=np.float32).tobytes() + cd["centroid_b64"] = base64.b64encode(raw).decode("ascii") + clusters_out.append(cd) + + return { + "schema_version": "2", + "embedding_model": ruleset_map.embedding_model, + "generated_at": ruleset_map.generated_at, + "files": files_out, + "atoms": atoms_out, + "clusters": clusters_out, + "summary": { + "n_atoms": ruleset_map.summary.n_atoms, + "n_charged": ruleset_map.summary.n_charged, + "n_neutral": ruleset_map.summary.n_neutral, + "n_topics": ruleset_map.summary.n_topics, + "n_topics_charged": ruleset_map.summary.n_topics_charged, + }, + } + + +def _deserialize_per_file(report_data: dict[str, Any]) -> tuple[FileAnalysis, ...]: + """Deserialize the per_file section of the API response.""" + items: list[FileAnalysis] = [] + for fa in report_data.get("per_file", []): + fa_file = fa.get("file") + if fa_file is None: + logger.warning("Skipping per_file entry with missing 'file' key") + continue + diagnostics: list[Diagnostic] = [] + for d in fa.get("diagnostics", []): + d_line = d.get("line") + d_severity = d.get("severity") + d_rule = d.get("rule") + d_message = d.get("message") + if d_line is None or d_severity is None or d_rule is None or d_message is None: + logger.warning( + "Skipping diagnostic with missing required field in file %s: %s", + fa_file, + d, + ) + continue + diagnostics.append( + Diagnostic( + file=d.get("file", fa_file), + line=d_line, + severity=d_severity, + rule=d_rule, + message=d_message, + fix=d.get("fix", ""), + line_2=d.get("line_2", 0), + ) + ) + items.append( + FileAnalysis( + file=fa_file, + diagnostics=tuple(diagnostics), + compliance_band=fa.get("compliance_band", ""), + stats=fa.get("stats", {}), + ) + ) + return tuple(items) + + +def _deserialize_cross_file(report_data: dict[str, Any]) -> tuple[CrossFileFinding, ...]: + """Deserialize the cross_file section of the API response.""" + items: list[CrossFileFinding] = [] + _required_keys = ("file_1", "file_2", "line_1", "line_2", "charge_1", "charge_2", "distance", "finding_type") + for cf in report_data.get("cross_file", []): + vals = {k: cf.get(k) for k in _required_keys} + if any(v is None for v in vals.values()): + logger.warning("Skipping cross_file entry with missing required field: %s", cf) + continue + items.append( + CrossFileFinding( + file_1=vals["file_1"], + file_2=vals["file_2"], + line_1=vals["line_1"], + line_2=vals["line_2"], + charge_1=vals["charge_1"], + charge_2=vals["charge_2"], + distance=vals["distance"], + finding_type=vals["finding_type"], + ) + ) + return tuple(items) + + +def _deserialize_quality(report_data: dict[str, Any]) -> QualityResult: + """Deserialize the quality section of the API response.""" + q_data = report_data.get("quality", {}) + context_items: list[ContextResult] = [] + for ctx in q_data.get("contexts", []): + ctx_name = ctx.get("context_name") + if ctx_name is None: + logger.warning("Skipping context entry with missing 'context_name'") + continue + target_items: list[TargetScore] = [] + for ts in ctx.get("per_target", []): + ts_line = ts.get("line") + ts_path = ts.get("file_path") + ts_band = ts.get("compliance_band") + ts_rank = ts.get("impact_rank") + ts_neff = ts.get("n_eff") + if any(v is None for v in (ts_line, ts_path, ts_band, ts_rank, ts_neff)): + logger.warning( + "Skipping per_target entry with missing required field in context %s: %s", + ctx_name, + ts, + ) + continue + target_items.append( + TargetScore( + line=ts_line, + file_path=ts_path, + compliance_band=ts_band, + impact_rank=ts_rank, + n_eff=ts_neff, + ) + ) + context_items.append( + ContextResult( + context_name=ctx_name, + files=tuple(ctx.get("files", [])), + compliance_band=ctx.get("compliance_band", ""), + n_charged=ctx.get("n_charged", 0), + n_atoms=ctx.get("n_atoms", 0), + per_target=tuple(target_items), + ) + ) + return QualityResult( + contexts=tuple(context_items), + compliance_band=q_data.get("compliance_band", ""), + weakest_context=q_data.get("weakest_context"), + strongest_context=q_data.get("strongest_context"), + ) + + +def _deserialize_hints(data: dict[str, Any]) -> tuple[Hint, ...]: + """Deserialize the hints section of the API response.""" + items: list[Hint] = [] + for h in data.get("hints", []): + h_file = h.get("file") + h_type = h.get("diagnostic_type") + h_count = h.get("count") + h_summary = h.get("summary") + if any(v is None for v in (h_file, h_type, h_count, h_summary)): + logger.warning("Skipping hint entry with missing required field: %s", h) + continue + items.append( + Hint( + file=h_file, + diagnostic_type=h_type, + count=h_count, + summary=h_summary, + severity=h.get("severity", "warning"), + error_count=h.get("error_count", 0), + warning_count=h.get("warning_count", 0), + ) + ) + return tuple(items) + + +def _deserialize_lint_result(data: dict[str, Any]) -> LintResult: + """Deserialize API JSON response to 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()) + + report = RulesetReport( + per_file=_deserialize_per_file(report_data), + cross_file=_deserialize_cross_file(report_data), + quality=_deserialize_quality(report_data), + stats=report_data.get("stats", {}), + ) + + return LintResult(report=report, hints=_deserialize_hints(data), tier=data.get("tier", "free")) diff --git a/src/reporails_cli/core/applicability.py b/src/reporails_cli/core/applicability.py index 88b1f095..753c6b23 100644 --- a/src/reporails_cli/core/applicability.py +++ b/src/reporails_cli/core/applicability.py @@ -1,53 +1,23 @@ """Feature detection (filesystem) and rule applicability. -Phase 1 of capability detection - scans filesystem for features. -Phase 2 (content detection) is in capability.py. +Filesystem detection populates DetectedFeatures for capability-gate +level detection and symlink resolution. Rule applicability is determined +by target file type existence, not level comparison. """ from __future__ import annotations import errno import logging +import re from pathlib import Path -import yaml - from reporails_cli.core.agents import DetectedAgent, get_all_instruction_files -from reporails_cli.core.models import DetectedFeatures, Level, Rule - -# Ordered levels for comparison (index = ordinal) -_LEVEL_ORDER = [Level.L0, Level.L1, Level.L2, Level.L3, Level.L4, Level.L5, Level.L6] +from reporails_cli.core.models import DetectedFeatures, Rule logger = logging.getLogger(__name__) -def _count_components(backbone_data: dict) -> int: # type: ignore[type-arg] - """Count distinct navigable areas declared in backbone.""" - version = backbone_data.get("version", 1) - if version == 1: - return len(backbone_data.get("components", {})) - - # v2+: collect distinct top-level directories from all path values - top_dirs: set[str] = set() - _collect_paths(backbone_data, top_dirs) - return len(top_dirs) - - -def _collect_paths(data: object, top_dirs: set[str]) -> None: - """Recursively extract top-level directories from backbone values.""" - if isinstance(data, dict): - for value in data.values(): - _collect_paths(value, top_dirs) - elif isinstance(data, list): - for item in data: - _collect_paths(item, top_dirs) - elif isinstance(data, str) and "/" in data and ":" not in data and "@" not in data: - # Extract top-level directory from path-like value (skip URLs) - top = data.lstrip(".").lstrip("/").split("/")[0] - if top: - top_dirs.add(top) - - def resolve_symlinked_files(target: Path, agents: list[DetectedAgent] | None = None) -> list[Path]: """Find instruction files that are symlinks pointing outside the scan directory.""" resolved: list[Path] = [] @@ -78,35 +48,14 @@ def resolve_symlinked_files(target: Path, agents: list[DetectedAgent] | None = N return resolved -def _detect_l6_features(features: DetectedFeatures, target: Path) -> None: - """Detect L6 features: skills directory, MCP config, memory directory.""" - # Check for skills directory (L6: dynamic_context) - skills_dirs = [".claude/skills", ".cursor/skills"] - for dirname in skills_dirs: - d = target / dirname - if d.exists() and any(d.iterdir()): - features.has_skills_dir = True - break - - # Check for MCP configuration (L6: extensibility) - mcp_files = [".mcp.json", ".claude/mcp.json"] - for fname in mcp_files: - if (target / fname).exists(): - features.has_mcp_config = True - break +_CONSTRAINT_RE = re.compile(r"\b(MUST|NEVER|ALWAYS)\b") +_SIZE_THRESHOLD = 500 # lines -def _detect_hierarchy_features( - features: DetectedFeatures, - target: Path, - agents: list[DetectedAgent] | None = None, -) -> None: - """Detect hierarchical structure from instruction files across directory levels.""" +def _has_hierarchy(target: Path, agents: list[DetectedAgent] | None) -> bool: + """Check if any agent has both root-level and nested instruction files.""" if agents is None: - from reporails_cli.core.agents import detect_agents - - agents = detect_agents(target) - + return False for detected in agents: names_at_root: set[str] = set() has_nested = False @@ -116,21 +65,22 @@ def _detect_hierarchy_features( else: has_nested = True if names_at_root and has_nested: - features.has_hierarchical_structure = True - break + return True + return False def detect_features_filesystem(target: Path, agents: list[DetectedAgent] | None = None) -> DetectedFeatures: - """Detect project features from file structure. + """Detect project features from file structure and content. - Phase 1 of capability detection - filesystem only, no content analysis. + Populates DetectedFeatures for capability-gate level detection, + display summaries, and symlink resolution. Args: target: Project root path agents: Pre-detected agents (avoids redundant filesystem scan) Returns: - DetectedFeatures with filesystem-based indicators + DetectedFeatures with all capability fields populated """ features = DetectedFeatures() @@ -154,8 +104,10 @@ def detect_features_filesystem(target: Path, agents: list[DetectedAgent] | None break # Check for backbone.yml - backbone_path = target / ".reporails" / "backbone.yml" + backbone_path = target / ".ails" / "backbone.yml" features.has_backbone = backbone_path.exists() + if features.has_backbone: + features.component_count = _count_components(backbone_path) # Count instruction files (all agents, not just CLAUDE.md) all_instruction_files = get_all_instruction_files(target, agents=agents) @@ -166,19 +118,7 @@ def detect_features_filesystem(target: Path, agents: list[DetectedAgent] | None features.has_instruction_file = True # Check for hierarchical structure - _detect_hierarchy_features(features, target, agents=agents) - - # Check for @imports and size control (simple checks, full in Phase 2) - if features.has_claude_md: - try: - content = root_claude.read_text(encoding="utf-8") - features.has_imports = "@" in content - features.is_size_controlled = content.count("\n") < 500 - except (OSError, UnicodeDecodeError): - pass - elif features.has_instruction_file: - # Non-Claude instruction file — assume size controlled - features.is_size_controlled = True + features.has_hierarchical_structure = _has_hierarchy(target, agents) # Check for shared files shared_patterns = [".shared", "shared", ".ai/shared"] @@ -187,17 +127,18 @@ def detect_features_filesystem(target: Path, agents: list[DetectedAgent] | None features.has_shared_files = True break - # L6 features: skills, MCP config - _detect_l6_features(features, target) + # L2 capabilities — content analysis on root instruction file + root_file = _find_root_instruction(target, all_instruction_files) + if root_file is not None: + _detect_content_features(root_file, features) - # Count components from backbone if present - if features.has_backbone: - try: - backbone_content = backbone_path.read_text(encoding="utf-8") - backbone_data = yaml.safe_load(backbone_content) - features.component_count = _count_components(backbone_data) - except (yaml.YAMLError, OSError): - pass + # L4 capabilities — path-scoped rules + features.has_path_scoped_rules = features.is_abstracted + + # L6 capabilities — skills, MCP, memory + features.has_skills_dir = _dir_has_content(target, [".claude/skills", ".cursor/skills", ".agents/skills"]) + features.has_mcp_config = (target / ".mcp.json").exists() or (target / ".claude" / "mcp.json").exists() + features.has_memory_dir = _dir_has_content(target, [".claude/memory", ".claude/projects"]) # Resolve symlinked instruction files (for regex engine extra targets) features.resolved_symlinks = resolve_symlinked_files(target, agents=agents) @@ -205,32 +146,85 @@ def detect_features_filesystem(target: Path, agents: list[DetectedAgent] | None return features +def _find_root_instruction(target: Path, instruction_files: list[Path]) -> Path | None: + """Find the root-level instruction file for content analysis.""" + for f in instruction_files: + if f.parent == target: + return f + return None + + +def _detect_content_features(root_file: Path, features: DetectedFeatures) -> None: + """Detect content-based features from the root instruction file.""" + try: + content = root_file.read_text(encoding="utf-8", errors="replace") + except OSError: + return + + line_count = content.count("\n") + features.is_size_controlled = line_count < _SIZE_THRESHOLD + features.has_explicit_constraints = bool(_CONSTRAINT_RE.search(content)) + features.has_imports = "@import" in content or "@ " in content + + +def _dir_has_content(target: Path, dirs: list[str]) -> bool: + """Check if any of the directories exist and have content.""" + for dirname in dirs: + d = target / dirname + if d.exists(): + try: + if any(d.iterdir()): + return True + except OSError: + pass + return False + + +def _count_components(backbone_path: Path) -> int: + """Count components declared in backbone.yml.""" + try: + from reporails_cli.core.utils import load_yaml_file + + data = load_yaml_file(backbone_path) + if not data: + return 0 + components = data.get("components", {}) + return len(components) if isinstance(components, dict) else 0 + except Exception: + return 0 + + def get_applicable_rules( rules: dict[str, Rule], - level: Level, + present_types: set[str], ) -> dict[str, Rule]: - """Filter rules to those applicable at the given level. + """Filter rules to those whose target file type exists. + + A rule fires when: + - rule.match.type is in present_types, OR + - rule.match is None / rule.match.type is None (wildcard — fires if any type present) - A rule applies when rule.level ≤ project_level (compared by ordinal). If rule A supersedes rule B, and both are applicable, drop B. Args: rules: Dict of all rules - level: Detected capability level + present_types: Set of file type names present in the project Returns: Dict of applicable rules """ - project_ordinal = _LEVEL_ORDER.index(level) + if not present_types: + return {} - # Filter by level ordinal applicable: dict[str, Rule] = {} for rule_id, rule in rules.items(): - try: - rule_level = Level(rule.level) - except ValueError: - continue - if _LEVEL_ORDER.index(rule_level) <= project_ordinal: + if rule.match is None or rule.match.type is None: + # Wildcard — fires if any type present + applicable[rule_id] = rule + elif isinstance(rule.match.type, list): + if any(t in present_types for t in rule.match.type): + applicable[rule_id] = rule + elif rule.match.type in present_types: applicable[rule_id] = rule # Handle supersession: if rule A supersedes rule B, drop B @@ -243,32 +237,3 @@ def get_applicable_rules( applicable = {k: v for k, v in applicable.items() if k not in superseded_ids} return applicable - - -def get_feature_summary(features: DetectedFeatures) -> str: - """Generate human-readable summary of detected features.""" - parts = [] - - # File count - if features.instruction_file_count == 0: - parts.append("No instruction files") - elif features.instruction_file_count == 1: - parts.append("1 instruction file") - else: - parts.append(f"{features.instruction_file_count} instruction files") - - # Features present - 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/bootstrap.py b/src/reporails_cli/core/bootstrap.py index e3a30f37..ee0da806 100644 --- a/src/reporails_cli/core/bootstrap.py +++ b/src/reporails_cli/core/bootstrap.py @@ -6,17 +6,15 @@ from pathlib import Path from typing import TYPE_CHECKING -import yaml +from reporails_cli.core.bundled import get_bundled_package_root, get_bundled_rules_path logger = logging.getLogger(__name__) if TYPE_CHECKING: - from reporails_cli.core.models import AgentConfig, GlobalConfig, ProjectConfig + from reporails_cli.core.models import AgentConfig, FileTypeDeclaration, GlobalConfig, ProjectConfig # Constants REPORAILS_HOME = Path.home() / ".reporails" -FRAMEWORK_REPO = "reporails/reporails-rules" -FRAMEWORK_RELEASE_URL = f"https://github.com/{FRAMEWORK_REPO}/releases/download" def get_reporails_home() -> Path: @@ -27,21 +25,36 @@ def get_reporails_home() -> Path: def get_rules_path() -> Path: """Get path to rules directory. - Returns local override from global config if set, otherwise prefers - local ./checks/ directory if it has .yml files (development mode), - otherwise uses ~/.reporails/rules/ (installed mode). + Resolution: config override → bundled (package default). """ - # Check for global config override (development mode) config = get_global_config() if config.framework_path and config.framework_path.is_dir(): + rules_sub = config.framework_path / "rules" + if rules_sub.is_dir(): + return rules_sub return config.framework_path - # Check for local checks directory (legacy development mode) - local_checks = Path.cwd() / "checks" - if local_checks.exists() and any(local_checks.rglob("*.yml")): - return local_checks + bundled = get_bundled_rules_path() + if bundled is not None: + return bundled + + # Unreachable when installed correctly, but return a sane default + return get_reporails_home() / "rules" + + +def get_framework_root() -> Path: + """Get the root where schemas/, registry/, sources.yml live. + + Resolution: config override → bundled package root. + """ + config = get_global_config() + if config.framework_path and config.framework_path.is_dir(): + return config.framework_path + + bundled_root = get_bundled_package_root() + if bundled_root is not None: + return bundled_root - # Fall back to global directory return get_reporails_home() / "rules" @@ -51,87 +64,50 @@ def get_core_rules_path() -> Path: def get_agent_rules_path(agent: str) -> Path: - """Get path to agent-specific rules (~/.reporails/rules/agents/{agent}/rules/).""" - return get_rules_path() / "agents" / agent / "rules" + """Get path to agent-specific rules (~/.reporails/rules/{agent}/).""" + return get_rules_path() / agent def get_agent_config_path(agent: str) -> Path: - """Get path to agent config file (~/.reporails/rules/agents/{agent}/config.yml).""" - return get_rules_path() / "agents" / agent / "config.yml" + """Get path to agent config file. + + The generic agent's config lives in core/ (not a separate directory). + """ + dir_name = "core" if agent == "generic" else agent + return get_rules_path() / dir_name / "config.yml" def get_agent_config(agent: str) -> AgentConfig: """Load agent config (excludes + overrides) from framework. - Args: - agent: Agent identifier (e.g., "claude") - - Returns: - AgentConfig with excludes and overrides, or defaults if missing/malformed + Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.models import AgentConfig + from reporails_cli.core.config import get_agent_config as _get_agent_config - config_path = get_agent_config_path(agent) - if not config_path.exists(): - return AgentConfig() + return _get_agent_config(agent) - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - return AgentConfig( - agent=data.get("agent", ""), - prefix=data.get("prefix", ""), - name=data.get("name", ""), - core=data.get("core", False), - excludes=data.get("excludes", []), - overrides=data.get("overrides", {}), - ) - except (yaml.YAMLError, OSError) as exc: - logger.warning("Failed to parse agent config %s: %s", config_path, exc) - return AgentConfig() - - -def get_agent_vars( + +def get_agent_file_types( agent: str = "claude", rules_paths: list[Path] | None = None, -) -> dict[str, str | list[str]]: - """Load template variables from agent config. +) -> list[FileTypeDeclaration]: + """Load file type declarations from agent config. Args: agent: Agent identifier (default: claude) - rules_paths: Optional rules directories to search first (before default path) + rules_paths: Optional rules directories to search first Returns: - Dict of template variables from the agent's config.yml vars section + List of FileTypeDeclaration from the agent's config.yml file_types section """ - # Build candidate config paths: explicit rules_paths first, then default - candidates: list[Path] = [] - if rules_paths: - candidates.extend(rules_dir / "agents" / agent / "config.yml" for rules_dir in rules_paths) - candidates.append(get_agent_config_path(agent)) - - for config_path in candidates: - if not config_path.exists(): - continue - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - vars_data = data.get("vars", {}) - # Ensure all values are str or list[str] - result: dict[str, str | list[str]] = {} - for key, value in vars_data.items(): - if isinstance(value, list): - result[key] = [str(v) for v in value] - else: - result[key] = str(value) - return result - except (yaml.YAMLError, OSError) as exc: - logger.warning("Failed to parse agent vars %s: %s", config_path, exc) - continue - return {} + from reporails_cli.core.classification import load_file_types + + return load_file_types(agent, rules_paths) def get_schemas_path() -> Path: - """Get path to rule schemas (~/.reporails/rules/schemas/).""" - return get_rules_path() / "schemas" + """Get path to rule schemas (schemas/ under framework root).""" + return get_framework_root() / "schemas" def get_global_packages_path() -> Path: @@ -164,84 +140,27 @@ def get_global_config_path() -> Path: def get_global_config() -> GlobalConfig: """Load global configuration from ~/.reporails/config.yml. - Returns default config if file doesn't exist. + Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.models import GlobalConfig - - config_path = get_global_config_path() - if not config_path.exists(): - return GlobalConfig() + from reporails_cli.core.config import get_global_config as _get_global_config - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - framework_path = data.get("framework_path") - recommended_path = data.get("recommended_path") - return GlobalConfig( - framework_path=Path(framework_path) if framework_path else None, - recommended_path=Path(recommended_path) if recommended_path else None, - auto_update_check=data.get("auto_update_check", True), - default_agent=data.get("default_agent", ""), - recommended=data.get("recommended", True), - ) - except (yaml.YAMLError, OSError) as exc: - logger.warning("Failed to parse global config %s: %s", config_path, exc) - return GlobalConfig() + return _get_global_config() def get_project_config(project_root: Path) -> ProjectConfig: - """Load project configuration from .reporails/config.yml. + """Load project configuration from .ails/config.yml. - Returns default config if file doesn't exist or is malformed. - - Args: - project_root: Root directory of the project - - Returns: - ProjectConfig with loaded or default values + Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.models import ProjectConfig - - config_path = project_root / ".reporails" / "config.yml" - if not config_path.exists(): - global_cfg = get_global_config() - return ProjectConfig( - default_agent=global_cfg.default_agent, - recommended=global_cfg.recommended, - ) + from reporails_cli.core.config import get_project_config as _get_project_config - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - has_recommended = "recommended" in data - config = ProjectConfig( - framework_version=data.get("framework_version"), - packages=data.get("packages", []), - disabled_rules=data.get("disabled_rules", []), - overrides=data.get("overrides", {}), - experimental=data.get("experimental", False), - recommended=data.get("recommended", True), - exclude_dirs=data.get("exclude_dirs", []), - default_agent=data.get("default_agent", ""), - ) - # Apply global defaults where project doesn't override - global_cfg = get_global_config() - if not config.default_agent: - config.default_agent = global_cfg.default_agent - if not has_recommended: - config.recommended = global_cfg.recommended - return config - except (yaml.YAMLError, OSError) as exc: - logger.warning("Failed to parse project config %s: %s", config_path, exc) - global_cfg = get_global_config() - return ProjectConfig( - default_agent=global_cfg.default_agent, - recommended=global_cfg.recommended, - ) + return _get_project_config(project_root) def get_package_paths(project_root: Path, packages: list[str]) -> list[Path]: """Resolve package names to directories. - Checks project-local (.reporails/packages/) first, then falls back + Checks project-local (.ails/packages/) first, then falls back to global (~/.reporails/packages/). Project-local overrides global for the same package name. Silently skips packages not found in either. @@ -256,7 +175,7 @@ def get_package_paths(project_root: Path, packages: list[str]) -> list[Path]: paths: list[Path] = [] for name in packages: # Project-local takes priority - local_dir = project_root / ".reporails" / "packages" / name + local_dir = project_root / ".ails" / "packages" / name if local_dir.is_dir(): paths.append(local_dir) continue @@ -290,6 +209,6 @@ def get_installed_recommended_version() -> str | None: def is_initialized() -> bool: - """Check if reporails has been initialized (rules framework downloaded).""" + """Check if rules are available (installed, config override, or bundled).""" rules_path = get_rules_path() return rules_path.is_dir() and (rules_path / "core").is_dir() diff --git a/src/reporails_cli/core/bundled.py b/src/reporails_cli/core/bundled.py new file mode 100644 index 00000000..55a4610c --- /dev/null +++ b/src/reporails_cli/core/bundled.py @@ -0,0 +1,59 @@ +"""Bundled rules resolution for zero-install mode. + +Resolves bundled rules from two locations: +1. Installed wheel — rules/ is inside the reporails_cli package directory +2. Development mode — framework/rules/ is at the repository root (two levels above src/reporails_cli/) +""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +# Cache to avoid repeated filesystem probes +_cached_rules: Path | None = None +_cache_checked = False + + +def _resolve_bundled_rules() -> Path | None: + """Locate bundled rules/ directory.""" + global _cached_rules, _cache_checked + if _cache_checked: + return _cached_rules + + _cache_checked = True + + try: + pkg = importlib.resources.files("reporails_cli") + with importlib.resources.as_file(pkg) as pkg_path: + # Installed wheel: rules/ lives inside the package + candidate = pkg_path / "rules" + if candidate.is_dir() and (candidate / "core").is_dir(): + _cached_rules = candidate + return _cached_rules + + # Development mode: src/reporails_cli/ → repo root is ../../ + repo_root = pkg_path.parent.parent + candidate = repo_root / "framework" / "rules" + if candidate.is_dir() and (candidate / "core").is_dir(): + _cached_rules = candidate + return _cached_rules + except (TypeError, FileNotFoundError, OSError): + pass + + return None + + +def get_bundled_rules_path() -> Path | None: + """Return path to bundled rules/ directory.""" + return _resolve_bundled_rules() + + +def get_bundled_package_root() -> Path | None: + """Return the root where rules/, schemas/, registry/ live side by side. + + In an installed wheel this is the package directory. + In development mode this is the repository root. + """ + bundled = _resolve_bundled_rules() + return bundled.parent if bundled is not None else None diff --git a/src/reporails_cli/core/cache.py b/src/reporails_cli/core/cache.py index 4abab187..71b39906 100644 --- a/src/reporails_cli/core/cache.py +++ b/src/reporails_cli/core/cache.py @@ -28,6 +28,43 @@ def content_hash(path: Path) -> str: return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()[:16] +_rfp_cache: dict[str, tuple[float, str]] = {} # key → (max_mtime, fingerprint) + + +def rules_fingerprint(rules_paths: list[Path]) -> str: + """Hash of all checks.yml and rule.md files across rules paths. + + Changes to rule definitions (new rules, modified checks, moved fields) + invalidate the judgment cache so stale verdicts aren't served. + Uses mtime-gated caching to skip rehashing when files haven't changed. + """ + cache_key = "|".join(str(p) for p in sorted(rules_paths)) + + # Collect mtimes — if max matches cached value, reuse fingerprint + max_mtime = 0.0 + rule_files: list[Path] = [] + for rp in sorted(rules_paths): + if not rp.is_dir(): + continue + for f in rp.rglob("checks.yml"): + rule_files.append(f) + max_mtime = max(max_mtime, f.stat().st_mtime) + for f in rp.rglob("rule.md"): + rule_files.append(f) + max_mtime = max(max_mtime, f.stat().st_mtime) + + cached = _rfp_cache.get(cache_key) + if cached and cached[0] == max_mtime: + return cached[1] + + h = hashlib.sha256() + for f in sorted(rule_files): + h.update(f.read_bytes()) + fp = "rules:" + h.hexdigest()[:16] + _rfp_cache[cache_key] = (max_mtime, fp) + return fp + + def structural_hash(path: Path) -> str: """Hash of semantic-relevant structure: headings, constraint lines, list items. @@ -50,7 +87,7 @@ def structural_hash(path: Path) -> str: # ============================================================================= -# Project-Local Cache (.reporails/) +# Project-Local Cache (.ails/) # ============================================================================= @@ -62,12 +99,12 @@ class ProjectCache: @property def reporails_dir(self) -> Path: - """Get project's .reporails directory.""" - return self.target / ".reporails" + """Get project's .ails directory.""" + return self.target / ".ails" @property def cache_dir(self) -> Path: - """Get project's cache directory (.reporails/.cache/).""" + """Get project's cache directory (.ails/.cache/).""" return self.reporails_dir / ".cache" @property @@ -123,15 +160,21 @@ def get_cached_files(self) -> list[Path] | None: return None # Judgment cache operations - def load_judgment_cache(self) -> dict[str, Any]: - """Load cached semantic judgments.""" + def load_judgment_cache(self, rules_fingerprint: str = "") -> dict[str, Any]: + """Load cached semantic judgments. + + If ``rules_fingerprint`` is provided and differs from the cached value, + the judgment cache is invalidated (rules changed since last evaluation). + """ if not self.judgment_cache_path.exists(): - return {"version": 1, "judgments": {}} + return {"version": 1, "judgments": {}, "rules_fingerprint": rules_fingerprint} try: result: dict[str, Any] = json.loads(self.judgment_cache_path.read_text(encoding="utf-8")) - return result except (json.JSONDecodeError, OSError): - return {"version": 1, "judgments": {}} + return {"version": 1, "judgments": {}, "rules_fingerprint": rules_fingerprint} + if rules_fingerprint and result.get("rules_fingerprint", "") != rules_fingerprint: + return {"version": 1, "judgments": {}, "rules_fingerprint": rules_fingerprint} + return result def save_judgment_cache(self, data: dict[str, Any]) -> None: """Save judgment cache atomically (write to temp, then rename).""" @@ -270,7 +313,7 @@ def cache_violation_dismissal(target: Path, violation: Any) -> None: def cache_judgments(target: Path, judgments: list[Any]) -> int: # pylint: disable=too-many-locals """Cache semantic judgment verdicts for a project.""" - from reporails_cli.core.engine import _find_project_root + from reporails_cli.core.engine_helpers import _find_project_root project_root = _find_project_root(target) cache = ProjectCache(project_root) diff --git a/src/reporails_cli/core/capability.py b/src/reporails_cli/core/capability.py index b85118ef..195c2017 100644 --- a/src/reporails_cli/core/capability.py +++ b/src/reporails_cli/core/capability.py @@ -1,130 +1,8 @@ -"""Capability detection - determines project capability level. - -Two-phase detection: -1. Filesystem (applicability.py) - directory/file existence -2. Content (this module) - regex pattern matching -""" +"""Feature summary generation for display purposes.""" from __future__ import annotations -from typing import Any - -from reporails_cli.core.levels import detect_orphan_features, determine_level_from_gates -from reporails_cli.core.models import ( - CapabilityResult, - ContentFeatures, - DetectedFeatures, - Level, -) - - -def detect_features_content(sarif: dict[str, Any]) -> ContentFeatures: - """Parse SARIF output to detect content features. - - Args: - sarif: SARIF output from capability pattern detection - - Returns: - ContentFeatures with detected flags - """ - has_sections = False - has_imports = False - has_explicit_constraints = False - has_path_scoped_rules = False - - for run in sarif.get("runs", []): - for result in run.get("results", []): - rule_id = result.get("ruleId", "") - - if "has-sections" in rule_id: - has_sections = True - elif "has-imports" in rule_id: - has_imports = True - elif "has-explicit-constraints" in rule_id: - has_explicit_constraints = True - elif "has-path-scoped-rules" in rule_id: - has_path_scoped_rules = True - - return ContentFeatures( - has_sections=has_sections, - has_imports=has_imports, - has_explicit_constraints=has_explicit_constraints, - has_path_scoped_rules=has_path_scoped_rules, - ) - - -def estimate_preliminary_level(features: DetectedFeatures) -> Level: - """Estimate capability level from filesystem features only. - - Uses gate-based detection with skip_content=True, which treats - content-only gates as passing (optimistic). This means slightly - more rules loaded for regex Pass 2, never fewer. - - Args: - features: Detected project features (filesystem only) - - Returns: - Preliminary Level (may be higher than final) - """ - return determine_level_from_gates(features, skip_content=True) - - -def merge_content_features( - features: DetectedFeatures, - content_features: ContentFeatures, -) -> DetectedFeatures: - """Merge content features into main features object. - - Args: - features: Base features from filesystem detection - content_features: Features from content detection - - Returns: - Updated DetectedFeatures - """ - features.has_sections = features.has_sections or content_features.has_sections - features.has_imports = features.has_imports or content_features.has_imports - features.has_explicit_constraints = features.has_explicit_constraints or content_features.has_explicit_constraints - features.has_path_scoped_rules = features.has_path_scoped_rules or content_features.has_path_scoped_rules - return features - - -def determine_capability_level( - features: DetectedFeatures, - content_features: ContentFeatures | None = None, -) -> CapabilityResult: - """Determine capability level from features using gate-based detection. - - Two-phase pipeline: - 1. Filesystem features (already in features) - 2. Content features (merged in) - - Args: - features: Detected project features - content_features: Optional content features to merge - - Returns: - CapabilityResult with level and summary - """ - # Merge content features if provided - if content_features: - merge_content_features(features, content_features) - - # Determine level via gate walk - level = determine_level_from_gates(features) - - # Check for orphan features - has_orphan = detect_orphan_features(features, level) - - # Generate summary - summary = get_feature_summary(features) - - return CapabilityResult( - features=features, - level=level, - has_orphan_features=has_orphan, - feature_summary=summary, - ) +from reporails_cli.core.results import DetectedFeatures def get_feature_summary(features: DetectedFeatures) -> str: @@ -138,7 +16,6 @@ def get_feature_summary(features: DetectedFeatures) -> str: """ parts = [] - # File count file_count = features.instruction_file_count if file_count == 0: parts.append("No instruction files") @@ -147,7 +24,6 @@ def get_feature_summary(features: DetectedFeatures) -> str: else: parts.append(f"{file_count} instruction files") - # Features present feature_list = [] if features.is_abstracted: feature_list.append("abstracted") diff --git a/src/reporails_cli/core/classification.py b/src/reporails_cli/core/classification.py new file mode 100644 index 00000000..470910db --- /dev/null +++ b/src/reporails_cli/core/classification.py @@ -0,0 +1,394 @@ +"""File classification engine — typed file targeting for rules. + +Replaces the template variable system. Agent configs declare file_types +with glob patterns and properties. Rules declare match criteria. The +classification engine resolves files to types and matches rules to files. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path, PurePosixPath + +import yaml + +from reporails_cli.core.models import ClassifiedFile, FileMatch, FileTypeDeclaration +from reporails_cli.core.utils import load_yaml_file + +logger = logging.getLogger(__name__) + + +def load_file_types( + agent: str, + rules_paths: list[Path] | None = None, +) -> list[FileTypeDeclaration]: + """Load file_types from agent config.yml. + + Searches rules_paths first, then falls back to default config path. + + Args: + agent: Agent identifier (e.g., "claude") + rules_paths: Optional rules directories to search first + + Returns: + List of FileTypeDeclaration, empty if no config found + """ + from reporails_cli.core.bootstrap import get_agent_config_path + + candidates: list[Path] = [] + if rules_paths: + candidates.extend(rules_dir / agent / "config.yml" for rules_dir in rules_paths) + candidates.append(get_agent_config_path(agent)) + + for config_path in candidates: + if not config_path.exists(): + continue + try: + data = load_yaml_file(config_path) + if not data: + logger.warning("Agent config is empty: %s", config_path) + continue + file_types_data = data.get("file_types", {}) + if not file_types_data: + continue + return _parse_file_types(file_types_data) + except (yaml.YAMLError, OSError) as exc: + logger.warning("Failed to parse agent file_types %s: %s", config_path, exc) + continue + return [] + + +def _parse_file_types(data: dict[str, object]) -> list[FileTypeDeclaration]: + """Parse file_types dict from agent config into FileTypeDeclaration list. + + Supports both v0.3.0 (patterns + properties nested) and v0.5.0 + (scopes with patterns, properties flattened) schema versions. + """ + from reporails_cli.core.agents import _extract_patterns + from reporails_cli.core.agents import _extract_properties as _agent_props + + declarations: list[FileTypeDeclaration] = [] + for name, spec in data.items(): + if not isinstance(spec, dict): + continue + patterns = _extract_patterns(spec) + # v0.3.0: properties nested; v0.5.0: flattened at file type level + raw_props = spec.get("properties") + if isinstance(raw_props, dict): + props = _extract_properties(raw_props) + else: + props = _extract_properties(_agent_props(spec)) + declarations.append( + FileTypeDeclaration( + name=name, + patterns=tuple(str(p) for p in patterns), + required=spec.get("required", False), + properties=props, + ) + ) + return declarations + + +def _extract_properties(props: dict[str, object] | None) -> dict[str, str | list[str]]: + """Extract properties from config. Preserves lists for multi-valued properties.""" + if not props: + return {} + result: dict[str, str | list[str]] = {} + for k, v in props.items(): + if v is None: + continue + if isinstance(v, list): + result[k] = [str(item) for item in v] + else: + result[k] = str(v) + return result + + +def _strip_fenced_blocks(text: str) -> tuple[str, set[str]]: + """Remove fenced code/data block interiors, return (text_outside, block_types). + + Detects code_block and data_block while building a version of the text + with fence interiors removed, so downstream detectors (table, list, prose, + inline formats) don't false-positive on content inside examples. + """ + _data_langs = {"mermaid", "yaml", "yml", "json", "toml", "xml", "csv"} + block_types: set[str] = set() + lines = text.split("\n") + outside_lines: list[str] = [] + in_fence = False + fence_marker = "" + + for line in lines: + m = re.match(r"^(`{3,}|~{3,})(\S*)", line) + if m and not in_fence: + # Opening fence + fence_marker = m.group(1) + lang = m.group(2).lower().strip() + block_types.add("data_block" if lang in _data_langs else "code_block") + in_fence = True + continue + if in_fence: + # Check for closing fence: same char, at least as many repeats + cm = re.match(r"^(`{3,}|~{3,})\s*$", line) + if cm and cm.group(1)[0] == fence_marker[0] and len(cm.group(1)) >= len(fence_marker): + in_fence = False + fence_marker = "" + continue + outside_lines.append(line) + + return "\n".join(outside_lines), block_types + + +def _has_prose(text_outside: str) -> bool: + """Check if text contains non-trivial prose paragraphs outside structural elements.""" + for line in text_outside.split("\n"): + line = line.strip() + if not line: + continue + if line.startswith(("#", "|", "-", "*", "+", ">", "`", "~")): + continue + if re.match(r"^\d+\.\s", line): + continue + if len(line) > 10: + return True + return False + + +def detect_content_format(text: str) -> list[str]: + """Detect which content region types are present in markdown text. + + Content format is intrinsic to freeform (markdown) files — not agent-specific. + Returns the list of content_format values found. + + Block-level values: + prose: natural language paragraphs + heading: markdown section headers + code_block: fenced code blocks + data_block: structured data/visualization (mermaid, yaml, json, toml) + table: markdown tables + list: ordered/unordered lists + blockquote: lines starting with > + + Inline values (detected outside code blocks): + inline_code: backtick-delimited inline code spans + bold: **text** or __text__ emphasis + link: [text](url) or [text][ref] hyperlinks + """ + formats: set[str] = set() + + # Strip frontmatter before analysis + stripped = re.sub(r"\A---\s*\n.*?\n---\s*\n", "", text, count=1, flags=re.DOTALL) + + # heading: lines starting with # (markdown headers) — valid outside code blocks + # (headings inside fenced blocks are stripped below) + # We detect on full text first, then refine with text_outside + # Actually, detect on text_outside to avoid false positives + text_outside, block_types = _strip_fenced_blocks(stripped) + formats.update(block_types) + + # heading: on text outside fences + if re.search(r"(?m)^#{1,6}\s+\S", text_outside): + formats.add("heading") + + # table: markdown tables (lines with | delimiters and separator row) + if re.search(r"(?m)^\|.*\|.*\n\|[\s:|-]+\|", text_outside): + formats.add("table") + + # list: lines starting with -, *, +, or 1. (after optional whitespace) + if re.search(r"(?m)^[ \t]*[-*+]\s+\S", text_outside) or re.search(r"(?m)^[ \t]*\d+\.\s+\S", text_outside): + formats.add("list") + + # blockquote: lines starting with > + if re.search(r"(?m)^>\s", text_outside): + formats.add("blockquote") + + if _has_prose(text_outside): + formats.add("prose") + + # Inline modes: detected outside code blocks to avoid false positives + # bold: **text** or __text__ + if re.search(r"\*\*[^*]+\*\*|__[^_]+__", text_outside): + formats.add("bold") + + # inline_code: `text` (single backtick, not triple) + if re.search(r"(? list[ClassifiedFile]: + """Classify files against type declarations. First pattern match wins. + + For freeform files, content_format is detected from file content. + + Args: + scan_root: Project root for computing relative paths + files: Files to classify + file_types: Type declarations from agent config + + Returns: + List of ClassifiedFile for matched files + """ + classified: list[ClassifiedFile] = [] + for file_path in files: + try: + rel = str(file_path.relative_to(scan_root)) + except ValueError: + rel = str(file_path) + + for ft in file_types: + if _matches_any_pattern(rel, ft.patterns): + props = dict(ft.properties) + # Detect content_format for freeform files + fmt = props.get("format") + is_freeform = fmt == "freeform" or (isinstance(fmt, list) and "freeform" in fmt) + if is_freeform and "content_format" not in props: + try: + text = file_path.read_text(encoding="utf-8", errors="replace") + cf = detect_content_format(text) + if cf: + props["content_format"] = cf + except OSError: + pass + classified.append( + ClassifiedFile( + path=file_path, + file_type=ft.name, + properties=props, + ) + ) + break # First match wins + return classified + + +def match_files( + classified: list[ClassifiedFile], + match: FileMatch, +) -> list[ClassifiedFile]: + """Filter classified files by property match. None properties are wildcards. + + Args: + classified: Previously classified files + match: Match criteria (None fields match everything) + + Returns: + Filtered list of ClassifiedFile + """ + return [cf for cf in classified if file_matches(cf, match)] + + +def resolve_match_to_paths( + classified: list[ClassifiedFile], + match: FileMatch | None, + scan_root: Path, +) -> list[str]: + """Resolve match criteria to relative path strings for the regex runner. + + Args: + classified: Previously classified files + match: Match criteria, or None for all files + scan_root: Project root for relative paths + + Returns: + List of relative path strings + """ + targets = classified if match is None else match_files(classified, match) + + paths: list[str] = [] + for cf in targets: + try: + paths.append(str(cf.path.relative_to(scan_root))) + except ValueError: + paths.append(str(cf.path)) + return paths + + +def _prop_matches(match_val: list[str] | str | None, actual: list[str] | str | None) -> bool: + """Check a property match criterion against an actual property value. + + Both sides can be scalar or list: + - match=None: wildcard (always matches) + - match=str vs actual=str: exact equality + - match=str vs actual=list: match_val in actual (file has the property) + - match=list vs actual=str: actual in match_val (rule accepts the value) + - match=list vs actual=list: sets intersect (any overlap) + """ + if match_val is None: + return True + if actual is None: + return False + if isinstance(match_val, list) and isinstance(actual, list): + return bool(set(match_val) & set(actual)) + if isinstance(match_val, list): + return actual in match_val + if isinstance(actual, list): + return match_val in actual + return actual == match_val + + +def file_matches(cf: ClassifiedFile, match: FileMatch) -> bool: + """Check if a classified file matches the given criteria.""" + if not _prop_matches(match.type, cf.file_type): + return False + for prop in ( + "scope", + "format", + "content_format", + "cardinality", + "lifecycle", + "maintainer", + "vcs", + "loading", + "precedence", + ): + if not _prop_matches(getattr(match, prop), cf.properties.get(prop)): + return False + return True + + +def _matches_any_pattern(rel_path: str, patterns: tuple[str, ...]) -> bool: + """Check if a relative path matches any glob pattern. + + Uses PurePosixPath.match() for glob matching. Handles ``**`` as + zero-or-more directory components by generating collapsed variants + (e.g. ``a/**/b`` also tries ``a/b``). + """ + p = PurePosixPath(rel_path) + for pattern in patterns: + clean = pattern.removeprefix("./") + for variant in _expand_doublestar(clean): + if p.match(variant): + return True + return False + + +def _expand_doublestar(pattern: str) -> list[str]: + """Expand a glob pattern into variants where each ``**/`` matches zero dirs. + + ``a/**/b/*.md`` yields: + - ``a/**/b/*.md`` (original — ** matches 1+ dirs) + - ``a/b/*.md`` (** collapsed — zero dirs) + + Multiple ``**/`` segments produce one variant per collapse. + """ + parts = pattern.split("/") + star_indices = [i for i, p in enumerate(parts) if p == "**"] + if not star_indices: + return [pattern] + # Build variants: original + one collapsed version per ** segment + variants = [pattern] + for idx in star_indices: + collapsed = [p for i, p in enumerate(parts) if i != idx] + if collapsed: + variants.append("/".join(collapsed)) + return variants diff --git a/src/reporails_cli/core/client_checks.py b/src/reporails_cli/core/client_checks.py new file mode 100644 index 00000000..2db243ef --- /dev/null +++ b/src/reporails_cli/core/client_checks.py @@ -0,0 +1,218 @@ +"""Client-side checks — run locally on RulesetMap atoms. + +Five checks: unformatted code tokens, bold on directives, +broad scope terms, charge ordering, orphan atoms. +""" + +from __future__ import annotations + +import re + +from reporails_cli.core.mapper.mapper import Atom, RulesetMap +from reporails_cli.core.models import LocalFinding + +_BOLD_TERM_RE = re.compile(r"\*\*([^*]+)\*\*") +_BOLD_NEGATION_RE = re.compile( + r"^(?:NEVER|ALWAYS|MUST|CRITICAL|IMPORTANT|DO NOT|NOT|WARNING|NOTE|NO)\b", + re.IGNORECASE, +) +# Bold spans followed by ":" are structural labels, not emphasis +_BOLD_LABEL_RE = re.compile(r"\*\*[^*]+\*\*\s*:") + +_SCOPE_RE = re.compile( + r"^(?:when|if|unless|before|after)\s+(.+?),\s+", + re.IGNORECASE, +) +_BROAD_SCOPE_WORDS = { + "external", + "third-party", + "services", + "dependencies", + "integrations", + "components", + "any", + "all", +} + +_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} + + +def run_client_checks(ruleset_map: RulesetMap) -> list[LocalFinding]: + """Run D-level client checks on a RulesetMap. + + Returns findings sorted by severity then line number. File paths + are normalized to project-relative display paths. + """ + findings: list[LocalFinding] = [] + + # Group atoms by file for filepath context + atoms_by_file: dict[str, list[Atom]] = {} + for atom in ruleset_map.atoms: + atoms_by_file.setdefault(atom.file_path, []).append(atom) + + for filepath, atoms in atoms_by_file.items(): + display = _relative_display_path(filepath) + findings.extend(_check_file(atoms, display)) + + findings.sort(key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.line)) + return findings + + +def _relative_display_path(file_path: str) -> str: + """Normalize file path for display. Uses merger's normalize_finding_path.""" + from reporails_cli.core.merger import normalize_finding_path + + return normalize_finding_path(file_path) + + +def _check_file(atoms: list[Atom], filepath: str) -> list[LocalFinding]: # noqa: C901 + """Run all 5 D-level checks on atoms from a single file.""" + findings: list[LocalFinding] = [] + + # 1. Unformatted code tokens + findings.extend( + LocalFinding( + file=filepath, + line=a.line, + severity="warning", + rule="format", + message=f"'{code_tok}' should be in backticks — use `{code_tok}`", + fix=f"Wrap in backticks: `{code_tok}`", + source="client_check", + ) + for a in atoms + for code_tok in a.unformatted_code + ) + + # 6. Instruction in heading — headings should organize, not instruct + findings.extend( + LocalFinding( + file=filepath, + line=a.line, + severity="info", + rule="heading_instruction", + message=( + f'Instruction in heading: "{a.text[:50]}" — ' + f"headings should organize content, not carry instructions. " + f"Move the instruction to the section body." + ), + fix="Use the heading as a section label and put the instruction in the first line of the section.", + source="client_check", + ) + for a in atoms + if a.kind == "heading" and a.charge_value != 0 + ) + + # 2. Bold pattern analysis + charged = [a for a in atoms if a.charge_value != 0] + for a in charged: + bold_spans = _BOLD_TERM_RE.findall(a.text) + if not bold_spans: + continue + # Skip structural labels: **Term**: (bold followed by colon) + if _BOLD_LABEL_RE.search(a.text): + continue + harmful_terms: list[str] = [] + for span in bold_spans: + if _BOLD_NEGATION_RE.match(span): + continue + if span.strip().rstrip(".!") == a.text.strip().lstrip("*").rstrip("*").strip().rstrip(".!"): + continue + harmful_terms.append(span) + if not harmful_terms: + continue + terms_str = ", ".join(f"**{t}**" for t in harmful_terms[:3]) + if a.charge_value == +1: + findings.append( + LocalFinding( + file=filepath, + line=a.line, + severity="info", + rule="bold", + message=( + f"Bold on terms: {terms_str}. " + f"Bold competes for attention between instructions — " + f"use `backtick` or *italic* instead." + ), + fix="Use `backtick` for code tokens or *italic* for emphasis.", + source="client_check", + ) + ) + + # 3. Scope warnings + for a in charged: + if not a.scope_conditional: + continue + m = _SCOPE_RE.match(a.text) + if not m: + continue + scope_text = m.group(1).lower() + broad_matches = [w for w in _BROAD_SCOPE_WORDS if w in scope_text] + if broad_matches: + findings.append( + LocalFinding( + file=filepath, + line=a.line, + severity="warning", + rule="scope", + message=( + f'Broad conditional scope: "{m.group(1)}". ' + f"Broad terms ({', '.join(broad_matches)}) may trigger unintended behavior." + ), + fix="Name the specific situation instead of using broad terms, or remove the condition.", + source="client_check", + ) + ) + + # 4. Instruction ordering + clusters: dict[int, list[Atom]] = {} + for a in charged: + if a.cluster_id >= 0: + clusters.setdefault(a.cluster_id, []).append(a) + + for cluster_atoms in clusters.values(): + directives = [a for a in cluster_atoms if a.charge_value == +1] + constraints = [a for a in cluster_atoms if a.charge_value == -1] + + if directives and constraints: + first_dir = min(directives, key=lambda a: a.position_index) + first_con = min(constraints, key=lambda a: a.position_index) + if first_con.position_index < first_dir.position_index: + findings.append( + LocalFinding( + file=filepath, + line=first_con.line, + severity="warning", + rule="ordering", + message=( + f"Prohibition at L{first_con.line} comes before " + f"directive at L{first_dir.line} — the model follows " + f"instructions better when you state what TO do first, " + f"then what NOT to do." + ), + fix="Reorder: directive first, reasoning in between, prohibition last.", + source="client_check", + ) + ) + # Directives-only is valid — the golden pattern (+1, 0, -1) only + # requires a prohibition when there's a behavior to suppress. + # "Use ruff" stands alone. Only flag constraints-only (missing directive). + elif constraints: + # 5. One-sided constraints + rep = min(constraints, key=lambda a: a.position_index) + findings.append( + LocalFinding( + file=filepath, + line=rep.line, + severity="info", + rule="orphan", + message=( + f"{len(constraints)} prohibition(s) with no matching directive on this topic. " + f"Adding a 'do this instead' counterpart strengthens compliance." + ), + fix="Add a related directive before the prohibition(s).", + source="client_check", + ) + ) + + return findings diff --git a/src/reporails_cli/core/config.py b/src/reporails_cli/core/config.py new file mode 100644 index 00000000..89824f00 --- /dev/null +++ b/src/reporails_cli/core/config.py @@ -0,0 +1,135 @@ +"""Config loading — agent, global, and project configuration. + +Split from bootstrap.py to separate config loading (YAML → dataclass) +from path resolution (filesystem layout knowledge). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +from reporails_cli.core.utils import load_yaml_file + +if TYPE_CHECKING: + from reporails_cli.core.models import AgentConfig, GlobalConfig, ProjectConfig + +logger = logging.getLogger(__name__) + + +def get_agent_config(agent: str) -> AgentConfig: + """Load agent config (excludes + overrides) from framework. + + Args: + agent: Agent identifier (e.g., "claude") + + Returns: + AgentConfig with excludes and overrides, or defaults if missing/malformed + """ + from reporails_cli.core.bootstrap import get_agent_config_path + from reporails_cli.core.models import AgentConfig + + config_path = get_agent_config_path(agent) + if not config_path.exists(): + return AgentConfig() + + try: + data = load_yaml_file(config_path) + if not data: + msg = f"Agent config is empty: {config_path}" + raise ValueError(msg) + return AgentConfig( + agent=data.get("agent", ""), + prefix=data.get("prefix", ""), + name=data.get("name", ""), + core=data.get("core", False), + excludes=data.get("excludes", []), + overrides=data.get("overrides", {}), + ) + except (yaml.YAMLError, OSError, ValueError) as exc: + logger.warning("Failed to parse agent config %s: %s", config_path, exc) + return AgentConfig() + + +def get_global_config() -> GlobalConfig: + """Load global configuration from ~/.reporails/config.yml. + + Returns default config if file doesn't exist. + """ + from reporails_cli.core.bootstrap import get_global_config_path + from reporails_cli.core.models import GlobalConfig + + config_path = get_global_config_path() + if not config_path.exists(): + return GlobalConfig() + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + framework_path = data.get("framework_path") + recommended_path = data.get("recommended_path") + return GlobalConfig( + framework_path=Path(framework_path) if framework_path else None, + recommended_path=Path(recommended_path) if recommended_path else None, + auto_update_check=data.get("auto_update_check", True), + default_agent=data.get("default_agent", ""), + recommended=data.get("recommended", True), + tier=data.get("tier", ""), + ) + except (yaml.YAMLError, OSError) as exc: + logger.warning("Failed to parse global config %s: %s", config_path, exc) + return GlobalConfig() + + +def get_project_config(project_root: Path) -> ProjectConfig: + """Load project configuration from .ails/config.yml. + + Returns default config if file doesn't exist or is malformed. + + Args: + project_root: Root directory of the project + + Returns: + ProjectConfig with loaded or default values + """ + from reporails_cli.core.models import ProjectConfig + + config_path = project_root / ".ails" / "config.yml" + if not config_path.exists(): + global_cfg = get_global_config() + return ProjectConfig( + default_agent=global_cfg.default_agent, + recommended=global_cfg.recommended, + ) + + try: + data = load_yaml_file(config_path) + if not data: + msg = f"Project config is empty: {config_path}" + raise ValueError(msg) + has_recommended = "recommended" in data + config = ProjectConfig( + framework_version=data.get("framework_version"), + packages=data.get("packages", []), + disabled_rules=data.get("disabled_rules", []), + overrides=data.get("overrides", {}), + recommended=data.get("recommended", True), + exclude_dirs=data.get("exclude_dirs", []), + default_agent=data.get("default_agent", ""), + ) + # Apply global defaults where project doesn't override + global_cfg = get_global_config() + if not config.default_agent: + config.default_agent = global_cfg.default_agent + if not has_recommended: + config.recommended = global_cfg.recommended + return config + except (yaml.YAMLError, OSError, ValueError) as exc: + logger.warning("Failed to parse project config %s: %s", config_path, exc) + global_cfg = get_global_config() + return ProjectConfig( + default_agent=global_cfg.default_agent, + recommended=global_cfg.recommended, + ) diff --git a/src/reporails_cli/core/content_checker.py b/src/reporails_cli/core/content_checker.py new file mode 100644 index 00000000..6616267a --- /dev/null +++ b/src/reporails_cli/core/content_checker.py @@ -0,0 +1,116 @@ +"""Content-quality checker — dispatches rule checks as atom queries. + +Replaces deterministic regex scanning for content-quality rules. +Each rule with type=content_query checks is dispatched to the +corresponding query function in content_queries.py. + +Queries run against files matching the rule's `match` field. +A finding is emitted once per rule, not per file. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from reporails_cli.core.content_queries import QUERY_REGISTRY, QueryResult +from reporails_cli.core.mapper.mapper import RulesetMap +from reporails_cli.core.models import ClassifiedFile, FileMatch, LocalFinding, Rule + +logger = logging.getLogger(__name__) + + +def _matching_files( + ruleset_map: RulesetMap, + classified: list[ClassifiedFile], + match: FileMatch | None, +) -> list[str]: + """Return file paths from the ruleset that match the rule's targeting criteria.""" + rm_paths = {fr.path for fr in ruleset_map.files} + + if match is None: + return sorted(rm_paths) + + from reporails_cli.core.classification import file_matches + + matched = [str(cf.path) for cf in classified if file_matches(cf, match) and str(cf.path) in rm_paths] + # Don't fall back to all files when a specific match type is set — + # a config rule shouldn't fire on memory files just because no config exists. + if matched: + return sorted(matched) + if match.type is not None: + return [] # No files of this type — skip, don't fall back + return sorted(rm_paths) + + +def run_content_checks( + ruleset_map: RulesetMap, + rules: dict[str, Rule], + classified: list[ClassifiedFile] | None = None, +) -> list[LocalFinding]: + """Run content-quality checks against RulesetMap atoms. + + Each content_query check tests whether the matched files have the + required content. One finding per rule failure. + """ + findings: list[LocalFinding] = [] + + if classified is None: + classified = [] + + primary_file = ruleset_map.files[0].path if ruleset_map.files else "" + + for rule in rules.values(): + for check in rule.checks: + if check.type != "content_query": + continue + if not check.query: + continue + + query_fn = QUERY_REGISTRY.get(check.query) + if query_fn is None: + logger.warning("Unknown content query: %s (check %s)", check.query, check.id) + continue + + check_args: dict[str, Any] = dict(check.args or {}) + + # Filter files by rule.match targeting + target_files = _matching_files(ruleset_map, classified, rule.match) + if not target_files: + continue # No files of this type — skip, don't report absence + + found = False + for fp in target_files: + result: QueryResult = query_fn(ruleset_map, fp, **check_args) + if result.found: + found = True + break + + passed = found if check.expect == "present" else not found + if not passed: + message = check_args.get("message", "") + if not message: + message = f"Content check failed: {check.query} (expect={check.expect})" + + display_path = _relative_path(target_files[0] if target_files else primary_file) + + findings.append( + LocalFinding( + file=display_path, + line=1, + severity=rule.severity.value, + rule=rule.id, + message=message, + source="content_query", + check_id=check.id, + ) + ) + + return findings + + +def _relative_path(file_path: str) -> str: + """Normalize file path for display. Uses merger's normalize_finding_path.""" + from reporails_cli.core.merger import normalize_finding_path + + return normalize_finding_path(file_path) diff --git a/src/reporails_cli/core/content_queries.py b/src/reporails_cli/core/content_queries.py new file mode 100644 index 00000000..fb20a9e2 --- /dev/null +++ b/src/reporails_cli/core/content_queries.py @@ -0,0 +1,223 @@ +"""Content-quality queries on RulesetMap atoms. + +Replaces deterministic regex checks for content presence/absence rules. +Each query inspects the mapper's AST-derived atoms — no regex on raw text. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from reporails_cli.core.mapper.mapper import Atom, RulesetMap + + +@dataclass(frozen=True) +class QueryResult: + """Result of a content query against RulesetMap atoms.""" + + found: bool + file: str = "" + line: int = 0 + evidence: str = "" + + +def _atoms_for_file(rm: RulesetMap, file_path: str) -> list[Atom]: + """Get atoms belonging to a specific file.""" + return [a for a in rm.atoms if a.file_path.endswith(file_path) or file_path in a.file_path] + + +def _all_file_paths(rm: RulesetMap) -> set[str]: + """Get unique file paths from RulesetMap.""" + return {fr.path for fr in rm.files} + + +# ────────────────────────────────────────────────────────────────── +# QUERY FUNCTIONS +# Each takes (rm, file_path, **args) and returns QueryResult. +# file_path scopes the query to a single file's atoms. +# ────────────────────────────────────────────────────────────────── + + +def has_headings(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has any markdown headings.""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.kind == "heading": + return QueryResult(True, file_path, a.line, f"Heading: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_heading_matching(rm: RulesetMap, file_path: str, **args: Any) -> QueryResult: + """Check if file has a heading matching any of the given terms.""" + terms = args.get("terms", []) + if not terms: + return QueryResult(False, file_path) + pattern = re.compile("|".join(re.escape(t) for t in terms), re.IGNORECASE) + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.kind == "heading" and pattern.search(a.text): + return QueryResult(True, file_path, a.line, f"Matched heading: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_code_blocks(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has code block content.""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.format == "code_block": + return QueryResult(True, file_path, a.line, "Code block found") + return QueryResult(False, file_path) + + +def has_layered_structure(rm: RulesetMap, file_path: str, **args: Any) -> QueryResult: + """Check if file has top-level heading structure (content layering).""" + min_headings = args.get("min_headings", 2) + atoms = _atoms_for_file(rm, file_path) + top_headings = [a for a in atoms if a.kind == "heading" and a.depth is not None and a.depth <= 2] + if len(top_headings) >= min_headings: + return QueryResult(True, file_path, top_headings[0].line, f"{len(top_headings)} top-level headings") + return QueryResult(False, file_path) + + +def has_named_tokens_matching(rm: RulesetMap, file_path: str, **args: Any) -> QueryResult: + """Check if file atoms contain any of the specified named tokens.""" + tokens = set(args.get("tokens", [])) + if not tokens: + return QueryResult(False, file_path) + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + overlap = tokens & {t.lower() for t in a.named_tokens} + if overlap: + return QueryResult(True, file_path, a.line, f"Named tokens: {', '.join(list(overlap)[:3])}") + return QueryResult(False, file_path) + + +def has_constraint_atoms(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has constraint atoms (charge_value == -1).""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.charge_value == -1: + return QueryResult(True, file_path, a.line, f"Constraint: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_directive_atoms(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has directive atoms (charge_value == +1).""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.charge_value == +1: + return QueryResult(True, file_path, a.line, f"Directive: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_role_definition(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file defines an agent role ('you are', 'your role', role: anchor).""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + lower = (a.plain_text or a.text).lower() + if "you are" in lower or "your role" in lower or a.role == "anchor": + return QueryResult(True, file_path, a.line, f"Role definition: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_valid_markdown(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has any parsed atoms (valid markdown that produced content).""" + atoms = _atoms_for_file(rm, file_path) + if atoms: + return QueryResult(True, file_path, atoms[0].line, f"{len(atoms)} atoms parsed") + return QueryResult(False, file_path) + + +def has_frontmatter_field(rm: RulesetMap, file_path: str, **args: Any) -> QueryResult: + """Check if the file record has specific frontmatter-derived fields.""" + field_name = args.get("field", "") + for fr in rm.files: + if fr.path.endswith(file_path) or file_path in fr.path: + if field_name == "scope" and fr.scope != "global": + return QueryResult(True, file_path, 1, f"scope={fr.scope}") + if field_name == "globs" and fr.globs: + return QueryResult(True, file_path, 1, f"globs={fr.globs}") + return QueryResult(False, file_path) + + +def has_non_italic_constraints(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has constraint atoms not fully wrapped in *italic*. + + Returns found=True when a constraint (-1) exists without full-sentence italic, + meaning the rule is violated (expect: absent to flag as violation). + """ + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.charge_value != -1 or a.kind == "heading": + continue + content = a.text.strip() + # Strip leading list markers + for prefix in ("- ", "* "): + if content.startswith(prefix): + content = content[len(prefix) :].strip() + break + if re.match(r"^\d+\.\s", content): + content = re.sub(r"^\d+\.\s+", "", content).strip() + # Full-sentence italic: starts and ends with single * (not **) + if content.startswith("*") and content.endswith("*") and not content.startswith("**"): + continue + return QueryResult(True, file_path, a.line, f"Constraint not italic: {a.text[:60]}") + return QueryResult(False, file_path) + + +def has_mermaid_blocks(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has mermaid code blocks (```mermaid).""" + atoms = _atoms_for_file(rm, file_path) + for a in atoms: + if a.format == "code_block" and "mermaid" in a.named_tokens: + return QueryResult(True, file_path, a.line, "Mermaid block found") + return QueryResult(False, file_path) + + +def has_branching_steps(rm: RulesetMap, file_path: str, **_args: Any) -> QueryResult: + """Check if file has numbered list items with conditional/branching language.""" + atoms = _atoms_for_file(rm, file_path) + numbered_count = 0 + branching = False + for a in atoms: + if a.format == "numbered": + numbered_count += 1 + if a.scope_conditional: + branching = True + if numbered_count >= 3 and branching: + return QueryResult(True, file_path, 0, f"{numbered_count} numbered steps with branching") + return QueryResult(False, file_path) + + +def has_charged_headings(rm: Any, file_path: str, **_args: Any) -> QueryResult: + """Check if file has heading atoms with charge (instructions in headings).""" + for a in rm.atoms: + if a.file_path != file_path: + continue + if a.kind == "heading" and a.charge_value != 0: + return QueryResult(True, file_path, a.line, f"Charged heading: {a.text[:60]}") + return QueryResult(False, file_path) + + +# ────────────────────────────────────────────────────────────────── +# REGISTRY — maps query names to functions +# ────────────────────────────────────────────────────────────────── + +QUERY_REGISTRY: dict[str, Any] = { + "has_headings": has_headings, + "has_heading_matching": has_heading_matching, + "has_code_blocks": has_code_blocks, + "has_layered_structure": has_layered_structure, + "has_named_tokens_matching": has_named_tokens_matching, + "has_constraint_atoms": has_constraint_atoms, + "has_directive_atoms": has_directive_atoms, + "has_role_definition": has_role_definition, + "has_valid_markdown": has_valid_markdown, + "has_frontmatter_field": has_frontmatter_field, + "has_charged_headings": has_charged_headings, + "has_non_italic_constraints": has_non_italic_constraints, + "has_mermaid_blocks": has_mermaid_blocks, + "has_branching_steps": has_branching_steps, +} diff --git a/src/reporails_cli/core/discover.py b/src/reporails_cli/core/discover.py index f40ab2a0..5375461c 100644 --- a/src/reporails_cli/core/discover.py +++ b/src/reporails_cli/core/discover.py @@ -1,10 +1,16 @@ """Discovery engine - detect agents and project structure. Lightweight discovery for backbone generation and map display. + +Detection data is loaded from bundled/project-types.yml — add entries +there to support new languages, frameworks, and tools without code changes. """ from __future__ import annotations +import functools +import json +import tomllib from pathlib import Path from typing import Any @@ -13,101 +19,248 @@ from reporails_cli.core.agents import DetectedAgent -def detect_project_structure(target: Path) -> dict[str, Any]: - """Detect common project structure directories and config files. +def _load_project_types() -> dict[str, Any]: + """Load and cache the bundled project-types.yml.""" + from reporails_cli.bundled import get_project_types_path + + path = get_project_types_path() + data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) + return data + - Args: - target: Project root +@functools.lru_cache(maxsize=1) +def _get_project_types() -> dict[str, Any]: + """Cached accessor for project types data.""" + return _load_project_types() - Returns: - Dict with detected structure keys (source, tests, docs, scripts, config) + +def _traverse_dotpath(data: dict[str, Any], path: str) -> Any: + """Traverse a dot-separated path into nested dicts. + + Given ``"project.dependencies"`` and ``{"project": {"dependencies": [...]}}``, + returns the list. Returns None if any segment is missing. + """ + node: Any = data + for key in path.split("."): + if not isinstance(node, dict): + return None + node = node.get(key) + return node + + +def _read_json(path: Path) -> dict[str, Any]: + """Read a JSON file, returning empty dict on failure.""" + try: + return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return] + except (OSError, json.JSONDecodeError, ValueError): + return {} + + +def _read_toml(path: Path) -> dict[str, Any]: + """Read a TOML file, returning empty dict on failure.""" + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return {} + + +def _read_yaml(path: Path) -> dict[str, Any]: + """Read a YAML file, returning empty dict on failure.""" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, yaml.YAMLError): + return {} + + +def _read_manifest(path: Path, fmt: str) -> dict[str, Any]: + """Read a manifest file based on its declared format.""" + if fmt == "toml": + return _read_toml(path) + if fmt == "json": + return _read_json(path) + if fmt == "yaml": + return _read_yaml(path) + return {} + + +def _find_manifests(target: Path) -> list[str]: + """Return manifest filenames that exist, in priority order. + + Supports glob-based entries (e.g., ``*.sln``) — returns the first + matching filename for those entries. """ - structure: dict[str, Any] = {} + pt = _get_project_types() + found: list[str] = [] + for m in pt["manifests"]: + if m.get("glob"): + matches = sorted(target.glob(m["file"])) + if matches: + found.append(matches[0].name) + elif (target / m["file"]).exists(): + found.append(m["file"]) + return found + + +def _get_manifest_spec(filename: str) -> dict[str, Any] | None: + """Look up the manifest spec for a given filename. + + For glob-based entries (e.g., ``*.sln``), matches if the filename + fits the glob pattern. + """ + pt = _get_project_types() + for m in pt["manifests"]: + if m.get("glob"): + if Path(filename).match(m["file"]): + return m # type: ignore[no-any-return] + elif m["file"] == filename: + return m # type: ignore[no-any-return] + return None + + +def _detect_classification(target: Path) -> dict[str, Any]: + """Detect project type, language, framework, and runtime. Delegates to discover_classify.""" + from reporails_cli.core.discover_classify import detect_classification + + return detect_classification(target) + + +def _detect_commands(target: Path) -> dict[str, str | None]: + """Detect build/test/lint/format commands. Delegates to discover_commands.""" + from reporails_cli.core.discover_commands import detect_commands + + return detect_commands(target) + + +def _detect_version_file(target: Path) -> str | None: + """Detect version file: standalone file or manifest with version field.""" + pt = _get_project_types() + + # Standalone version files + for candidate in pt["meta"]["version_files"]: + if (target / candidate).is_file(): + return str(candidate) + + # Fallback: manifest with embedded version + for manifest in _find_manifests(target): + spec = _get_manifest_spec(manifest) + if not spec: + continue + version_path = spec.get("version_path") + if not version_path: + continue + data = _read_manifest(target / manifest, spec["format"]) + if _traverse_dotpath(data, version_path): + return manifest + return None + + +def _detect_ci(target: Path) -> str | None: + """Detect CI system from known file/directory patterns.""" + pt = _get_project_types() + for pattern in pt["meta"]["ci_patterns"]: + path = target / pattern["path"] + if pattern["type"] == "dir": + if path.is_dir(): + return pattern["display"] # type: ignore[no-any-return] + elif path.is_file(): + return pattern["display"] # type: ignore[no-any-return] + return None + + +def _detect_meta(target: Path) -> dict[str, str | None]: + """Detect meta pointers: version file, changelog, manifest, CI.""" + pt = _get_project_types() + manifests = _find_manifests(target) + + changelog: str | None = None + for candidate in pt["meta"]["changelogs"]: + if (target / candidate).is_file(): + changelog = candidate + break + + return { + "version_file": _detect_version_file(target), + "changelog": changelog, + "manifest": manifests[0] if manifests else None, + "ci": _detect_ci(target), + } + - # Directory patterns: (key, candidate_dirs) - dir_patterns: list[tuple[str, list[str]]] = [ - ("source", ["src", "lib", "app"]), - ("tests", ["tests", "test", "__tests__"]), - ("docs", ["docs", "documentation"]), - ("scripts", ["scripts", "bin"]), - ] +def _detect_paths(target: Path) -> dict[str, str | None]: + """Detect project directory structure for backbone v3 paths section.""" + pt = _get_project_types() + paths: dict[str, str | None] = dict.fromkeys(pt["paths"]) - for key, candidates in dir_patterns: + for key, candidates in pt["paths"].items(): for candidate in candidates: candidate_path = target / candidate if candidate_path.is_dir(): - # Use the most specific subdir if there's only one child - # e.g., src/reporails_cli/ -> src/reporails_cli/ children = [c for c in candidate_path.iterdir() if c.is_dir() and not c.name.startswith((".", "__"))] - if len(children) == 1 and key == "source": - structure[key] = str(children[0].relative_to(target)) + "/" + if len(children) == 1 and key == "src": + paths[key] = str(children[0].relative_to(target)) + "/" else: - structure[key] = candidate + "/" + paths[key] = candidate + "/" break - # Config files at project root - config_patterns = [ - "pyproject.toml", - "package.json", - "Cargo.toml", - "go.mod", - "Makefile", - "Dockerfile", - "docker-compose.yml", - "tsconfig.json", - ".semgrepignore", - ] - config_files = [f for f in config_patterns if (target / f).exists()] - if config_files: - structure["config"] = config_files - - return structure + return paths -def generate_backbone_yaml(target: Path, agents: list[DetectedAgent]) -> str: - """Generate backbone.yml content from detected agents. +def _strip_nulls(data: Any) -> Any: + """Recursively strip null values from dicts.""" + if isinstance(data, dict): + return {k: _strip_nulls(v) for k, v in data.items() if v is not None} + if isinstance(data, list): + return [_strip_nulls(item) for item in data] + return data + - Args: - target: Project root - agents: Detected agents +# Keep the old name as an alias for backward compatibility (commands.py import) +def detect_project_structure(target: Path) -> dict[str, Any]: + """Detect project structure — v2 compatibility wrapper. - Returns: - YAML string for backbone file + Deprecated: use _detect_paths() for v3 backbone generation. """ + return _detect_paths(target) + + +def generate_backbone_yaml(target: Path, agents: list[DetectedAgent]) -> str: + """Generate backbone.yml v3 content from detected agents and project.""" data: dict[str, Any] = { - "version": 2, + "version": 3, "generator": "ails map", + "auto_heal": True, + "directive": ( + "If any path, command, or classification in this file does not " + "match the project when accessed, update the entry and continue." + ), } - # Build agents section + # Identity + data["identity"] = _detect_classification(target) + + # Topology — agents agents_data: dict[str, Any] = {} for agent in agents: agent_data: dict[str, Any] = {} - - # Main instruction file (first root-level file) root_files = [f for f in agent.instruction_files if f.parent == target] if root_files: agent_data["main_instruction_file"] = str(root_files[0].relative_to(target)) - - # Detected directories agent_data.update(agent.detected_directories) - - # First config file if agent.config_files: agent_data["config"] = str(agent.config_files[0].relative_to(target)) - agents_data[agent.agent_type.id] = agent_data - if agents_data: - data["agents"] = agents_data + data["agents"] = agents_data or {} + + # Topology — paths + data["paths"] = _detect_paths(target) - # Build structure section - structure = detect_project_structure(target) - if structure: - data["structure"] = structure + clean_data = _strip_nulls(data) - header = "# Auto-generated by ails map. Customize freely.\n" - yaml_output: str = yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) + header = "# Auto-generated by ails map — backbone v3\n# See docs/specs/backbone-schema.yml for schema reference.\n" + yaml_output: str = yaml.dump(clean_data, default_flow_style=False, sort_keys=False, allow_unicode=True) return header + yaml_output @@ -116,24 +269,13 @@ def generate_backbone_placeholder() -> str: Created automatically during `ails check` if no backbone exists. Users should run `ails map --save` to populate it. - - Returns: - YAML string for placeholder backbone file """ - return "# Run `ails map --save` to populate.\nversion: 2\n" + return "# Run `ails map --save` to populate.\nversion: 3\n" def save_backbone(target: Path, content: str) -> Path: - """Save backbone.yml to target's .reporails directory. - - Args: - target: Project root - content: YAML content - - Returns: - Path to saved file - """ - backbone_dir = target / ".reporails" + """Save backbone.yml to target's .ails directory.""" + backbone_dir = target / ".ails" backbone_dir.mkdir(parents=True, exist_ok=True) backbone_path = backbone_dir / "backbone.yml" diff --git a/src/reporails_cli/core/discover_classify.py b/src/reporails_cli/core/discover_classify.py new file mode 100644 index 00000000..98c54343 --- /dev/null +++ b/src/reporails_cli/core/discover_classify.py @@ -0,0 +1,161 @@ +"""Classification detection for backbone v3 discovery engine. + +Detects project type, language, framework, and runtime. +Data-driven from bundled/project-types.yml. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from reporails_cli.core.discover import ( + _find_manifests, + _get_manifest_spec, + _get_project_types, + _read_manifest, + _traverse_dotpath, +) + + +def _normalize_pypi_dep(dep: str) -> str: + """Extract bare package name from a PEP 508 dependency string.""" + return dep.lower().split(">")[0].split("<")[0].split("=")[0].split("[")[0].strip() + + +def _collect_deps_from_manifest(target: Path, manifest: str) -> list[str]: + """Collect lowercased dependency names from a manifest file.""" + spec = _get_manifest_spec(manifest) + if not spec: + return [] + + dep_paths: list[str] = spec.get("dependency_paths", []) + if not dep_paths: + return [] + + data = _read_manifest(target / manifest, spec["format"]) + deps: list[str] = [] + for dotpath in dep_paths: + raw = _traverse_dotpath(data, dotpath) + if raw is None: + continue + if isinstance(raw, list): + # PEP 508 strings (pyproject.toml) + if spec.get("language") == "python": + deps.extend(_normalize_pypi_dep(d) for d in raw) + else: + deps.extend(str(d).lower() for d in raw) + elif isinstance(raw, dict): + deps.extend(str(d).lower() for d in raw) + return deps + + +def _detect_js_runtime(target: Path, spec: dict[str, Any]) -> str: + """Detect JavaScript runtime from lockfiles declared in manifest spec.""" + lockfiles: dict[str, list[str]] = spec.get("runtime_lockfiles", {}) + for runtime, files in lockfiles.items(): + if not files: + continue + if any((target / f).exists() for f in files): + return runtime + # Default: last entry with empty file list, or "node" + for runtime, files in lockfiles.items(): + if not files: + return runtime + return "node" + + +def _detect_project_type(target: Path, manifests: list[str]) -> str | None: + """Infer project type from directory shape and manifest entries.""" + if not manifests: + return None + + if _detect_monorepo(target, manifests): + return "monorepo" + + if (target / "app").is_dir(): + return "app" + + has_src = (target / "src").is_dir() + has_tests = (target / "tests").is_dir() or (target / "test").is_dir() + + if has_src and has_tests and _has_cli_entry_points(target, manifests): + return "cli" + if has_src: + return "library" + return None + + +def _detect_monorepo(target: Path, manifests: list[str]) -> bool: + """Check if project uses workspaces.""" + for manifest in manifests: + spec = _get_manifest_spec(manifest) + if not spec: + continue + workspace_path = spec.get("workspace_path") + if not workspace_path: + continue + data = _read_manifest(target / manifest, spec["format"]) + if _traverse_dotpath(data, workspace_path): + return True + return False + + +def _has_cli_entry_points(target: Path, manifests: list[str]) -> bool: + """Check if any manifest declares CLI entry points.""" + for manifest in manifests: + spec = _get_manifest_spec(manifest) + if not spec: + continue + cli_path = spec.get("cli_entry_path") + if not cli_path: + continue + data = _read_manifest(target / manifest, spec["format"]) + if _traverse_dotpath(data, cli_path): + return True + return False + + +def detect_classification(target: Path) -> dict[str, Any]: + """Detect project type, language, framework, and runtime.""" + manifests = _find_manifests(target) + languages: list[str] = [] + runtime: str | None = None + dep_strings: list[str] = [] + + for manifest in manifests: + spec = _get_manifest_spec(manifest) + if not spec: + continue + + lang = spec["language"] + + # TypeScript override + ts_marker = spec.get("typescript_marker") + if ts_marker and (target / ts_marker).exists(): + lang = "typescript" + + languages.append(lang) + + # Runtime detection + if spec.get("runtime"): + runtime = runtime or spec["runtime"] + elif spec.get("runtime_lockfiles") and (not runtime or runtime == "cpython"): + runtime = _detect_js_runtime(target, spec) + + dep_strings.extend(_collect_deps_from_manifest(target, manifest)) + + # Framework detection + pt = _get_project_types() + framework: str | None = None + for fw in pt["frameworks"]: + if any(fw["match"] in d for d in dep_strings): + framework = fw["name"] + break + + return { + "type": _detect_project_type(target, manifests), + "language": languages or None, + "framework": framework, + "runtime": runtime, + } diff --git a/src/reporails_cli/core/discover_commands.py b/src/reporails_cli/core/discover_commands.py new file mode 100644 index 00000000..22da0675 --- /dev/null +++ b/src/reporails_cli/core/discover_commands.py @@ -0,0 +1,148 @@ +"""Command detection for backbone v3 discovery engine. + +Detects build/test/lint/format commands from task runners, manifest scripts, +Makefiles, and tool config files. Data-driven from bundled/project-types.yml. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from reporails_cli.core.discover import ( + _get_project_types, + _read_json, + _read_toml, + _traverse_dotpath, +) + + +def _detect_task_runner_commands( + target: Path, + commands: dict[str, str | None], +) -> None: + """Fill commands from YAML-declared task runners (mutates commands dict).""" + pt = _get_project_types() + pyproject = target / "pyproject.toml" + if not pyproject.exists(): + return + data = _read_toml(pyproject) + + for runner in pt["task_runners"]: + tasks = _traverse_dotpath(data, runner["config_path"]) + if not tasks or not isinstance(tasks, dict): + continue + prefix = runner["command_prefix"] + for key in list(commands): + if key in tasks: + task = tasks[key] + if isinstance(task, str) or (isinstance(task, dict) and task.get("cmd")): + commands[key] = f"{prefix} {key}" + # Test aliases (e.g., "qa" → test fallback) + for alias in runner.get("test_aliases", []): + if not commands["test"] and alias in tasks: + commands["test"] = f"{prefix} {alias}" + + +def _detect_script_source_commands( + target: Path, + commands: dict[str, str | None], +) -> None: + """Fill commands from YAML-declared script sources (mutates commands dict).""" + pt = _get_project_types() + for source in pt["script_sources"]: + if source.get("type") == "makefile": + _detect_makefile_commands(target, commands, source["command_prefix"]) + continue + + source_file = target / source["file"] + if not source_file.exists(): + continue + + scripts_path = source.get("scripts_path") + if not scripts_path: + continue + + data = _read_json(source_file) + scripts = _traverse_dotpath(data, scripts_path) + if not isinstance(scripts, dict): + continue + + prefix = source["command_prefix"] + for key in commands: + if not commands[key] and key in scripts: + commands[key] = f"{prefix} {key}" + + +def _detect_makefile_commands( + target: Path, + commands: dict[str, str | None], + prefix: str, +) -> None: + """Fill commands from Makefile targets (mutates commands dict).""" + makefile = target / "Makefile" + if not makefile.exists(): + return + try: + content = makefile.read_text(encoding="utf-8") + except OSError: + return + command_keys = set(commands) + for line in content.splitlines(): + if line and not line[0].isspace() and ":" in line: + make_target = line.split(":")[0].strip() + if make_target in command_keys and not commands[make_target]: + commands[make_target] = f"{prefix} {make_target}" + + +def _infer_tool_commands(target: Path, commands: dict[str, str | None]) -> None: + """Infer remaining commands from YAML-declared tool config checks.""" + pt = _get_project_types() + tool_cmds: dict[str, list[dict[str, Any]]] = pt.get("tool_commands", {}) + + for cmd_key, probes in tool_cmds.items(): + if commands.get(cmd_key): + continue # Already filled by higher-priority source + for probe in probes: + if _probe_matches(target, probe): + commands[cmd_key] = probe["command"] + break + + +def _probe_matches(target: Path, probe: dict[str, Any]) -> bool: + """Check if a tool command probe matches the target project.""" + # Direct file check (supports glob patterns like *.sln) + if "file" in probe and "config_paths" not in probe: + filename: str = probe["file"] + if "*" in filename: + return bool(sorted(target.glob(filename))) + return (target / filename).exists() + + # Config path check within a file + if "config_paths" in probe: + config_file = target / probe.get("config_file", "pyproject.toml") + if not config_file.exists(): + return False + data = _read_toml(config_file) if config_file.name.endswith(".toml") else _read_json(config_file) + return bool(any(_traverse_dotpath(data, cp) is not None for cp in probe["config_paths"])) + + return False + + +def detect_commands(target: Path) -> dict[str, str | None]: + """Detect build/test/lint/format commands from task runners and manifests. + + Priority: explicit task runner > manifest scripts > Makefile > inferred from tool configs. + """ + commands: dict[str, str | None] = {"build": None, "test": None, "lint": None, "format": None} + + # 1. Task runners (poe, taskipy) + _detect_task_runner_commands(target, commands) + + # 2. Script sources (package.json scripts, Makefile) + _detect_script_source_commands(target, commands) + + # 3. Infer from tool configs + _infer_tool_commands(target, commands) + + return commands diff --git a/src/reporails_cli/core/download.py b/src/reporails_cli/core/download.py index c3901719..3a501acb 100644 --- a/src/reporails_cli/core/download.py +++ b/src/reporails_cli/core/download.py @@ -87,7 +87,11 @@ def copy_bundled_yml_files(dest: Path) -> int: def copy_local_framework(source: Path) -> tuple[Path, int]: - """Copy rules from a local framework directory (dev mode).""" + """Copy rules from a local framework directory (dev mode). + + The framework repo has: rules/ (core + agent dirs), schemas/, docs/, registry/. + We copy into ~/.reporails/rules/ with a flat layout. + """ rules_path = get_reporails_home() / "rules" if rules_path.exists(): @@ -95,7 +99,18 @@ def copy_local_framework(source: Path) -> tuple[Path, int]: rules_path.mkdir(parents=True, exist_ok=True) count = 0 - for dir_name in ("core", "agents", "schemas", "docs"): + + # Copy rule directories (core/ + agent dirs like claude/, codex/, etc.) + rules_src = source / "rules" + if rules_src.is_dir(): + for child in sorted(rules_src.iterdir()): + if child.is_dir(): + dest_dir = rules_path / child.name + shutil.copytree(child, dest_dir) + count += sum(1 for _ in dest_dir.rglob("*") if _.is_file()) + + # Copy schemas and docs from repo root + for dir_name in ("schemas", "docs", "registry"): source_dir = source / dir_name if source_dir.exists() and source_dir.is_dir(): dest_dir = rules_path / dir_name diff --git a/src/reporails_cli/core/engine.py b/src/reporails_cli/core/engine.py deleted file mode 100644 index 17d82417..00000000 --- a/src/reporails_cli/core/engine.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Validation engine - orchestration only, no domain logic. - -Coordinates other modules to run validation. Helpers in engine_helpers.py. -""" - -from __future__ import annotations - -import contextlib -import logging -import time -from collections.abc import Callable -from pathlib import Path - -from reporails_cli.core.agents import ( - clear_agent_cache, - detect_agents, - filter_agents_by_exclude_dirs, - get_all_instruction_files, - get_all_scannable_files, -) -from reporails_cli.core.applicability import detect_features_filesystem, get_applicable_rules -from reporails_cli.core.bootstrap import ( - get_agent_vars, - is_initialized, -) -from reporails_cli.core.cache import record_scan -from reporails_cli.core.engine_helpers import ( - _compute_category_summary as _compute_category_summary, -) -from reporails_cli.core.engine_helpers import ( - _detect_capabilities as _detect_capabilities, -) -from reporails_cli.core.engine_helpers import ( - _filter_cached_judgments as _filter_cached_judgments, -) -from reporails_cli.core.engine_helpers import ( - _filter_dismissed_violations as _filter_dismissed_violations, -) -from reporails_cli.core.engine_helpers import ( - _find_project_root as _find_project_root, -) -from reporails_cli.core.engine_helpers import ( - _run_rule_validation as _run_rule_validation, -) -from reporails_cli.core.init import run_init -from reporails_cli.core.models import ( - PendingSemantic, - Rule, - SkippedExperimental, - ValidationResult, -) -from reporails_cli.core.registry import clear_rule_cache, get_experimental_rules, load_rules -from reporails_cli.core.sarif import dedupe_violations -from reporails_cli.core.scorer import calculate_score, estimate_friction - -ProgressCallback = Callable[[str, int, int], None] - -logger = logging.getLogger(__name__) - - -def build_template_context( - agent: str, - instruction_files: list[Path], - rules_paths: list[Path] | None = None, -) -> dict[str, str | list[str]]: - """Build template context for rule placeholder resolution. - - With an agent: loads vars from agent config (e.g., claude → CLAUDE.md patterns). - Without an agent: derives minimal vars from detected instruction files so - core rules can resolve {{instruction_files}} and {{main_instruction_file}}. - """ - if agent: - vars = get_agent_vars(agent, rules_paths=rules_paths) - if vars: - return vars - # Agent has no config.yml — fall through to file-based derivation - # Derive from detected files so rules can resolve {{instruction_files}} - rel_patterns = [f"**/{f.name}" for f in instruction_files] - return { - "instruction_files": rel_patterns, - "main_instruction_file": rel_patterns[:1], - } - - -def run_validation( # pylint: disable=too-many-arguments,too-many-locals,too-many-statements - target: Path, - rules: dict[str, Rule] | None = None, - rules_paths: list[Path] | None = None, - use_cache: bool = True, - record_analytics: bool = True, - agent: str = "", - include_experimental: bool = False, - exclude_dirs: list[str] | None = None, - on_progress: ProgressCallback | None = None, -) -> ValidationResult: - """Run full validation: capability detection then rule validation.""" - start_time = time.perf_counter() - _notify = on_progress or (lambda *_: None) - _notify("Loading rules", 1, 3) - scan_root = target.parent if target.is_file() else target - project_root = _find_project_root(scan_root) - - # Clear file-discovery and rule caches when refresh requested - if not use_cache: - clear_agent_cache() - clear_rule_cache() - - # Auto-init if needed (downloads rules framework) - if not is_initialized(): - logger.info("Downloading rules framework...") - run_init() - - # Detect agents once — reuse for all file lookups (avoids redundant recursive globs) - # Use scan_root (not project_root) so only files inside the target are scanned - all_agents = detect_agents(scan_root) - - # Resolve effective agent: explicit flag or generic default (agents.md convention) - from reporails_cli.core.agents import filter_agents_by_id - - effective_agent = agent if agent else "generic" - agents = filter_agents_by_id(all_agents, effective_agent) - agents = filter_agents_by_exclude_dirs(agents, scan_root, exclude_dirs) - - instruction_files = get_all_instruction_files(scan_root, agents=agents) - - # Get template vars for yml placeholder resolution - template_context = build_template_context(effective_agent, instruction_files, rules_paths) - - # Load rules if not provided - if rules is None: - rules = load_rules( - rules_paths, - include_experimental=include_experimental, - project_root=project_root, - agent=effective_agent, - scan_root=scan_root, - ) - - # Track skipped experimental rules (for display when not included) - skipped_experimental = None - if not include_experimental: - exp_rules = get_experimental_rules(rules_paths[0] if rules_paths else None) - if exp_rules: - skipped_experimental = SkippedExperimental( - rule_count=len(exp_rules), - rules=tuple(sorted(exp_rules.keys())), - ) - - _notify("Checking files", 2, 3) - - # PASS 1: Capability Detection (determines final level) - features = detect_features_filesystem(scan_root, agents=agents) - capability, extra_targets = _detect_capabilities( - scan_root, - template_context, - features, - instruction_files=instruction_files or None, - ) - final_level = capability.level - - # Filter rules by FINAL level - applicable_rules = get_applicable_rules(rules, final_level) - - # Target-scoped files for rule validation (includes config files for path-filtered rules) - target_instruction_files = get_all_scannable_files(scan_root, agents=agents) or None - if target_instruction_files and extra_targets: - target_instruction_files = list(target_instruction_files) + list(extra_targets) - - # PASS 2: Rule Validation (mechanical + regex + semantic) - violations, judgment_requests = _run_rule_validation( - applicable_rules, - scan_root, - template_context, - extra_targets, - target_instruction_files, - exclude_dirs, - ) - - # Semantic Cache: filter already-evaluated judgments - judgment_requests, violations = _filter_cached_judgments( - judgment_requests, - violations, - scan_root, - project_root, - use_cache, - ) - - # Dismissed violations: filter deterministic violations cached as 'pass' - violations = _filter_dismissed_violations(violations, scan_root, project_root, use_cache) - - _notify("Scoring", 3, 3) - - # Scoring - unique_violations = dedupe_violations(violations) - category_summary = _compute_category_summary(applicable_rules, unique_violations) - score = calculate_score(len(applicable_rules), unique_violations) - friction = estimate_friction(unique_violations) - rules_failed = len({v.rule_id for v in unique_violations}) - - # Record analytics - elapsed_ms = (time.perf_counter() - start_time) * 1000 - if record_analytics: - with contextlib.suppress(OSError): - record_scan( - target, - score, - final_level.value, - len(violations), - len(applicable_rules), - elapsed_ms, - features.instruction_file_count, - ) - - # Build pending semantic summary - pending_semantic = None - if judgment_requests: - unique_rules = sorted({jr.rule_id for jr in judgment_requests}) - unique_files = {jr.location.rsplit(":", 1)[0] for jr in judgment_requests} - pending_semantic = PendingSemantic( - rule_count=len(unique_rules), - file_count=len(unique_files), - rules=tuple(unique_rules), - ) - - return ValidationResult( - score=score, - level=final_level, - violations=tuple(unique_violations), - judgment_requests=tuple(judgment_requests), - rules_checked=len(applicable_rules), - rules_passed=len(applicable_rules) - rules_failed, - rules_failed=rules_failed, - feature_summary=capability.feature_summary, - has_orphan_features=capability.has_orphan_features, - friction=friction, - category_summary=category_summary, - is_partial=bool(judgment_requests), # Partial if semantic rules pending - pending_semantic=pending_semantic, - skipped_experimental=skipped_experimental, - ) - - -def run_validation_sync( # pylint: disable=too-many-arguments - target: Path, - rules: dict[str, Rule] | None = None, - rules_paths: list[Path] | None = None, - use_cache: bool = True, - record_analytics: bool = True, - agent: str = "", - include_experimental: bool = False, - exclude_dirs: list[str] | None = None, - on_progress: ProgressCallback | None = None, -) -> ValidationResult: - """Synchronous entry point for run_validation.""" - return run_validation( - target, - rules, - rules_paths, - use_cache, - record_analytics, - agent, - include_experimental, - exclude_dirs, - on_progress, - ) diff --git a/src/reporails_cli/core/engine_helpers.py b/src/reporails_cli/core/engine_helpers.py index 89657fe5..c29f11b3 100644 --- a/src/reporails_cli/core/engine_helpers.py +++ b/src/reporails_cli/core/engine_helpers.py @@ -4,25 +4,16 @@ from pathlib import Path -from reporails_cli.bundled import get_capability_patterns_path -from reporails_cli.core.agents import get_all_instruction_files from reporails_cli.core.cache import ProjectCache, content_hash, structural_hash -from reporails_cli.core.capability import detect_features_content, determine_capability_level from reporails_cli.core.models import ( Category, CategoryStats, + ClassifiedFile, JudgmentRequest, Rule, - RuleType, Severity, Violation, ) -from reporails_cli.core.pipeline import build_initial_state -from reporails_cli.core.pipeline_exec import execute_rule_checks -from reporails_cli.core.regex import get_rule_yml_paths -from reporails_cli.core.regex import run_validation as run_regex_validation -from reporails_cli.core.results import CapabilityResult, DetectedFeatures -from reporails_cli.core.sarif import distribute_sarif_by_rule def _find_project_root(target: Path) -> Path: @@ -36,7 +27,7 @@ def _find_project_root(target: Path) -> Path: while current != current.parent: if (current / ".git").exists() and first_git is None: first_git = current - backbone = current / ".reporails" / "backbone.yml" + backbone = current / ".ails" / "backbone.yml" if backbone.exists(): return current current = current.parent @@ -53,19 +44,21 @@ def _find_project_root(target: Path) -> Path: # Category enum → single-letter display code _CATEGORY_CODE: dict[Category, str] = { Category.STRUCTURE: "S", - Category.CONTENT: "C", + Category.COHERENCE: "C", + Category.DIRECTION: "D", Category.EFFICIENCY: "E", Category.MAINTENANCE: "M", Category.GOVERNANCE: "G", } # Canonical display order -_CATEGORY_ORDER = ("S", "C", "E", "M", "G") +_CATEGORY_ORDER = ("S", "C", "D", "E", "M", "G") # Code → human name _CATEGORY_NAMES = { "S": "Structure", - "C": "Content", + "C": "Coherence", + "D": "Direction", "E": "Efficiency", "M": "Maintenance", "G": "Governance", @@ -117,87 +110,47 @@ def _compute_category_summary( return tuple(stats) -def _detect_capabilities( - project_root: Path, - template_context: dict[str, str | list[str]], - features: DetectedFeatures, - instruction_files: list[Path] | None = None, -) -> tuple[CapabilityResult, list[Path] | None]: - """Run PASS 1: capability detection and return (capability_result, extra_targets).""" - extra_targets = features.resolved_symlinks or None - - # Project-scoped instruction files for capability detection - project_instruction_files = instruction_files or get_all_instruction_files(project_root) or None - if project_instruction_files and extra_targets: - project_instruction_files = list(project_instruction_files) + list(extra_targets) - - capability_patterns = get_capability_patterns_path() - capability_sarif: dict[str, object] = {} - if capability_patterns.exists(): - capability_sarif = run_regex_validation( - [capability_patterns], - project_root, - template_context, - extra_targets=extra_targets, - instruction_files=project_instruction_files, - ) - - content_features = detect_features_content(capability_sarif) - capability = determine_capability_level(features, content_features) - return capability, extra_targets - +def _group_rules_by_target_files( + rules: dict[str, Rule], + classified_files: list[ClassifiedFile], +) -> dict[frozenset[Path], dict[str, Rule]]: + """Group rules by resolved file targets for batched regex calls.""" + from reporails_cli.core.classification import match_files -def _run_rule_validation( - applicable_rules: dict[str, Rule], - scan_root: Path, - template_context: dict[str, str | list[str]], - extra_targets: list[Path] | None, - target_instruction_files: list[Path] | None, - exclude_dirs: list[str] | None, -) -> tuple[list[Violation], list[JudgmentRequest]]: - """Run PASS 2: batch regex gather, then per-rule ordered check iteration.""" - state = build_initial_state(target_instruction_files, scan_root) - - # Batch regex: gather ALL deterministic + semantic yml paths - regex_rules = {k: v for k, v in applicable_rules.items() if v.type in (RuleType.DETERMINISTIC, RuleType.SEMANTIC)} - rule_yml_paths = get_rule_yml_paths(regex_rules) - rule_sarif: dict[str, object] = {} - if rule_yml_paths: - rule_sarif = run_regex_validation( - rule_yml_paths, - scan_root, - template_context, - extra_targets=extra_targets, - instruction_files=target_instruction_files, - exclude_dirs=exclude_dirs, - ) - state._sarif_by_rule = distribute_sarif_by_rule(rule_sarif, regex_rules) + groups: dict[frozenset[Path], dict[str, Rule]] = {} + for rule_id, rule in rules.items(): + if rule.match is not None: + matched = match_files(classified_files, rule.match) + file_set = frozenset(cf.path for cf in matched) + else: + file_set = frozenset(cf.path for cf in classified_files) + groups.setdefault(file_set, {})[rule_id] = rule + return groups - # Pre-compute bound template vars once (avoids re-computing per rule) - from reporails_cli.core.mechanical.runner import bind_instruction_files - effective_vars = bind_instruction_files(template_context, scan_root, target_instruction_files) +def _collect_body_only_paths(group_rules: dict[str, Rule]) -> set[Path] | None: + """Collect yml_paths for rules whose match.format is exclusively 'freeform'. - # Per-rule iteration (ordered check execution) - all_judgment_requests: list[JudgmentRequest] = [] - for rule in applicable_rules.values(): - jrs = execute_rule_checks( - rule, - state, - scan_root, - effective_vars, - None, # instruction_files already bound in effective_vars - ) - all_judgment_requests.extend(jrs) + These rules target body content only — their regex checks should strip + YAML frontmatter before matching (frontmatter is metadata, not content). - return state.findings, all_judgment_requests + Rules with format lists containing 'frontmatter' need to see the full file. + """ + paths: set[Path] = set() + for rule in group_rules.values(): + if rule.yml_path is None or not rule.yml_path.exists(): + continue + if rule.match is not None and rule.match.format == "freeform": + paths.add(rule.yml_path) + return paths or None -def _filter_dismissed_violations( +def _filter_dismissed_violations( # pylint: disable=too-many-locals violations: list[Violation], scan_root: Path, project_root: Path, use_cache: bool, + rules_fp: str = "", ) -> list[Violation]: """Filter out dismissed violations (cached as 'pass' in judgment cache). @@ -209,6 +162,11 @@ def _filter_dismissed_violations( return violations cache = ProjectCache(project_root) + cache_data = cache.load_judgment_cache(rules_fingerprint=rules_fp) + all_judgments = cache_data.get("judgments", {}) + if not all_judgments: + return violations + filtered: list[Violation] = [] for v in violations: raw_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location @@ -217,6 +175,11 @@ def _filter_dismissed_violations( except ValueError: rel_path = raw_path + entry = all_judgments.get(rel_path) + if not entry: + filtered.append(v) + continue + try: full_file = scan_root / rel_path file_hash = content_hash(full_file) @@ -225,27 +188,36 @@ def _filter_dismissed_violations( filtered.append(v) continue - cached = cache.get_cached_judgment(rel_path, file_hash, structural_hash=struct_hash) - if cached and v.rule_id in cached: - verdict = cached[v.rule_id] - if verdict.get("verdict") == "pass": - continue # Dismissed — skip + # Match on content or structural hash + if entry.get("content_hash") != file_hash and entry.get("structural_hash") != struct_hash: + filtered.append(v) + continue + + results = entry.get("results", {}) + if v.rule_id in results and results[v.rule_id].get("verdict") == "pass": + continue # Dismissed — skip filtered.append(v) return filtered -def _filter_cached_judgments( +def _filter_cached_judgments( # pylint: disable=too-many-locals judgment_requests: list[JudgmentRequest], violations: list[Violation], scan_root: Path, project_root: Path, use_cache: bool, + rules_fp: str = "", ) -> tuple[list[JudgmentRequest], list[Violation]]: """Filter already-evaluated judgments from cache. Returns (remaining_requests, updated_violations).""" if not judgment_requests or not use_cache: return judgment_requests, violations cache = ProjectCache(project_root) + cache_data = cache.load_judgment_cache(rules_fingerprint=rules_fp) + all_judgments = cache_data.get("judgments", {}) + if not all_judgments: + return judgment_requests, violations + filtered_requests: list[JudgmentRequest] = [] for jr in judgment_requests: raw_path = jr.location.rsplit(":", 1)[0] if ":" in jr.location else jr.location @@ -261,9 +233,19 @@ def _filter_cached_judgments( filtered_requests.append(jr) continue - cached = cache.get_cached_judgment(rel_path, file_hash, structural_hash=struct_hash) - if cached and jr.rule_id in cached: - verdict = cached[jr.rule_id] + entry = all_judgments.get(rel_path) + if not entry: + filtered_requests.append(jr) + continue + + # Match on content or structural hash + if entry.get("content_hash") != file_hash and entry.get("structural_hash") != struct_hash: + filtered_requests.append(jr) + continue + + results = entry.get("results", {}) + if jr.rule_id in results: + verdict = results[jr.rule_id] if verdict.get("verdict") == jr.pass_value: continue # Previously evaluated as pass — skip violations.append( diff --git a/src/reporails_cli/core/harness.py b/src/reporails_cli/core/harness.py index 83b85c23..71e9f241 100644 --- a/src/reporails_cli/core/harness.py +++ b/src/reporails_cli/core/harness.py @@ -21,7 +21,9 @@ import yaml +from reporails_cli.core.classification import classify_files, load_file_types from reporails_cli.core.mechanical.checks import MECHANICAL_CHECKS, CheckResult +from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration from reporails_cli.core.regex import run_validation as run_regex_validation from reporails_cli.core.utils import parse_frontmatter @@ -72,49 +74,51 @@ class HarnessResult: def load_agent_config( rules_root: Path, agent: str, -) -> tuple[dict[str, str | list[str]], list[str]]: - """Load agent config.yml and return (vars, excludes). +) -> tuple[list[FileTypeDeclaration], list[str]]: + """Load agent config.yml and return (file_types, excludes). Args: rules_root: Rules repository root directory. agent: Agent name (e.g., "claude"). Returns: - Tuple of (template_vars, exclude_patterns). + Tuple of (file_type declarations, exclude_patterns). """ - config_path = rules_root / "agents" / agent / "config.yml" + config_path = rules_root / agent / "config.yml" if not config_path.exists(): logger.warning("Agent config not found: %s", config_path) - return {}, [] + return [], [] try: data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except (yaml.YAMLError, OSError) as exc: logger.warning("Failed to load agent config: %s", exc) - return {}, [] + return [], [] + + file_types = load_file_types(agent, [rules_root]) + return file_types, data.get("excludes", []) - raw_vars = data.get("vars", {}) - vars_out: dict[str, str | list[str]] = {} - for key, value in raw_vars.items(): - if isinstance(value, list): - vars_out[key] = [str(v) for v in value] - else: - vars_out[key] = str(value) - return vars_out, data.get("excludes", []) +def _classify_fixture( + fixture_root: Path, + file_types: list[FileTypeDeclaration], +) -> list[ClassifiedFile]: + """Classify files in a fixture directory against file type declarations.""" + files = [f for f in fixture_root.rglob("*") if f.is_file() and f.name not in _FIXTURE_EXCLUDES] + return classify_files(fixture_root, files, file_types) def _build_prefix_to_agent_map(rules_root: Path) -> dict[str, str]: """Build NAMESPACE_PREFIX → agent_name map from agent configs. - Reads each agents//config.yml for the ``prefix`` field. + Reads each {agent}/config.yml for the ``prefix`` field. + Agent directories sit alongside core/ (flat layout). Returns mapping like ``{"CLAUDE": "claude", "CODEX": "codex"}``. """ - agents_dir = rules_root / "agents" - if not agents_dir.is_dir(): - return {} mapping: dict[str, str] = {} - for agent_dir in sorted(agents_dir.iterdir()): - if not agent_dir.is_dir(): + if not rules_root.is_dir(): + return mapping + for agent_dir in sorted(rules_root.iterdir()): + if not agent_dir.is_dir() or agent_dir.name == "core": continue config_path = agent_dir / "config.yml" if not config_path.exists(): @@ -141,11 +145,10 @@ class RuleInfo: # pylint: disable=too-many-instance-attributes title: str category: str rule_type: str - level: str - targets: str + match: dict[str, str] checks: list[dict[str, Any]] rule_dir: Path - rule_yml: Path + checks_yml: Path @property def has_checks(self) -> bool: @@ -176,23 +179,27 @@ def _rule_matches_exclude(rule_id: str, patterns: list[str]) -> bool: def _scan_root(root: Path, agent: str | None = None) -> list[Path]: - """Find rule directories under a single root (core/ + agents/*/rules/).""" + """Find rule directories under a single root. + + Layout: core/{slug}/ for core rules, {agent}/{slug}/ for agent rules. + Agent directories sit alongside core/ and contain a config.yml. + """ dirs: list[Path] = [] core_dir = root / "core" if core_dir.exists(): - for cat_dir in sorted(core_dir.iterdir()): - if cat_dir.is_dir(): - dirs.extend(d for d in sorted(cat_dir.iterdir()) if d.is_dir()) - - agents_dir = root / "agents" - if agents_dir.exists(): - for agent_dir in sorted(agents_dir.iterdir()): - if agent and agent_dir.name != agent: + dirs.extend(d for d in sorted(core_dir.iterdir()) if d.is_dir()) + + # Agent dirs are siblings of core/ that contain a config.yml + if root.is_dir(): + for candidate in sorted(root.iterdir()): + if not candidate.is_dir() or candidate.name == "core": + continue + if not (candidate / "config.yml").exists(): continue - rules_subdir = agent_dir / "rules" - if rules_subdir.is_dir(): - dirs.extend(d for d in sorted(rules_subdir.iterdir()) if d.is_dir()) + if agent and candidate.name != agent: + continue + dirs.extend(d for d in sorted(candidate.iterdir()) if d.is_dir() and d.name != "tests") return dirs @@ -226,7 +233,7 @@ def discover_rules( # pylint: disable=too-many-locals for root, slug_dir in search_pairs: rule_md = slug_dir / "rule.md" - rule_yml = slug_dir / "rule.yml" + checks_yml = slug_dir / "checks.yml" if not rule_md.exists(): continue @@ -250,6 +257,14 @@ def discover_rules( # pylint: disable=too-many-locals if _rule_matches_exclude(rule_id, excludes): continue + checks = meta.get("checks", []) + if not checks and checks_yml.exists(): + try: + yml_data = yaml.safe_load(checks_yml.read_text(encoding="utf-8")) + checks = yml_data.get("checks", []) if isinstance(yml_data, dict) else [] + except (OSError, yaml.YAMLError): + checks = [] + rules.append( RuleInfo( rule_id=rule_id, @@ -257,11 +272,10 @@ def discover_rules( # pylint: disable=too-many-locals title=meta.get("title", ""), category=meta.get("category", ""), rule_type=meta.get("type", ""), - level=meta.get("level", ""), - targets=meta.get("targets", ""), - checks=meta.get("checks", []), + match=meta.get("match") or {}, + checks=checks, rule_dir=slug_dir, - rule_yml=rule_yml, + checks_yml=checks_yml, ) ) @@ -271,55 +285,10 @@ def discover_rules( # pylint: disable=too-many-locals # ── Check execution ───────────────────────────────────────────────── -def _resolve_var(template: str, agent_vars: dict[str, str | list[str]]) -> list[str]: - """Resolve a template variable like {{instruction_files}} to its values.""" - if not template.startswith("{{") or not template.endswith("}}"): - return [template] - var_name = template[2:-2] - value = agent_vars.get(var_name, template) - if isinstance(value, list): - return value - return [value] - - -def _resolve_vars_in_rule(rule: dict[str, Any], agent_vars: dict[str, str | list[str]]) -> dict[str, Any]: - """Recursively resolve {{var}} placeholders in an OpenGrep rule dict.""" - import copy - - rule = copy.deepcopy(rule) - - def resolve(value: Any) -> Any: - if isinstance(value, str): - for key, val in agent_vars.items(): - placeholder = "{{" + key + "}}" - if placeholder in value: - if value == placeholder and isinstance(val, list): - return val - if isinstance(val, list): - value = value.replace(placeholder, val[0] if val else "") - else: - value = value.replace(placeholder, str(val)) - return value - if isinstance(value, list): - expanded: list[Any] = [] - for item in value: - resolved = resolve(item) - if isinstance(resolved, list): - expanded.extend(resolved) - else: - expanded.append(resolved) - return expanded - if isinstance(value, dict): - return {k: resolve(v) for k, v in value.items()} - return value - - return resolve(rule) # type: ignore[no-any-return] - - def _run_mechanical_check( check: dict[str, Any], fixture_root: Path, - agent_vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Run a single mechanical check against a fixture directory.""" check_name = check.get("check", "") or check.get("name", "") @@ -330,36 +299,35 @@ def _run_mechanical_check( logger.warning("Unknown mechanical check: %s", check_name) return CheckResult(passed=False, message=f"Unknown mechanical check: {check_name}") - result = fn(fixture_root, args, agent_vars) + result = fn(fixture_root, args, classified_files) - if check.get("negate"): + if check.get("expect", "present") == "absent": result = CheckResult(passed=not result.passed, message=result.message) return result # type: ignore[no-any-return] def _run_deterministic_check( # pylint: disable=too-many-locals - rule_yml: Path, + checks_yml: Path, check: dict[str, Any], fixture_root: Path, - agent_vars: dict[str, str | list[str]], ) -> tuple[bool, int, str]: """Run a deterministic check via the CLI regex engine against fixtures. Returns: Tuple of (engine_ok, findings_count, message). """ - if not rule_yml.exists(): - return False, 0, f"rule.yml not found: {rule_yml}" + if not checks_yml.exists(): + return False, 0, f"checks.yml not found: {checks_yml}" try: - yml_content = yaml.safe_load(rule_yml.read_text(encoding="utf-8")) + yml_content = yaml.safe_load(checks_yml.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError) as exc: - return False, 0, f"Failed to load rule.yml: {exc}" + return False, 0, f"Failed to load checks.yml: {exc}" - yml_rules = yml_content.get("rules", []) + yml_rules = yml_content.get("checks") or [] if not yml_rules: - return False, 0, "rule.yml has no patterns (rules: [])" + return False, 0, "checks.yml has no patterns (checks: [])" # Find the matching rule entry by check ID check_id = check.get("id", "") @@ -374,17 +342,13 @@ def _run_deterministic_check( # pylint: disable=too-many-locals matching_rule = yml_rules[0] if matching_rule is None: - return False, 0, f"No pattern for check {check_id} in rule.yml" - - # Resolve template variables - if agent_vars: - matching_rule = _resolve_vars_in_rule(matching_rule, agent_vars) + return False, 0, f"No pattern for check {check_id} in checks.yml" # Write a temp rule file and run the CLI regex engine import tempfile with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tmp: - yaml.dump({"rules": [matching_rule]}, tmp) + yaml.dump({"checks": [matching_rule]}, tmp) tmp_path = Path(tmp.name) try: @@ -393,7 +357,6 @@ def _run_deterministic_check( # pylint: disable=too-many-locals sarif = run_regex_validation( [tmp_path], fixture_root, - agent_vars, instruction_files=fixture_files if fixture_files else None, ) findings = 0 @@ -468,21 +431,20 @@ def _apply_scaffold_action( action_type: str, args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> None: """Apply a single scaffold action to the temp directory.""" if action_type == "file": - _scaffold_file(args, tmp_dir, agent_vars) + _scaffold_file(args, tmp_dir, file_types) elif action_type == "dir": path = str(args.get("path", "")) - resolved = _resolve_var_str(path, agent_vars) - target = tmp_dir / resolved + target = tmp_dir / path if not target.is_dir(): target.mkdir(parents=True, exist_ok=True) elif action_type == "glob_count": - _scaffold_glob_count(args, tmp_dir, agent_vars) + _scaffold_glob_count(args, tmp_dir) elif action_type == "file_removal": - _scaffold_file_removal(args, tmp_dir, agent_vars) + _scaffold_file_removal(args, tmp_dir) elif action_type == "git_marker": marker = tmp_dir / ".git_marker" if not marker.exists() and not (tmp_dir / ".git").exists(): @@ -492,20 +454,18 @@ def _apply_scaffold_action( def _scaffold_file( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> None: """Scaffold a single file for file_exists/glob_match checks.""" path_pattern = str(args.get("path", "")) if path_pattern: - resolved = _resolve_var_str(path_pattern, agent_vars) - concrete = _glob_to_concrete(resolved) + concrete = _glob_to_concrete(path_pattern) else: - patterns = agent_vars.get("instruction_files", []) - if isinstance(patterns, str): - patterns = [patterns] - if not patterns: + # Use first file type pattern as fallback + if file_types: + concrete = _glob_to_concrete(file_types[0].patterns[0] if file_types[0].patterns else "scaffold.md") + else: return - concrete = _glob_to_concrete(str(patterns[0])) target = tmp_dir / concrete if not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) @@ -515,16 +475,14 @@ def _scaffold_file( def _scaffold_glob_count( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], ) -> None: """Scaffold files for glob_count/file_count checks.""" raw_pattern = str(args.get("pattern", "**/*")) - resolved = _resolve_var_str(raw_pattern, agent_vars) min_count = int(args.get("min", 1)) - existing = list(tmp_dir.glob(resolved)) + existing = list(tmp_dir.glob(raw_pattern)) needed = max(0, min_count - len(existing)) for i in range(needed): - concrete = _glob_to_concrete(resolved) + concrete = _glob_to_concrete(raw_pattern) base, ext = concrete.rsplit(".", 1) if "." in concrete else (concrete, "") name = f"{base}_{i}.{ext}" if ext else f"{base}_{i}" target = tmp_dir / name @@ -535,7 +493,7 @@ def _scaffold_glob_count( def _scaffold_fixture( fixture_root: Path, checks: list[dict[str, Any]], - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> Path | None: """Create a scaffolded copy of a pass fixture with M check dependencies. @@ -553,28 +511,14 @@ def _scaffold_fixture( shutil.copytree(fixture_root, tmp_dir, dirs_exist_ok=True) for action_type, args in actions: - _apply_scaffold_action(action_type, args, tmp_dir, agent_vars) + _apply_scaffold_action(action_type, args, tmp_dir, file_types) return tmp_dir -def _resolve_var_str(template: str, agent_vars: dict[str, str | list[str]]) -> str: - """Resolve {{var}} placeholders in a string, picking first list element.""" - result = template - for key, value in agent_vars.items(): - placeholder = "{{" + key + "}}" - if placeholder in result: - if isinstance(value, list): - result = result.replace(placeholder, value[0] if value else "") - else: - result = result.replace(placeholder, str(value)) - return result - - def _scaffold_file_removal( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], ) -> None: """Remove forbidden file from pass scaffold (for file_absent checks).""" import glob as globmod @@ -582,14 +526,13 @@ def _scaffold_file_removal( pattern = str(args.get("pattern", "")) if not pattern: return - resolved = _resolve_var_str(pattern, agent_vars) # Try as plain path first - target = tmp_dir / resolved + target = tmp_dir / pattern if target.exists(): target.unlink() return # Try as glob - for match in globmod.glob(str(tmp_dir / resolved), recursive=True): + for match in globmod.glob(str(tmp_dir / pattern), recursive=True): Path(match).unlink() @@ -623,21 +566,35 @@ def _apply_fail_scaffold_action( action_type: str, args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> None: """Apply a single fail scaffold action to the temp directory.""" if action_type == "filename_mismatch": - _scaffold_filename_mismatch(args, tmp_dir, agent_vars) + _scaffold_filename_mismatch(args, tmp_dir, file_types) elif action_type == "glob_count_deficit": - _scaffold_glob_count_deficit(args, tmp_dir, agent_vars) + _scaffold_glob_count_deficit(args, tmp_dir) elif action_type == "file_present": - _scaffold_file_present(args, tmp_dir, agent_vars) + _scaffold_file_present(args, tmp_dir) + + +def _get_target_patterns_from_args( + args: dict[str, Any], + file_types: list[FileTypeDeclaration], +) -> list[str]: + """Get file patterns from args or file_types for scaffolding.""" + path_pattern = args.get("path", "") + if path_pattern: + return [str(path_pattern)] + # Fall back to first file type's patterns + if file_types: + return list(file_types[0].patterns) + return [] def _scaffold_filename_mismatch( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> None: """Rename existing files so they fail filename_matches_pattern. @@ -646,7 +603,7 @@ def _scaffold_filename_mismatch( """ import glob as globmod - for fp in _get_target_patterns_from_args(args, agent_vars): + for fp in _get_target_patterns_from_args(args, file_types): resolved = str(tmp_dir / fp) for match_path in globmod.glob(resolved, recursive=True): match = Path(match_path) @@ -655,7 +612,7 @@ def _scaffold_filename_mismatch( match.rename(dest) return # One rename is sufficient to cause failure # No existing files to rename — create one with a bad name - patterns = _get_target_patterns_from_args(args, agent_vars) + patterns = _get_target_patterns_from_args(args, file_types) concrete = _glob_to_concrete(patterns[0] if patterns else "**/*.md") base = Path(concrete) target = tmp_dir / base.parent / f"_scaffold_invalid{base.suffix or '.md'}" @@ -666,13 +623,11 @@ def _scaffold_filename_mismatch( def _scaffold_glob_count_deficit( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], ) -> None: """Remove files to get count below min for glob_count/file_count checks.""" raw_pattern = str(args.get("pattern", "**/*")) - resolved = _resolve_var_str(raw_pattern, agent_vars) min_count = int(args.get("min", 1)) - existing = sorted(tmp_dir.glob(resolved)) + existing = sorted(tmp_dir.glob(raw_pattern)) existing_files = [f for f in existing if f.is_file()] # Remove files until count is below min target_count = max(0, min_count - 1) @@ -684,38 +639,22 @@ def _scaffold_glob_count_deficit( def _scaffold_file_present( args: dict[str, Any], tmp_dir: Path, - agent_vars: dict[str, str | list[str]], ) -> None: """Create the forbidden file so file_absent fails.""" pattern = str(args.get("pattern", "")) if not pattern: return - resolved = _resolve_var_str(pattern, agent_vars) # Create as plain path - target = tmp_dir / resolved + target = tmp_dir / pattern target.parent.mkdir(parents=True, exist_ok=True) if not target.exists(): target.touch() -def _get_target_patterns_from_args( - args: dict[str, Any], - agent_vars: dict[str, str | list[str]], -) -> list[str]: - """Get file patterns from args or vars (harness-side mirror of checks._get_target_patterns).""" - path_pattern = args.get("path", "") - if path_pattern: - return [_resolve_var_str(str(path_pattern), agent_vars)] - patterns = agent_vars.get("instruction_files", []) - if isinstance(patterns, str): - patterns = [patterns] - return list(patterns) - - def _scaffold_fail_fixture( fixture_root: Path, checks: list[dict[str, Any]], - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> Path | None: """Create a scaffolded copy of a fail fixture with M check dependencies. @@ -735,11 +674,11 @@ def _scaffold_fail_fixture( # First, apply pass scaffolding so structural deps exist pass_actions = _collect_scaffold_actions(checks) for action_type, args in pass_actions: - _apply_scaffold_action(action_type, args, tmp_dir, agent_vars) + _apply_scaffold_action(action_type, args, tmp_dir, file_types) # Then apply fail actions to break the targeted checks for action_type, args in fail_actions: - _apply_fail_scaffold_action(action_type, args, tmp_dir, agent_vars) + _apply_fail_scaffold_action(action_type, args, tmp_dir, file_types) return tmp_dir @@ -749,7 +688,7 @@ def _scaffold_fail_fixture( def _prepare_scaffolds( rule: RuleInfo, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> tuple[Path | None, Path | None, Path, Path]: """Prepare scaffolded fixtures for a rule. @@ -760,11 +699,11 @@ def _prepare_scaffolds( scaffolded_pass: Path | None = None if rule.has_pass_fixture: - scaffolded_pass = _scaffold_fixture(pass_dir, rule.checks, agent_vars) + scaffolded_pass = _scaffold_fixture(pass_dir, rule.checks, file_types) scaffolded_fail: Path | None = None if rule.has_fail_fixture: - scaffolded_fail = _scaffold_fail_fixture(fail_dir, rule.checks, agent_vars) + scaffolded_fail = _scaffold_fail_fixture(fail_dir, rule.checks, file_types) effective_pass = scaffolded_pass if scaffolded_pass else pass_dir effective_fail = scaffolded_fail if scaffolded_fail else fail_dir @@ -773,7 +712,7 @@ def _prepare_scaffolds( def run_rule( rule: RuleInfo, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> HarnessResult: """Run all checks for a rule against its fixtures. @@ -800,18 +739,18 @@ def run_rule( return result fail_violation_found = False - scaffolded_pass, scaffolded_fail, effective_pass_dir, effective_fail_dir = _prepare_scaffolds(rule, agent_vars) + scaffolded_pass, scaffolded_fail, effective_pass_dir, effective_fail_dir = _prepare_scaffolds(rule, file_types) try: for check in rule.checks: check_id = check.get("id", "unknown") check_type = check.get("type", "unknown") - negate = check.get("negate", False) + expect = check.get("expect", "present") # === Pass fixture: ALL checks must pass === if rule.has_pass_fixture: passed, run = _check_fixture( - check, check_id, check_type, negate, effective_pass_dir, rule, agent_vars, "pass" + check, check_id, check_type, expect, effective_pass_dir, rule, file_types, "pass" ) result.check_runs.append(run) if not passed: @@ -820,7 +759,7 @@ def run_rule( # === Fail fixture: at least ONE check must detect a violation === if rule.has_fail_fixture: violation, run = _check_fixture_for_violation( - check, check_id, check_type, negate, effective_fail_dir, rule, agent_vars + check, check_id, check_type, expect, effective_fail_dir, rule, file_types ) result.check_runs.append(run) if violation: @@ -842,24 +781,25 @@ def _check_fixture( check: dict[str, Any], check_id: str, check_type: str, - negate: bool, + expect: str, fixture_root: Path, rule: RuleInfo, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], fixture_name: str, ) -> tuple[bool, CheckRun]: """Run a check against a pass fixture. Returns (passed, CheckRun).""" if check_type == "mechanical": - cr = _run_mechanical_check(check, fixture_root, agent_vars) + classified = _classify_fixture(fixture_root, file_types) + cr = _run_mechanical_check(check, fixture_root, classified) return cr.passed, CheckRun(check_id, check_type, fixture_name, cr.passed, cr.message) if check_type == "deterministic": - ok, count, msg = _run_deterministic_check(rule.rule_yml, check, fixture_root, agent_vars) - passed = (ok and count > 0) if negate else (ok and count == 0) + ok, count, msg = _run_deterministic_check(rule.checks_yml, check, fixture_root) + passed = (ok and count > 0) if expect == "present" else (ok and count == 0) return passed, CheckRun(check_id, check_type, fixture_name, passed, msg) - if check_type == "semantic": - return True, CheckRun(check_id, check_type, fixture_name, True, "semantic — skipped (no LLM)") + if check_type == "content_query": + return True, CheckRun(check_id, check_type, fixture_name, True, "content_query — skipped (requires mapper)") return False, CheckRun(check_id, check_type, fixture_name, False, f"unknown check type: {check_type}") @@ -868,24 +808,25 @@ def _check_fixture_for_violation( check: dict[str, Any], check_id: str, check_type: str, - negate: bool, + expect: str, fixture_root: Path, rule: RuleInfo, - agent_vars: dict[str, str | list[str]], + file_types: list[FileTypeDeclaration], ) -> tuple[bool, CheckRun]: """Run a check against a fail fixture. Returns (violation_found, CheckRun).""" if check_type == "mechanical": - cr = _run_mechanical_check(check, fixture_root, agent_vars) + classified = _classify_fixture(fixture_root, file_types) + cr = _run_mechanical_check(check, fixture_root, classified) violation = not cr.passed return violation, CheckRun(check_id, check_type, "fail", True, cr.message) if check_type == "deterministic": - ok, count, msg = _run_deterministic_check(rule.rule_yml, check, fixture_root, agent_vars) - violation = (ok and count == 0) if negate else (ok and count > 0) + ok, count, msg = _run_deterministic_check(rule.checks_yml, check, fixture_root) + violation = (ok and count == 0) if expect == "present" else (ok and count > 0) return violation, CheckRun(check_id, check_type, "fail", True, msg) - if check_type == "semantic": - return False, CheckRun(check_id, check_type, "fail", True, "semantic — skipped (no LLM)") + if check_type == "content_query": + return False, CheckRun(check_id, check_type, "fail", True, "content_query — skipped (requires mapper)") return False, CheckRun(check_id, check_type, "fail", False, f"unknown check type: {check_type}") @@ -897,9 +838,9 @@ def _build_agent_cache( rules_root: Path, default_agent: str, prefix_map: dict[str, str], -) -> dict[str, tuple[dict[str, str | list[str]], list[str]]]: +) -> dict[str, tuple[list[FileTypeDeclaration], list[str]]]: """Load configs for all agents into a cache keyed by agent name.""" - cache: dict[str, tuple[dict[str, str | list[str]], list[str]]] = {} + cache: dict[str, tuple[list[FileTypeDeclaration], list[str]]] = {} for agent_name in {default_agent} | set(prefix_map.values()): cache[agent_name] = load_agent_config(rules_root, agent_name) return cache @@ -927,8 +868,8 @@ def run_harness( """Discover and run all rules, returning per-rule results. Discovers rules across all agents and dispatches each rule to the - correct agent's vars based on its rule_id prefix. CORE/RRAILS rules - use the default agent's vars. + correct agent's file_types based on its rule_id prefix. CORE/RRAILS rules + use the default agent's file_types. Args: rules_root: Primary rules repository root. @@ -940,7 +881,7 @@ def run_harness( # Build prefix→agent mapping and per-agent config cache prefix_map = _build_prefix_to_agent_map(rules_root) agent_cache = _build_agent_cache(rules_root, agent, prefix_map) - default_vars, default_excludes = agent_cache[agent] + default_file_types, default_excludes = agent_cache[agent] # Discover rules across ALL agents (agent=None) rules = discover_rules( @@ -956,14 +897,14 @@ def run_harness( for rule in rules: rule_agent = _get_rule_agent(rule.rule_id, prefix_map) if rule_agent and rule_agent in agent_cache: - rule_vars, rule_excludes = agent_cache[rule_agent] + rule_file_types, rule_excludes = agent_cache[rule_agent] # Skip rules excluded by their own agent config if _rule_matches_exclude(rule.rule_id, rule_excludes): continue else: - # CORE/RRAILS rules use default agent vars - rule_vars = default_vars - results.append(run_rule(rule, rule_vars)) + # CORE/RRAILS rules use default agent file_types + rule_file_types = default_file_types + results.append(run_rule(rule, rule_file_types)) return results @@ -983,22 +924,15 @@ class ScoreDelta: def score_fixture( - fixture_dir: Path, - rules_paths: list[Path], + fixture_dir: Path, # noqa: ARG001 + rules_paths: list[Path], # noqa: ARG001 ) -> float: """Run full validation scoring on a fixture directory. - Returns the score (0-10) from the validation engine. + Returns 0.0 — scoring pending pipeline rewrite (0.5.0). """ - from reporails_cli.core.engine import run_validation_sync - - result = run_validation_sync( - fixture_dir, - rules_paths=rules_paths, - use_cache=False, - record_analytics=False, - ) - return result.score + logger.warning("score_fixture: scoring disabled pending 0.5.0 pipeline rewrite") + return 0.0 def score_rules( @@ -1056,6 +990,129 @@ def score_rules( return deltas +# ── Rule lint ─────────────────────────────────────────────────────── + + +@dataclass +class LintError: + """A structural integrity error found in a rule.""" + + rule_id: str + check_name: str + message: str + + +def _lint_single_rule( + rule: RuleInfo, + seen_ids: dict[str, Path], + category_codes: dict[str, Any], +) -> list[LintError]: + """Run lint checks on a single rule. Mutates seen_ids for duplicate tracking.""" + errors: list[LintError] = [] + + # 1. ID-category match + parts = rule.rule_id.split(":") + if len(parts) >= 3: + cat_code = parts[1] + expected_cat = category_codes.get(cat_code) + if expected_cat is None: + errors.append( + LintError( + rule_id=rule.rule_id, + check_name="id_category_match", + message=f"Unknown category code '{cat_code}' in rule ID", + ) + ) + elif expected_cat.value != rule.category: + errors.append( + LintError( + rule_id=rule.rule_id, + check_name="id_category_match", + message=( + f"ID has category code '{cat_code}' ({expected_cat.value}) " + f"but category field is '{rule.category}'" + ), + ) + ) + + # 2. Check ID prefix + expected_prefix = rule.rule_id.replace(":", ".") + for check in rule.checks: + check_id = check.get("id", "") + if check_id and not check_id.startswith(expected_prefix + "."): + errors.append( + LintError( + rule_id=rule.rule_id, + check_name="check_id_prefix", + message=f"Check '{check_id}' does not start with '{expected_prefix}.'", + ) + ) + + # 3. No duplicate rule IDs + if rule.rule_id in seen_ids: + errors.append( + LintError( + rule_id=rule.rule_id, + check_name="duplicate_rule_id", + message=f"Duplicate rule ID (also at {seen_ids[rule.rule_id]})", + ) + ) + else: + seen_ids[rule.rule_id] = rule.rule_dir + + # 4. Required frontmatter + for field_name, value in [ + ("id", rule.rule_id), + ("slug", rule.slug), + ("title", rule.title), + ("category", rule.category), + ("type", rule.rule_type), + ]: + if not value: + errors.append( + LintError( + rule_id=rule.rule_id or "(unknown)", + check_name="required_frontmatter", + message=f"Missing required field: {field_name}", + ) + ) + + # Severity requires re-reading frontmatter (not stored in RuleInfo) + rule_md = rule.rule_dir / "rule.md" + if rule_md.exists(): + meta = parse_frontmatter(rule_md.read_text(encoding="utf-8")) + if meta and not meta.get("severity"): + errors.append( + LintError( + rule_id=rule.rule_id or "(unknown)", + check_name="required_frontmatter", + message="Missing required field: severity", + ) + ) + + return errors + + +def lint_rules(rules: list[RuleInfo]) -> list[LintError]: + """Run structural integrity checks on all discovered rules. + + Checks: + 1. ID-category match — category code in rule ID matches category field + 2. Check ID prefix — all check IDs start with the dotted rule ID prefix + 3. No duplicate rule IDs — across all namespaces + 4. Required frontmatter — id, slug, title, category, type, severity present + """ + from reporails_cli.core.models import CATEGORY_CODES + + errors: list[LintError] = [] + seen_ids: dict[str, Path] = {} + + for rule in rules: + errors.extend(_lint_single_rule(rule, seen_ids, CATEGORY_CODES)) + + return errors + + # ── Coverage baseline ────────────────────────────────────────────── diff --git a/src/reporails_cli/core/levels.py b/src/reporails_cli/core/levels.py index a0241bed..c4749555 100644 --- a/src/reporails_cli/core/levels.py +++ b/src/reporails_cli/core/levels.py @@ -1,75 +1,166 @@ -"""Level configuration and capability-based level detection. +"""Level configuration and project level determination. -Capability taxonomy and level definitions loaded from framework registry. -Detection logic (how to detect each capability) is CLI-owned. +Two level mechanisms coexist: + +1. Target existence (determine_project_level): computes present file types + for rule applicability. Uses axis divergence from file type properties. + +2. Capability gates (determine_level_from_gates): computes the displayed + project level from detected features. Cumulative walk L1→L6, stop at + first level with no detected capability. + +The displayed level comes from (2). Rule applicability comes from (1). """ from __future__ import annotations from collections.abc import Callable -from functools import lru_cache +from pathlib import Path from typing import TYPE_CHECKING -import yaml - -from reporails_cli.core.bootstrap import get_rules_path from reporails_cli.core.models import Level if TYPE_CHECKING: - from reporails_cli.core.models import DetectedFeatures + from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration + from reporails_cli.core.results import DetectedFeatures # Level labels — canonical mapping (must match framework registry/levels.yml) LEVEL_LABELS: dict[Level, str] = { Level.L0: "Absent", - Level.L1: "Basic", - Level.L2: "Scoped", - Level.L3: "Structured", - Level.L4: "Abstracted", - Level.L5: "Maintained", + Level.L1: "Present", + Level.L2: "Structured", + Level.L3: "Substantive", + Level.L4: "Actionable", + Level.L5: "Refined", Level.L6: "Adaptive", } # Ordered levels for walking (L1 → L6) _LEVEL_ORDER = [Level.L1, Level.L2, Level.L3, Level.L4, Level.L5, Level.L6] -# Capabilities whose detection depends on content analysis (regex Phase 2). -# When skip_content=True, these are treated as detected (optimistic preliminary). -CONTENT_CAPABILITIES: frozenset[str] = frozenset( - { - "project_constraints", - "path_scoping", - } -) - -# Fallback level→capability mapping when framework registry is unavailable. -# Mirrors registry/levels.yml — updated when framework version bumps. -_FALLBACK_LEVEL_CAPS: dict[str, list[str]] = { + +def get_level_labels() -> dict[Level, str]: + """Get all level labels.""" + return LEVEL_LABELS + + +# =========================================================================== +# Mechanism 1: Target existence (for rule applicability) +# =========================================================================== + +# Baseline property values — "main" instruction file defaults. +# All 8 axes from the instruction topology tensor. +# Properties that match these contribute zero depth. +_BASELINE: dict[str, str] = { + "format": "freeform", + "scope": "global", + "cardinality": "singleton", + "loading": "session_start", + "precedence": "project", + "lifecycle": "static", + "maintainer": "human", + "vcs": "committed", +} + + +def _property_depth(properties: dict[str, str | list[str]]) -> int: + """Count how many structural properties diverge from baseline. + + A list-valued property (e.g., format: [frontmatter, freeform]) diverges + if it contains values beyond the baseline — the extra values represent + additional structural complexity. + """ + count = 0 + for prop, base in _BASELINE.items(): + actual = properties.get(prop, base) + if isinstance(actual, list): + # Diverges if the list contains any non-baseline value + count += 1 if any(v != base for v in actual) else 0 + elif actual != base: + count += 1 + return count + + +def determine_project_level( + scan_root: Path, + file_types: list[FileTypeDeclaration], + classified_files: list[ClassifiedFile], +) -> tuple[Level, set[str]]: + """Determine present file types for rule applicability. + + Returns (level_from_divergence, set of present file type names). + The level value is NOT used for display — only present_types matters. + """ + present: set[str] = set() + max_depth = 0 + + # Types already matched to actual files + for cf in classified_files: + present.add(cf.file_type) + max_depth = max(max_depth, _property_depth(cf.properties)) + + # Types declared but not yet in classified_files (check filesystem) + for ft in file_types: + if ft.name not in present and _type_exists(scan_root, ft.patterns): + present.add(ft.name) + max_depth = max(max_depth, _property_depth(ft.properties)) + + if not present: + return Level.L0, present + + return Level(f"L{min(max_depth + 1, 6)}"), present + + +def _type_exists(scan_root: Path, patterns: tuple[str, ...]) -> bool: + """Check if any file matching the patterns exists under scan_root.""" + for pattern in patterns: + clean = pattern.removeprefix("./") + if clean.startswith("/"): + # Absolute paths (e.g., /etc/claude-code/skills/**) are system-level + # and cannot be relative-globbed under scan_root — skip + continue + if "*" not in clean: + if (scan_root / clean).exists(): + return True + else: + try: + next(scan_root.glob(clean)) + return True + except StopIteration: + pass + return False + + +# =========================================================================== +# Mechanism 2: Capability gates (for displayed project level) +# =========================================================================== + +# Level → capability mapping. Hardcoded — v4 levels.yml no longer has +# capability keys, so we don't load from YAML. +LEVEL_CAPS: dict[str, list[str]] = { "L0": [], "L1": ["instruction_file"], - "L2": ["project_constraints", "size_controlled"], + "L2": ["explicit_constraints", "size_controlled"], "L3": ["external_references", "multiple_files"], "L4": ["path_scoping"], - "L5": ["structural_integrity", "org_policy", "navigation"], + "L5": ["org_policy", "navigation"], "L6": ["dynamic_context", "extensibility", "state_persistence"], } -# --------------------------------------------------------------------------- -# Capability → detection mapping (CLI-owned) -# --------------------------------------------------------------------------- - -CAPABILITY_DETECTORS: dict[str, Callable[[DetectedFeatures], bool]] = { +# Capability → detection mapping (CLI-owned). +# Each lambda takes a DetectedFeatures and returns bool. +FEATURE_DETECTORS: dict[str, Callable[..., bool]] = { # L1 "instruction_file": lambda f: f.has_instruction_file, # L2 - "project_constraints": lambda f: f.has_explicit_constraints, + "explicit_constraints": lambda f: f.has_explicit_constraints, "size_controlled": lambda f: f.is_size_controlled, # L3 - "external_references": lambda f: f.has_imports, + "external_references": lambda f: f.has_imports or f.has_multiple_instruction_files, "multiple_files": lambda f: f.has_multiple_instruction_files, # L4 "path_scoping": lambda f: f.has_path_scoped_rules or f.is_abstracted, # L5 - "structural_integrity": lambda _f: False, # Not filesystem-detectable "org_policy": lambda f: f.has_shared_files, "navigation": lambda f: f.has_backbone or f.component_count >= 3, # L6 @@ -79,170 +170,56 @@ } -# --------------------------------------------------------------------------- -# Framework registry loading -# --------------------------------------------------------------------------- - - -@lru_cache(maxsize=1) -def _load_level_capabilities() -> dict[str, list[str]]: - """Load level → capability mapping from framework registry. - - Returns dict like: {"L1": ["instruction_file"], "L2": [...], ...} - Falls back to hardcoded mapping if framework not downloaded yet. - - Returns: - Level → capability list mapping - """ - levels_path = get_rules_path() / "registry" / "levels.yml" - if not levels_path.exists(): - return _FALLBACK_LEVEL_CAPS - - try: - data = yaml.safe_load(levels_path.read_text(encoding="utf-8")) or {} - except (yaml.YAMLError, OSError): - return _FALLBACK_LEVEL_CAPS - - result: dict[str, list[str]] = {} - for level_key, level_data in data.get("levels", {}).items(): - if isinstance(level_data, dict): - result[level_key] = level_data.get("capabilities", []) - return result if result else _FALLBACK_LEVEL_CAPS - - -def get_level_labels() -> dict[Level, str]: - """Get all level labels.""" - return LEVEL_LABELS - - -# --------------------------------------------------------------------------- -# Level determination -# --------------------------------------------------------------------------- - - -def _detect_capability( - features: DetectedFeatures, - capability_id: str, - skip_content: bool = False, -) -> bool: - """Check if a capability is detected for the given features. - - Args: - features: Detected project features - capability_id: Capability identifier from registry - skip_content: If True, content-dependent capabilities treated as detected - - Returns: - True if capability is detected - """ - if skip_content and capability_id in CONTENT_CAPABILITIES: - return True - - detector = CAPABILITY_DETECTORS.get(capability_id) - if detector is None: - return False - return detector(features) - - -def _level_has_capability( - features: DetectedFeatures, - level_key: str, - level_caps: dict[str, list[str]], - skip_content: bool = False, -) -> bool: - """Check if at least one capability at the given level is detected (OR). - - Args: - features: Detected project features - level_key: Level identifier (e.g., "L2") - level_caps: Level → capability mapping from framework - skip_content: If True, content capabilities treated as detected - - Returns: - True if at least one capability at this level is detected - """ - capabilities = level_caps.get(level_key, []) - if not capabilities: - # Level with no capabilities defined — treated as passing - return True - return any(_detect_capability(features, cap_id, skip_content) for cap_id in capabilities) - - -def determine_level_from_gates( - features: DetectedFeatures, - skip_content: bool = False, -) -> Level: - """Determine capability level using cumulative capability ladder. +def determine_level_from_gates(features: DetectedFeatures) -> Level: + """Determine project level using cumulative capability walk. A project is at the highest level where ALL levels L1 through N have at least one detected capability (OR within level, AND across levels). - Args: - features: Detected project features - skip_content: If True, content capabilities treated as detected (preliminary) - - Returns: - Highest level where all cumulative capabilities pass + Walk from L6 down to L1, find highest where all cumulative levels pass. """ - level_caps = _load_level_capabilities() - - # Walk from L6 down to L1, find highest where all cumulative levels pass for level in reversed(_LEVEL_ORDER): - if _all_levels_pass(features, level, level_caps, skip_content): + if _all_levels_pass(features, level): return level return Level.L0 -def _all_levels_pass( - features: DetectedFeatures, - target_level: Level, - level_caps: dict[str, list[str]], - skip_content: bool, -) -> bool: - """Check if all levels from L1 through target_level have at least one capability. +def _all_levels_pass(features: DetectedFeatures, target_level: Level) -> bool: + """Check if all levels from L1 through target_level have at least one capability.""" + target_index = _LEVEL_ORDER.index(target_level) + return all(_level_has_capability(features, lvl.value) for lvl in _LEVEL_ORDER[: target_index + 1]) - Args: - features: Detected project features - target_level: Level to check up to - level_caps: Level → capability mapping - skip_content: If True, content capabilities treated as detected - Returns: - True if all cumulative levels pass - """ - target_index = _LEVEL_ORDER.index(target_level) +def _level_has_capability(features: DetectedFeatures, level_key: str) -> bool: + """Check if at least one capability at the given level is detected (OR).""" + capabilities = LEVEL_CAPS.get(level_key, []) + if not capabilities: + return True + return any(_detect_capability(features, cap_id) for cap_id in capabilities) - for lvl in _LEVEL_ORDER[: target_index + 1]: - if not _level_has_capability(features, lvl.value, level_caps, skip_content): - return False - return True +def _detect_capability(features: DetectedFeatures, capability_id: str) -> bool: + """Check if a capability is detected for the given features.""" + detector = FEATURE_DETECTORS.get(capability_id) + 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: L3 project with skills directory (L6 feature) → has_orphan = True - Display as "L3+" to indicate advanced features present. - - Args: - features: Detected project features - base_level: Base capability level - - Returns: - True if capabilities above base level are detected + Example: L2 project with skills directory (L6 feature) → has_orphan = True + Display as "L2+" to indicate advanced features present. """ - if base_level not in _LEVEL_ORDER: + if base_level == Level.L6: return False - level_caps = _load_level_capabilities() - - base_index = _LEVEL_ORDER.index(base_level) + base_index = _LEVEL_ORDER.index(base_level) if base_level in _LEVEL_ORDER else -1 - # Check capabilities from levels above base for level in _LEVEL_ORDER[base_index + 1 :]: - capabilities = level_caps.get(level.value, []) + capabilities = LEVEL_CAPS.get(level.value, []) for cap_id in capabilities: if _detect_capability(features, cap_id): return True diff --git a/src/reporails_cli/core/mapper/__init__.py b/src/reporails_cli/core/mapper/__init__.py new file mode 100644 index 00000000..06adcee1 --- /dev/null +++ b/src/reporails_cli/core/mapper/__init__.py @@ -0,0 +1,39 @@ +"""Mapper — client-side instruction file analysis. + +Classifies instruction files into atoms, embeds them, clusters by topic, +and produces a compact RulesetMap wire format. +""" + +from reporails_cli.core.mapper.mapper import ( + Atom, + ClusterRecord, + FileRecord, + Models, + RulesetMap, + RulesetSummary, + TopicCluster, + content_hash, + get_models, + load_ruleset_map, + map_file, + map_ruleset, + save_ruleset_map, + tokenize, +) + +__all__ = [ + "Atom", + "ClusterRecord", + "FileRecord", + "Models", + "RulesetMap", + "RulesetSummary", + "TopicCluster", + "content_hash", + "get_models", + "load_ruleset_map", + "map_file", + "map_ruleset", + "save_ruleset_map", + "tokenize", +] diff --git a/src/reporails_cli/core/mapper/daemon.py b/src/reporails_cli/core/mapper/daemon.py new file mode 100644 index 00000000..9ec7b917 --- /dev/null +++ b/src/reporails_cli/core/mapper/daemon.py @@ -0,0 +1,311 @@ +"""Mapper daemon — persistent background process keeping models loaded. + +Serves map requests over a Unix domain socket. The sentence-transformers +model stays in memory between invocations, eliminating the 5-10s model +load time on subsequent runs. + +Socket: .ails/.cache/mapper.sock +PID file: .ails/.cache/mapper.pid +Protocol: JSON-line over Unix domain socket (one JSON object per line). +""" + +from __future__ import annotations + +import json +import logging +import os +import signal +import socket +import threading +import time +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Idle timeout defaults to 1 hour; override with AILS_DAEMON_IDLE_S env var +# (e.g. AILS_DAEMON_IDLE_S=5 for fast integration tests, or a large number +# for long dev loops). +_IDLE_TIMEOUT_S = int(os.environ.get("AILS_DAEMON_IDLE_S", "3600")) +_SOCKET_BACKLOG = 2 +_MAX_REQUEST_BYTES = 10_000_000 # 10MB + + +def _socket_path(cache_dir: Path) -> Path: + return cache_dir / "mapper.sock" + + +def _pid_path(cache_dir: Path) -> Path: + return cache_dir / "mapper.pid" + + +def is_daemon_running(cache_dir: Path) -> bool: + """Check if daemon process is alive.""" + pid_file = _pid_path(cache_dir) + if not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) # signal 0 = existence check + return True + except (ValueError, ProcessLookupError, PermissionError, OSError): + # Stale PID file — clean up + pid_file.unlink(missing_ok=True) + _socket_path(cache_dir).unlink(missing_ok=True) + return False + + +def stop_daemon(cache_dir: Path) -> bool: + """Send shutdown command to daemon. Returns True if stopped.""" + if not is_daemon_running(cache_dir): + return False + sock_path = _socket_path(cache_dir) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(5.0) + sock.connect(str(sock_path)) + sock.sendall(json.dumps({"cmd": "shutdown"}).encode() + b"\n") + resp = sock.recv(4096) + logger.debug("Daemon shutdown response: %s", resp.decode()) + except (OSError, TimeoutError): + # Force kill + pid_file = _pid_path(cache_dir) + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, signal.SIGTERM) + except (ValueError, ProcessLookupError, OSError): + pass + # Clean up + _pid_path(cache_dir).unlink(missing_ok=True) + _socket_path(cache_dir).unlink(missing_ok=True) + return True + + +def start_daemon(cache_dir: Path) -> int: + """Start daemon in a forked subprocess. Returns child PID.""" + if is_daemon_running(cache_dir): + pid = int(_pid_path(cache_dir).read_text().strip()) + logger.debug("Daemon already running (PID %d)", pid) + return pid + + # Clean stale socket + _socket_path(cache_dir).unlink(missing_ok=True) + + cache_dir.mkdir(parents=True, exist_ok=True) + + pid = os.fork() + if pid > 0: + # Parent — wait briefly for daemon to be ready + for _ in range(20): + time.sleep(0.1) + if _socket_path(cache_dir).exists(): + break + return pid + + # Child — become daemon + try: + os.setsid() + # Redirect std streams + devnull = os.open(os.devnull, os.O_RDWR) + os.dup2(devnull, 0) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + os.close(devnull) + + _daemon_main(cache_dir) + except Exception: + pass + finally: + os._exit(0) # daemon child must not return + + return 0 # unreachable + + +def _daemon_main(cache_dir: Path) -> None: + """Main daemon loop: bind socket, warm models in background, serve requests. + + Socket is bound BEFORE model warmup so the parent's ``start_daemon`` can + return as soon as the socket exists (microseconds after fork) and + proceed with file discovery + M probes in parallel with model loading. + ``map_ruleset`` requests block on ``warmup_done`` before dispatching; + ``ping`` and ``shutdown`` are answered immediately regardless. + """ + # CRITICAL: install the torch import blocker in the forked daemon + # child. The parent installed it at CLI entry, but after fork this + # child re-enters Python's import machinery and must reinstall so + # the blocker survives in this process's sys.meta_path. Without this, + # the daemon's `import spacy` would drag in torch (~20s) on cold start. + from reporails_cli.core import _torch_blocker + + _torch_blocker.install() + + # Write PID file + _pid_path(cache_dir).write_text(str(os.getpid())) + + # Set up signal handlers for graceful shutdown + _shutdown = False + + def _handle_signal(_signum: int, _frame: object) -> None: + nonlocal _shutdown + _shutdown = True + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + # Suppress ML library noise before any ML library is imported in this process + import logging as _logging + + os.environ["TRANSFORMERS_VERBOSITY"] = "error" + os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1" + os.environ["TOKENIZERS_PARALLELISM"] = "false" + os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1" + for lib in ("sentence_transformers", "transformers", "huggingface_hub"): + _logging.getLogger(lib).setLevel(_logging.ERROR) + + # Create Unix socket BEFORE starting model warmup, so the parent can + # return from start_daemon() as soon as the socket exists. + sock_path = _socket_path(cache_dir) + sock_path.unlink(missing_ok=True) + server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_sock.bind(str(sock_path)) + server_sock.listen(_SOCKET_BACKLOG) + server_sock.settimeout(1.0) # poll for shutdown + + # Warm models in a background thread. map_ruleset handlers block on this + # event; ping/shutdown do not. Parallel warmup loads spaCy + ST concurrently. + from reporails_cli.core.mapper.mapper import get_models + + models = get_models() + warmup_done = threading.Event() + + def _warmup() -> None: + try: + models.warmup() + except Exception: + logger.debug("daemon warmup failed", exc_info=True) + finally: + warmup_done.set() + + threading.Thread(target=_warmup, name="mapper-warmup", daemon=True).start() + + last_activity = time.monotonic() + + while not _shutdown: + # Idle timeout check + if time.monotonic() - last_activity > _IDLE_TIMEOUT_S: + break + + try: + conn, _ = server_sock.accept() + except TimeoutError: + continue + except OSError: + break + + last_activity = time.monotonic() + try: + _handle_connection(conn, models, cache_dir, warmup_done) + except Exception: + pass + finally: + conn.close() + + # Cleanup + server_sock.close() + sock_path.unlink(missing_ok=True) + _pid_path(cache_dir).unlink(missing_ok=True) + + +def _handle_connection( + conn: socket.socket, + models: Any, + cache_dir: Path, + warmup_done: threading.Event, +) -> None: + """Handle a single client connection. + + ``map_ruleset`` requests block on ``warmup_done`` before dispatching — + this lets the parent connect to the socket before models are loaded + and get a fast response once they are. ``ping`` and ``shutdown`` never + block so callers can reason about daemon liveness even mid-warmup. + """ + conn.settimeout(300.0) # enough for cold warmup + map + data = b"" + while b"\n" not in data: + chunk = conn.recv(65536) + if not chunk: + return + data += chunk + if len(data) > _MAX_REQUEST_BYTES: + conn.sendall(json.dumps({"ok": False, "error": "request too large"}).encode() + b"\n") + return + + line = data.split(b"\n", 1)[0] + try: + request = json.loads(line) + except json.JSONDecodeError: + conn.sendall(json.dumps({"ok": False, "error": "invalid JSON"}).encode() + b"\n") + return + + response = _dispatch(request, models, cache_dir, warmup_done) + conn.sendall(json.dumps(response, separators=(",", ":")).encode() + b"\n") + + +def _dispatch( + request: dict[str, Any], + models: Any, + cache_dir: Path, + warmup_done: threading.Event, +) -> dict[str, Any]: + """Dispatch a request to the appropriate handler. + + ``map_ruleset`` does NOT block on ``warmup_done``. Models are loaded + lazily on first access with a thread-safe lock inside the ``Models`` + singleton, so if a request arrives before the warmup thread finishes + it will just wait on the lock inside ``map_ruleset`` when (and only + when) it actually needs the model. For cache-hit requests where + neither the spaCy nor the ST model is touched, warmup is skipped + entirely — the request returns before warmup completes. + """ + cmd = request.get("cmd", "") + + if cmd == "ping": + return {"ok": True, "pid": os.getpid(), "warm": warmup_done.is_set()} + + if cmd == "shutdown": + # Signal will be caught by the main loop + os.kill(os.getpid(), signal.SIGTERM) + return {"ok": True} + + if cmd == "map_ruleset": + return _handle_map_ruleset(request, models, cache_dir) + + return {"ok": False, "error": f"unknown command: {cmd}"} + + +def _handle_map_ruleset(request: dict[str, Any], models: Any, cache_dir: Path) -> dict[str, Any]: + """Handle map_ruleset request — build RulesetMap from paths.""" + from reporails_cli.core.mapper.mapper import map_ruleset + + paths_str = request.get("paths", []) + paths = [Path(p) for p in paths_str] + root = Path(request["root"]) if "root" in request else None + + try: + ruleset_map = map_ruleset(paths, models=models, root=root, cache_dir=cache_dir) + # Serialize to JSON-compatible dict + import tempfile + + from reporails_cli.core.mapper.mapper import save_ruleset_map + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + save_ruleset_map(ruleset_map, Path(f.name)) + tmp_path = f.name + + result_json = Path(tmp_path).read_text() + Path(tmp_path).unlink() + + return {"ok": True, "ruleset_map": json.loads(result_json)} + except Exception as e: + return {"ok": False, "error": str(e)} diff --git a/src/reporails_cli/core/mapper/daemon_client.py b/src/reporails_cli/core/mapper/daemon_client.py new file mode 100644 index 00000000..dca96678 --- /dev/null +++ b/src/reporails_cli/core/mapper/daemon_client.py @@ -0,0 +1,124 @@ +"""Daemon client — talks to the mapper daemon over Unix socket. + +Falls back gracefully: if daemon is not running or unreachable, +returns None and caller uses in-process mapping. +""" + +from __future__ import annotations + +import json +import logging +import socket +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _socket_path(cache_dir: Path) -> Path: + return cache_dir / "mapper.sock" + + +def connect(cache_dir: Path, timeout: float = 5.0) -> socket.socket | None: + """Connect to daemon socket. Returns None if unreachable.""" + sock_path = _socket_path(cache_dir) + if not sock_path.exists(): + return None + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(str(sock_path)) + return sock + except (OSError, TimeoutError): + return None + + +def send_request(sock: socket.socket, request: dict[str, Any], timeout: float = 120.0) -> dict[str, Any] | None: + """Send JSON-line request, read JSON-line response.""" + try: + sock.settimeout(timeout) + sock.sendall(json.dumps(request, separators=(",", ":")).encode() + b"\n") + + data = b"" + while b"\n" not in data: + chunk = sock.recv(65536) + if not chunk: + return None + data += chunk + + line = data.split(b"\n", 1)[0] + result: dict[str, Any] = json.loads(line) + return result + except (OSError, TimeoutError, json.JSONDecodeError): + return None + finally: + sock.close() + + +def ping(cache_dir: Path) -> dict[str, Any] | None: + """Ping daemon. Returns response dict or None if unreachable.""" + sock = connect(cache_dir) + if sock is None: + return None + return send_request(sock, {"cmd": "ping"}, timeout=5.0) + + +def map_ruleset_via_daemon( + paths: list[Path], + root: Path, + cache_dir: Path, +) -> Any: + """Map ruleset via daemon. Returns RulesetMap or None on failure. + + Caller should fall back to in-process mapping when this returns None. + """ + sock = connect(cache_dir) + if sock is None: + return None + + request = { + "cmd": "map_ruleset", + "paths": [str(p) for p in paths], + "root": str(root), + } + # 300s matches the daemon-side conn timeout. Covers cold ST import (~29s) + # + spaCy load + first-encode, plus mapping work, with headroom. + response = send_request(sock, request, timeout=300.0) + if response is None or not response.get("ok"): + logger.debug("Daemon map_ruleset failed: %s", response.get("error") if response else "no response") + return None + + # Deserialize the RulesetMap from JSON + map_data = response.get("ruleset_map") + if map_data is None: + return None + + try: + import tempfile + + from reporails_cli.core.mapper.mapper import load_ruleset_map + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(map_data, f) + tmp_path = Path(f.name) + + result = load_ruleset_map(tmp_path) + tmp_path.unlink() + return result + except Exception: + logger.debug("Failed to deserialize daemon response", exc_info=True) + return None + + +def ensure_daemon(cache_dir: Path) -> bool: + """Ensure daemon is running. Start it if not. Returns True if available.""" + from reporails_cli.core.mapper.daemon import is_daemon_running, start_daemon + + if is_daemon_running(cache_dir): + return True + + try: + start_daemon(cache_dir) + return is_daemon_running(cache_dir) + except OSError: + return False diff --git a/src/reporails_cli/core/mapper/map_cache.py b/src/reporails_cli/core/mapper/map_cache.py new file mode 100644 index 00000000..47a15cf5 --- /dev/null +++ b/src/reporails_cli/core/mapper/map_cache.py @@ -0,0 +1,152 @@ +"""Per-file atom and embedding cache for incremental map updates. + +Stores classified atoms and int8 embeddings keyed by content hash. +On subsequent runs, unchanged files skip tokenization and embedding +entirely. Only re-clustering runs every time (cheap, depends on full +atom set). + +Cache location: .ails/.cache/map-cache.json +Invalidation: content hash mismatch, model name change, schema version change. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from reporails_cli.core.mapper.mapper import ( + EMBEDDING_MODEL, + SCHEMA_VERSION, + Atom, +) + +logger = logging.getLogger(__name__) + +_CACHE_VERSION = 1 +_MAX_CACHE_ENTRIES = 500 # enough for large monorepos + + +@dataclass +class CachedFileEntry: + """Cached tokenization + embedding result for a single file.""" + + content_hash: str + atoms: list[dict[str, Any]] = field(default_factory=list) + + +class MapCache: + """Per-file atom cache with content-hash keying. + + Usage: + cache = MapCache(project_root / ".ails" / ".cache") + cache.load() + entry = cache.get("sha256:abc...") + if entry is None: + atoms = tokenize(content) + cache.put(content_hash, CachedFileEntry(content_hash, [asdict(a) for a in atoms])) + cache.save() + """ + + def __init__(self, cache_dir: Path) -> None: + self.cache_dir = cache_dir + self.cache_path = cache_dir / "map-cache.json" + self._entries: dict[str, CachedFileEntry] = {} + self._model: str = EMBEDDING_MODEL + self._schema: str = SCHEMA_VERSION + self._dirty = False + + def load(self) -> None: + """Load cache from disk. Invalidates on model/schema mismatch.""" + if not self.cache_path.exists(): + return + try: + raw = json.loads(self.cache_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + logger.debug("Map cache corrupt or unreadable, starting fresh") + return + + # Invalidate on model or schema change + if raw.get("model") != self._model: + logger.debug("Map cache model mismatch (%s != %s), invalidating", raw.get("model"), self._model) + return + if raw.get("schema") != self._schema: + logger.debug("Map cache schema mismatch, invalidating") + return + + entries = raw.get("entries", {}) + for chash, entry_data in entries.items(): + self._entries[chash] = CachedFileEntry( + content_hash=chash, + atoms=entry_data.get("atoms", []), + ) + logger.debug("Map cache loaded: %d entries", len(self._entries)) + + def save(self) -> None: + """Save cache to disk atomically (write-to-tmp + rename).""" + if not self._dirty: + return + self.cache_dir.mkdir(parents=True, exist_ok=True) + data = { + "version": _CACHE_VERSION, + "model": self._model, + "schema": self._schema, + "entries": {chash: {"atoms": entry.atoms} for chash, entry in self._entries.items()}, + } + tmp = self.cache_path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8") + tmp.replace(self.cache_path) + self._dirty = False + logger.debug("Map cache saved: %d entries", len(self._entries)) + + def get(self, content_hash: str) -> CachedFileEntry | None: + """Look up cached atoms by content hash. Returns None on miss.""" + return self._entries.get(content_hash) + + def put(self, content_hash: str, entry: CachedFileEntry) -> None: + """Store atoms for a content hash.""" + self._entries[content_hash] = entry + self._dirty = True + + def evict_stale(self, known_hashes: set[str]) -> int: + """Remove entries not in the current file set. Returns count evicted.""" + stale = set(self._entries.keys()) - known_hashes + for h in stale: + del self._entries[h] + if stale: + self._dirty = True + return len(stale) + + @property + def size(self) -> int: + """Number of cached entries.""" + return len(self._entries) + + +def atoms_to_dicts(atoms: list[Atom]) -> list[dict[str, Any]]: + """Serialize atoms to dicts for cache storage.""" + result = [] + for a in atoms: + d = asdict(a) + # Convert tuple fields to lists for JSON + if d.get("embedding_int8") is not None: + d["embedding_int8"] = list(d["embedding_int8"]) + if d.get("topics"): + d["topics"] = list(d["topics"]) + result.append(d) + return result + + +def dicts_to_atoms(dicts: list[dict[str, Any]]) -> list[Atom]: + """Deserialize atom dicts back to Atom instances.""" + atoms = [] + for d in dicts: + # Convert lists back to tuples + if d.get("embedding_int8") is not None: + d["embedding_int8"] = tuple(d["embedding_int8"]) + if d.get("topics") is not None: + d["topics"] = tuple(d["topics"]) + atoms.append(Atom(**d)) + return atoms diff --git a/src/reporails_cli/core/mapper/mapper.py b/src/reporails_cli/core/mapper/mapper.py new file mode 100644 index 00000000..0ecf9fc9 --- /dev/null +++ b/src/reporails_cli/core/mapper/mapper.py @@ -0,0 +1,3058 @@ +# pylint: disable=C0302 +# ruff: noqa: C901, SIM102, SIM108, N806, PERF401, F541, RUF034 +"""Mapper — client-side spectrograph for instruction file analysis. + +Classifies instruction files into atoms, embeds them, clusters by topic, +and produces a compact RulesetMap. This module is the client-side component +of the reporails architecture. It contains NO equation constants — only +classification, embedding, and clustering logic. + +The RulesetMap is the wire format: ~32KB covering an entire instruction +ruleset, suitable for transmission to the server for equation evaluation. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from markdown_it import MarkdownIt + +if TYPE_CHECKING: + pass # sentence_transformers types if needed + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION = "1.0.0" +EMBEDDING_MODEL = "all-MiniLM-L6-v2" + +# Topic clustering threshold (L2 distance on L2-normalized embeddings). +TOPIC_CLUSTER_THRESHOLD = 1.2 + + +# ────────────────────────────────────────────────────────────────── +# DATA MODEL +# ────────────────────────────────────────────────────────────────── + + +@dataclass +class InlineToken: + """A word-level token with format context from AST parsing. + + Used by Phase 3 backtick filter to determine if a ROOT word + falls inside a backtick span without regex heuristics. + """ + + text: str + format: str # "backtick" | "bold" | "italic" | "plain" + + +@dataclass +class Atom: + """A classified content atom from an instruction file.""" + + line: int + text: str + kind: str # heading | excitation + charge: str # CONSTRAINT | DIRECTIVE | IMPERATIVE | NEUTRAL | AMBIGUOUS + charge_value: int # q: -1 (constraint), 0 (neutral/ambiguous), +1 (directive/imperative). NOT b_i. + modality: str # imperative | direct | absolute | hedged | none + specificity: str # named | abstract + scope_conditional: bool = False # True when conditional frame (if/when/unless) detected + format: str = "prose" # prose | heading | list | numbered | table | blockquote | code_block | data_block + named_tokens: list[str] = field(default_factory=list) + italic_tokens: list[str] = field(default_factory=list) + bold_tokens: list[str] = field(default_factory=list) + unformatted_code: list[str] = field(default_factory=list) + position_index: int = 0 # 0-based index among non-heading atoms + token_count: int = 0 # approximate word-level token count + file_path: str = "" # source file (for cross-file analysis) + cluster_id: int = -1 # topic cluster assignment + embedding_int8: tuple[int, ...] | None = None # int8 quantized 384-d embedding + heading_context: str = "" # parent heading text (for context-aware embedding) + depth: int | None = None # heading level 1-6 (set on heading atoms) + plain_text: str = "" # AST-stripped text for NLP/embedding + rule: str = "" # which classifier rule fired (p1_negation_phrase, p3c_verb0_use, etc.) + ambiguous: bool = False # True when charge depends on verb-noun interpretation + charge_confidence: float = 1.0 # 0.0-1.0 confidence in charge classification + embedded_charge_markers: list[str] = field(default_factory=list) # charge markers found in neutral atoms + # Optional fields (topographer-classified maps) + topics: tuple[str, ...] = () # noun phrases from topographer + role: str = "" # directive | constraint | anchor | glue + + +@dataclass +class TopicCluster: + """A group of atoms on the same topic, from embedding-based clustering.""" + + topic_id: int + atoms: list[Atom] + charged: list[Atom] + j: float # per-topic charge density (structural stat only) + centroid: tuple[float, ...] = () # L2-normalized mean of member embeddings + + +@dataclass +class FileRecord: + """A source file in the ruleset with M2 loading metadata.""" + + path: str + content_hash: str # sha256:hex + loading: str = "session_start" # session_start | on_demand | on_invocation + scope: str = "global" # global | path_scoped | task_scoped + globs: tuple[str, ...] = () # activation patterns (on_demand/on_invocation) + agent: str = "generic" # owning agent (claude, codex, copilot, etc.) + description: str = "" # frontmatter name+description (always in base context) + description_embedding: tuple[int, ...] | None = None # int8 quantized embedding + + +@dataclass +class ClusterRecord: + """A topic cluster with centroid.""" + + id: int + n_atoms: int + n_charged: int + n_neutral: int + centroid: tuple[float, ...] = () # 384-d embedding (empty if single-atom cluster) + + +@dataclass +class RulesetSummary: + """Aggregate statistics for the ruleset.""" + + n_atoms: int + n_charged: int + n_neutral: int + n_topics: int = 0 + n_topics_charged: int = 0 + + +@dataclass +class RulesetMap: + """Compact map of an instruction ruleset — the wire format.""" + + schema_version: str + embedding_model: str + generated_at: str # ISO 8601 + files: tuple[FileRecord, ...] + atoms: tuple[Atom, ...] + clusters: tuple[ClusterRecord, ...] = () + summary: RulesetSummary = field(default_factory=lambda: RulesetSummary(0, 0, 0)) + + +# ────────────────────────────────────────────────────────────────── +# KNOWN CODE TOKENS — things that should be in backticks +# ────────────────────────────────────────────────────────────────── + +KNOWN_CODE_TOKENS: set[str] = { + # Python + "pytest", + "unittest", + "mypy", + "ruff", + "black", + "flake8", + "pylint", + "pip", + "pipx", + "poetry", + "pdm", + "dataclass", + "dataclasses", + "pydantic", + "fastapi", + "flask", + "django", + "numpy", + "scipy", + "pandas", + "sklearn", + "spacy", + "transformers", + # JS/TS + "npm", + "npx", + "yarn", + "pnpm", + "webpack", + "vite", + "eslint", + "prettier", + "typescript", + "tsx", + "jsx", + # Tools + "git", + "docker", + "kubectl", + "terraform", + "ansible", + "curl", + "wget", + "jq", + "sed", + "awk", + "grep", + # Formats / config + "json", + "yaml", + "toml", + # Our project + "ails", + "reporails", + "topographer", + "conftest", + "parametrize", +} + +# Abbreviations that look like dotted names but aren't code +_DOTTED_EXCLUSIONS: set[str] = { + "e.g", + "i.e", + "a.m", + "p.m", + "vs.", + "cf.", + "al.", + "e.g.", + "i.e.", + "a.m.", + "p.m.", +} + +# Patterns that look like code but aren't in backticks +CODE_SHAPE_RE = re.compile( + r"(?= 0.80, count >= 5 across 434 projects +_VERBS_CORE: set[str] = { + "add", + "apply", + "ask", + "assume", + "call", + "check", + "clone", + "commit", + "configure", + "copy", + "create", + "define", + "deploy", + "document", + "edit", + "enable", + "ensure", + "execute", + "export", + "follow", + "generate", + "handle", + "identify", + "implement", + "import", + "include", + "install", + "invoke", + "keep", + "lint", + "list", + "load", + "locate", + "maintain", + "mark", + "minimize", + "modify", + "monitor", + "navigate", + "open", + "optimize", + "organize", + "preserve", + "preview", + "provide", + "pull", + "push", + "put", + "query", + "read", + "refactor", + "register", + "restart", + "return", + "reuse", + "review", + "run", + "search", + "set", + "show", + "skip", + "switch", + "sync", + "update", + "use", + "validate", + "verify", + "view", + "wrap", + "write", +} +# SUPPLEMENT: legitimate verbs too low-frequency in 434-project corpus +_VERBS_SUPPLEMENT: set[str] = { + "accept", + "achieve", + "activate", + "adapt", + "adjust", + "advise", + "analyze", + "annotate", + "assess", + "assign", + "assist", + "audit", + "avoid", + "be", + "begin", + "capture", + "choose", + "clarify", + "classify", + "collaborate", + "compare", + "confirm", + "consolidate", + "coordinate", + "continue", + "convert", + "customize", + "debounce", + "deduplicate", + "delete", + "derive", + "describe", + "deserialize", + "determine", + "detect", + "display", + "distinguish", + "document", + "enforce", + "establish", + "evaluate", + "examine", + "explain", + "expose", + "extend", + "extract", + "fall", + "favor", + "fetch", + "find", + "flag", + "give", + "go", + "group", + "highlight", + "improve", + "inject", + "inspect", + "integrate", + "investigate", + "iterate", + "leverage", + "limit", + "link", + "look", + "make", + "manage", + "map", + "match", + "maximize", + "migrate", + "mock", + "move", + "normalize", + "note", + "offer", + "omit", + "parametrize", + "parse", + "pass", + "patch", + "place", + "populate", + "prefer", + "prefix", + "prepare", + "present", + "print", + "prioritize", + "proceed", + "produce", + "profile", + "propose", + "raise", + "recommend", + "record", + "refer", + "release", + "remember", + "rename", + "render", + "repeat", + "replace", + "report", + "request", + "require", + "reset", + "resolve", + "respect", + "respond", + "restrict", + "reuse", + "revert", + "sanitize", + "save", + "scaffold", + "scan", + "scope", + "serialize", + "seed", + "select", + "send", + "separate", + "serve", + "sort", + "specify", + "store", + "structure", + "submit", + "suggest", + "summarize", + "support", + "surface", + "take", + "throttle", + "transform", + "treat", + "trigger", + "understand", + "upload", + "utilize", + "wait", + "warn", + "wire", +} +# AMBIGUOUS: corpus ratio 0.60-0.80 or genuinely dual noun/verb in tech context +_VERBS_AMBIGUOUS: set[str] = { + "abstract", + "archive", + "benchmark", + "break", + "build", + "cache", + "clean", + "close", + "complete", + "connect", + "consider", + "delegate", + "design", + "fail", + "fix", + "focus", + "format", + "get", + "help", + "ignore", + "initialize", + "inline", + "log", + "name", + "outline", + "override", + "plan", + "process", + "prototype", + "react", + "reference", + "remove", + "research", + "route", + "see", + "split", + "start", + "state", + "stop", + "stub", + "target", + "test", + "toggle", + "trace", + "track", + "work", +} +_ALL_VERBS = _VERBS_CORE | _VERBS_SUPPLEMENT | _VERBS_AMBIGUOUS + +_CONDITIONAL_MARKERS: set[str] = { + # Conditional + "if", + "unless", + "provided", + "given", + "assuming", + "whether", + # Temporal + "when", + "whenever", + "before", + "after", + "while", + "until", + "once", + "during", + "upon", + # Restrictive + "except", + "where", + # General + "for", +} + +# Context words that can precede an imperative verb without blocking detection. +_CONTEXT_WORDS = _CONDITIONAL_MARKERS | { + # Determiners, articles, adverbs + "each", + "every", + "all", + "any", + "first", + "then", + "also", + "next", + "finally", + "immediately", + "the", + "a", + "an", + "this", + "that", + "these", + "those", + "now", + "here", + "there", + "instead", + "to", + "and", + "or", + "not", + "with", + "in", + "on", + "at", + "by", + "from", + "into", + "only", + "just", + "simply", + "please", + "automatically", + "optionally", + "alternatively", + "additionally", + # CLI tools — invocation context preceding a verb + "npm", + "npx", + "bun", + "pnpm", + "yarn", + "cargo", + "pip", + "uv", + "dotnet", + "docker", + "git", + "go", + "python", + "node", + "deno", + "make", + "composer", + "mix", + "flutter", + "dart", + "swift", + "java", + "mvn", + "gradle", + "gradlew", + "ruby", + "zig", + "nix", + "brew", + "apt", + "snap", + "curl", + "wget", + "pytest", + "ruff", + "eslint", + "prettier", + "vitest", + "jest", + "mocha", + "turbo", + "nx", + "lerna", + "rushx", + "hatch", + "poetry", + "pipx", + "uvx", + "helm", + "kubectl", + "terraform", + "ansible", + "ssh", + "scp", +} + +_CLASSIFY_WORD_RE = re.compile(r"[a-zA-Z']+") + +# Finite verbs that signal a descriptive sentence (subject + predicate). +# Only includes unambiguous 3rd-person forms — words like "tests", "returns", +# "calls" are excluded because they're commonly nouns in instruction files +# ("Run tests", "Use early returns", "API calls"). +_FINITE_VERB_RE = re.compile( + r"\b(is|are|was|were|has|have|had|does|did" + r"|applies|operates|contains|provides|requires|includes" + r"|degrades|produces|generates|supports|handles" + r"|manages|maintains|sends|connects|implements" + r"|triggers|fetches|stores|processes|validates|accepts" + r"|exists|means|comes|needs|works|gets|goes|takes" + r"|tells|lives|varies)\b", +) + +# Probable sentence subjects — block mid-sentence verb promotion +_PROBABLE_SUBJECTS = { + "it", + "this", + "that", + "they", + "we", + "he", + "she", + "everything", + "nothing", + "something", + "anything", +} + + +def _strip_md_for_classify(text: str) -> str: + """Strip markdown markers for charge classification. Keeps content.""" + t = re.sub(r"`([^`]*)`", r"\1", text) + t = re.sub(r"\*{2}([^*]+)\*{2}", r"\1", t) + t = re.sub(r"(?#0123456789. ") + + +def _classify_words(text: str) -> list[str]: + """Extract alphabetic words from text.""" + return _CLASSIFY_WORD_RE.findall(text) + + +def _starts_with_bold_verb(md_text: str) -> bool: + """Check if text starts with single-word **Verb** pattern. + + Multi-word bold spans like **Build configuration** are labels, not + instructions — only single-word bold verbs (**Use**, **Run**) qualify. + """ + raw = md_text.strip().lstrip("-+>#0123456789. ") + m = re.match(r"^\*{2}([^*]+)\*{2}", raw) + if not m: + return False + bold_words = _CLASSIFY_WORD_RE.findall(m.group(1)) + return bool(bold_words and len(bold_words) == 1 and bold_words[0].lower() in _ALL_VERBS) + + +def _after_bold_label(md_text: str) -> str | None: + """Return text after **Label**: / **Label** — patterns, or None.""" + raw = md_text.strip().lstrip("-+>#0123456789. ") + m = re.match(r"^\*{2}[^*]+\*{2}\s*[:\u2014\u2013.!?/-]\s*", raw) + if m: + return raw[m.end() :] + m = re.match(r"^\*{2}[^*]+\*{2}\s+", raw) + if m: + return raw[m.end() :] + return None + + +def _is_descriptive(words: list[str], clean: str) -> bool: + """Detect descriptive sentences where the first word is a noun subject. + + Only checks word positions 1-2 of the main clause for finite verbs. + A finite verb deeper in the sentence is in a subordinate structure. + """ + if len(words) < 2: + return False + main = re.split( + r"[.!?]\s+|\s+[-\u2014\u2013]+\s+" + r"|\s+(?:if|when|where|unless|while|although|that|which)\s+", + clean, + maxsplit=1, + flags=re.IGNORECASE, + )[0] + mw = _CLASSIFY_WORD_RE.findall(main) + if len(mw) < 2: + return False + check = " ".join(mw[1 : min(3, len(mw))]) + return bool(_FINITE_VERB_RE.search(check)) + + +def _find_verb_idx(lowers: list[str]) -> int: + """Index of first known verb in word list, or -1.""" + for i, w in enumerate(lowers): + if w in _ALL_VERBS: + return i + return -1 + + +def _classify_phase3_spacy( + clean: str, + md_text: str, + nlp: Any, + has_cond_prefix: bool, + *, + shallow: bool = False, + inline_tokens: list[InlineToken] | None = None, +) -> tuple[str, int, str, str, bool] | None: + """Phase 3 imperative detection via spaCy dependency parse. + + Returns 5-tuple (charge, cv, modality, rule_trace, scope_conditional) + or None to fall through to verb lexicon. + + When shallow=True (called from bold-label recursive path), only + charge when root is VB at position 0 — avoids over-charging + descriptive text after labels. + """ + doc = nlp(clean) + + # Find ROOT token + root = None + for tok in doc: + if tok.dep_ == "ROOT": + root = tok + break + if root is None: + return None + + tag = root.tag_ + + # Backtick filter: neutralise when the ROOT word is inside a backtick span. + # Two paths: inline_tokens (AST-derived, used by pipeline) and regex + # fallback (used by direct classify_charge calls from tests/calibration). + _root_lower = root.text.lower() + if inline_tokens is not None: + # AST path: check if the FIRST token matching ROOT word is backtick. + # Must be first-occurrence — "Run `uv run`" has ROOT "Run" (plain) + # but a different "run" inside backticks. + for itok in inline_tokens: + if itok.text.lower() == _root_lower: + if itok.format == "backtick": + return ("NEUTRAL", 0, "none", "p3_spacy_backtick", False) + break # first occurrence is not backtick — stop + else: + # Regex fallback: position-0 heuristic for direct calls + _root_pos_in_clean = clean.lower().find(_root_lower) + if _root_pos_in_clean != -1: + _in_backtick = False + for m in _BACKTICK_RE.finditer(md_text): + span_lower = m.group().lower() + if _root_lower in _CLASSIFY_WORD_RE.findall(span_lower): + if _root_pos_in_clean == 0: + _in_backtick = True + break + if _in_backtick: + return ("NEUTRAL", 0, "none", "p3_spacy_backtick", False) + + # Position-0 advcl verb rescue: when a colon or dash creates a clause + # boundary, spaCy demotes the imperative verb at position 0 to advcl or + # ccomp and picks a verb from the after-clause as ROOT. But the instruction + # IS the pre-clause verb. "Check kill list: do not test..." has ROOT="test" + # with has_subj=True, but "Check" at position 0 is the imperative. + if ( + root.i > 0 + and len(doc) > 0 + and doc[0].tag_ in {"VB", "VBP"} + and doc[0].dep_ in ("advcl", "ccomp", "ROOT") + and doc[0].text.lower() in _ALL_VERBS + and doc[0].text.lower() not in _VERBS_AMBIGUOUS + ): + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb_advcl_rescue", sc) + + # Colon filter: "Noun: verb ..." label pattern — only when the colon + # appears in the first 4 tokens (actual labels like "Testing:" or + # "Security: be mindful"). Mid-sentence colons ("When X: do Y") are + # clause breaks, not labels. + # + # Guard: skip when position 0 is a known verb — "Read theory state: ..." + # is "Verb Object: continuation", not a descriptive label. + _t0_is_verb = len(doc) > 0 and doc[0].text.lower() in _ALL_VERBS + if not _t0_is_verb: + for tok in doc: + if tok.i >= root.i: + break + if tok.text == ":" and 0 < tok.i <= 4: + # Guard: if any word before the colon is a conditional marker, + # this is a conditional clause break ("before X: do Y"), not + # a descriptive label. + _pre_colon_has_cond = any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[: tok.i]) + if _pre_colon_has_cond: + break # not a label — fall through to normal classification + prev = doc[tok.i - 1] + if prev.tag_ in {"NN", "NNS", "NNP", "NNPS"}: + return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) + + # Check for subject dependents + has_subj = any(child.dep_ in ("nsubj", "nsubjpass") for child in root.children) + + # General position-0 verb rescue: spaCy commonly demotes position-0 + # imperative verbs to csubj, compound, nmod, or other non-root deps + # and picks a later word as ROOT. When position 0 has a known verb + # from our corpus-calibrated lexicon, it's the imperative regardless + # of what spaCy thinks ROOT is. + # Examples: "Create custom commands..." (Create=csubj, ROOT=workflows) + # "Verify data location is..." (Verify=csubj, ROOT=is) + # "Use Taskmaster to expand..." (Use=compound, ROOT=expand) + # "Update docs/roadmap.md if..." (Update=nmod, ROOT=added) + if root.i > 0 and len(doc) > 0: + _t0_lower = doc[0].text.lower() + if ( + _t0_lower in _ALL_VERBS + and _t0_lower not in _VERBS_AMBIGUOUS + and doc[0].dep_ in ("csubj", "compound", "nmod", "dep", "amod", "advcl", "ccomp") + ): + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + return ("IMPERATIVE", 1, "imperative", "p3_spacy_verb0_rescue", sc) + + # POS classification + if tag in {"NN", "NNS", "NNP", "NNPS"}: + # Lexicon override: spaCy often mistaggs imperative verbs as nouns + # ("Use", "List", "Report", "Focus", etc.). If the root is at + # position 0 and the word is in our verb lexicon, treat as VB. + if root.i == 0 and root.text.lower() in _ALL_VERBS: + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0", sc) + # Position-0 verb rescue: spaCy picked an NN as ROOT but position 0 + # has a known non-ambiguous verb — "Audit conditions", "Scan experiments/". + # The verb was demoted (compound, amod, etc.) but is the imperative. + # Excludes _VERBS_AMBIGUOUS ("Cache operations", "Process body") which + # are genuinely noun compounds. + _t0_lower = doc[0].text.lower() + if root.i > 0 and not has_subj and _t0_lower in _ALL_VERBS and _t0_lower not in _VERBS_AMBIGUOUS: + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0_rescue", sc) + # Post-colon verb rescue: "[conditional frame]: [verb] ..." + # spaCy picked a noun from after the colon as ROOT (often a path + # component like "experiments"), but there's a known verb right after + # the colon. Only fires when a conditional marker appears pre-colon, + # confirming this is a conditional instruction, not a label. + # E.g. "IMMEDIATELY before writing conditions: Re-read ..." + _colon_idx = next((t.i for t in doc if t.text == ":"), -1) + if _colon_idx > 0: + _pre_has_cond = any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[:_colon_idx]) + if _pre_has_cond: + _post_colon = [t for t in doc if t.i > _colon_idx] + for _pt in _post_colon[:3]: + if _pt.text.lower() in _ALL_VERBS and _pt.tag_ in {"VB", "VBP", "VBG", "VBN"}: + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_postcolon_verb", True) + return ("NEUTRAL", 0, "none", "p3_spacy_nn", False) + if tag == "VBZ": + return ("NEUTRAL", 0, "none", "p3_spacy_vbz", False) + if tag == "VBD": + return ("NEUTRAL", 0, "none", "p3_spacy_vbd", False) + if tag == "VBN": + return ("NEUTRAL", 0, "none", "p3_spacy_vbn", False) + if tag == "VBG": + return ("NEUTRAL", 0, "none", "p3_spacy_vbg", False) + + if tag == "VB": + if has_subj: + return ("NEUTRAL", 0, "none", "p3_spacy_vb_subj", False) + # In shallow mode (bold-label recursive), only charge if all tokens + # before the root are context words (adverbs, conditionals, etc.). + # Mid-sentence VB buried in descriptive text is noise. + if shallow and root.i > 0: + pre_words = {t.text.lower() for t in doc[: root.i]} + if not (pre_words <= _CONTEXT_WORDS): + return None # fall through to lexicon + # Lexicon cross-check: spaCy often mistaggs tech nouns as VB + # ("Plugin", "Vite", "Frontend", "Sarif"). Only charge when + # the root word is independently confirmed as a verb by our + # corpus-calibrated lexicon. Unknown words fall through to the + # lexicon path which has its own disambiguation. + if root.text.lower() not in _ALL_VERBS: + return None # fall through to lexicon + # Imperative — detect scope + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb", sc) + + if tag == "VBP": + if has_subj: + return ("NEUTRAL", 0, "none", "p3_spacy_vbp_subj", False) + # In shallow mode, same restriction as VB. + if shallow and root.i > 0: + pre_words = {t.text.lower() for t in doc[: root.i]} + if not (pre_words <= _CONTEXT_WORDS): + return None + # Same lexicon cross-check as VB — fall through for unknown words + if root.text.lower() not in _ALL_VERBS: + return None + # No subject — likely imperative, but VBP is ambiguous + cond_check = _CONDITIONAL_MARKERS - {"for"} + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in cond_check for w in lowers) + sc = has_cond_prefix or has_cond + # Check if next token is DT or has dobj dependency + next_tok = doc[root.i + 1] if root.i + 1 < len(doc) else None + if next_tok and (next_tok.tag_ == "DT" or next_tok.dep_ == "dobj"): + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vbp_det", sc) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vbp!amb", sc) + + # Non-verb tags (JJ, RB, CD, etc.) → NEUTRAL + # But rescue known verbs at position 0 that spaCy mistagged + # (e.g. "Close" as JJ, "Scan" as JJ). + if tag not in {"VB", "VBP", "VBZ", "VBD", "VBN", "VBG"}: + if root.i == 0 and root.text.lower() in _ALL_VERBS: + amb = "!amb" if root.text.lower() in _VERBS_AMBIGUOUS else "" + return ("IMPERATIVE", 1, "imperative", f"p3_spacy_{tag.lower()}_verb0{amb}", False) + # Post-colon verb rescue (same logic as NN branch): conditional + # markers like "After", "Before", "When" can be ROOT (IN/RB/etc.) + # with the imperative verb after the colon. + _colon_idx_nv = next((t.i for t in doc if t.text == ":"), -1) + if _colon_idx_nv > 0: + _pre_has_cond_nv = any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[:_colon_idx_nv]) + if _pre_has_cond_nv: + _post_colon_nv = [t for t in doc if t.i > _colon_idx_nv] + for _pt in _post_colon_nv[:3]: + if _pt.text.lower() in _ALL_VERBS and _pt.tag_ in {"VB", "VBP", "VBG", "VBN"}: + return ("IMPERATIVE", 1, "imperative", "p3_spacy_postcolon_verb", True) + return ("NEUTRAL", 0, "none", f"p3_spacy_{tag.lower()}", False) + + # Unrecognized — fall through to lexicon + return None + + +def classify_charge( + md_text: str, + *, + plain_text: str | None = None, + _shallow: bool = False, + inline_tokens: list[InlineToken] | None = None, +) -> tuple[str, int, str, str, bool]: + """Classify an atom's charge and modality using deterministic rules. + + Input: raw markdown text (with formatting markers intact). + Returns: (charge, charge_value, modality, rule_trace, scope_conditional) + + rule_trace identifies which rule fired (e.g. "p1_negation_phrase", + "p3c_verb0_use"). Traces ending with "!amb" indicate the classification + depends on a verb-noun interpretation (ambiguous charge). + + Three-phase classification: + Phase 1 — Negation/prohibition patterns → CONSTRAINT + Phase 2 — Modal verbs and absolute adverbs → DIRECTIVE + Phase 3 — Imperative verb detection (corpus-calibrated lexicon) → IMPERATIVE + + When _shallow=True (recursive from Phase 3b), only high-precision + phases fire: Phase 1, Phase 2, Phase 3a, Phase 3c. Phases 3d-3g + (deep mid-sentence detection) are skipped to avoid noise from + descriptive text after bold labels. + """ + if plain_text is not None: + clean = plain_text.strip().lstrip("-+>#0123456789. ") + else: + clean = _strip_md_for_classify(md_text) + if len(clean) < 3: + return "NEUTRAL", 0, "none", "short_text", False + + words = _classify_words(clean) + if not words: + return "NEUTRAL", 0, "none", "no_words", False + lowers = [w.lower() for w in words] + + # Helper: detect conditional scope frame from sentence-initial markers + has_cond_prefix = lowers[0] in _CONDITIONAL_MARKERS if lowers else False + + # ── Phase 1: CONSTRAINT ── + if _NEGATION_PHRASES_RE.match(clean): + return "CONSTRAINT", -1, "direct", "p1_negation_phrase", False + if _PROHIBITION_START_RE.match(clean): + # "No X is/are/was/were Y" is descriptive, not a prohibition. + if lowers[0] == "no" and any( + v in {"is", "are", "was", "were", "has", "have", "does", "did"} for v in lowers[1:8] + ): + pass # fall through — descriptive "No X is Y" pattern + else: + return "CONSTRAINT", -1, "absolute" if lowers[0] == "never" else "direct", "p1_prohibition_start", False + if words[0] in ("NOT", "NO", "NEVER"): + return "CONSTRAINT", -1, "absolute", "p1_caps_negation", False + # First-clause restriction for mid-sentence negation. + _first_clause = re.split(r"[,;.]", clean, maxsplit=1)[0] + if _MID_NEGATION_RE.search(_first_clause): + return "CONSTRAINT", -1, "direct", "p1_mid_negation", has_cond_prefix + if _LATE_DONOT_RE.search(_first_clause): + return "CONSTRAINT", -1, "direct", "p1_late_donot", has_cond_prefix + + # ── Phase 2: DIRECTIVE ── + for i, w in enumerate(lowers): + if w in _MODAL_ABSOLUTE: + if i + 1 < len(lowers) and lowers[i + 1] in ("not", "never", "n't"): + return "CONSTRAINT", -1, "absolute", "p2_modal_negated", False + return "DIRECTIVE", 1, "absolute", f"p2_modal_{w}", False + if w in _MODAL_HEDGED: + if i + 1 < len(lowers) and lowers[i + 1] in ("not", "never", "n't"): + return "CONSTRAINT", -1, "hedged", f"p2_hedged_{w}_negated", False + # "should" fires at any position — corpus data shows "X should Y" + # is always a directive regardless of subject. "could"/"might" only + # fire at position 0 or after you/we/conditional (ambiguous otherwise). + if w == "should": + return "DIRECTIVE", 1, "hedged", f"p2_hedged_{w}", False + if ( + i == 0 + or (i > 0 and lowers[i - 1] in ("you", "we")) + or (i > 0 and lowers[i - 1] in _CONDITIONAL_MARKERS) + ): + return "DIRECTIVE", 1, "hedged", f"p2_hedged_{w}", False + continue + if w == "will" and i > 0 and lowers[i - 1] == "you": + if i + 1 < len(lowers) and lowers[i + 1] in ("not", "never", "n't"): + return "CONSTRAINT", -1, "absolute", "p2_you_will_not", False + return "DIRECTIVE", 1, "absolute", "p2_you_will", False + for w in lowers[:6]: + if w in _ABSOLUTE_ADVERBS: + if w == "only" and not any(v in _ALL_VERBS for v in lowers): + continue + return "DIRECTIVE", 1, "absolute", f"p2_adverb_{w}", False + + # ── Phase 3: IMPERATIVE ── + + # 3a: DISABLED — 20.7% precision. + + # 3b: Bold label + verb after it — shallow recursive call. + if not _shallow: + after = _after_bold_label(md_text) + if after is not None: + after_clean = _strip_md_for_classify(after) + if after_clean: + sub_c, sub_cv, sub_m, _sub_trace, sub_sc = classify_charge( + after, + plain_text=after_clean, + _shallow=True, + ) + if sub_cv != 0: + return sub_c, sub_cv, sub_m, "p3b_bold_label", sub_sc + + # 3_spacy: primary Phase 3 (spaCy dependency parse) + nlp = get_models().nlp + if nlp is not None: + result = _classify_phase3_spacy( + clean, + md_text, + nlp, + has_cond_prefix, + shallow=_shallow, + inline_tokens=inline_tokens, + ) + if result is not None: + return result + + # 3c-3g: fallback verb lexicon (when spaCy unavailable or returns None) + verb_idx = _find_verb_idx(lowers) + if verb_idx == -1: + return "NEUTRAL", 0, "none", "p3_no_verb", False + + # 3c: Verb at position 0 + if verb_idx == 0: + verb = lowers[0] + # Ambiguity check: verb-noun words (test, build, state, ...) are + # ambiguous UNLESS position 1 is a determiner — "State the X" is + # clearly imperative (VB DT NN), "State machine" is a compound noun. + _DETERMINERS = { + "the", + "a", + "an", + "any", + "all", + "each", + "every", + "this", + "that", + "these", + "those", + "your", + "our", + "my", + "its", + "their", + "his", + "her", + "no", + "some", + "both", + "either", + "neither", + } + amb = "" + if verb in _VERBS_AMBIGUOUS: + pos1 = lowers[1] if len(lowers) > 1 else "" + if pos1 not in _DETERMINERS: + amb = "!amb" + cond_check = _CONDITIONAL_MARKERS - {"for"} + has_cond = any(w in cond_check for w in lowers[1:8]) + return "IMPERATIVE", 1, "imperative", f"p3c_verb0_{verb}{amb}", has_cond + + # In shallow mode (recursive from 3b), skip deep detection phases 3d-3g + if _shallow: + return "NEUTRAL", 0, "none", "p3_shallow_stop", False + + # 3d: Verb after context words only + pre = set(lowers[:verb_idx]) + if pre <= _CONTEXT_WORDS: + has_cond = bool(pre & _CONDITIONAL_MARKERS) + if "not" in pre: + return "CONSTRAINT", -1, "direct", "p3d_context_not", has_cond + verb = lowers[verb_idx] + amb = "!amb" if verb in _VERBS_AMBIGUOUS else "" + return "IMPERATIVE", 1, "imperative", f"p3d_context_{verb}{amb}", has_cond + + # 3e: Verb after sentence/clause break + sentences = re.split(r"(?<=[.!?:;])\s+", clean) + if len(sentences) > 1: + for sent in sentences[1:]: + sw = _classify_words(sent) + if not sw: + continue + sl = [w.lower() for w in sw] + if sl[0] in _ALL_VERBS: + has_cond = any(w in _CONDITIONAL_MARKERS for w in sl[:6]) + amb = "!amb" if sl[0] in _VERBS_AMBIGUOUS else "" + return "IMPERATIVE", 1, "imperative", f"p3e_break_{sl[0]}{amb}", has_cond + if sl[0] in _CONDITIONAL_MARKERS: + return "IMPERATIVE", 1, "imperative", "p3e_break_cond", True + + # 3f: Conditional marker at sentence start + if lowers[0] in _CONDITIONAL_MARKERS: + return "IMPERATIVE", 1, "imperative", f"p3f_cond_{lowers[0]}", True + + # 3g: Mid-sentence verb with conditional marker before it + _DECLARATIVE_STARTS = _PROBABLE_SUBJECTS | { + "the", + "a", + "an", + "its", + "their", + "our", + "your", + "my", + "his", + "her", + } + if lowers[0] not in _DECLARATIVE_STARTS and verb_idx <= 7: + if pre & _CONDITIONAL_MARKERS: + verb = lowers[verb_idx] + amb = "!amb" if verb in _VERBS_AMBIGUOUS else "" + if "not" in pre: + return "CONSTRAINT", -1, "direct", f"p3g_mid_not{amb}", True + return "IMPERATIVE", 1, "imperative", f"p3g_mid_{verb}{amb}", True + + return "NEUTRAL", 0, "none", "fallthrough", False + + +def check_specificity( + text: str, +) -> tuple[str, list[str], list[str], list[str], list[str]]: + """Check for named constructs, italic tokens, bold tokens, and unformatted code tokens. + + Returns: + (named|abstract, named_tokens, unformatted_code_tokens, italic_tokens, bold_tokens) + """ + backtick_content = set(_BACKTICK_RE.findall(text)) + named = [m.strip("`") for m in backtick_content] + + text_no_bold = _BOLD_TERM_RE.sub("", text) + italic = _ITALIC_RE.findall(text_no_bold) + + # Bold tokens — exclude negation phrases (bold on prohibitions is harmless) + bold_raw = _BOLD_TERM_RE.findall(text) + bold = [b for b in bold_raw if not _BOLD_NEGATION_RE.match(b)] + + text_no_bt = _BACKTICK_RE.sub("", text) + unformatted: list[str] = [] + + for tok in KNOWN_CODE_TOKENS: + pat = re.compile(r"(?<=~!]\s*[\d.]") +_LABEL_ONLY_RE = re.compile(r"^[A-Z][a-z\s]{0,30}:\s*$") +_PIPE_REF_RE = re.compile(r"^`[^`]+`\s*\|") +_FILE_LISTING_RE = re.compile(r"^\*{2}[a-zA-Z_./]+\.\w+\*{2}\s*[-\u2013\u2014:]") +_CLAUSE_SPLIT_RE = re.compile(r"\s[\u2014\u2013]\s|:\s*[\"'\u201c]") + + +def _strip_frontmatter(content: str) -> tuple[str, int]: + """Strip YAML frontmatter. Returns (stripped_content, lines_removed).""" + if not content.startswith("---"): + return content, 0 + end = content.find("\n---", 3) + if end == -1: + return content, 0 + # Count lines in frontmatter including both --- delimiters + fm = content[: end + 4] + offset = fm.count("\n") + (0 if fm.endswith("\n") else 0) + rest = content[end + 4 :] + if rest.startswith("\n"): + rest = rest[1:] + offset += 1 + return rest, offset + + +def _split_at_softbreaks(children: list[Any]) -> list[list[Any]]: + """Split inline children into per-line segments at softbreak boundaries.""" + segments: list[list[Any]] = [[]] + for child in children: + if child.type == "softbreak": + segments.append([]) + else: + segments[-1].append(child) + return [s for s in segments if s] + + +def _extract_texts( + segment: list[Any], +) -> tuple[str, str, list[InlineToken]]: + """Extract md_text, plain_text, and inline_tokens from AST children. + + md_text preserves `backtick`, **bold**, *italic* markers for check_specificity(). + plain_text strips all markers for 3rd-person detection. + inline_tokens provides per-word format context for Phase 3 backtick filter. + """ + md_parts: list[str] = [] + plain_parts: list[str] = [] + inline_tokens: list[InlineToken] = [] + format_stack: list[str] = ["plain"] + + for child in segment: + if child.type == "text": + md_parts.append(child.content) + plain_parts.append(child.content) + current_fmt = format_stack[-1] + for word in child.content.split(): + inline_tokens.append(InlineToken(text=word, format=current_fmt)) + elif child.type == "code_inline": + md_parts.append(f"`{child.content}`") + plain_parts.append(child.content) + for word in child.content.split(): + inline_tokens.append(InlineToken(text=word, format="backtick")) + elif child.type == "strong_open": + md_parts.append("**") + format_stack.append("bold") + elif child.type == "strong_close": + md_parts.append("**") + if len(format_stack) > 1: + format_stack.pop() + elif child.type == "em_open": + md_parts.append("*") + format_stack.append("italic") + elif child.type == "em_close": + md_parts.append("*") + if len(format_stack) > 1: + format_stack.pop() + elif child.type in ("link_open", "link_close"): + pass # skip link markers, text child handles content + elif child.type == "html_inline": + md_parts.append(child.content) + plain_parts.append(child.content) + current_fmt = format_stack[-1] + for word in child.content.split(): + inline_tokens.append(InlineToken(text=word, format=current_fmt)) + + md_text = "".join(md_parts).strip() + plain_text = "".join(plain_parts).strip() + return md_text, plain_text, inline_tokens + + +def _determine_format(block_stack: list[str]) -> str: + """Map the current block nesting stack to a format string.""" + for tag in reversed(block_stack): + if tag == "table": + return "table" + if tag == "blockquote": + return "blockquote" + if tag == "ordered_list": + return "numbered" + if tag == "bullet_list": + return "list" + return "prose" + + +def _is_structural(md_text: str) -> bool: + """Check if text is structural meta-text that should be forced NEUTRAL. + + Only catches genuinely non-instructive CONTENT patterns — reference + tables, file listings, version notes. Formatting (bold labels, italic + emphasis) does NOT override charge — formatting is handled separately, + not in charge classification. + """ + # Single-word bold definitions: **Term**: description + # But not if the term is a verb — that's an instruction label + is_defn = bool(_DEFN_LABEL_RE.match(md_text)) + if is_defn: + m = re.match(r"^\*{2}(\S+)\*{2}", md_text) + if m and m.group(1).lower() in _ALL_VERBS: + is_defn = False + + return bool( + _QUOTED_START_RE.match(md_text) + or is_defn + or _CMD_REF_RE.match(md_text) + or _VERSION_NOTE_RE.match(md_text) + or _LABEL_ONLY_RE.match(md_text) + or _PIPE_REF_RE.match(md_text) + or _FILE_LISTING_RE.match(md_text) + ) + + +def _classify_content( + md_text: str, + plain_text: str, + fmt: str, + *, + inline_tokens: list[InlineToken] | None = None, +) -> tuple[str, int, str, str, bool]: + """Classify an atom's charge and modality. + + Uses md_text for structural detection and rule-based classification. + Uses plain_text for 3rd-person description detection. + Returns: (charge, charge_value, modality, rule_trace, scope_conditional) + """ + if fmt == "table" or _is_structural(md_text): + return "NEUTRAL", 0, "none", "structural", False + + # 3rd-person descriptions on plain text (no markers to confuse) + if _THIRD_PERSON_RE.match(plain_text): + return "NEUTRAL", 0, "none", "third_person", False + + return classify_charge(md_text, plain_text=plain_text, inline_tokens=inline_tokens) + + +def tokenize(content: str) -> list[Atom]: + """Split instruction file content into classified atoms. + + Uses markdown-it-py AST for structure (headings, lists, blockquotes, + bold/italic/code spans) and rule-based charge classification. + """ + stripped_content, line_offset = _strip_frontmatter(content) + tokens = _md_parser.parse(stripped_content) + + atoms: list[Atom] = [] + pos_idx = 0 + current_heading = "" + block_stack: list[str] = [] + + # Map block types from markdown-it token types + _BLOCK_TYPES = { + "bullet_list_open": "bullet_list", + "ordered_list_open": "ordered_list", + "blockquote_open": "blockquote", + "table_open": "table", + } + _BLOCK_CLOSE = { + "bullet_list_close", + "ordered_list_close", + "blockquote_close", + "table_close", + } + + i = 0 + while i < len(tokens): + tok = tokens[i] + + # Track block nesting + if tok.type in _BLOCK_TYPES: + block_stack.append(_BLOCK_TYPES[tok.type]) + elif tok.type in _BLOCK_CLOSE: + if block_stack: + block_stack.pop() + + # Emit code block atoms (fence) — captures language tag for mermaid detection + if tok.type == "fence": + lang = (tok.info or "").strip().lower() + atoms.append( + Atom( + line=tok.map[0] + 1 if tok.map else 0, + text=tok.content[:200], + kind="excitation", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity="named" if lang else "abstract", + format="code_block", + named_tokens=[lang] if lang else [], + file_path="", + plain_text=f"code_block:{lang}" if lang else "code_block", + ) + ) + i += 1 + continue + + # Skip horizontal rules + if tok.type == "hr": + i += 1 + continue + + # Headings: depth from tag, text from next inline token + if tok.type == "heading_open": + depth = int(tok.tag[1]) + line_num = (tok.map[0] if tok.map else 0) + line_offset + 1 + # Next token is the inline with heading text + if i + 1 < len(tokens) and tokens[i + 1].type == "inline": + heading_text = tokens[i + 1].content + else: + heading_text = "" + current_heading = heading_text + # Headings are content — "## Never push to main" is a constraint. + h_charge, h_cv, h_mod, h_rule, h_sc = classify_charge(heading_text) + h_spec, h_named, _h_unfmt, _h_italic, _h_bold = check_specificity(heading_text) + atoms.append( + Atom( + line=line_num, + text=heading_text, + kind="heading", + charge=h_charge, + charge_value=h_cv, + modality=h_mod, + specificity=h_spec, + format="heading", + depth=depth, + named_tokens=h_named, + token_count=len(heading_text.split()), + rule=h_rule, + scope_conditional=h_sc, + ) + ) + i += 3 # skip heading_open, inline, heading_close + continue + + # Table rows: collect cells, join with " | ", one atom per row + if tok.type == "tr_open": + line_num = (tok.map[0] if tok.map else 0) + line_offset + 1 + cells: list[str] = [] + j = i + 1 + while j < len(tokens) and tokens[j].type != "tr_close": + if tokens[j].type == "inline": + cells.append(tokens[j].content.strip()) + j += 1 + cell_text = " | ".join(cells) + if len(cell_text) >= 5: + spec, named, unformatted, italic, bold = check_specificity( + cell_text, + ) + token_count = len(_BACKTICK_RE.sub("x", cell_text).split()) + atoms.append( + Atom( + line=line_num, + text=cell_text, + kind="excitation", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity=spec, + format="table", + named_tokens=named, + italic_tokens=italic, + bold_tokens=bold, + unformatted_code=unformatted, + position_index=pos_idx, + token_count=token_count, + heading_context=current_heading, + ) + ) + pos_idx += 1 + i = j + 1 # skip past tr_close + continue + + # Process inline content tokens + if tok.type == "inline" and tok.children: + base_line = (tok.map[0] if tok.map else 0) + line_offset + 1 + fmt = _determine_format(block_stack) + + # Table inlines are handled by tr_open above + if fmt == "table": + i += 1 + continue + + segments = _split_at_softbreaks(tok.children) + for seg_idx, segment in enumerate(segments): + md_text, plain_text, inline_tokens = _extract_texts(segment) + if len(md_text) < 5: + continue + + charge, cv, mod, rule_trace, scope_cond = _classify_content( + md_text, + plain_text, + fmt, + inline_tokens=inline_tokens, + ) + spec, named, unformatted, italic, bold = check_specificity( + md_text, + ) + token_count = len(_BACKTICK_RE.sub("x", md_text).split()) + + atoms.append( + Atom( + line=base_line + seg_idx, + text=md_text, + kind="excitation", + charge=charge, + charge_value=cv, + modality=mod, + scope_conditional=scope_cond, + specificity=spec, + format=fmt, + named_tokens=named, + italic_tokens=italic, + bold_tokens=bold, + unformatted_code=unformatted, + position_index=pos_idx, + token_count=token_count, + heading_context=current_heading, + plain_text=plain_text, + rule=rule_trace, + ambiguous=rule_trace.endswith("!amb"), + ) + ) + pos_idx += 1 + + i += 1 + + # Post-classification pass: split multi-sentence atoms when sub-sentences + # carry different charges. Catches both "Don't X. Use Y instead." (charged + # atom with charge flip) and "You are X. Never do Y." (neutral atom with + # embedded constraint). Without this, compound sentences get classified by + # their first clause and charged sub-sentences are lost. + atoms = _split_mixed_charge_atoms(atoms) + + # Assign confidence scores based on rule trace reliability. + for atom in atoms: + atom.charge_confidence = _rule_confidence(atom.rule, atom.charge) + + # Scan neutral atoms for embedded charge markers. + # Runs AFTER confidence scoring so it can lower confidence for flagged atoms. + _scan_neutral_for_embedded_markers(atoms) + + return atoms + + +# Sentence boundary: period/exclamation/question followed by whitespace +# and then uppercase letter or markdown emphasis (*bold*, **italic**). +# Excludes common abbreviations (e.g., i.e., etc., vs.). +_SENTENCE_SPLIT_RE = re.compile( + r"(? list[Atom]: + """Split multi-sentence atoms when sub-sentences carry different charges. + + Handles two cases: + 1. Charged atom with charge flip: "Do not output cheerleading. Go straight + to content." -> CONSTRAINT + IMPERATIVE. + 2. Neutral atom with embedded charge: "You are the reviewer. Never burden + the user." -> NEUTRAL + CONSTRAINT. + + Without case 2, compound sentences classified by their first clause lose + charged sub-sentences entirely (5.8% false-negative rate in audit). + + Only splits when: (1) atom has 2+ sentences, (2) at least one sub-sentence + has a different charge than the atom's current classification. + """ + result: list[Atom] = [] + for atom in atoms: + if atom.kind == "heading": + result.append(atom) + continue + + # Try to split at sentence boundaries + splits = list(_SENTENCE_SPLIT_RE.finditer(atom.text)) + if not splits: + result.append(atom) + continue + + # Build sentence segments from split points + boundaries = [0] + [m.end() for m in splits] + [len(atom.text)] + sentences = [atom.text[boundaries[i] : boundaries[i + 1]].strip() for i in range(len(boundaries) - 1)] + sentences = [s for s in sentences if len(s) >= 5] + + if len(sentences) < 2: + result.append(atom) + continue + + # Classify each sentence independently + classified = [] + for sent in sentences: + plain = _BACKTICK_RE.sub("x", sent) + plain = re.sub(r"[*_]+", "", plain).strip() + charge, cv, mod, rule, scope = _classify_content(sent, plain, atom.format) + classified.append((sent, charge, cv, mod, rule, scope)) + + # Only split if charges actually differ + charges = {cv for _, _, cv, _, _, _ in classified} + if len(charges) < 2: + result.append(atom) + continue + + # Produce separate atoms for each sentence + for sent, charge, cv, mod, rule, scope in classified: + spec, named, unformatted, italic, bold = check_specificity(sent) + token_count = len(_BACKTICK_RE.sub("x", sent).split()) + result.append( + Atom( + line=atom.line, + text=sent, + kind="excitation", + charge=charge, + charge_value=cv, + modality=mod, + scope_conditional=scope, + specificity=spec, + format=atom.format, + named_tokens=named, + italic_tokens=italic, + bold_tokens=bold, + unformatted_code=unformatted, + position_index=atom.position_index, + token_count=token_count, + heading_context=atom.heading_context, + plain_text=re.sub(r"[*_`]+", "", sent).strip(), + rule=rule, + ambiguous=rule.endswith("!amb"), + ) + ) + + # Re-index positions + pos_idx = 0 + for a in result: + if a.kind != "heading": + a.position_index = pos_idx + pos_idx += 1 + + return result + + +# ────────────────────────────────────────────────────────────────── +# CONFIDENCE SCORING +# ────────────────────────────────────────────────────────────────── + +# Rule traces grouped by reliability. High-precision rules produce +# confident classifications. Ambiguous rules (verb-noun, rescue paths) +# produce lower confidence. +_HIGH_CONFIDENCE_RULES = frozenset( + { + "p1_negation_phrase", + "p1_prohibition_start", + "p1_caps_negation", + "p1_mid_negation", + "p1_late_donot", + "p2_modal_must", + "p2_modal_shall", + "p2_you_will", + "p2_you_will_not", + "p2_modal_negated", + "p2_adverb_always", + "p2_adverb_every", + "p2_adverb_only", + "p3_spacy_vb", + "p3_spacy_vbp_det", + "p3_spacy_nn_verb0", + } +) + +_MEDIUM_CONFIDENCE_RULES = frozenset( + { + "p2_hedged_should", + "p2_hedged_could", + "p2_hedged_might", + "p2_hedged_should_negated", + "p3_spacy_verb0_rescue", + "p3_spacy_nn_verb0_rescue", + "p3_spacy_vb_advcl_rescue", + "p3_spacy_nn_postcolon_verb", + "p3b_bold_label", + "p3c_verb0_use", + "p3c_verb0_run", + "p3c_verb0_add", + "p3d_context_use", + "p3d_context_run", + "p3e_break_use", + "p3e_break_run", + } +) + + +def _rule_confidence(rule: str, charge: str) -> float: + """Assign confidence score based on which classification rule fired.""" + if charge == "NEUTRAL": + # Neutral classifications from explicit rules are high confidence. + # Fallthrough neutrals are slightly lower (nothing matched). + if rule == "fallthrough": + return 0.85 + return 0.95 + + if rule in _HIGH_CONFIDENCE_RULES: + return 0.95 + + if rule in _MEDIUM_CONFIDENCE_RULES: + return 0.80 + + # Ambiguous rules (verb-noun words) + if rule.endswith("!amb"): + return 0.60 + + # Conditional/scope rules + if "cond" in rule or "3f_" in rule or "3g_" in rule: + return 0.70 + + # Default for any other charged rule + return 0.75 + + +# ────────────────────────────────────────────────────────────────── +# NEUTRAL ATOM SCANNER — detect embedded charge markers +# ────────────────────────────────────────────────────────────────── + +# Patterns that indicate charge language in text classified as neutral. +# These are the "prohibited words" — if they appear in neutral atoms, +# the atom is flagged for review. +_EMBEDDED_CONSTRAINT_RE = re.compile( + r"\b(" + r"never|don'?t|do\s+not|must\s+not|should\s+not|cannot|can'?t" + r"|avoid\b|refrain|prohibit" + r")\b", + re.IGNORECASE, +) +_EMBEDDED_DIRECTIVE_RE = re.compile( + r"\b(" + r"must|shall|always|ensure that|require that" + r")\b", + re.IGNORECASE, +) +_EMBEDDED_IMPERATIVE_RE = re.compile( + r"(?:^|[.!?]\s+)(" + # Only non-ambiguous verbs — words that are almost always imperative + # at sentence start. Excludes verb-noun words (test, build, set, check, + # run, read, trace, etc.) that produce false positives on descriptions. + r"use|add|create|install|configure|make" + r"|update|follow|keep|write|verify|ensure" + r"|remove|delete|include|exclude|specify|define|implement" + r")\b", + re.IGNORECASE, +) + + +def _scan_neutral_for_embedded_markers(atoms: list[Atom]) -> None: + """Scan neutral atoms for embedded charge markers. + + Reclassifies to AMBIGUOUS when charge language appears in text that + the classifier couldn't resolve. AMBIGUOUS atoms are excluded from + diagnostics until the user rephrases them. The map records what + markers were found so diagnostics can suggest specific fixes. + + This enforces Path A: unambiguous instruction language is required + for accurate analysis. The tool refuses to score what it can't classify. + """ + # Rules that produce correct neutralizations — don't second-guess these. + # Backtick filter: ROOT verb inside code markup (not an instruction). + # Third person: "The system processes..." (description, not instruction). + # Structural: tables, file listings, pipe references. + _TRUSTED_NEUTRAL_RULES = frozenset( + { + "third_person", + "short_text", + "no_words", + } + ) + + for atom in atoms: + if atom.charge != "NEUTRAL" or atom.kind == "heading": + continue + if atom.rule in _TRUSTED_NEUTRAL_RULES: + continue + text = atom.text + markers: list[str] = [] + for m in _EMBEDDED_CONSTRAINT_RE.finditer(text): + markers.append(f"constraint:{m.group().strip()}") + for m in _EMBEDDED_DIRECTIVE_RE.finditer(text): + markers.append(f"directive:{m.group().strip()}") + for m in _EMBEDDED_IMPERATIVE_RE.finditer(text): + markers.append(f"imperative:{m.group(1).strip()}") + if markers: + atom.embedded_charge_markers = markers + atom.charge = "AMBIGUOUS" + atom.charge_confidence = 0.0 + + +def _embed_text(atom: Atom) -> str: + """Build embedding text for an atom. + + Uses plain_text (AST-stripped) for cleaner embeddings — formatting markers + (**bold**, *italic*, `backtick`) add noise without semantic content. + Heading context is NOT prepended — headings are their own atoms. + Prepending created double-counting and artificial clustering by + heading rather than by semantic content. + """ + return atom.plain_text or atom.text + + +# ────────────────────────────────────────────────────────────────── +# TOPIC CLUSTERING +# ────────────────────────────────────────────────────────────────── + + +def cluster_topics( + atoms: list[Atom], +) -> list[TopicCluster]: + """Cluster atoms into topic groups using pre-computed embeddings. + + Uses AgglomerativeClustering with distance_threshold on the already-embedded + int8 vectors from map_ruleset(). Does NOT re-encode — uses embedding_int8 + directly, dequantized to float32 for clustering. + + Falls back to single cluster when embeddings are missing. + """ + import numpy as np + + exc = [a for a in atoms if a.kind != "heading"] + if not exc: + return [] + + # Use pre-computed embeddings (already set by map_ruleset) + embedded = [a for a in exc if a.embedding_int8 is not None] + if len(embedded) < 2: + # Not enough embeddings — single cluster + charged = [a for a in exc if a.charge_value != 0] + j = len(charged) / len(exc) if exc else 0.0 + for a in exc: + a.cluster_id = 0 + return [TopicCluster(topic_id=0, atoms=exc, charged=charged, j=j)] + + # Dequantize int8 embeddings to float32 for clustering + vecs = np.array([list(a.embedding_int8) for a in embedded if a.embedding_int8 is not None], dtype=np.float32) + + from sklearn.cluster import AgglomerativeClustering + from sklearn.preprocessing import normalize + + embeddings_norm = normalize(vecs, norm="l2") + clustering = AgglomerativeClustering( + n_clusters=None, + distance_threshold=TOPIC_CLUSTER_THRESHOLD, + metric="euclidean", + linkage="average", + ) + labels = clustering.fit_predict(embeddings_norm) + + # Per-label index bookkeeping so we can compute centroids from the + # normalized float32 vectors we already have (no re-encoding needed). + clusters: dict[int, list[Atom]] = {} + indices: dict[int, list[int]] = {} + for i, (atom, label) in enumerate(zip(embedded, labels, strict=True)): + lbl = int(label) + atom.cluster_id = lbl + clusters.setdefault(lbl, []).append(atom) + indices.setdefault(lbl, []).append(i) + + # Assign unembedded atoms (headings already filtered, but safety net) to cluster -1 + for a in exc: + if a.embedding_int8 is None: + a.cluster_id = -1 + + result: list[TopicCluster] = [] + for tid in sorted(clusters): + cluster_atoms = clusters[tid] + charged = [a for a in cluster_atoms if a.charge_value != 0] + n_total = len(cluster_atoms) + n_chg = len(charged) + j = n_chg / n_total if n_total else 0.0 + + # Centroid: mean of L2-normalized member vectors, re-normalized to the + # unit sphere. Closed 2026-04 wire-format gap: ClusterRecord.centroid + # was declared but never populated, forcing server consumers to re-derive. + member_vecs = embeddings_norm[indices[tid]] + mean_vec = member_vecs.mean(axis=0) + norm = float(np.linalg.norm(mean_vec)) + if norm > 1e-12: + mean_vec = mean_vec / norm + centroid = tuple(float(x) for x in mean_vec.tolist()) + + result.append( + TopicCluster( + topic_id=tid, + atoms=cluster_atoms, + charged=charged, + j=j, + centroid=centroid, + ) + ) + + return result + + +# ────────────────────────────────────────────────────────────────── +# MODEL LOADING (lazy singleton) +# ────────────────────────────────────────────────────────────────── + + +_UNSET = object() + + +class Models: + """Lazy-loaded models. Load once, reuse across files. + + Thread-safe: both ``.st`` and ``.nlp`` lazy loads are guarded by a lock + so the daemon's background warmup thread and a serving thread can't + double-initialise the same model. + """ + + def __init__(self) -> None: + import threading as _threading + + self._st: Any | None = None + self._nlp: Any = _UNSET + self._st_lock = _threading.Lock() + self._nlp_lock = _threading.Lock() + + @property + def st(self) -> Any: + if self._st is None: + with self._st_lock: + if self._st is None: + # ONNX Runtime directly on the bundled MiniLM-L6-v2 ONNX + # export — no torch, no sentence-transformers. Loads in + # ~0.3s (vs ~20s for `import torch`), produces bit-identical + # output to the PyTorch reference (float32 epsilon). + # ORT and PyTorch hit the SAME per-atom throughput on this + # model (~67 atoms/s bs=32, ~86 atoms/s length-sorted) — + # both dispatch to MLAS kernels. The torch import cost was + # the only real bottleneck, and the _torch_blocker hook at + # CLI/MCP/daemon entry points eliminates it. + try: + from reporails_cli.core.mapper.onnx_embedder import OnnxEmbedder + except ImportError as exc: + raise RuntimeError("onnxruntime / tokenizers not installed.\nRun: uv sync") from exc + self._st = OnnxEmbedder() + return self._st + + @property + def nlp(self) -> Any | None: + if self._nlp is _UNSET: + with self._nlp_lock: + if self._nlp is _UNSET: + try: + import spacy + + # Phase 3 classification only reads tok.dep_ / tok.tag_ / + # tok.text / tok.i / root.children. That needs tok2vec + + # tagger + parser only; ner / lemmatizer / attribute_ruler + # are dead weight on both load time and per-doc inference. + self._nlp = spacy.load( + "en_core_web_sm", + disable=["ner", "lemmatizer", "attribute_ruler"], + ) + except (ImportError, OSError): + self._nlp = None + return self._nlp + + def warmup(self) -> None: + """Eagerly load both models in parallel. + + Both loads are CPU-bound in native code that releases the GIL, so + threads actually parallelise. Saves roughly ``min(T_spacy, T_st)`` + on cold start. Idempotent — safe to call multiple times. + """ + from concurrent.futures import ThreadPoolExecutor + + with ThreadPoolExecutor(max_workers=2) as pool: + fut_st = pool.submit(lambda: self.st) + fut_nlp = pool.submit(lambda: self.nlp) + # Surface exceptions from the ST load (nlp load already tolerates + # ImportError/OSError and stores None). + fut_st.result() + fut_nlp.result() + + +_models: Models | None = None + + +def get_models() -> Models: + """Get or create the lazy model singleton.""" + global _models + if _models is None: + _models = Models() + return _models + + +# ────────────────────────────────────────────────────────────────── +# RULESET MAP CONSTRUCTION +# ────────────────────────────────────────────────────────────────── + + +def _quantize_int8(vec: Any) -> tuple[int, ...]: + """Quantize a float32 embedding vector to int8 (-128..127). + + Preserves cosine similarity with < 1% error for all-MiniLM-L6-v2 vectors. + """ + import numpy as np + + arr = np.asarray(vec, dtype=np.float32) + # Scale to [-127, 127] range based on max absolute value + scale = max(float(np.abs(arr).max()), 1e-10) + quantized = np.clip(np.round(arr * 127.0 / scale), -128, 127).astype(np.int8) + return tuple(int(v) for v in quantized) + + +def content_hash(text: str) -> str: + """Compute SHA-256 hash of text with sha256: prefix.""" + h = hashlib.sha256(text.encode("utf-8")).hexdigest() + return f"sha256:{h}" + + +# ────────────────────────────────────────────────────────────────── +# @PATH IMPORT EXPANSION +# ────────────────────────────────────────────────────────────────── + +# Match @path references in instruction files. +# Claude Code: @README, @docs/guide.md, @~/path, @./relative +# Gemini 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 — + the file content is spliced in at the reference position before the + model sees it. The mapper must see the same expanded content. + + - Resolves paths relative to the importing file's directory + - Expands ~/... to home directory + - Recursively expands up to MAX_IMPORT_DEPTH (5 hops) + - Detects circular imports via visited set + - Only expands markdown-compatible files + - Skips @references inside fenced code blocks + """ + if depth >= _MAX_IMPORT_DEPTH: + return content + if visited is None: + visited = {str(source_path.resolve())} + + # Find fenced code block ranges to skip + code_ranges: list[tuple[int, int]] = [] + for m in _FENCED_BLOCK_RE.finditer(content): + code_ranges.append((m.start(), m.end())) + + def _in_code_block(pos: int) -> bool: + return any(start <= pos < end for start, end in code_ranges) + + def _replace(match: re.Match[str]) -> str: + if _in_code_block(match.start()): + return match.group(0) # inside code block — don't expand + + ref = match.group(1) + + # Resolve path relative to importing file + if ref.startswith("~"): + target = Path(ref).expanduser() + else: + target = source_path.parent / ref + + # Resolve symlinks — catches circular symlinks (ELOOP) + try: + target = target.resolve(strict=False) + except (OSError, RuntimeError): + return match.group(0) # circular or broken symlink + + # Skip known non-markdown extensions + if target.suffix.lower() in _NON_EXPANDABLE_EXT: + return match.group(0) + + # Circular import detection + target_key = str(target) + if target_key in visited: + return match.group(0) + + # Read and recursively expand + if not target.is_file(): + return match.group(0) # broken reference — leave as-is + + try: + imported = target.read_text(encoding="utf-8", errors="replace") + except OSError: + return match.group(0) + + visited.add(target_key) + expanded = expand_imports(imported, target, depth=depth + 1, visited=visited) + return expanded + + return _IMPORT_REF_RE.sub(_replace, content) + + +def map_file(path: Path) -> tuple[list[Atom], str]: + """Classify a single instruction file into atoms. + + Returns: + (atoms, content_hash) + """ + content = path.read_text(encoding="utf-8", errors="replace") + atoms = tokenize(content) + for a in atoms: + a.file_path = str(path) + return atoms, content_hash(content) + + +def _parse_frontmatter_description(path: Path) -> str: + """Extract name + description from YAML frontmatter. + + These fields are surfaced into the model's base context by all agents + (Agent Skills standard) for skill/agent discoverability. The combined + string is what competes for attention even when the file isn't invoked. + """ + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if not text.startswith("---"): + return "" + end = text.find("\n---", 3) + if end == -1: + return "" + try: + import yaml + + data = yaml.safe_load(text[3:end]) + if not isinstance(data, dict): + return "" + name = str(data.get("name", "")) + desc = str(data.get("description", "")) + if name and desc: + return f"{name}: {desc}" + return name or desc + except Exception: + return "" + + +def _parse_frontmatter_globs(path: Path) -> tuple[str, ...]: + """Extract globs from YAML frontmatter of a rule/skill file.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return () + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + return () + # Find closing --- + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + front = "\n".join(lines[1:i]) + break + else: + return () + try: + import yaml + + data = yaml.safe_load(front) + if isinstance(data, dict) and "globs" in data: + globs = data["globs"] + if isinstance(globs, list): + return tuple(str(g) for g in globs) + if isinstance(globs, str): + return (globs,) + except Exception: + pass + return () + + +def _load_registry() -> dict[str, dict[str, Any]]: + """Load all agent registry configs. Returns {agent: config_dict}.""" + try: + from reporails_cli.core.bootstrap import get_rules_path + + registry_dir = get_rules_path() + except ImportError: + registry_dir = Path(__file__).parent.parent / "data" / "registry" + configs: dict[str, dict[str, Any]] = {} + if not registry_dir.is_dir(): + return configs + try: + import yaml + except ImportError: + return configs + for config_path in sorted(registry_dir.glob("*/config.yml")): + try: + data = yaml.safe_load(config_path.read_text()) + agent = data.get("agent", config_path.parent.name) + configs[agent] = data + except Exception: + continue + return configs + + +def _detect_file_loading( + path: Path, + root: Path, + registry: dict[str, dict[str, Any]], +) -> tuple[str, str, tuple[str, ...], str]: + """Determine loading/scope/globs/agent for an instruction file. + + Matches the file against all agent registry patterns. + Falls back to session_start/global/generic if no match. + + Returns: + (loading, scope, globs, agent) + """ + rel = str(path.relative_to(root)) if path.is_relative_to(root) else str(path) + rel_lower = rel.lower() + + import fnmatch + + # Collect all matches, then pick the most specific (longest literal prefix). + # This prevents catch-all patterns like **/AGENTS.md from beating + # directory-scoped patterns like .gemini/agents/*.md. + best_match: tuple[int, str, str, dict[str, Any]] | None = None # (specificity, agent, ft_name, props) + + for agent_id, config in registry.items(): + for _ft_name, ft in (config.get("file_types") or {}).items(): + # Support both v0.3.0 (patterns + properties) and v0.5.0 (scopes) + from reporails_cli.core.agents import _extract_patterns, _extract_properties + + patterns = _extract_patterns(ft) if isinstance(ft, dict) else [] + props = ft.get("properties", {}) if isinstance(ft, dict) else {} + if not props: + props = _extract_properties(ft) if isinstance(ft, dict) else {} + for pat in patterns: + pat_lower = pat.lower() + # Try multiple normalizations for ** glob patterns + candidates = [pat_lower] + if "**/" in pat_lower: + candidates.append(pat_lower.replace("**/", "")) # zero depth + candidates.append(pat_lower.replace("**/", "*/")) # one depth + matched = any(fnmatch.fnmatch(rel_lower, c) for c in candidates) + if matched: + # Specificity = length of literal prefix before first glob char + specificity = len(pat_lower.split("*")[0]) + if best_match is None or specificity > best_match[0]: + best_match = (specificity, agent_id, _ft_name, props) + + if best_match: + _, agent_id, _, props = best_match + loading = props.get("loading", "session_start") + scope = props.get("scope", "global") + globs: tuple[str, ...] = () + if loading in ("on_demand", "on_invocation"): + globs = _parse_frontmatter_globs(path) + if loading == "on_demand" and not globs: + loading = "session_start" + scope = "global" + return loading, scope, globs, agent_id + + # Fallback: single file = session_start, global, generic + return "session_start", "global", (), "generic" + + +def map_ruleset( + paths: list[Path], + *, + models: Models | None = None, + root: Path | None = None, + cache_dir: Path | None = None, +) -> RulesetMap: + """Build a compact ruleset map from instruction files. + + This is the main client-side entry point. Classifies all files, + embeds atoms, clusters by topic, and produces the wire format. + + When cache_dir is provided, uses incremental caching: unchanged files + (by content hash) reuse cached atoms and embeddings. Only changed + files are re-tokenized and re-embedded. Clustering always re-runs. + """ + from reporails_cli.core.mapper.map_cache import ( + CachedFileEntry, + MapCache, + atoms_to_dicts, + dicts_to_atoms, + ) + + if models is None: + models = get_models() + if root is None: + root = paths[0].parent if paths else Path(".") + + # Load incremental cache + map_cache: MapCache | None = None + if cache_dir is not None: + map_cache = MapCache(cache_dir) + map_cache.load() + + registry = _load_registry() + + # Classify all files — cache hits skip tokenization + file_records: list[FileRecord] = [] + all_atoms: list[Atom] = [] + atoms_needing_embed: list[Atom] = [] # only uncached atoms need embedding + + for path in paths: + raw_content = path.read_text(encoding="utf-8", errors="replace") + # Expand @path inline imports (Claude Code, Gemini CLI). + # The model sees expanded content — the mapper must too. + content = expand_imports(raw_content, path) + chash = content_hash(content) + + # Check cache + cached = map_cache.get(chash) if map_cache else None + if cached is not None: + atoms = dicts_to_atoms(cached.atoms) + # Restore file_path (cache stores it, but verify) + for a in atoms: + a.file_path = str(path) + all_atoms.extend(atoms) + # Cached atoms already have embeddings — no re-embed needed + else: + atoms = tokenize(content) + for a in atoms: + a.file_path = str(path) + all_atoms.extend(atoms) + # All atoms need embedding — headings included + atoms_needing_embed.extend(atoms) + # Store in cache (without embeddings yet — we'll update after embedding) + if map_cache is not None: + map_cache.put(chash, CachedFileEntry(chash, atoms_to_dicts(atoms))) + + loading, scope, globs, agent = _detect_file_loading(path, root, registry) + # Extract frontmatter description for on_invocation files — these + # descriptions are always in the model's base context (Agent Skills + # standard: name+description surfaced for discoverability). + desc = _parse_frontmatter_description(path) if loading == "on_invocation" else "" + file_records.append( + FileRecord( + path=str(path), + content_hash=chash, + loading=loading, + scope=scope, + globs=globs, + agent=agent, + description=desc, + ) + ) + + # Embed only uncached non-heading atoms. Dedup by embedding-text + # (same atom text + same heading_context = same embedding) so repeated + # patterns like "Use `ruff`" under the same heading only hit the + # encoder once per run. + if atoms_needing_embed: + texts = [_embed_text(a) for a in atoms_needing_embed] + unique_texts: list[str] = [] + text_index: dict[str, int] = {} + atom_to_unique: list[int] = [] + for t in texts: + idx = text_index.get(t) + if idx is None: + idx = len(unique_texts) + text_index[t] = idx + unique_texts.append(t) + atom_to_unique.append(idx) + unique_embeddings = models.st.encode(unique_texts) + for atom, u_idx in zip(atoms_needing_embed, atom_to_unique, strict=True): + atom.embedding_int8 = _quantize_int8(unique_embeddings[u_idx]) + + # Update cache with embeddings + if map_cache is not None: + # Group newly-embedded atoms by file, update cache entries + by_file: dict[str, list[Atom]] = {} + for a in all_atoms: + by_file.setdefault(a.file_path, []).append(a) + for frec in file_records: + file_atoms = by_file.get(frec.path, []) + if any(a in atoms_needing_embed for a in file_atoms): + map_cache.put(frec.content_hash, CachedFileEntry(frec.content_hash, atoms_to_dicts(file_atoms))) + + # Evict stale cache entries and save + if map_cache is not None: + known_hashes = {fr.content_hash for fr in file_records} + map_cache.evict_stale(known_hashes) + map_cache.save() + + # Embed check: ensure ALL atoms have embeddings (cached or fresh) + exc = list(all_atoms) + unembedded = [a for a in exc if a.embedding_int8 is None] + if unembedded: + # Same dedup as above + texts = [_embed_text(a) for a in unembedded] + unique_texts = [] + text_index = {} + atom_to_unique = [] + for t in texts: + idx = text_index.get(t) + if idx is None: + idx = len(unique_texts) + text_index[t] = idx + unique_texts.append(t) + atom_to_unique.append(idx) + unique_embeddings = models.st.encode(unique_texts) + for atom, u_idx in zip(unembedded, atom_to_unique, strict=True): + atom.embedding_int8 = _quantize_int8(unique_embeddings[u_idx]) + + # Embed file descriptions (on_invocation files only — their name+description + # is always in the model's base context per Agent Skills standard). + desc_texts = [fr.description for fr in file_records if fr.description] + if desc_texts and models is not None: + desc_embeddings = models.st.encode(desc_texts) + desc_idx = 0 + for fr in file_records: + if fr.description: + fr.description_embedding = _quantize_int8(desc_embeddings[desc_idx]) + desc_idx += 1 + + # Cluster by topic + topics = cluster_topics(all_atoms) + cluster_records: list[ClusterRecord] = [] + for tc in topics: + cluster_records.append( + ClusterRecord( + id=tc.topic_id, + n_atoms=len(tc.atoms), + n_charged=len(tc.charged), + n_neutral=len(tc.atoms) - len(tc.charged), + centroid=tc.centroid, + ) + ) + + # Summary + n_charged = sum(1 for a in exc if a.charge_value != 0) + n_topics_charged = sum(1 for tc in topics if tc.charged) + + summary = RulesetSummary( + n_atoms=len(exc), + n_charged=n_charged, + n_neutral=len(exc) - n_charged, + n_topics=len(topics), + n_topics_charged=n_topics_charged, + ) + + ruleset = RulesetMap( + schema_version=SCHEMA_VERSION, + embedding_model=EMBEDDING_MODEL, + generated_at=datetime.now(UTC).isoformat(), + files=tuple(file_records), + atoms=tuple(all_atoms), + clusters=tuple(cluster_records), + summary=summary, + ) + + # Validate — log warnings, raise on errors + findings = validate_atoms(ruleset.atoms) + errors = [f for f in findings if f.severity == "error"] + warns = [f for f in findings if f.severity == "warn"] + for f in errors: + logger.error("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) + for f in warns: + logger.warning("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) + if errors: + raise ValueError( + f"Map validation failed with {len(errors)} error(s). First: [{errors[0].rule}] {errors[0].message}" + ) + + return ruleset + + +# ────────────────────────────────────────────────────────────────── +# SERIALIZATION +# ────────────────────────────────────────────────────────────────── + + +def _atom_to_dict(atom: Atom) -> dict[str, Any]: + """Serialize an Atom to a JSON-compatible dict.""" + d: dict[str, Any] = { + "line": atom.line, + "text": atom.text, + "kind": atom.kind, + "charge": atom.charge, + "charge_value": atom.charge_value, + "modality": atom.modality, + "specificity": atom.specificity, + "scope_conditional": atom.scope_conditional, + "format": atom.format, + "position_index": atom.position_index, + "token_count": atom.token_count, + "file_path": atom.file_path, + "cluster_id": atom.cluster_id, + "plain_text": atom.plain_text, + } + # Inline formatting — converged format + inline: list[dict[str, str]] = [] + for tok in atom.named_tokens: + inline.append({"term": tok, "style": "backtick"}) + for tok in atom.italic_tokens: + inline.append({"term": tok, "style": "italic"}) + for tok in atom.bold_tokens: + inline.append({"term": tok, "style": "bold"}) + for tok in atom.unformatted_code: + inline.append({"term": tok, "style": "none"}) + if inline: + d["inline"] = inline + if atom.embedding_int8 is not None: + raw = bytes(v & 0xFF for v in atom.embedding_int8) + d["embedding_b64"] = base64.b64encode(raw).decode("ascii") + # Optional topographer fields + if atom.topics: + d["topics"] = list(atom.topics) + if atom.role: + d["role"] = atom.role + if atom.heading_context: + d["heading_context"] = atom.heading_context + if atom.depth is not None: + d["depth"] = atom.depth + if atom.rule: + d["rule"] = atom.rule + if atom.ambiguous: + d["ambiguous"] = True + return d + + +def _decode_embedding_b64(b64: str | None) -> tuple[int, ...] | None: + """Decode a base64-encoded int8 embedding vector.""" + if b64 is None: + return None + raw = base64.b64decode(b64) + # Convert unsigned bytes back to signed int8 (-128..127) + return tuple(v if v < 128 else v - 256 for v in raw) + + +def _atom_from_dict(d: dict[str, Any]) -> Atom: + """Deserialize an Atom from a dict.""" + # Parse converged inline format back to separate lists + named_tokens: list[str] = [] + italic_tokens: list[str] = [] + bold_tokens: list[str] = [] + unformatted_code: list[str] = [] + for span in d.get("inline", []): + style = span.get("style", "none") + term = span["term"] + if style == "backtick": + named_tokens.append(term) + elif style == "italic": + italic_tokens.append(term) + elif style == "bold": + bold_tokens.append(term) + elif style == "none": + unformatted_code.append(term) + + return Atom( + line=d["line"], + text=d["text"], + kind=d.get("kind", "excitation"), + charge=d["charge"], + charge_value=d["charge_value"], + modality=d["modality"], + specificity=d.get("specificity", "abstract"), + scope_conditional=d.get("scope_conditional", False), + format=d.get("format", d.get("format_type", "prose")), + named_tokens=named_tokens, + italic_tokens=italic_tokens, + bold_tokens=bold_tokens, + unformatted_code=unformatted_code, + position_index=d.get("position_index", 0), + token_count=d.get("token_count", 0), + file_path=d.get("file_path", ""), + cluster_id=d.get("cluster_id", -1), + embedding_int8=_decode_embedding_b64(d.get("embedding_b64")), + plain_text=d.get("plain_text", ""), + heading_context=d.get("heading_context", ""), + depth=d.get("depth"), + rule=d.get("rule", ""), + ambiguous=d.get("ambiguous", False), + topics=tuple(d.get("topics", [])), + role=d.get("role", ""), + ) + + +def save_ruleset_map(ruleset_map: RulesetMap, path: Path) -> None: + """Serialize a RulesetMap to JSON.""" + import numpy as np + + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "schema_version": ruleset_map.schema_version, + "embedding_model": ruleset_map.embedding_model, + "generated_at": ruleset_map.generated_at, + "files": [ + { + "path": f.path, + "content_hash": f.content_hash, + "loading": f.loading, + "scope": f.scope, + "agent": f.agent, + **({"globs": list(f.globs)} if f.globs else {}), + **({"description": f.description} if f.description else {}), + **( + { + "description_embedding_b64": base64.b64encode( + np.asarray(f.description_embedding, dtype=np.int8).tobytes() + ).decode("ascii") + } + if f.description_embedding + else {} + ), + } + for f in ruleset_map.files + ], + "atoms": [_atom_to_dict(a) for a in ruleset_map.atoms], + "clusters": [ + { + "id": c.id, + "n_atoms": c.n_atoms, + "n_charged": c.n_charged, + "n_neutral": c.n_neutral, + **( + { + "centroid_b64": base64.b64encode(np.asarray(c.centroid, dtype=np.float32).tobytes()).decode( + "ascii" + ) + } + if c.centroid + else {} + ), + } + for c in ruleset_map.clusters + ], + "summary": { + "n_atoms": ruleset_map.summary.n_atoms, + "n_charged": ruleset_map.summary.n_charged, + "n_neutral": ruleset_map.summary.n_neutral, + "n_topics": ruleset_map.summary.n_topics, + "n_topics_charged": ruleset_map.summary.n_topics_charged, + }, + } + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def load_ruleset_map(path: Path) -> RulesetMap: + """Deserialize a RulesetMap from JSON.""" + import numpy as np + + data = json.loads(path.read_text(encoding="utf-8")) + + files = tuple( + FileRecord( + path=f["path"], + content_hash=f["content_hash"], + loading=f.get("loading", "session_start"), + scope=f.get("scope", "global"), + globs=tuple(f.get("globs", [])), + agent=f.get("agent", "generic"), + description=f.get("description", ""), + description_embedding=_decode_embedding_b64(f.get("description_embedding_b64")), + ) + for f in data["files"] + ) + atoms = tuple(_atom_from_dict(a) for a in data["atoms"]) + clusters = tuple( + ClusterRecord( + id=c["id"], + n_atoms=c["n_atoms"], + n_charged=c["n_charged"], + n_neutral=c["n_neutral"], + centroid=( + tuple(np.frombuffer(base64.b64decode(c["centroid_b64"]), dtype=np.float32).tolist()) + if c.get("centroid_b64") + else () + ), + ) + for c in data.get("clusters", []) + ) + s = data["summary"] + summary = RulesetSummary( + n_atoms=s["n_atoms"], + n_charged=s["n_charged"], + n_neutral=s["n_neutral"], + n_topics=s.get("n_topics", 0), + n_topics_charged=s.get("n_topics_charged", 0), + ) + + return RulesetMap( + schema_version=data["schema_version"], + embedding_model=data["embedding_model"], + generated_at=data["generated_at"], + files=files, + atoms=atoms, + clusters=clusters, + summary=summary, + ) + + +# ────────────────────────────────────────────────────────────────── +# MAP VALIDATION +# ────────────────────────────────────────────────────────────────── + + +@dataclass +class MapFinding: + """A validation finding from map inspection.""" + + severity: str # error | warn | info + rule: str + message: str + line: int = 0 + text: str = "" + charge: str = "" + + +# Deterministic: negation at start MUST be constraint +_MUST_CONSTRAINT_RE = re.compile( + r"^(never|do not|don't|must not|shall not|cannot|can't|avoid|NO |NOT )\b", + re.IGNORECASE, +) +# Strong charge words that should not appear in NEUTRAL atoms (unless quoted) +_STRONG_CHARGE_RE = re.compile( + r"\b(MUST|SHALL|NEVER|ALWAYS|FORBIDDEN|PROHIBITED)\b", +) +_QUOTED_ATOM_RE = re.compile(r'^["\u201c\u201e]') + + +def validate_atoms(atoms: tuple[Atom, ...] | list[Atom]) -> list[MapFinding]: + """Validate atoms against deterministic invariants. + + Three layers: + 1. Schema — charge/modality/value consistency (hard errors) + 2. Deterministic charge — negation→constraint, heading→neutral (must hold) + 3. Statistical + suspicious — distribution anomalies, charge words in neutral + + Works on raw atom lists (from map_file) or RulesetMap.atoms. + Returns list of findings. Empty list = clean. + """ + findings: list[MapFinding] = [] + valid_charges = {"CONSTRAINT", "DIRECTIVE", "IMPERATIVE", "NEUTRAL", "AMBIGUOUS"} + valid_mods = {"imperative", "direct", "absolute", "hedged", "none"} + + exc: list[Atom] = [] + + for a in atoms: + # ── Schema invariants ── + if a.charge not in valid_charges: + findings.append( + MapFinding( + "error", + "schema", + f"Invalid charge: {a.charge}", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.modality not in valid_mods: + findings.append( + MapFinding( + "error", + "schema", + f"Invalid modality: {a.modality}", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.charge_value not in (-1, 0, 1): + findings.append( + MapFinding( + "error", + "schema", + f"Invalid charge_value: {a.charge_value}", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.charge_value == 0 and a.charge not in ("NEUTRAL", "AMBIGUOUS"): + findings.append( + MapFinding( + "error", + "consistency", + f"charge_value=0 but charge={a.charge}", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.charge_value != 0 and a.charge == "NEUTRAL": + findings.append( + MapFinding( + "error", + "consistency", + f"charge_value≠0 but charge=NEUTRAL", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.charge_value == 0 and a.modality != "none": + findings.append( + MapFinding( + "error", + "consistency", + f"NEUTRAL with modality={a.modality}", + a.line, + a.text[:80], + a.charge, + ) + ) + if a.charge_value != 0 and a.modality == "none": + findings.append( + MapFinding( + "error", + "consistency", + f"Charged with modality=none", + a.line, + a.text[:80], + a.charge, + ) + ) + # Charged headings are valid — "## Never push to main" is a constraint. + + if a.kind == "excitation": + exc.append(a) + + # ── Deterministic charge invariants ── + for a in exc: + clean = _strip_md_for_classify(a.text) + + if _MUST_CONSTRAINT_RE.match(clean) and a.charge_value != -1: + findings.append( + MapFinding( + "warn", + "must_constraint", + f"Negation at start but charge={a.charge}", + a.line, + a.text[:80], + a.charge, + ) + ) + + # NEUTRAL with strong charge words (skip quoted examples) + if a.charge_value == 0 and not _QUOTED_ATOM_RE.match(a.text.strip()): + if _STRONG_CHARGE_RE.search(a.text): + findings.append( + MapFinding( + "info", + "suspicious_neutral", + "NEUTRAL atom contains strong charge word", + a.line, + a.text[:80], + a.charge, + ) + ) + + # ── Statistical checks ── + n_exc = len(exc) + if n_exc > 0: + n_charged = sum(1 for a in exc if a.charge_value != 0) + ratio = n_charged / n_exc + if ratio > 0.90: + findings.append( + MapFinding( + "warn", + "distribution", + f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually high", + ) + ) + if ratio < 0.05 and n_exc > 10: + findings.append( + MapFinding( + "warn", + "distribution", + f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually low", + ) + ) + + return findings + + +def validate_map(ruleset_map: RulesetMap) -> list[MapFinding]: + """Validate a RulesetMap. Delegates to validate_atoms.""" + return validate_atoms(ruleset_map.atoms) diff --git a/src/reporails_cli/core/mapper/onnx_embedder.py b/src/reporails_cli/core/mapper/onnx_embedder.py new file mode 100644 index 00000000..3e817d17 --- /dev/null +++ b/src/reporails_cli/core/mapper/onnx_embedder.py @@ -0,0 +1,183 @@ +"""ONNX-Runtime-based embedder for ``all-MiniLM-L6-v2``. + +Replaces the ``sentence-transformers`` path in ``Models.st``. Ships as a +pure-CPU ONNX graph + HuggingFace tokenizer, bundled with the wheel. + +Why ONNX Runtime and not sentence-transformers? +------------------------------------------------ + +``sentence-transformers`` pulls in ``torch`` (~20 s cold import, ~500 MB +installed). On CPU, its inference throughput on +``sentence-transformers/all-MiniLM-L6-v2`` is **identical** to +``onnxruntime``'s: both dispatch to MLAS kernels under the hood and hit +the same per-atom compute ceiling (measured: 68 vs 67 atoms/s at bs=32 +on this machine). There is no "PyTorch is faster" secret on this +workload — verified empirically. + +We therefore ship the ONNX path exclusively and save the 20 s torch +import. Combined with the ``_torch_blocker`` meta-path hook, torch never +enters ``sys.modules`` on the CLI critical path. + +Bit-identity +------------ + +The fp32 ONNX model is a Xenova-maintained export of +``sentence-transformers/all-MiniLM-L6-v2`` and produces output +bit-identical to the PyTorch reference (cosine similarity = 1.0 within +float32 epsilon on this repo's 906 atoms; 405 findings exact match). + +Length-sorted batching +---------------------- + +The dominant CPU op is the FFN matmul ``(B·T, 384) @ (384, 1536)``. Its +cost scales with the padded sequence length ``T``. When atoms in a batch +have wildly different lengths, the short ones pad up to the longest and +waste compute on pad tokens. + +We therefore sort atoms by approximate token length before batching, +split into ``BUCKET_SIZE`` chunks, encode each chunk with tight dynamic +padding, and scatter results back into the caller's original order. +Measured speedup on this repo: ``+28 %`` (67 → 86 atoms/s). + +Order is preserved — callers see an ``ndarray`` where row ``i`` is the +embedding of ``texts[i]``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import numpy as np + +# Architectural constants (all-MiniLM-L6-v2) +_HIDDEN_DIM = 384 +_MAX_LENGTH = 128 # sentence-transformers default for this model +_BUCKET_SIZE = 16 # sweet spot on this workload; see plan verification +_DEFAULT_MODEL_SUBDIR = "minilm-l6-v2" + + +class OnnxEmbedder: + """Drop-in replacement for ``SentenceTransformer`` on the encode path. + + API contract: ``encode(texts: list[str]) -> np.ndarray`` returning + ``(len(texts), 384)`` L2-normalised float32 embeddings in the same + order as ``texts``. + """ + + def __init__( + self, + model_dir: Path | None = None, + threads: int | None = None, + ) -> None: + # Imports are local so the ``_torch_blocker`` + ``OnnxEmbedder`` + # module can be imported cheaply without loading ORT up-front. + import onnxruntime as ort + from tokenizers import Tokenizer + + from reporails_cli.bundled import get_models_path + + if model_dir is None: + model_dir = get_models_path() / _DEFAULT_MODEL_SUBDIR + + onnx_path = model_dir / "onnx" / "model.onnx" + tokenizer_path = model_dir / "tokenizer.json" + if not onnx_path.is_file(): + raise RuntimeError( + f"bundled ONNX model not found at {onnx_path}. " + "Run `uv run python scripts/fetch_bundled_model.py` to populate it." + ) + if not tokenizer_path.is_file(): + raise RuntimeError( + f"bundled tokenizer not found at {tokenizer_path}. " + "Run `uv run python scripts/fetch_bundled_model.py` to populate it." + ) + + # Session options tuned for transformer-shaped matmuls on CPU. + # ORT_ENABLE_ALL applies the built-in BERT-specific fusions + # (attention, skip-layer-norm, bias-gelu) where it detects them. + opts = ort.SessionOptions() + opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + opts.intra_op_num_threads = int(os.environ.get("AILS_ORT_THREADS", "4") if threads is None else threads) + opts.inter_op_num_threads = 1 + opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL + opts.enable_cpu_mem_arena = True + opts.enable_mem_pattern = True + opts.enable_mem_reuse = True + + self._session = ort.InferenceSession( + str(onnx_path), + sess_options=opts, + providers=["CPUExecutionProvider"], + ) + + self._tokenizer = Tokenizer.from_file(str(tokenizer_path)) + self._tokenizer.enable_truncation(max_length=_MAX_LENGTH) + self._tokenizer.enable_padding(pad_id=0, pad_token="[PAD]", length=None) + + # Whether this particular ONNX export needs token_type_ids — + # depends on how the export was produced. The Xenova build does. + self._needs_token_type_ids = any(i.name == "token_type_ids" for i in self._session.get_inputs()) + + def encode(self, texts: list[str]) -> np.ndarray: + """Encode ``texts`` to L2-normalised 384-d float32 embeddings. + + Uses length-sorted bucketed batching for tight dynamic padding. + Output row ``i`` corresponds to ``texts[i]`` (original order). + """ + import numpy as np + + n = len(texts) + if n == 0: + return np.empty((0, _HIDDEN_DIM), dtype=np.float32) + + # Length-sort (ascending). We use character length as a cheap + # proxy for token length — close enough for sort ordering and + # avoids a full tokenization pass up front. + indexed = sorted(enumerate(texts), key=lambda it: len(it[1])) + sorted_idx = [i for i, _ in indexed] + sorted_texts = [t for _, t in indexed] + + # Encode each bucket; scatter back to original order. + sorted_out = np.empty((n, _HIDDEN_DIM), dtype=np.float32) + for start in range(0, n, _BUCKET_SIZE): + batch = sorted_texts[start : start + _BUCKET_SIZE] + sorted_out[start : start + len(batch)] = self._encode_batch(batch) + + result = np.empty((n, _HIDDEN_DIM), dtype=np.float32) + for new_i, orig_i in enumerate(sorted_idx): + result[orig_i] = sorted_out[new_i] + return result + + # ────────────────────────────────────────────────────────────── + # Internal + # ────────────────────────────────────────────────────────────── + + def _encode_batch(self, batch: list[str]) -> np.ndarray: + """Forward one bucket through the ONNX graph + mean-pool + L2-normalise.""" + import numpy as np + + encs = self._tokenizer.encode_batch(batch) + # (B, T_max) where T_max is the max length within this bucket + ids = np.array([e.ids for e in encs], dtype=np.int64) + masks = np.array([e.attention_mask for e in encs], dtype=np.int64) + + feed: dict[str, np.ndarray] = {"input_ids": ids, "attention_mask": masks} + if self._needs_token_type_ids: + feed["token_type_ids"] = np.zeros_like(ids) + + # Last hidden state: (B, T_max, 384) + last_hidden = self._session.run(None, feed)[0] + + # Mean-pool across valid tokens using the attention mask as weights. + mask_f = masks[:, :, None].astype(np.float32) # (B, T_max, 1) + summed = (last_hidden * mask_f).sum(axis=1) # (B, 384) + counts = mask_f.sum(axis=1).clip(min=1e-9) # (B, 1) + pooled = summed / counts # (B, 384) + + # L2 normalise (same convention as sentence-transformers default). + norms = np.linalg.norm(pooled, axis=-1, keepdims=True).clip(min=1e-12) + normalised: np.ndarray = (pooled / norms).astype(np.float32) + return normalised diff --git a/src/reporails_cli/core/mechanical/__init__.py b/src/reporails_cli/core/mechanical/__init__.py index 5e92eb60..d487087c 100644 --- a/src/reporails_cli/core/mechanical/__init__.py +++ b/src/reporails_cli/core/mechanical/__init__.py @@ -4,10 +4,9 @@ """ from reporails_cli.core.mechanical.runner import ( - bind_instruction_files, dispatch_single_check, resolve_location, run_mechanical_checks, ) -__all__ = ["bind_instruction_files", "dispatch_single_check", "resolve_location", "run_mechanical_checks"] +__all__ = ["dispatch_single_check", "resolve_location", "run_mechanical_checks"] diff --git a/src/reporails_cli/core/mechanical/checks.py b/src/reporails_cli/core/mechanical/checks.py index 94ac338d..fb760dfd 100644 --- a/src/reporails_cli/core/mechanical/checks.py +++ b/src/reporails_cli/core/mechanical/checks.py @@ -1,12 +1,4 @@ -"""Mechanical check implementations. - -Each check receives: - root: Path to the project root - args: Check-specific arguments from rule frontmatter - vars: Resolved template variables from agent config - -Returns a CheckResult indicating pass/fail with a message. -""" +"""Mechanical check implementations.""" from __future__ import annotations @@ -15,6 +7,8 @@ from pathlib import Path from typing import Any +from reporails_cli.core.models import ClassifiedFile + def _safe_float(value: Any, default: float = float("inf")) -> float: """Safely convert a value to float, returning default on failure.""" @@ -34,19 +28,6 @@ class CheckResult: location: str | None = None # Per-file location override (e.g., "SKILL.md:0") -def _resolve_path(template: str, vars: dict[str, str | list[str]]) -> str: - """Resolve template variables in a path string.""" - result = template - for key, value in vars.items(): - placeholder = "{{" + key + "}}" - if placeholder in result: - if isinstance(value, list): - result = result.replace(placeholder, value[0] if value else "") - else: - result = result.replace(placeholder, str(value)) - return result - - _glob_cache: dict[tuple[str, str], list[Path]] = {} @@ -62,58 +43,75 @@ def _resolve_glob_targets(pattern: str, root: Path) -> list[Path]: return result -def _get_target_patterns( +def _get_target_files( args: dict[str, Any], - vars: dict[str, str | list[str]], -) -> list[str]: - """Get file patterns: args.path > args._targets (from rule.targets) > vars.instruction_files.""" + classified_files: list[ClassifiedFile], + root: Path, +) -> list[Path]: + """Get target file paths: args.path > args._match_type > all classified files. + + Priority: + 1. Explicit glob pattern in args["path"] — resolved against root + 2. Match type from args["_match_type"] — filter classified files by type + 3. Fallback: all classified file paths + """ path_pattern = args.get("path", "") if path_pattern: - return [_resolve_path(str(path_pattern), vars)] - targets = args.get("_targets", "") # injected from rule.targets by dispatch - if targets: - return _expand_file_pattern(str(targets), vars) - patterns = vars.get("instruction_files", []) - if isinstance(patterns, str): - patterns = [patterns] - return list(patterns) - - -def _expand_file_pattern( - pattern: str, - vars: dict[str, str | list[str]], -) -> list[str]: - """Expand a file glob pattern that may reference a list variable. - - If pattern is exactly "{{key}}" and key maps to a list, returns all - elements. Otherwise resolves as a single string via _resolve_path. - """ - for key, value in vars.items(): - placeholder = "{{" + key + "}}" - if pattern == placeholder and isinstance(value, list): - return list(value) - return [_resolve_path(pattern, vars)] + return _resolve_glob_targets(str(path_pattern), root) + + match_type = args.get("_match_type", "") + if match_type and classified_files: + matched = [cf.path for cf in classified_files if cf.file_type == match_type] + if matched: + return matched + # No files of this type — return empty, don't fall back to all files. + # A config rule shouldn't check memory files just because no config exists. + return [] + + if classified_files: + return [cf.path for cf in classified_files] + + return [] + + +def _get_counted_files( + args: dict[str, Any], + classified_files: list[ClassifiedFile], + root: Path, +) -> set[Path]: + """Get files for counting/sizing checks: args.pattern > classified files.""" + raw_pattern = str(args.get("pattern", "")) + if raw_pattern: + all_files: set[Path] = set() + all_files.update(m for m in _resolve_glob_targets(raw_pattern, root) if m.is_file()) + return all_files + + if classified_files: + return {cf.path for cf in classified_files if cf.path.is_file()} + + # No classified files — return empty instead of globbing entire project tree + return set() def file_exists( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that at least one file matching the target pattern exists.""" - for pattern in _get_target_patterns(args, vars): - if _resolve_glob_targets(pattern, root): - return CheckResult(passed=True, message="File found") + files = _get_target_files(args, classified_files, root) + if any(f.exists() for f in files): + return CheckResult(passed=True, message="File found") return CheckResult(passed=False, message="No matching files found") def directory_exists( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that a directory exists.""" - path = _resolve_path(str(args.get("path", "")), vars) + path = str(args.get("path", "")) if (root / path).is_dir(): return CheckResult(passed=True, message=f"Directory exists: {path}") return CheckResult(passed=False, message=f"Directory not found: {path}") @@ -122,10 +120,10 @@ def directory_exists( def directory_contains( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that a directory contains at least min_count files.""" - path = _resolve_path(str(args.get("path", "")), vars) + path = str(args.get("path", "")) pattern = str(args.get("pattern", "*")) min_count = int(args.get("min", 1)) target = root / path @@ -140,7 +138,7 @@ def directory_contains( def git_tracked( root: Path, _args: dict[str, Any], - _vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that the project is git-tracked. @@ -154,42 +152,37 @@ def git_tracked( def frontmatter_key( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that files have a specific YAML frontmatter key.""" import yaml key = str(args.get("key", "")) - for pattern in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(pattern, root): - if not match.is_file(): - continue - try: - content = match.read_text(encoding="utf-8") - # Quick frontmatter parse - if content.startswith("---"): - end = content.find("---", 3) - if end > 0: - fm = yaml.safe_load(content[3:end]) - if isinstance(fm, dict) and key in fm: - return CheckResult(passed=True, message=f"Key '{key}' found") - except (OSError, ValueError): - continue + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + if content.startswith("---"): + end = content.find("---", 3) + if end > 0: + fm = yaml.safe_load(content[3:end]) + if isinstance(fm, dict) and key in fm: + return CheckResult(passed=True, message=f"Key '{key}' found") + except (OSError, ValueError): + continue return CheckResult(passed=False, message=f"Frontmatter key '{key}' not found") def file_count( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that file count is within bounds.""" min_count = int(args.get("min", 0)) max_count = _safe_float(args.get("max"), float("inf")) - raw_pattern = str(args.get("pattern", "**/*")) - all_files: set[Path] = set() - for pattern in _expand_file_pattern(raw_pattern, vars): - all_files.update(m for m in _resolve_glob_targets(pattern, root) if m.is_file()) + all_files = _get_counted_files(args, classified_files, root) count = len(all_files) if min_count <= count <= max_count: return CheckResult(passed=True, message=f"File count {count} within bounds") @@ -199,53 +192,51 @@ def file_count( def line_count( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that file line count is within bounds.""" max_lines = _safe_float(args.get("max"), float("inf")) min_lines = int(args.get("min", 0)) - for pattern in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(pattern, root): - if not match.is_file(): - continue - try: - rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name - count = len(match.read_text(encoding="utf-8").splitlines()) - if count > max_lines: - return CheckResult( - passed=False, - message=f"{match.name}: {count} lines exceeds max {max_lines}", - location=f"{rel}:0", - ) - if count < min_lines: - return CheckResult( - passed=False, - message=f"{match.name}: {count} lines below min {min_lines}", - location=f"{rel}:0", - ) - except OSError as e: - return CheckResult(passed=False, message=f"Error reading {match.name}: {e}") + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name + count = len(match.read_text(encoding="utf-8").splitlines()) + if count > max_lines: + return CheckResult( + passed=False, + message=f"{match.name}: {count} lines exceeds max {max_lines}", + location=f"{rel}:0", + ) + if count < min_lines: + return CheckResult( + passed=False, + message=f"{match.name}: {count} lines below min {min_lines}", + location=f"{rel}:0", + ) + except OSError as e: + return CheckResult(passed=False, message=f"Error reading {match.name}: {e}") return CheckResult(passed=True, message="Line counts within bounds") def byte_size( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that file size is within bounds.""" max_bytes = _safe_float(args.get("max"), float("inf")) min_bytes = int(_safe_float(args.get("min", 0), 0)) - for pattern in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(pattern, root): - if not match.is_file(): - continue - rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name - size = match.stat().st_size - if size > max_bytes: - return CheckResult(passed=False, message=f"{match.name}: {size}B exceeds max", location=f"{rel}:0") - if size < min_bytes: - return CheckResult(passed=False, message=f"{match.name}: {size}B below min", location=f"{rel}:0") + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name + size = match.stat().st_size + if size > max_bytes: + return CheckResult(passed=False, message=f"{match.name}: {size}B exceeds max", location=f"{rel}:0") + if size < min_bytes: + return CheckResult(passed=False, message=f"{match.name}: {size}B below min", location=f"{rel}:0") return CheckResult(passed=True, message="File sizes within bounds") @@ -260,9 +251,12 @@ def byte_size( extract_imports, file_absent, filename_matches_pattern, + frontmatter_present, frontmatter_valid_glob, + frontmatter_valid_yaml, import_depth, path_resolves, + valid_markdown, ) # Registry of mechanical checks @@ -272,6 +266,9 @@ def byte_size( "directory_contains": directory_contains, "git_tracked": git_tracked, "frontmatter_key": frontmatter_key, + "frontmatter_present": frontmatter_present, + "frontmatter_valid_yaml": frontmatter_valid_yaml, + "valid_markdown": valid_markdown, "file_count": file_count, "line_count": line_count, "byte_size": byte_size, diff --git a/src/reporails_cli/core/mechanical/checks_advanced.py b/src/reporails_cli/core/mechanical/checks_advanced.py index 3eb2adbd..3f83e1e3 100644 --- a/src/reporails_cli/core/mechanical/checks_advanced.py +++ b/src/reporails_cli/core/mechanical/checks_advanced.py @@ -15,62 +15,148 @@ from reporails_cli.core.mechanical.checks import ( CheckResult, - _expand_file_pattern, - _get_target_patterns, + _get_counted_files, + _get_target_files, _resolve_glob_targets, - _resolve_path, _safe_float, ) +from reporails_cli.core.models import ClassifiedFile + + +def frontmatter_present( + root: Path, + args: dict[str, Any], + classified_files: list[ClassifiedFile], +) -> CheckResult: + """Check that at least one target file has a YAML frontmatter block.""" + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + if content.startswith("---"): + end = content.find("---", 3) + if end > 0: + return CheckResult(passed=True, message="Frontmatter block found") + except OSError: + continue + return CheckResult(passed=False, message="No frontmatter block found") + + +def frontmatter_valid_yaml( + root: Path, + args: dict[str, Any], + classified_files: list[ClassifiedFile], +) -> CheckResult: + """Check that all frontmatter blocks contain valid YAML mappings.""" + checked = 0 + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + if not content.startswith("---"): + continue + end = content.find("---", 3) + if end < 0: + continue + checked += 1 + fm = yaml.safe_load(content[3:end]) + if not isinstance(fm, dict): + rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name + return CheckResult( + passed=False, + message=f"Frontmatter is not a YAML mapping in {match.name}", + location=f"{rel}:1", + ) + except yaml.YAMLError as e: + rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name + return CheckResult( + passed=False, + message=f"Invalid YAML in {match.name}: {e}", + location=f"{rel}:1", + ) + except OSError: + continue + if checked == 0: + return CheckResult(passed=True, message="No frontmatter to validate") + return CheckResult(passed=True, message=f"All {checked} frontmatter block(s) valid") + + +_BROKEN_HEADING_RE = re.compile(r"^#{1,6}[^ #\n]", re.MULTILINE) + + +def valid_markdown( + root: Path, + args: dict[str, Any], + classified_files: list[ClassifiedFile], +) -> CheckResult: + """Check for structural markdown issues in target files.""" + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + m = _BROKEN_HEADING_RE.search(content) + if m: + line_num = content[: m.start()].count("\n") + 1 + rel = str(match.relative_to(root)) if match.is_relative_to(root) else match.name + return CheckResult( + passed=False, + message=f"Broken heading (missing space after #) in {match.name}", + location=f"{rel}:{line_num}", + ) + except OSError: + continue + return CheckResult(passed=True, message="Markdown structure valid") def path_resolves( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that target paths exist.""" - for pattern in _get_target_patterns(args, vars): - if _resolve_glob_targets(pattern, root): - return CheckResult(passed=True, message="Target paths exist") + files = _get_target_files(args, classified_files, root) + if files: + return CheckResult(passed=True, message="Target paths exist") return CheckResult(passed=False, message="No matching paths found") def extract_imports( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check for @import references in instruction files.""" imports_found: list[str] = [] - for pattern in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(pattern, root): - if not match.is_file(): - continue - try: - content = match.read_text(encoding="utf-8") - imports_found.extend(re.findall(r"@[\w./-]+", content)) - except OSError: - continue + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + imports_found.extend(re.findall(r"@[\w./-]+", content)) + except OSError: + continue if imports_found: return CheckResult( passed=True, message=f"Found {len(imports_found)} import(s)", annotations={"discovered_imports": imports_found}, ) - return CheckResult(passed=False, message="No imports found") + # No imports is valid — nothing to validate. Pass with empty annotations + # so check_import_targets_exist sees no work and also passes. + return CheckResult(passed=True, message="No imports found") def aggregate_byte_size( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check total byte size of all matching files.""" max_bytes = _safe_float(args.get("max"), float("inf")) - raw_pattern = str(args.get("pattern", "**/*")) - all_files: set[Path] = set() - for pattern in _expand_file_pattern(raw_pattern, vars): - all_files.update(m for m in _resolve_glob_targets(pattern, root) if m.is_file()) + all_files = _get_counted_files(args, classified_files, root) total = sum(f.stat().st_size for f in all_files) if total <= max_bytes: return CheckResult(passed=True, message=f"Total {total}B within limit") @@ -80,10 +166,10 @@ def aggregate_byte_size( def import_depth( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that @import chains do not exceed max depth.""" - max_depth = int(args.get("max", 5)) + max_depth_val = int(args.get("max", 5)) def follow(filepath: Path, visited: set[Path], depth: int) -> int: if filepath in visited or not filepath.is_file(): @@ -101,26 +187,25 @@ def follow(filepath: Path, visited: set[Path], depth: int) -> int: max_d = max(max_d, follow(target, visited, depth + 1)) return max_d - for pattern in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(pattern, root): - if not match.is_file(): - continue - deepest = follow(match, set(), 0) - if deepest > max_depth: - return CheckResult( - passed=False, - message=f"{match.name}: depth {deepest} exceeds max {max_depth}", - ) - return CheckResult(passed=True, message=f"Import depth within limit ({max_depth})") + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + deepest = follow(match, set(), 0) + if deepest > max_depth_val: + return CheckResult( + passed=False, + message=f"{match.name}: depth {deepest} exceeds max {max_depth_val}", + ) + return CheckResult(passed=True, message=f"Import depth within limit ({max_depth_val})") def directory_file_types( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that all files in a directory match allowed extensions.""" - path = _resolve_path(str(args.get("path", "")), vars) + path = str(args.get("path", "")) extensions: list[str] = list(args.get("extensions", [])) target = root / path if not target.is_dir(): @@ -134,10 +219,10 @@ def directory_file_types( def frontmatter_valid_glob( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that YAML frontmatter path entries use valid glob syntax.""" - path = _resolve_path(str(args.get("path", "")), vars) + path = str(args.get("path", "")) target = root / path if not target.is_dir(): return CheckResult(passed=True, message=f"Directory not found: {path} (OK)") @@ -170,7 +255,7 @@ def frontmatter_valid_glob( def count_at_most( _root: Path, args: dict[str, Any], - _vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that a metadata list has at most N entries. @@ -178,7 +263,6 @@ def count_at_most( Args: threshold (int, default 0), plus the metadata key name -> list. """ threshold = int(args.get("threshold", 0)) - # Find the metadata list — it's injected as args[key] by the pipeline items: list[str] = [] for value in args.values(): if isinstance(value, list): @@ -192,7 +276,7 @@ def count_at_most( def count_at_least( _root: Path, args: dict[str, Any], - _vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that a metadata list has at least N entries. @@ -213,7 +297,7 @@ def count_at_least( def check_import_targets_exist( root: Path, args: dict[str, Any], - _vars: dict[str, str | list[str]], + _classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that all @import paths from metadata resolve to existing files. @@ -229,7 +313,6 @@ def check_import_targets_exist( return CheckResult(passed=True, message="No import paths to check") missing: list[str] = [] for ref in import_paths: - # Strip leading @ if present clean = ref.lstrip("@") if not (root / clean).exists(): missing.append(clean) @@ -244,7 +327,7 @@ def check_import_targets_exist( def filename_matches_pattern( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that target filenames match a regex pattern. @@ -257,15 +340,14 @@ def filename_matches_pattern( compiled = re.compile(pattern) except re.error as e: return CheckResult(passed=False, message=f"filename_matches_pattern: invalid regex: {e}") - for fp in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(fp, root): - if not match.is_file(): - continue - if not compiled.search(match.name): - return CheckResult( - passed=False, - message=f"{match.name}: does not match pattern {pattern}", - ) + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + if not compiled.search(match.name): + return CheckResult( + passed=False, + message=f"{match.name}: does not match pattern {pattern}", + ) return CheckResult(passed=True, message="All filenames match pattern") @@ -286,37 +368,54 @@ def _scope_dir_from_glob(glob_pattern: str) -> str: return "/".join(dirs) +def _resolve_scope_dir(match_type: str, classified_files: list[ClassifiedFile]) -> str: + """Resolve a scope directory from classified files matching the given type.""" + if not match_type: + return "" + for cf in classified_files: + if cf.file_type == match_type: + rel = str(cf.path.parent) + # Extract the non-glob prefix from the relative path + return _scope_dir_from_glob(rel) + return "" + + def file_absent( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that NO file matching the pattern exists. - When ``_targets`` is injected (from rule.targets), scopes the search + When ``_match_type`` is injected (from rule.match.type), scopes the search to the target directory instead of the project root. """ pattern = str(args.get("pattern", "")) if not pattern: return CheckResult(passed=False, message="file_absent: no pattern specified") - resolved = _resolve_path(pattern, vars) + match_type = str(args.get("_match_type", "")) + scope_dir = _resolve_scope_dir(match_type, classified_files) + + # If the rule targets a specific file type but no files of that type were + # classified, the scope cannot be resolved. Falling through to the project + # root would produce false positives (e.g. root README.md flagged by a + # skill-scoped rule), so return pass. + if match_type and not scope_dir: + return CheckResult(passed=True, message=f"No {match_type} files classified") - # Determine search scope: rule targets directory or project root - targets = str(args.get("_targets", "")) - scope_dir = _scope_dir_from_glob(_resolve_path(targets, vars)) if targets else "" if scope_dir: - search_pattern = f"{scope_dir}/**/{resolved}" - direct_path = root / scope_dir / resolved + search_pattern = f"{scope_dir}/**/{pattern}" + direct_path = root / scope_dir / pattern else: - search_pattern = resolved - direct_path = root / resolved + search_pattern = pattern + direct_path = root / pattern matches = _resolve_glob_targets(search_pattern, root) if matches: name = str(matches[0].relative_to(root)) if matches[0].is_relative_to(root) else matches[0].name return CheckResult(passed=False, message=f"Forbidden file exists: {name}") if direct_path.exists(): - rel = f"{scope_dir}/{resolved}" if scope_dir else resolved + rel = f"{scope_dir}/{pattern}" if scope_dir else pattern return CheckResult(passed=False, message=f"Forbidden file exists: {rel}") return CheckResult(passed=True, message="Forbidden file not found") @@ -324,7 +423,7 @@ def file_absent( def content_absent( root: Path, args: dict[str, Any], - vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], ) -> CheckResult: """Check that a regex pattern does NOT appear in matching files.""" pattern = str(args.get("pattern", "")) @@ -334,17 +433,16 @@ def content_absent( compiled = re.compile(pattern) except re.error as e: return CheckResult(passed=False, message=f"content_absent: invalid regex: {e}") - for fp in _get_target_patterns(args, vars): - for match in _resolve_glob_targets(fp, root): - if not match.is_file(): - continue - try: - content = match.read_text(encoding="utf-8") - if compiled.search(content): - return CheckResult( - passed=False, - message=f"{match.name}: forbidden pattern found", - ) - except OSError: - continue + for match in _get_target_files(args, classified_files, root): + if not match.is_file(): + continue + try: + content = match.read_text(encoding="utf-8") + if compiled.search(content): + return CheckResult( + passed=False, + message=f"{match.name}: forbidden pattern found", + ) + except OSError: + continue return CheckResult(passed=True, message="Forbidden pattern not found") diff --git a/src/reporails_cli/core/mechanical/runner.py b/src/reporails_cli/core/mechanical/runner.py index 01861418..47b3c93a 100644 --- a/src/reporails_cli/core/mechanical/runner.py +++ b/src/reporails_cli/core/mechanical/runner.py @@ -6,11 +6,11 @@ from __future__ import annotations import logging -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any from reporails_cli.core.mechanical.checks import MECHANICAL_CHECKS, CheckResult -from reporails_cli.core.models import Check, Rule, Violation +from reporails_cli.core.models import Check, ClassifiedFile, Rule, Violation logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def dispatch_single_check( check: Check, rule: Rule, root: Path, - effective_vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], location: str, ) -> tuple[Violation | None, CheckResult | None]: """Dispatch a single mechanical check and return (violation, raw_result). @@ -28,7 +28,7 @@ def dispatch_single_check( check: Check definition (must have type="mechanical" and check name). rule: Parent rule for context (id, title). root: Project root directory. - effective_vars: Template variables with concrete instruction file paths. + classified_files: Classified files for file targeting. location: Pre-resolved location string for violation reporting. Returns: @@ -44,25 +44,24 @@ def dispatch_single_check( args: dict[str, Any] = dict(check.args or {}) - # Inject rule targets so checks can scope to the rule's file targets - # instead of falling back to all instruction_files. - if rule.targets and "_targets" not in args: - args["_targets"] = rule.targets + # Inject rule match type so checks can scope to the rule's file targets. + if rule.match is not None and rule.match.type and "_targets" not in args: + args["_match_type"] = rule.match.type try: - result = fn(root, args, effective_vars) + result = fn(root, args, classified_files) except Exception: logger.exception("Mechanical check %s failed for rule %s", check.check, rule.id) return None, None - passed = result.passed if not check.negate else not result.passed + passed = result.passed if check.expect == "present" else not result.passed if not passed: violation = Violation( rule_id=rule.id, rule_title=rule.title, location=result.location or location, message=result.message, - severity=check.severity, + severity=rule.severity, check_id=check.id, ) return violation, result @@ -73,8 +72,7 @@ def dispatch_single_check( def run_mechanical_checks( rules: dict[str, Rule], target: Path, - template_vars: dict[str, str | list[str]], - instruction_files: list[Path] | None = None, + classified_files: list[ClassifiedFile], ) -> list[Violation]: """Run mechanical checks from rules and return violations. @@ -85,141 +83,102 @@ def run_mechanical_checks( Args: rules: Dict of applicable rules (any rule type accepted) target: Project root directory - template_vars: Agent config template variables - instruction_files: Pre-resolved instruction file paths from engine. - When provided, replaces glob patterns in template_vars so checks - operate on discovered files rather than re-globbing. + classified_files: Classified files for file targeting Returns: List of Violation objects for failed checks """ - effective_vars = bind_instruction_files(template_vars, target, instruction_files) violations: list[Violation] = [] + # Index classified files by type for surface-existence checks + types_present = {cf.file_type for cf in classified_files} + for rule in rules.values(): - location = resolve_location(target, rule, effective_vars) + # Skip rules whose target surface doesn't exist in this project. + # A rule with match: {type: config} shouldn't fire when no config files exist. + if rule.match and rule.match.type: + match_types = [rule.match.type] if isinstance(rule.match.type, str) else rule.match.type + if not any(mt in types_present for mt in match_types): + continue + + location = resolve_location(rule, classified_files, target) for check in rule.checks: if check.type != "mechanical": continue - violation, _result = dispatch_single_check(check, rule, target, effective_vars, location) + violation, _result = dispatch_single_check(check, rule, target, classified_files, location) if violation: violations.append(violation) return violations -def _matches_any_pattern(path: str, patterns: list[str]) -> bool: - """Check if a relative path matches any of the given glob patterns. - - Uses PurePosixPath.match() for glob matching. For ``**/`` prefixed patterns, - also checks the tail to handle zero-directory matches (e.g., ``CLAUDE.md`` - matching ``**/CLAUDE.md``). - """ - p = PurePosixPath(path) - for pattern in patterns: - if p.match(pattern): - return True - # **/X should also match X at root (zero directories) - if pattern.startswith("**/") and p.match(pattern[3:]): - return True - return False - - -def bind_instruction_files( - template_vars: dict[str, str | list[str]], - target: Path, - instruction_files: list[Path] | None, -) -> dict[str, str | list[str]]: - """Replace instruction_files glob patterns with concrete relative paths. - - When the engine provides pre-resolved instruction files, convert them to - relative paths and inject into template_vars. Also binds main_instruction_file - by filtering discovered files against the original glob patterns from the - agent config (e.g., ``["**/CLAUDE.md"]``). - - Args: - template_vars: Original template variables with glob patterns - target: Project root for computing relative paths - instruction_files: Pre-resolved file paths, or None to keep patterns - - Returns: - Template vars with instruction_files and main_instruction_file replaced - """ - if not instruction_files: - return template_vars - - # Convert absolute paths to relative strings - relative: list[str] = [] - for f in instruction_files: +def _relativize(path: Path, root: Path | None) -> str: + """Return path relative to root, or just the name as fallback.""" + if root is not None: try: - relative.append(str(f.relative_to(target))) + return str(path.relative_to(root)) except ValueError: - relative.append(str(f)) - - if not relative: - return template_vars - - result = dict(template_vars) - result["instruction_files"] = relative + pass + return path.name - # Bind main_instruction_file by filtering against original glob patterns - main_patterns = template_vars.get("main_instruction_file") - if main_patterns is not None: - if isinstance(main_patterns, str): - main_patterns = [main_patterns] - if main_patterns: - main_matched = [r for r in relative if _matches_any_pattern(r, main_patterns)] - if main_matched: - result["main_instruction_file"] = main_matched - return result +def _first_classified_path( + classified_files: list[ClassifiedFile], + root: Path | None, + *type_names: str, +) -> str | None: + """Return first relative path from classified files matching any type name.""" + for type_name in type_names: + for cf in classified_files: + if cf.file_type == type_name: + return _relativize(cf.path, root) + return None def resolve_location( - target: Path, rule: Rule, - template_vars: dict[str, str | list[str]], + classified_files: list[ClassifiedFile], + root: Path | None = None, ) -> str: """Resolve a location string for mechanical violations. - For rules targeting ``{{instruction_files}}`` or ``{{main_instruction_file}}``, - prefers the first ``main_instruction_file`` entry so violations are attributed - to the root instruction file (e.g., ``CLAUDE.md``) rather than skill files - or scoped rule snippets. + Uses rule.match to determine the best file for violation attribution. + For rules matching 'main' type or match-all ({}), prefers the main file. Args: - target: Project root rule: Rule definition - template_vars: Agent config template variables (may have concrete paths) + classified_files: Classified files for path lookup + root: Project root for relativizing absolute paths Returns: Location string (e.g., "CLAUDE.md:0" or ".:0") """ - if not rule.targets: + if rule.match is None: + return ".:0" + + # For main type or match-all, prefer main file + if rule.match.type is None or rule.match.type == "main": + path = _first_classified_path(classified_files, root, "main") + if path: + return f"{path}:0" + + # For typed matches, find a file of that type — don't fall back to + # unrelated files (a config rule shouldn't attribute to a memory file) + if rule.match.type: + type_names = rule.match.type if isinstance(rule.match.type, list) else [rule.match.type] + path = _first_classified_path(classified_files, root, *type_names) + if path: + return f"{path}:0" + # No files of this type — attribute to project root, not a random file return ".:0" - # For instruction_files targets, prefer main_instruction_file for location - if "{{instruction_files}}" in rule.targets or "{{main_instruction_file}}" in rule.targets: - main = template_vars.get("main_instruction_file") - if isinstance(main, list) and main: - return f"{main[0]}:0" - - # Resolve template variables - resolved = rule.targets - for key, value in template_vars.items(): - placeholder = "{{" + key + "}}" - if placeholder in resolved: - if isinstance(value, list): - resolved = resolved.replace(placeholder, value[0] if value else "") - else: - resolved = resolved.replace(placeholder, str(value)) - - # If still has unresolved placeholders, use as-is - if "{{" in resolved: - return f"{resolved}:0" - - # If resolved to a concrete file path, use it directly - if (target / resolved).exists(): - return f"{resolved}:0" - - return f"{resolved}:0" + # Wildcard match — prefer main file, then any file + path = _first_classified_path(classified_files, root, "main") + if path: + return f"{path}:0" + + if classified_files: + cf = classified_files[0] + return f"{_relativize(cf.path, root)}:0" + + return ".:0" diff --git a/src/reporails_cli/core/mechanical_fixers.py b/src/reporails_cli/core/mechanical_fixers.py new file mode 100644 index 00000000..c91ad5ce --- /dev/null +++ b/src/reporails_cli/core/mechanical_fixers.py @@ -0,0 +1,289 @@ +"""Mechanical fixers for atom-level instruction quality issues. + +Each fixer reads raw file content, locates atoms by line number, and +performs surgical text transformations. All fixers are idempotent — +running twice produces no changes on the second run. + +Application order per file: format → bold → italic_constraint → ordering. +Within each fixer, edits are applied bottom-to-top by line number to +preserve line number stability for subsequent edits. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from reporails_cli.core.mapper.mapper import Atom, RulesetMap + + +@dataclass(frozen=True) +class MechanicalFix: + """Outcome of a mechanical fix applied to raw file content.""" + + fix_type: str # format | bold | italic_constraint | ordering + file_path: str + line: int + description: str + before: str + after: str + + +# ────────────────────────────────────────────────────────────────── +# Fix 1: Unformatted code → backticks +# ────────────────────────────────────────────────────────────────── + +_BACKTICK_RE = re.compile(r"`[^`]+`") + + +def _is_inside_backticks(text: str, token: str) -> bool: + """Check if every occurrence of token in text is inside backtick spans.""" + stripped = _BACKTICK_RE.sub("", text) + return token not in stripped + + +def fix_unformatted_code(atoms: list[Atom], lines: list[str]) -> list[MechanicalFix]: + """Wrap unformatted code tokens in backticks.""" + fixes: list[MechanicalFix] = [] + for atom in atoms: + if not atom.unformatted_code: + continue + idx = atom.line - 1 + if idx < 0 or idx >= len(lines): + continue + original = lines[idx] + modified = original + for token in atom.unformatted_code: + if _is_inside_backticks(modified, token): + continue + # Replace the first non-backticked occurrence + # Use word boundary to avoid partial matches + pattern = re.compile(r"(? list[MechanicalFix]: + """Replace **term** with *term* on constraint atoms (charge_value == -1).""" + fixes: list[MechanicalFix] = [] + for atom in atoms: + if atom.charge_value != -1: + continue + idx = atom.line - 1 + if idx < 0 or idx >= len(lines): + continue + original = lines[idx] + # Skip structural labels (**Label**:) + if _BOLD_LABEL_RE.search(original): + continue + + modified = original + for match in _BOLD_TERM_RE.finditer(original): + term = match.group(1) + # Skip negation keywords (they're emphasis, not competition) + if _BOLD_NEGATION_RE.match(term): + continue + # Replace **term** with *term* + modified = modified.replace(f"**{term}**", f"*{term}*", 1) + + if modified != original: + lines[idx] = modified + fixes.append( + MechanicalFix( + fix_type="bold", + file_path=atom.file_path, + line=atom.line, + description="Replaced bold with italic on constraint", + before=original.rstrip(), + after=modified.rstrip(), + ) + ) + return fixes + + +# ────────────────────────────────────────────────────────────────── +# Fix 3: Non-italic constraints → wrap in *...* +# ────────────────────────────────────────────────────────────────── + +_LIST_MARKER_RE = re.compile(r"^(\s*(?:[-*+]|\d+[.)]) )") +_ITALIC_WRAP_RE = re.compile(r"^\*[^*].*[^*]\*$") + + +def fix_italic_constraints(atoms: list[Atom], lines: list[str]) -> list[MechanicalFix]: + """Wrap constraint atoms (charge_value == -1) in full-sentence italic.""" + fixes: list[MechanicalFix] = [] + for atom in atoms: + if atom.charge_value != -1: + continue + if atom.kind == "heading": + continue + idx = atom.line - 1 + if idx < 0 or idx >= len(lines): + continue + original = lines[idx] + content = original.rstrip() + + # Extract list marker prefix if present + marker_match = _LIST_MARKER_RE.match(content) + prefix = marker_match.group(1) if marker_match else "" + body = content[len(prefix) :] + + # Already fully wrapped in italic? + stripped = body.strip() + if _ITALIC_WRAP_RE.match(stripped): + continue + # Already starts with * and ends with * (might be partial) + if stripped.startswith("*") and stripped.endswith("*") and not stripped.startswith("**"): + continue + # Contains an italic-wrapped constraint portion (e.g., "text. *Do NOT X.*") + # Wrapping the whole line would nest italic markers and break markdown. + if re.search(r"(? list[MechanicalFix]: + """Within each cluster, swap constraint-before-directive to directive-first.""" + fixes: list[MechanicalFix] = [] + + # Group charged atoms by cluster_id + clusters: dict[int, list[Atom]] = {} + for a in atoms: + if a.charge_value != 0 and a.cluster_id >= 0: + clusters.setdefault(a.cluster_id, []).append(a) + + for cluster_atoms in clusters.values(): + directives = [a for a in cluster_atoms if a.charge_value == +1] + constraints = [a for a in cluster_atoms if a.charge_value == -1] + if not directives or not constraints: + continue + + first_dir = min(directives, key=lambda a: a.position_index) + first_con = min(constraints, key=lambda a: a.position_index) + if first_con.position_index >= first_dir.position_index: + continue + + # Only swap single-line atoms + con_idx = first_con.line - 1 + dir_idx = first_dir.line - 1 + if con_idx < 0 or dir_idx < 0 or con_idx >= len(lines) or dir_idx >= len(lines): + continue + # Don't swap across large distances (> 10 lines) + if abs(dir_idx - con_idx) > 10: + continue + + # Swap the lines + lines[con_idx], lines[dir_idx] = lines[dir_idx], lines[con_idx] + fixes.append( + MechanicalFix( + fix_type="ordering", + file_path=first_con.file_path, + line=first_con.line, + description=f"Swapped constraint (L{first_con.line}) with directive (L{first_dir.line})", + before=f"L{first_con.line}: constraint, L{first_dir.line}: directive", + after=f"L{first_con.line}: directive, L{first_dir.line}: constraint", + ) + ) + return fixes + + +# ────────────────────────────────────────────────────────────────── +# Orchestrator +# ────────────────────────────────────────────────────────────────── + + +def apply_mechanical_fixes( + ruleset_map: RulesetMap, + scan_root: Path, # noqa: ARG001 + *, + dry_run: bool = False, + fix_types: set[str] | 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. + """ + all_fixes: list[MechanicalFix] = [] + + # Group atoms by file + atoms_by_file: dict[str, list[Atom]] = {} + for atom in ruleset_map.atoms: + atoms_by_file.setdefault(atom.file_path, []).append(atom) + + allowed = fix_types or {"format", "bold", "italic_constraint", "ordering"} + + for file_path, atoms in atoms_by_file.items(): + path = Path(file_path) + if not path.is_file(): + continue + + 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") + + all_fixes.extend(file_fixes) + + return all_fixes diff --git a/src/reporails_cli/core/memory_checks.py b/src/reporails_cli/core/memory_checks.py new file mode 100644 index 00000000..dec323e1 --- /dev/null +++ b/src/reporails_cli/core/memory_checks.py @@ -0,0 +1,124 @@ +"""Memory index validation — broken links and missing frontmatter. + +Extracted from equation.py. Reads the local filesystem to validate +MEMORY.md index entries. Runs client-side (not in the API). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from reporails_cli.core.models import LocalFinding + +_MEMORY_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+\.md)\)(?:\s*[—\-]\s*(.+))?") +_FRONTMATTER_REQUIRED = {"name", "description", "type"} + +_RULE_BROKEN_LINK = "CORE:E:0010" +_RULE_MISSING_FM = "CORE:E:0011" + + +def validate_memory_files( # noqa: C901 + file_paths: list[str], +) -> list[LocalFinding]: + """Validate memory index files — check links and frontmatter. + + Args: + file_paths: All instruction file paths from the ruleset map. + + Returns: + List of LocalFinding for memory index issues. + """ + findings: list[LocalFinding] = [] + + for file_path in file_paths: + if "memory" not in file_path.lower() and "MEMORY" not in file_path: + continue + + fp = Path(file_path) + if not fp.is_absolute(): + fp = Path.cwd() / fp + + try: + raw = fp.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + memory_dir = fp.parent + + for line_num, line in enumerate(raw.splitlines(), 1): + m = _MEMORY_LINK_RE.search(line) + if not m: + continue + + link_target = m.group(2) + target_path = memory_dir / link_target + + if not target_path.exists(): + findings.append( + LocalFinding( + file=file_path, + line=line_num, + severity="error", + rule=_RULE_BROKEN_LINK, + message=f"Broken memory link — `{link_target}` does not exist.", + fix="Remove the entry or create the missing memory file.", + source="client_check", + ) + ) + continue + + try: + target_content = target_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + if target_content.startswith("---"): + end = target_content.find("\n---", 3) + if end > 0: + try: + import yaml + + fm = yaml.safe_load(target_content[3:end]) + if isinstance(fm, dict): + missing = _FRONTMATTER_REQUIRED - set(fm.keys()) + if missing: + findings.append( + LocalFinding( + file=file_path, + line=line_num, + severity="warning", + rule=_RULE_MISSING_FM, + message=f"`{link_target}` missing frontmatter: {', '.join(sorted(missing))}.", + fix="Add the missing fields to the memory file's YAML frontmatter.", + source="client_check", + ) + ) + except Exception: + pass + else: + findings.append( + LocalFinding( + file=file_path, + line=line_num, + severity="warning", + rule=_RULE_MISSING_FM, + message=f"`{link_target}` has unclosed frontmatter block.", + fix="Close the YAML frontmatter with `---` on its own line.", + source="client_check", + ) + ) + else: + findings.append( + LocalFinding( + file=file_path, + line=line_num, + severity="warning", + rule=_RULE_MISSING_FM, + message=f"`{link_target}` has no frontmatter — memories need name, description, type.", + fix="Add YAML frontmatter with name, description, and type fields.", + source="client_check", + ) + ) + + return findings diff --git a/src/reporails_cli/core/merger.py b/src/reporails_cli/core/merger.py new file mode 100644 index 00000000..6177b934 --- /dev/null +++ b/src/reporails_cli/core/merger.py @@ -0,0 +1,216 @@ +"""Result merger — combines local findings with server diagnostics. + +Deduplicates when server and local checks fire on the same (file, line, rule), +keeping the server version (richer fix text from equation computation). +All file paths are normalized to project-relative before dedup and output. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from reporails_cli.core.api_client import ( + CrossFileFinding, + FileAnalysis, + QualityResult, + RulesetReport, +) +from reporails_cli.core.models import LocalFinding + +_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} + + +def normalize_finding_path(file_path: str, project_root: Path | None = None) -> str: + """Normalize a finding's file path to project-relative. + + Handles absolute paths, relative paths, and external paths (~/.claude/...). + Ensures all three sources (m_probe, client_check, server) produce the same + path for the same file, so dedup and display grouping work correctly. + + Priority: project-relative > home-relative > as-is. + """ + p = Path(file_path) + + # Without project_root, return paths as-is (no resolution) + if project_root is None: + return str(p) + + # Resolve to absolute for comparison + resolved = project_root / p if not p.is_absolute() else p + + # Project-relative first — paths within the project get short relative form + if project_root is not None: + try: + return str(resolved.relative_to(project_root)) + except ValueError: + pass # Not within project — fall through + + # External paths (outside project) — shorten with ~/ + home = Path.home() + if resolved.is_absolute(): + try: + return "~/" + str(resolved.relative_to(home)) + except ValueError: + pass + + # Already relative or fallback + return str(p) + + +@dataclass(frozen=True) +class FindingItem: + """A single finding in the combined output, from any source.""" + + file: str + line: int + severity: str # "error" | "warning" | "info" + rule: str # theory-native label or rule_id + message: str + fix: str = "" + source: str = "local" # "m_probe" | "client_check" | "server" + line_2: int = 0 # secondary line for cross-file findings + + +@dataclass(frozen=True) +class CombinedStats: + """Aggregate statistics for the combined result.""" + + total_findings: int = 0 + errors: int = 0 + warnings: int = 0 + infos: int = 0 + cross_file_conflicts: int = 0 + cross_file_repetitions: int = 0 + m_probe_count: int = 0 + client_check_count: int = 0 + server_diagnostic_count: int = 0 + + +@dataclass(frozen=True) +class CombinedResult: + """Merged local + server findings — the output of the new pipeline.""" + + findings: tuple[FindingItem, ...] = () + cross_file: tuple[CrossFileFinding, ...] = () + quality: QualityResult | None = None + per_file_analysis: tuple[FileAnalysis, ...] = () + stats: CombinedStats = field(default_factory=CombinedStats) + offline: bool = True + hints: tuple[Any, ...] = () # tuple[Hint, ...] from tier gating + + +def merge_results( + m_probe_findings: list[LocalFinding], + client_check_findings: list[LocalFinding], + server_report: RulesetReport | None, + hints: tuple[Any, ...] = (), + project_root: Path | None = None, +) -> CombinedResult: + """Merge M-probe findings, client checks, and server diagnostics. + + When server_report is None, returns local findings only with offline=True. + When present, deduplicates: server diagnostic at same (file, line, rule) + replaces the local finding. All paths normalized to project-relative. + """ + def _norm(fp: str) -> str: + return normalize_finding_path(fp, project_root) + items: list[FindingItem] = [] + + # Collect server diagnostics and build dedup set + server_keys: set[tuple[str, int, str]] = set() + server_count = 0 + cross_file: tuple[CrossFileFinding, ...] = () + quality: QualityResult | None = None + per_file: tuple[FileAnalysis, ...] = () + + if server_report is not None: + cross_file = server_report.cross_file + quality = server_report.quality + per_file = server_report.per_file + + for fa in server_report.per_file: + for diag in fa.diagnostics: + norm_file = _norm(diag.file) + server_keys.add((norm_file, diag.line, diag.rule)) + items.append( + FindingItem( + file=norm_file, + line=diag.line, + severity=diag.severity, + rule=diag.rule, + message=diag.message, + fix=diag.fix, + source="server", + line_2=diag.line_2, + ) + ) + server_count += 1 + + # Convert local findings, deduplicating against server + m_probe_count = 0 + client_count = 0 + for finding in m_probe_findings: + norm_file = _norm(finding.file) + key = (norm_file, finding.line, finding.rule) + if key not in server_keys: + items.append( + FindingItem( + file=norm_file, + line=finding.line, + severity=finding.severity, + rule=finding.rule, + message=finding.message, + fix=finding.fix, + source="m_probe", + ) + ) + m_probe_count += 1 + + for finding in client_check_findings: + norm_file = _norm(finding.file) + key = (norm_file, finding.line, finding.rule) + if key not in server_keys: + items.append( + FindingItem( + file=norm_file, + line=finding.line, + severity=finding.severity, + rule=finding.rule, + message=finding.message, + fix=finding.fix, + source="client_check", + ) + ) + client_count += 1 + + # Sort by file, severity, line + items.sort(key=lambda f: (f.file, _SEVERITY_ORDER.get(f.severity, 9), f.line)) + + # Compute stats + errors = sum(1 for f in items if f.severity == "error") + warnings = sum(1 for f in items if f.severity == "warning") + infos = sum(1 for f in items if f.severity == "info") + conflicts = sum(1 for cf in cross_file if cf.finding_type == "conflict") + repetitions = sum(1 for cf in cross_file if cf.finding_type == "repetition") + + return CombinedResult( + findings=tuple(items), + cross_file=cross_file, + quality=quality, + per_file_analysis=per_file, + stats=CombinedStats( + total_findings=len(items), + errors=errors, + warnings=warnings, + infos=infos, + cross_file_conflicts=conflicts, + cross_file_repetitions=repetitions, + m_probe_count=m_probe_count, + client_check_count=client_count, + server_diagnostic_count=server_count, + ), + offline=server_report is None, + hints=hints, + ) diff --git a/src/reporails_cli/core/models.py b/src/reporails_cli/core/models.py index 727cf3fc..f74f1163 100644 --- a/src/reporails_cli/core/models.py +++ b/src/reporails_cli/core/models.py @@ -12,16 +12,18 @@ class Category(str, Enum): """Rule categories matching framework.""" STRUCTURE = "structure" - CONTENT = "content" + COHERENCE = "coherence" + DIRECTION = "direction" + EFFICIENCY = "efficiency" MAINTENANCE = "maintenance" GOVERNANCE = "governance" - EFFICIENCY = "efficiency" # Category code -> Category enum mapping (first letter of rule ID) CATEGORY_CODES: dict[str, Category] = { "S": Category.STRUCTURE, - "C": Category.CONTENT, + "C": Category.COHERENCE, + "D": Category.DIRECTION, "E": Category.EFFICIENCY, "M": Category.MAINTENANCE, "G": Category.GOVERNANCE, @@ -29,11 +31,17 @@ class Category(str, Enum): class RuleType(str, Enum): - """How the rule is detected. Three types.""" + """How the rule is checked locally.""" MECHANICAL = "mechanical" # Python structural check DETERMINISTIC = "deterministic" # Regex pattern -> direct violation - SEMANTIC = "semantic" # LLM judgment required + + +class Execution(str, Enum): + """Where the rule's checks execute.""" + + LOCAL = "local" # checks.yml on client + SERVER = "server" # diagnostic from API, no local checks class Severity(str, Enum): @@ -63,32 +71,72 @@ class PatternConfidence(str, Enum): class Level(str, Enum): - """Capability levels from framework.""" + """Project levels from framework.""" L0 = "L0" # Absent - L1 = "L1" # Basic - L2 = "L2" # Scoped - L3 = "L3" # Structured - L4 = "L4" # Abstracted - L5 = "L5" # Maintained + L1 = "L1" # Present + L2 = "L2" # Structured + L3 = "L3" # Substantive + L4 = "L4" # Actionable + L5 = "L5" # Refined L6 = "L6" # Adaptive @dataclass(frozen=True) -class Check: # pylint: disable=too-many-instance-attributes +class FileTypeDeclaration: + """A typed file declaration from agent config file_types section.""" + + name: str # "main", "scoped_rule", "skill", etc. + patterns: tuple[str, ...] # glob patterns + required: bool = False + properties: dict[str, str | list[str]] = field(default_factory=dict) # property name → value(s) + + +@dataclass(frozen=True) +class ClassifiedFile: + """A file matched to a type declaration with resolved properties.""" + + path: Path + file_type: str # type name from FileTypeDeclaration.name + properties: dict[str, str | list[str]] = field(default_factory=dict) + + +@dataclass(frozen=True) +class FileMatch: # pylint: disable=too-many-instance-attributes + """Property-based file targeting. None properties are wildcards. + + Each property can be: + - None: wildcard (matches any value) + - str: exact match + - list[str]: OR match (value must be in the list) + """ + + type: list[str] | str | None = None + scope: list[str] | str | None = None + format: list[str] | str | None = None + content_format: list[str] | None = None + cardinality: list[str] | str | None = None + lifecycle: list[str] | str | None = None + maintainer: list[str] | str | None = None + vcs: list[str] | str | None = None + loading: list[str] | str | None = None + precedence: list[str] | str | None = None + + +@dataclass(frozen=True) +class Check: """A specific check within a rule. Checks have a type matching their gate: mechanical (Python function), - deterministic (regex pattern), or semantic (LLM evaluation). + deterministic (regex pattern), or content_query (atom-based). """ id: str # e.g., "CORE:S:0001:check:0001" - severity: Severity - type: str = "deterministic" # "mechanical" | "deterministic" | "semantic" - name: str = "" # Human-readable (optional) + type: str = "deterministic" # "mechanical" | "deterministic" | "content_query" check: str | None = None # Mechanical function name - args: dict[str, Any] | None = None # Mechanical check arguments - negate: bool = False # If True, finding = pass (content present), no finding = violation + args: dict[str, Any] | None = None # Mechanical/content_query arguments + query: str | None = None # Content query function name (type=content_query) + expect: str = "present" # "present" = no match is violation; "absent" = match is violation metadata_keys: list[str] = field(default_factory=list) # D→M metadata bus keys @@ -101,27 +149,22 @@ class Rule: # pylint: disable=too-many-instance-attributes title: str # e.g., "Instruction File Exists" category: Category type: RuleType - level: str # e.g., "L2" - minimum level this rule applies to + severity: Severity = Severity.MEDIUM # Rule-level severity # Identity slug: str = "" # e.g., "instruction-file-exists" - targets: str = "" # e.g., "{{instruction_files}}" + execution: Execution = Execution.LOCAL # Where checks run + match: FileMatch | None = None # Property-based file targeting supersedes: str | None = None # Coordinate of rule this replaces # Checks (all rule types) checks: list[Check] = field(default_factory=list) - # Semantic fields (semantic rules) - question: str | None = None - criteria: list[dict[str, str]] | str | None = None # [{key, check}, ...] or string - choices: list[dict[str, str]] | list[str] | None = None # [{value, label}, ...] - pass_value: str | None = None - examples: dict[str, list[str]] | None = None # {good: [...], bad: [...]} - # References sources: list[str] = field(default_factory=list) see_also: list[str] = field(default_factory=list) backed_by: list[str] = field(default_factory=list) # Source IDs from sources.yml + concern: str | None = None # Content concern: identity, directive, knowledge, constraint # Pattern quality pattern_confidence: PatternConfidence | None = None @@ -131,6 +174,20 @@ class Rule: # pylint: disable=too-many-instance-attributes yml_path: Path | None = None +@dataclass(frozen=True) +class LocalFinding: + """A finding from local M-probe or D-level client check.""" + + file: str # relative file path + line: int # 1-based line number + severity: str # "error" | "warning" | "info" + rule: str # rule_id (M probes) or theory label (client checks) + message: str # human-readable description + fix: str = "" # suggested fix text + source: str = "local" # "m_probe" | "client_check" + check_id: str = "" # specific check that triggered this + + @dataclass(frozen=True) class Violation: """A rule violation found during analysis.""" @@ -173,17 +230,15 @@ class JudgmentResponse: # Re-exports for backward compatibility from reporails_cli.core.results import ( # noqa: E402 AgentConfig, - CapabilityResult, CategoryStats, - ContentFeatures, DetectedFeatures, FrictionEstimate, GlobalConfig, InitResult, PendingSemantic, ProjectConfig, + RuleResult, ScanDelta, - SkippedExperimental, UpdateResult, ValidationResult, ) @@ -191,12 +246,13 @@ class JudgmentResponse: __all__ = [ "CATEGORY_CODES", "AgentConfig", - "CapabilityResult", "Category", "CategoryStats", "Check", - "ContentFeatures", + "ClassifiedFile", "DetectedFeatures", + "FileMatch", + "FileTypeDeclaration", "FrictionEstimate", "GlobalConfig", "InitResult", @@ -207,10 +263,10 @@ class JudgmentResponse: "PendingSemantic", "ProjectConfig", "Rule", + "RuleResult", "RuleType", "ScanDelta", "Severity", - "SkippedExperimental", "Tier", "UpdateResult", "ValidationResult", diff --git a/src/reporails_cli/core/pipeline.py b/src/reporails_cli/core/pipeline.py deleted file mode 100644 index f760ad1a..00000000 --- a/src/reporails_cli/core/pipeline.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Pipeline state — shared mutable state for per-rule ordered check execution. - -Provides PipelineState (shared mutable context across rule checks), TargetMeta -(per-target metadata/annotations), and build_initial_state() factory. - -Execution logic lives in pipeline_exec.py to stay within module size limits. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from reporails_cli.core.check_cache import CheckCache -from reporails_cli.core.models import RuleType, Violation - -# Blocking checks — implementation-intrinsic, not schema-declared. -# A failed blocking check excludes the target for ALL remaining rules. -BLOCKING_CHECKS: frozenset[str] = frozenset({"file_exists", "directory_exists", "path_resolves"}) - -# Rule type ceiling: which check types are allowed per rule type. -CEILING: dict[RuleType, frozenset[str]] = { - RuleType.MECHANICAL: frozenset({"mechanical"}), - RuleType.DETERMINISTIC: frozenset({"mechanical", "deterministic"}), - RuleType.SEMANTIC: frozenset({"mechanical", "deterministic", "semantic"}), -} - - -@dataclass -class TargetMeta: - """Per-target metadata for pipeline state. - - Tracks exclusion (blocking checks) and annotations (D->M metadata). - """ - - path: Path - annotations: dict[str, Any] = field(default_factory=dict) - excluded: bool = False - excluded_by: str | None = None - - -@dataclass -class PipelineState: - """Shared mutable state across all rule checks within a single validation run. - - Attributes: - targets: Map of path string -> TargetMeta for all instruction files. - findings: Accumulated violations from all gates (mechanical, deterministic). - candidates: Deterministic SARIF results available for semantic consumption. - _sarif_by_rule: Pre-distributed SARIF results keyed by rule_id. - """ - - targets: dict[str, TargetMeta] = field(default_factory=dict) - findings: list[Violation] = field(default_factory=list) - candidates: list[dict[str, Any]] = field(default_factory=list) - _sarif_by_rule: dict[str, list[dict[str, Any]]] = field(default_factory=dict) - check_cache: CheckCache = field(default_factory=CheckCache) - - def active_targets(self) -> list[TargetMeta]: - """Return targets not excluded by blocking checks.""" - return [t for t in self.targets.values() if not t.excluded] - - def exclude_target(self, path_str: str, check_id: str) -> None: - """Mark a target as excluded by a blocking check.""" - meta = self.targets.get(path_str) - if meta and not meta.excluded: - meta.excluded = True - meta.excluded_by = check_id - - def annotate_target(self, path_str: str, key: str, value: Any) -> None: - """Add an annotation to a target (D->M metadata).""" - meta = self.targets.get(path_str) - if meta: - meta.annotations[key] = value - - def get_rule_sarif(self, rule_id: str) -> list[dict[str, Any]]: - """Get pre-distributed SARIF results for a specific rule.""" - return self._sarif_by_rule.get(rule_id, []) - - -def build_initial_state( - instruction_files: list[Path] | None, - scan_root: Path, -) -> PipelineState: - """Build initial pipeline state from discovered instruction files. - - Args: - instruction_files: Pre-resolved instruction file paths, or None. - scan_root: Project root for computing relative paths. - - Returns: - PipelineState with targets populated from instruction files. - """ - state = PipelineState() - if instruction_files: - for f in instruction_files: - try: - rel = str(f.relative_to(scan_root)) - except ValueError: - rel = str(f) - state.targets[rel] = TargetMeta(path=f) - return state diff --git a/src/reporails_cli/core/pipeline_exec.py b/src/reporails_cli/core/pipeline_exec.py deleted file mode 100644 index 7709e9fe..00000000 --- a/src/reporails_cli/core/pipeline_exec.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Pipeline execution — per-rule ordered check dispatch. - -Consumes PipelineState from pipeline.py. Handles mechanical dispatch, -deterministic SARIF consumption, negation, semantic short-circuit, -and annotation propagation. -""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any - -from reporails_cli.core.mechanical.runner import ( - bind_instruction_files, - dispatch_single_check, - resolve_location, -) -from reporails_cli.core.models import ( - Check, - JudgmentRequest, - Rule, - Violation, -) -from reporails_cli.core.pipeline import BLOCKING_CHECKS, CEILING, PipelineState -from reporails_cli.core.sarif import extract_check_id, get_location, get_severity -from reporails_cli.core.semantic import build_request_from_sarif_result - -logger = logging.getLogger(__name__) - - -def execute_rule_checks( # pylint: disable=too-many-arguments - rule: Rule, - state: PipelineState, - scan_root: Path, - template_vars: dict[str, str | list[str]], - instruction_files: list[Path] | None, -) -> list[JudgmentRequest]: - """Execute a rule's ordered check sequence against pipeline state. - - Walks rule.checks in order. For each check: - - Verifies check.type is allowed by rule.type ceiling - - mechanical: dispatches via dispatch_single_check, handles blocking/annotations - - deterministic: reads from state._sarif_by_rule, converts to violations - - semantic: short-circuits if no candidates, else builds JudgmentRequest - - Returns: - List of JudgmentRequests for semantic checks (may be empty). - """ - allowed = CEILING.get(rule.type) - if allowed is None: - logger.warning("Unknown rule type '%s' for rule %s, skipping all checks", rule.type, rule.id) - return [] - # Skip bind when instruction_files is None — caller already bound vars - effective_vars = ( - bind_instruction_files(template_vars, scan_root, instruction_files) - if instruction_files is not None - else template_vars - ) - location = resolve_location(scan_root, rule, effective_vars) - judgment_requests: list[JudgmentRequest] = [] - det_candidate_count = 0 - - for check in rule.checks: - if check.type not in allowed: - logger.warning( - "Check type '%s' exceeds ceiling for rule type '%s' (rule %s), skipping", - check.type, - rule.type.value, - rule.id, - ) - continue - - if check.type == "mechanical": - _handle_mechanical(check, rule, state, scan_root, effective_vars, location) - elif check.type == "deterministic": - det_candidate_count += _handle_deterministic(check, rule, state, effective_vars, scan_root) - elif check.type == "semantic": - _handle_semantic(rule, state, scan_root, det_candidate_count, judgment_requests) - - return judgment_requests - - -def _handle_mechanical( - check: Check, - rule: Rule, - state: PipelineState, - scan_root: Path, - effective_vars: dict[str, str | list[str]], - location: str, -) -> None: - """Process a mechanical check within per-rule iteration.""" - # M←annotations: inject metadata from earlier D checks into args - if check.metadata_keys: - loc_path = location.rsplit(":", 1)[0] if ":" in location else "." - meta = state.targets.get(loc_path) - if meta: - injected_args = dict(check.args or {}) - for key in check.metadata_keys: - if key in meta.annotations: - injected_args[key] = meta.annotations[key] - # Replace check args with injected version (Check is frozen, pass via vars) - check = Check( - id=check.id, - severity=check.severity, - type=check.type, - name=check.name, - check=check.check, - args=injected_args, - negate=check.negate, - metadata_keys=check.metadata_keys, - ) - - violation, raw_result = dispatch_single_check(check, rule, scan_root, effective_vars, location) - - if violation: - state.findings.append(violation) - if check.check in BLOCKING_CHECKS: - loc_path = violation.location.rsplit(":", 1)[0] if ":" in violation.location else "." - state.exclude_target(loc_path, check.check or "unknown") - - if raw_result and raw_result.annotations: - _propagate_annotations(raw_result.annotations, state, location) - - -def _handle_deterministic( # pylint: disable=too-many-locals - check: Check, - rule: Rule, - state: PipelineState, - template_vars: dict[str, str | list[str]], - scan_root: Path, -) -> int: - """Process a deterministic check. Returns candidate count.""" - sarif_results = state.get_rule_sarif(rule.id) - check_results = _filter_sarif_for_check(sarif_results, check) - - if check.negate: - _handle_negated_deterministic(check, rule, state, check_results, template_vars, scan_root) - return 0 - - for result in check_results: - location = get_location(result) - sarif_rule_id = result.get("ruleId", "") - check_id = extract_check_id(sarif_rule_id) - severity = get_severity(rule, check_id) - message = result.get("message", {}).get("text", "") - state.findings.append( - Violation( - rule_id=rule.id, - rule_title=rule.title, - location=location, - message=message, - severity=severity, - check_id=check_id, - ) - ) - - # D→annotations: write matched texts to the metadata bus - if check.metadata_keys and check_results: - match_texts = [r.get("message", {}).get("text", "") for r in check_results] - loc_path = get_location(check_results[0]).rsplit(":", 1)[0] if check_results else "." - for key in check.metadata_keys: - state.annotate_target(loc_path, key, match_texts) - - return len(check_results) - - -def _handle_negated_deterministic( - check: Check, - rule: Rule, - state: PipelineState, - check_results: list[dict[str, Any]], - template_vars: dict[str, str | list[str]], - scan_root: Path, -) -> None: - """Handle negated deterministic check (finding = pass, no finding = violation).""" - if check_results: - return # Content found -> pass - - location = resolve_location(scan_root, rule, template_vars) - - state.findings.append( - Violation( - rule_id=rule.id, - rule_title=rule.title, - location=location, - message="Expected content not found", - severity=check.severity, - check_id=":".join(check.id.split(":")[3:]) if check.id.count(":") >= 4 else check.id, - ) - ) - - -def _handle_semantic( - rule: Rule, - state: PipelineState, - scan_root: Path, - det_candidate_count: int, - judgment_requests: list[JudgmentRequest], -) -> None: - """Process a semantic check within per-rule iteration.""" - sarif_results = state.get_rule_sarif(rule.id) - if not sarif_results and det_candidate_count == 0: - return # Short-circuit: no candidates -> semantic never fires - - # Deduplicate by file — semantic reads full file content, so multiple - # D matches in the same file need exactly one LLM evaluation, not N. - seen_files: set[str] = set() - for result in sarif_results: - file_path = get_location(result).rsplit(":", 1)[0] - if file_path in seen_files: - continue - seen_files.add(file_path) - request = build_request_from_sarif_result(rule, result, scan_root) - if request: - judgment_requests.append(request) - - -def _filter_sarif_for_check( - sarif_results: list[dict[str, Any]], - check: Check, -) -> list[dict[str, Any]]: - """Filter SARIF results to those matching a specific check by ID suffix.""" - if not sarif_results: - return [] - - parts = check.id.split(":") - if len(parts) >= 5: - expected_suffix = ":".join(parts[3:]) - else: - # Rule-level check (no suffix) — return all results for this rule - return list(sarif_results) - - matched = [] - for result in sarif_results: - sarif_rule_id = result.get("ruleId", "") - check_id = extract_check_id(sarif_rule_id) - if check_id == expected_suffix: - matched.append(result) - return matched - - -def _propagate_annotations( - annotations: dict[str, Any], - state: PipelineState, - location: str, -) -> None: - """Propagate check result annotations to pipeline state.""" - loc_path = location.rsplit(":", 1)[0] if ":" in location else "." - for key, value in annotations.items(): - state.annotate_target(loc_path, key, value) diff --git a/src/reporails_cli/core/regex/__init__.py b/src/reporails_cli/core/regex/__init__.py index 00c9d820..58ea3e1b 100644 --- a/src/reporails_cli/core/regex/__init__.py +++ b/src/reporails_cli/core/regex/__init__.py @@ -6,14 +6,14 @@ from reporails_cli.core.regex.runner import ( checks_per_file, - get_rule_yml_paths, - run_capability_detection, + get_checks_paths, + run_checks, run_validation, ) __all__ = [ "checks_per_file", - "get_rule_yml_paths", - "run_capability_detection", + "get_checks_paths", + "run_checks", "run_validation", ] diff --git a/src/reporails_cli/core/regex/compiler.py b/src/reporails_cli/core/regex/compiler.py index 1c963ec4..5202b81b 100644 --- a/src/reporails_cli/core/regex/compiler.py +++ b/src/reporails_cli/core/regex/compiler.py @@ -1,8 +1,4 @@ -"""YAML rule file → compiled regex checks. - -Parses OpenGrep-compatible YAML rule files and compiles regex patterns -for execution by the runner module. -""" +"""YAML rule file → compiled regex checks.""" from __future__ import annotations @@ -14,13 +10,7 @@ import yaml -from reporails_cli.core.templates import TEMPLATE_PATTERN, resolve_templates_str - -# Use C YAML loader when available -try: - from yaml import CSafeLoader as _YamlLoader -except ImportError: - from yaml import SafeLoader as _YamlLoader # type: ignore[assignment] +from reporails_cli.core.utils import load_yaml_file logger = logging.getLogger(__name__) @@ -29,7 +19,7 @@ @dataclass(frozen=True) -class CompiledCheck: +class CompiledCheck: # pylint: disable=too-many-instance-attributes """A single compiled regex check from a YAML rule file.""" id: str @@ -39,6 +29,7 @@ class CompiledCheck: negative_patterns: tuple[re.Pattern[str], ...] # AND — none must match either_patterns: tuple[re.Pattern[str], ...] # OR — any must match path_includes: tuple[str, ...] # Resolved path filters + body_only: bool = False # True → strip frontmatter before matching @dataclass @@ -142,15 +133,23 @@ def _compile_single_rule(rule_entry: dict[str, Any]) -> CompiledCheck | None: return None +def _parse_entries(data: dict[str, Any], yml_path: Path) -> list[dict[str, Any]] | None: + """Extract deterministic entries from YAML data ('checks' key).""" + entries = data.get("checks") + if not isinstance(entries, list): + logger.warning("Rule file %s has no valid 'checks' list, skipping", yml_path) + return None + return [e for e in entries if e.get("type", "deterministic") == "deterministic"] + + def compile_rules( yml_paths: list[Path], - template_context: dict[str, str | list[str]] | None = None, + body_only_paths: set[Path] | None = None, ) -> CompiledRuleSet: """Compile YAML rule files into a CompiledRuleSet. Args: yml_paths: Paths to YAML rule files - template_context: Template variables for {{placeholder}} resolution Returns: CompiledRuleSet with compiled checks and skipped rule IDs @@ -162,18 +161,16 @@ def compile_rules( continue try: - # Read file once — check for templates in memory (avoids double-read) - raw_content = yml_path.read_text(encoding="utf-8") - if template_context and TEMPLATE_PATTERN.search(raw_content): - content = resolve_templates_str(raw_content, template_context) - else: - content = raw_content + data = load_yaml_file(yml_path) + if not data: + logger.warning("Rule file %s has no valid data, skipping", yml_path) + continue - data = yaml.load(content, Loader=_YamlLoader) - if not data or "rules" not in data or not isinstance(data["rules"], list): + entries = _parse_entries(data, yml_path) + if entries is None: continue - for rule_entry in data["rules"]: + for rule_entry in entries: rule_id = rule_entry.get("id", "unknown") operator = _get_operator(rule_entry) @@ -188,6 +185,17 @@ def compile_rules( try: check = _compile_single_rule(rule_entry) if check is not None: + if body_only_paths and yml_path in body_only_paths: + check = CompiledCheck( + id=check.id, + message=check.message, + severity=check.severity, + patterns=check.patterns, + negative_patterns=check.negative_patterns, + either_patterns=check.either_patterns, + path_includes=check.path_includes, + body_only=True, + ) result.checks.append(check) else: result.skipped.append(rule_id) @@ -224,13 +232,7 @@ def _scope_inline_flags(pattern: str) -> str: def _get_combinable_pattern(check: CompiledCheck) -> str | None: - """Get a single embeddable pattern string for combined regex batching. - - Returns None if the check cannot be combined (e.g., has negative patterns - or multiple AND patterns). Handles: - - Single patterns (with or without inline flags like (?i)) - - Either-pattern alternatives (joined as alternation) - """ + """Get a single embeddable pattern string for combined regex batching.""" if check.negative_patterns: return None # Can't batch negatives (require all-must-not-match logic) diff --git a/src/reporails_cli/core/regex/runner.py b/src/reporails_cli/core/regex/runner.py index 217b0c9e..38adc274 100644 --- a/src/reporails_cli/core/regex/runner.py +++ b/src/reporails_cli/core/regex/runner.py @@ -10,21 +10,33 @@ import fnmatch import logging import re +import signal from pathlib import Path, PurePosixPath from typing import Any -from reporails_cli.bundled import get_capability_patterns_path -from reporails_cli.core.models import Rule +from reporails_cli.core.models import LocalFinding, Rule from reporails_cli.core.regex.compiler import ( CombinedPattern, CompiledCheck, - build_combined_patterns, compile_rules, ) logger = logging.getLogger(__name__) +def _strip_frontmatter(content: str) -> str: + """Replace YAML frontmatter with blank lines to preserve line numbers.""" + if not content.startswith("---"): + return content + end = content.find("\n---", 3) + if end == -1: + return content + end_of_closing = content.find("\n", end + 4) + if end_of_closing == -1: + end_of_closing = len(content) + return "\n" * content[:end_of_closing].count("\n") + content[end_of_closing:] + + def _find_line_number(content: str, match: re.Match[str]) -> int: """Count newlines before match start position.""" return content[: match.start()].count("\n") + 1 @@ -37,11 +49,7 @@ def _get_snippet(match: re.Match[str], max_len: int = 200) -> str: def _file_matches_path_filter(file_path: str, path_includes: tuple[str, ...]) -> bool: - """Check if a file path matches any of the path include patterns. - - Handles ``**`` as zero-or-more directory components (matching glob/OpenGrep - semantics) via PurePosixPath.match(), with fnmatch fallback for simple patterns. - """ + """Check if a file path matches any of the path include patterns.""" if not path_includes: return True filename = Path(file_path).name @@ -50,12 +58,10 @@ def _file_matches_path_filter(file_path: str, path_includes: tuple[str, ...]) -> for pattern in path_includes: if "{{" in pattern: continue - # PurePosixPath.match handles ** as zero-or-more directories (Python 3.12+) if "**" in pattern: clean_pattern = pattern.lstrip("./") if p.match(clean_pattern): return True - # Also check if ** should match zero directories if "**/" in clean_pattern: collapsed = clean_pattern.replace("**/", "") if fnmatch.fnmatch(rel, collapsed) or fnmatch.fnmatch(filename, collapsed): @@ -90,7 +96,7 @@ def _resolve_scan_targets( resolved = ifile.resolve() if resolved not in seen and resolved.exists(): seen.add(resolved) - targets.append(resolved) + targets.append(ifile) _append_extra(seen, targets, extra_targets) return targets @@ -112,8 +118,39 @@ def _is_text_file(file_path: Path) -> bool: return False -def _match_check(check: CompiledCheck, content: str) -> list[re.Match[str]]: +_REGEX_TIMEOUT_S = 0.5 + + +class _RegexTimeoutError(Exception): + """Raised when a regex search exceeds the time limit.""" + + +def _alarm_handler(_signum: int, _frame: object) -> None: + raise _RegexTimeoutError + + +def _safe_search(pat: re.Pattern[str], content: str) -> re.Match[str] | None: + """Run pat.search with a signal-based timeout against catastrophic backtracking.""" + prev = signal.signal(signal.SIGALRM, _alarm_handler) + signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_S) + try: + return pat.search(content) + except _RegexTimeoutError: + logger.warning("Regex timed out after %.1fs: %s", _REGEX_TIMEOUT_S, pat.pattern[:80]) + return None + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, prev) + + +def _match_check( + check: CompiledCheck, + content: str, + body_content: str | None = None, +) -> list[re.Match[str]]: """Execute a single compiled check against file content.""" + if check.body_only: + content = body_content if body_content is not None else _strip_frontmatter(content) if check.either_patterns: return [m for pat in check.either_patterns if (m := pat.search(content))] @@ -160,11 +197,7 @@ def _should_exclude(file_path: Path, scan_root: Path, exclude_dirs: list[str] | def _partition_checks( checks: list[CompiledCheck], ) -> tuple[list[CompiledCheck], dict[str, list[CompiledCheck]]]: - """Pre-partition checks into universal (no path filter) and path-filtered groups. - - Returns: - (universal_checks, path_pattern_to_checks_map) - """ + """Pre-partition checks into universal (no path filter) and path-filtered groups.""" universal: list[CompiledCheck] = [] by_pattern: dict[str, list[CompiledCheck]] = {} for check in checks: @@ -193,7 +226,6 @@ def _get_applicable_checks( applicable = list(universal) for checks in by_pattern.values(): - # All checks in this group share the same path_includes if _file_matches_path_filter(rel_path, checks[0].path_includes): applicable.extend(checks) return applicable @@ -236,6 +268,52 @@ def _emit_results( ) +def _safe_finditer(pat: re.Pattern[str], content: str) -> list[re.Match[str]]: + """Run pat.finditer with a signal-based timeout against catastrophic backtracking.""" + prev = signal.signal(signal.SIGALRM, _alarm_handler) + signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_S) + try: + return list(pat.finditer(content)) + except _RegexTimeoutError: + logger.warning("Regex finditer timed out after %.1fs: %s", _REGEX_TIMEOUT_S, pat.pattern[:80]) + return [] + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, prev) + + +def _retry_shadowed( + group_to_check: dict[str, CompiledCheck], + matched_checks: set[str], + content: str, + file_uri: str, + results: list[dict[str, Any]], + rule_defs: dict[str, dict[str, Any]], +) -> None: + """Retry shadowed checks under a single timeout guard.""" + prev = signal.signal(signal.SIGALRM, _alarm_handler) + signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_S * 2) + try: + for group_name, check in group_to_check.items(): + if group_name in matched_checks: + continue + if check.patterns: + m = check.patterns[0].search(content) + if m: + _emit_results(check, [m], file_uri, content, results, rule_defs) + elif check.either_patterns: + for pat in check.either_patterns: + m = pat.search(content) + if m: + _emit_results(check, [m], file_uri, content, results, rule_defs) + break + except _RegexTimeoutError: + logger.warning("Shadowed check retry timed out after %.1fs", _REGEX_TIMEOUT_S * 2) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, prev) + + def _scan_combined( content: str, file_uri: str, @@ -243,26 +321,23 @@ def _scan_combined( results: list[dict[str, Any]], rule_defs: dict[str, dict[str, Any]], ) -> None: - """Scan content using combined alternation patterns for batch matching. - - Emits at most one match per check per file, matching the behavior of - re.search() in the individual check path. - """ + """Scan content using combined alternation patterns for batch matching.""" for combined in combined_patterns: - # Track which checks already matched (first match per check) matched_checks: set[str] = set() - for m in combined.regex.finditer(content): + for m in _safe_finditer(combined.regex, content): group_name = m.lastgroup if group_name and group_name not in matched_checks: check = combined.group_to_check[group_name] _emit_results(check, [m], file_uri, content, results, rule_defs) matched_checks.add(group_name) - # Early exit when all checks have matched if len(matched_checks) == len(combined.group_to_check): break + if len(matched_checks) < len(combined.group_to_check): + _retry_shadowed(combined.group_to_check, matched_checks, content, file_uri, results, rule_defs) -_MAX_FILE_SIZE = 1_048_576 # 1 MB — skip files larger than this + +_MAX_FILE_SIZE = 1_048_576 # 1 MB def _scan_file( @@ -289,50 +364,46 @@ def _scan_file( except ValueError: file_uri = str(file_path) - # Use combined patterns for simple checks + body_content: str | None = None + if any(c.body_only for c in checks): + body_content = _strip_frontmatter(content) + if combined_patterns: _scan_combined(content, file_uri, combined_patterns, results, rule_defs) - # Run remaining complex checks individually - for check in checks: - matches = _match_check(check, content) - if not matches: - continue - - if first_match_only: - _emit_results(check, matches[:1], file_uri, content, results, rule_defs) - else: - _emit_results(check, matches, file_uri, content, results, rule_defs) + prev = signal.signal(signal.SIGALRM, _alarm_handler) + signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_S * len(checks)) + try: + for check in checks: + matches = _match_check(check, content, body_content) + if not matches: + continue + + if first_match_only: + _emit_results(check, matches[:1], file_uri, content, results, rule_defs) + else: + _emit_results(check, matches, file_uri, content, results, rule_defs) + except _RegexTimeoutError: + logger.warning("File scan timed out: %s", file_path) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, prev) def run_validation( # pylint: disable=too-many-locals yml_paths: list[Path], target: Path, - template_context: dict[str, str | list[str]] | None = None, extra_targets: list[Path] | None = None, instruction_files: list[Path] | None = None, exclude_dirs: list[str] | None = None, + body_only_paths: set[Path] | None = None, ) -> dict[str, Any]: - """Execute regex validation with specified rule configs, returns SARIF-shaped dict. - - Main entry point for regex-based rule validation. - - Args: - yml_paths: Paths to YAML rule files - target: Directory or file to scan - template_context: Template variables for {{placeholder}} resolution - extra_targets: Additional file paths to scan - instruction_files: Explicit list of files to scan - exclude_dirs: Directory names to exclude from scanning - - Returns: - SARIF-compatible dict with runs[].results[] - """ + """Execute regex validation with specified rule configs, returns SARIF-shaped dict.""" valid_paths = [p for p in yml_paths if p and p.exists()] if not valid_paths: return {"runs": []} - ruleset = compile_rules(valid_paths, template_context) + ruleset = compile_rules(valid_paths, body_only_paths=body_only_paths) if not ruleset.checks: return {"runs": []} @@ -347,25 +418,18 @@ def run_validation( # pylint: disable=too-many-locals results: list[dict[str, Any]] = [] rule_defs: dict[str, dict[str, Any]] = {} - # Partition checks into universal (no path filter) and path-filtered groups universal, by_pattern = _partition_checks(ruleset.checks) - # Build combined patterns for universal checks only — path-filtered checks - # run individually (combined alternation is slower for complex/flagged patterns) - combined, complex_universal = build_combined_patterns(universal) - for file_path in scan_targets: if not file_path.is_file(): continue if _should_exclude(file_path, scan_root, exclude_dirs): continue - # Get path-filtered checks applicable to this file path_checks = _get_applicable_checks(file_path, scan_root, [], by_pattern) - individual = complex_universal + path_checks + individual = universal + path_checks - # Skip files with no applicable checks - if not individual and not combined: + if not individual: continue if not _is_text_file(file_path): @@ -376,101 +440,145 @@ def run_validation( # pylint: disable=too-many-locals individual, results, rule_defs, - combined_patterns=combined, ) return _build_sarif(results, list(rule_defs.values())) -def run_capability_detection( # pylint: disable=too-many-locals +def run_checks( # noqa: C901 + yml_paths: list[Path], target: Path, - extra_targets: list[Path] | None = None, instruction_files: list[Path] | None = None, -) -> dict[str, Any]: - """Run capability detection using bundled patterns. - - Only needs boolean presence per check, so uses first_match_only=True - for faster scanning. - - Args: - target: Directory to scan - extra_targets: Additional file paths to scan - instruction_files: Explicit list of files to scan + exclude_dirs: list[str] | None = None, + body_only_paths: set[Path] | None = None, +) -> list[LocalFinding]: + """Execute regex validation and return LocalFinding list. - Returns: - SARIF-compatible dict + Handles expect semantics correctly: + - expect: absent → regex MATCH = violation (content should not be present) + - expect: present → regex NO MATCH = violation (content should be present) """ - patterns_path = get_capability_patterns_path() - if not patterns_path.exists(): - logger.warning("Capability patterns not found: %s", patterns_path) - return {"runs": []} - - # Use first_match_only for capability detection — only need presence, not all matches - valid_paths = [patterns_path] - ruleset = compile_rules(valid_paths) - if not ruleset.checks: - return {"runs": []} - - scan_targets = _resolve_scan_targets(target, instruction_files, extra_targets) - if not scan_targets: - return {"runs": []} - - scan_root = target if target.is_dir() else target.parent - results: list[dict[str, Any]] = [] - rule_defs: dict[str, dict[str, Any]] = {} - universal, by_pattern = _partition_checks(ruleset.checks) - combined, complex_universal = build_combined_patterns(universal) - - for file_path in scan_targets: - if not file_path.is_file(): - continue - - path_checks = _get_applicable_checks(file_path, scan_root, [], by_pattern) - individual = complex_universal + path_checks + import yaml - if not individual and not combined: + # Load check definitions to get expect values (deterministic checks only) + expect_map: dict[str, str] = {} # check_id -> "present" | "absent" + message_map: dict[str, str] = {} # check_id -> message + for yml_path in yml_paths: + if not yml_path.exists(): continue - if not _is_text_file(file_path): + try: + data = yaml.safe_load(yml_path.read_text(encoding="utf-8")) + for check_def in data.get("checks", []): + # Only deterministic (regex) checks — skip mechanical, content_query, fallback + if check_def.get("type") != "deterministic": + continue + if check_def.get("fallback"): + continue + cid = check_def.get("id", "") + expect_map[cid] = check_def.get("expect", "present") + message_map[cid] = check_def.get("message", "") + except (yaml.YAMLError, OSError): continue - _scan_file( - file_path, - scan_root, - individual, - results, - rule_defs, - first_match_only=True, - combined_patterns=combined, - ) - return _build_sarif(results, list(rule_defs.values())) + # Run regex engine — returns SARIF with matches + sarif = run_validation( + yml_paths, + target, + instruction_files=instruction_files, + exclude_dirs=exclude_dirs, + body_only_paths=body_only_paths, + ) + + # Collect which (check_id, file) pairs had matches + matched_pairs: set[tuple[str, str]] = set() + match_details: dict[tuple[str, str], tuple[int, str]] = {} # (check_id, file) -> (line, msg) + + for run in sarif.get("runs", []): + for result in run.get("results", []): + check_id = result.get("ruleId", "") + msg = result.get("message", {}).get("text", "") + file_path = "" + line = 0 + for loc in result.get("locations", []): + phys = loc.get("physicalLocation", {}) + file_path = phys.get("artifactLocation", {}).get("uri", "") + line = phys.get("region", {}).get("startLine", 0) + break + matched_pairs.add((check_id, file_path)) + match_details[(check_id, file_path)] = (line, msg) + + findings: list[LocalFinding] = [] + + # Get scanned files for expect:present checks (need to know what WASN'T matched) + scan_root = target if target.is_dir() else target.parent + scan_targets = _resolve_scan_targets(target, instruction_files, None) + scanned_files = [] + for fp in scan_targets: + if fp.is_file() and not _should_exclude(fp, scan_root, exclude_dirs): + try: + scanned_files.append(str(fp.relative_to(scan_root))) + except ValueError: + scanned_files.append(str(fp)) + + for check_id, expect in expect_map.items(): + # Convert dot-separated ID to colon-separated + parts = check_id.split(".") + rule_id = f"{parts[0]}:{parts[1]}:{parts[2]}" if len(parts) >= 3 else check_id + check_suffix = "" + if "check" in parts: + idx = parts.index("check") + if idx + 1 < len(parts): + check_suffix = f"check:{parts[idx + 1]}" + + msg = message_map.get(check_id, "") + + if expect == "absent": + # Match = violation + for file_path in scanned_files: + if (check_id, file_path) in matched_pairs: + line, match_msg = match_details[(check_id, file_path)] + findings.append( + LocalFinding( + file=file_path, + line=line, + severity="warning", + rule=rule_id, + message=match_msg or msg, + source="m_probe", + check_id=check_suffix, + ) + ) + else: + # expect: present — NO match = violation + findings.extend( + LocalFinding( + file=file_path, + line=1, + severity="warning", + rule=rule_id, + message=msg, + source="m_probe", + check_id=check_suffix, + ) + for file_path in scanned_files + if (check_id, file_path) not in matched_pairs + ) + + return findings def checks_per_file( yml_paths: list[Path], scan_root: Path, - template_context: dict[str, str | list[str]] | None, - instruction_files: list[Path] | None, + instruction_files: list[Path] | None = None, ) -> dict[str, list[str]]: - """List compiled regex check IDs applicable to each file (path filter logic only, no matching). - - Args: - yml_paths: Rule YAML paths - scan_root: Project root - template_context: Template variables - instruction_files: Files to count against - - Returns: - Dict of relative file path -> list of check IDs - """ - ruleset = compile_rules([p for p in yml_paths if p and p.exists()], template_context) + """List compiled regex check IDs applicable to each file.""" + ruleset = compile_rules([p for p in yml_paths if p and p.exists()]) if not ruleset.checks: return {} universal, by_pattern = _partition_checks(ruleset.checks) - combined, complex_universal = build_combined_patterns(universal) - base_ids = [c.id for c in complex_universal] - for cp in combined: - base_ids.extend(c.id for c in cp.group_to_check.values()) + base_ids = [c.id for c in universal] result: dict[str, list[str]] = {} for file_path in instruction_files or []: @@ -486,13 +594,6 @@ def checks_per_file( return result -def get_rule_yml_paths(rules: dict[str, Rule]) -> list[Path]: - """Get list of .yml paths for rules that have them and exist. - - Args: - rules: Dict of rules - - Returns: - List of paths to existing .yml files - """ +def get_checks_paths(rules: dict[str, Rule]) -> list[Path]: + """Get list of checks.yml paths for rules that have them and exist.""" return [r.yml_path for r in rules.values() if r.yml_path is not None and r.yml_path.exists()] diff --git a/src/reporails_cli/core/registry.py b/src/reporails_cli/core/registry.py index eba63af8..894732a0 100644 --- a/src/reporails_cli/core/registry.py +++ b/src/reporails_cli/core/registry.py @@ -15,18 +15,13 @@ ) from reporails_cli.core.models import ( AgentConfig, - Check, ProjectConfig, Rule, Severity, - Tier, ) from reporails_cli.core.rule_builder import ( CORE_WEIGHT_THRESHOLD as CORE_WEIGHT_THRESHOLD, ) -from reporails_cli.core.rule_builder import ( - _load_source_weights, -) from reporails_cli.core.rule_builder import ( build_rule as build_rule, ) @@ -34,7 +29,7 @@ derive_tier as derive_tier, ) from reporails_cli.core.rule_builder import ( - get_rule_yml_paths as get_rule_yml_paths, + get_checks_paths as get_checks_paths, ) from reporails_cli.core.rule_builder import ( get_rules_by_category as get_rules_by_category, @@ -42,7 +37,7 @@ from reporails_cli.core.rule_builder import ( get_rules_by_type as get_rules_by_type, ) -from reporails_cli.core.utils import parse_frontmatter +from reporails_cli.core.utils import clear_yaml_cache, load_yaml_file, parse_frontmatter logger = logging.getLogger(__name__) @@ -54,6 +49,7 @@ def clear_rule_cache() -> None: """Clear the rule loading cache. Called by --refresh and after ails update.""" _path_cache.clear() + clear_yaml_cache() def get_rules_dir() -> Path: @@ -78,8 +74,8 @@ def _load_from_path(path: Path) -> dict[str, Rule]: return rules for md_path in path.rglob("rule.md"): - # Skip test fixtures (tests/pass/, tests/fail/ directories) - if "tests" in md_path.parts: + # Skip test fixtures and deferred (not-yet-ready) rules + if "tests" in md_path.parts or "_deferred" in md_path.parts: continue try: @@ -89,9 +85,17 @@ def _load_from_path(path: Path) -> dict[str, Rule]: if not frontmatter: continue - # Look for corresponding .yml file - yml_path_candidate = md_path.with_suffix(".yml") - yml_path: Path | None = yml_path_candidate if yml_path_candidate.exists() else None + # Look for corresponding checks.yml file + checks_yml = md_path.parent / "checks.yml" + yml_path: Path | None = checks_yml if checks_yml.exists() else None + + # Pre-parse checks.yml so build_rule doesn't re-parse it + if yml_path is not None and not frontmatter.get("checks"): + try: + yml_data = load_yaml_file(yml_path) + frontmatter["checks"] = (yml_data or {}).get("checks", []) + except Exception: + pass rule = build_rule(frontmatter, md_path, yml_path) rules[rule.id] = rule @@ -106,12 +110,11 @@ def _load_from_path(path: Path) -> dict[str, Rule]: def load_rules( # pylint: disable=too-many-locals rules_paths: list[Path] | None = None, - include_experimental: bool = False, project_root: Path | None = None, agent: str = "", scan_root: Path | None = None, ) -> dict[str, Rule]: - """Load rules from directories, filtered by tier, agent, and project config.""" + """Load rules from directories, filtered by agent and project config.""" if not rules_paths: rules_paths = [get_rules_dir()] @@ -128,7 +131,7 @@ def load_rules( # pylint: disable=too-many-locals rules.update(_load_from_path(core_path)) if agent: - agent_rules_path = primary / "agents" / agent / "rules" + agent_rules_path = primary / agent rules.update(_load_from_path(agent_rules_path)) # 2. Load additional rule sources @@ -148,12 +151,7 @@ def load_rules( # pylint: disable=too-many-locals agent_prefix = agent_config.prefix or agent.upper() rules = {k: v for k, v in rules.items() if not _is_other_agent_rule(k, agent_prefix)} - # 5. Filter by tier if experimental not included - if not include_experimental: - weights = _load_source_weights(primary, extra_paths or None) - rules = {k: v for k, v in rules.items() if derive_tier(v.backed_by, weights) == Tier.CORE} - - # 6. Remove disabled rules (merge from project_root + scan_root configs) + # 5. Remove disabled rules (merge from project_root + scan_root configs) config = _load_project_config(project_root) disabled: set[str] = set(config.disabled_rules or []) if scan_root and scan_root != project_root: @@ -197,40 +195,38 @@ def _apply_agent_overrides( rules: dict[str, Rule], overrides: dict[str, dict[str, Any]], ) -> dict[str, Rule]: - """Apply agent check-level overrides (severity, disabled).""" + """Apply agent overrides: severity at rule level, disabled at check level.""" result = {} for rule_id, rule in rules.items(): - new_checks = [] - for check in rule.checks: - override = overrides.get(check.id) - if override is None: - new_checks.append(check) - continue - if override.get("disabled", False): - continue # Drop this check - new_severity = override.get("severity") + # Rule-level severity override (keyed by rule_id) + rule_override = overrides.get(rule_id) + new_rule = rule + if rule_override: + new_severity = rule_override.get("severity") if new_severity: try: parsed_severity = Severity(new_severity) + new_rule = replace(new_rule, severity=parsed_severity) except ValueError: - logger.warning("Invalid severity '%s' in override for %s, skipping", new_severity, check.id) - new_checks.append(check) - continue - new_checks.append( - Check( - id=check.id, - severity=parsed_severity, - type=check.type, - name=check.name, - check=check.check, - args=check.args, - negate=check.negate, - metadata_keys=check.metadata_keys, - ) - ) - else: - new_checks.append(check) - result[rule_id] = replace(rule, checks=new_checks) + logger.warning("Invalid severity '%s' in override for %s, skipping", new_severity, rule_id) + + # Check-level overrides: only handle disabled + new_checks = [] + for check in new_rule.checks: + override = overrides.get(check.id) + if override is not None and override.get("disabled", False): + continue # Drop this check + # Legacy: check-level severity override → lift to rule + if override and not rule_override: + check_severity = override.get("severity") + if check_severity: + try: + parsed_severity = Severity(check_severity) + new_rule = replace(new_rule, severity=parsed_severity) + except ValueError: + pass + new_checks.append(check) + result[rule_id] = replace(new_rule, checks=new_checks) return result @@ -239,24 +235,3 @@ def _load_project_config(project_root: Path | None) -> ProjectConfig: if project_root is None: return ProjectConfig() return get_project_config(project_root) - - -def get_experimental_rules(rules_dir: Path | None = None) -> dict[str, Rule]: - """Get experimental-tier rules (skipped when experimental is disabled).""" - if rules_dir is None: - rules_dir = get_rules_dir() - - if not rules_dir.exists(): - return {} - - # Load all rules, then filter to experimental only - all_rules: dict[str, Rule] = {} - - core_path = rules_dir / "core" - all_rules.update(_load_from_path(core_path)) - - agents_path = rules_dir / "agents" - all_rules.update(_load_from_path(agents_path)) - - weights = _load_source_weights(rules_dir) - return {k: v for k, v in all_rules.items() if derive_tier(v.backed_by, weights) == Tier.EXPERIMENTAL} diff --git a/src/reporails_cli/core/results.py b/src/reporails_cli/core/results.py index 3111dd54..dc8ed019 100644 --- a/src/reporails_cli/core/results.py +++ b/src/reporails_cli/core/results.py @@ -20,84 +20,65 @@ @dataclass class DetectedFeatures: # pylint: disable=too-many-instance-attributes - """Features detected in a project for capability scoring. + """Features detected in a project for capability-gate level detection. - Populated in two phases: - - Phase 1: Filesystem detection (applicability.py) - - Phase 2: Content detection (capability.py via regex) + Used by determine_level_from_gates() for project level assignment, + by display summaries, and for symlink resolution. """ - # === Phase 1: Filesystem detection === - # Base existence - has_instruction_file: bool = False # Any instruction file found - has_claude_md: bool = False # CLAUDE.md at root (legacy compat) + has_instruction_file: bool = False + has_claude_md: bool = False # CLAUDE.md at root # Directory structure is_abstracted: bool = False # .claude/rules/, .claude/skills/, etc. - has_shared_files: bool = False # .shared/, shared/, cross-refs - has_backbone: bool = False # .reporails/backbone.yml + has_shared_files: bool = False # .shared/, shared/ + has_backbone: bool = False # .ails/backbone.yml # Discovery - component_count: int = 0 # Components from discovery instruction_file_count: int = 0 has_multiple_instruction_files: bool = False - has_hierarchical_structure: bool = False # nested CLAUDE.md files - detected_agents: list[str] = field(default_factory=list) - - # Symlink resolution (for regex engine extra targets) - resolved_symlinks: list[Path] = field(default_factory=list) + has_hierarchical_structure: bool = False # nested instruction files + component_count: int = 0 # components from backbone.yml # L2 capabilities - is_size_controlled: bool = False # Root instruction file under size threshold + is_size_controlled: bool = False # root instruction file under size threshold + has_explicit_constraints: bool = False # MUST/NEVER keywords in content + has_imports: bool = False # @import references in content + + # L4 capabilities + has_path_scoped_rules: bool = False # rules with path scope frontmatter # L6 capabilities has_skills_dir: bool = False # .claude/skills/ etc. with content has_mcp_config: bool = False # .mcp.json or similar - has_memory_dir: bool = False # Memory/state persistence - - # === Phase 2: Content detection (regex) === + has_memory_dir: bool = False # memory/state persistence directory - # Content analysis - has_sections: bool = False # Has H2+ headers - has_imports: bool = False # @imports or file references - has_explicit_constraints: bool = False # MUST/NEVER keywords - has_path_scoped_rules: bool = False # Rules with paths: frontmatter - - -@dataclass(frozen=True) -class ContentFeatures: - """Intermediate result from regex content analysis.""" - - has_sections: bool = False - has_imports: bool = False - has_explicit_constraints: bool = False - has_path_scoped_rules: bool = False + # Symlink resolution (for regex engine extra targets) + resolved_symlinks: list[Path] = field(default_factory=list) @dataclass(frozen=True) -class CapabilityResult: - """Result of capability detection pipeline.""" +class FrictionEstimate: + """Friction estimate from violations.""" - features: DetectedFeatures - level: Level # Base level (L1-L6) - has_orphan_features: bool # Has features above base level (display as L3+) - feature_summary: str # Human-readable + level: str # "extreme", "high", "medium", "small", "none" @dataclass(frozen=True) -class FrictionEstimate: - """Friction estimate from violations.""" +class RuleResult: + """Per-rule pass/fail outcome for the violation matrix.""" - level: str # "extreme", "high", "medium", "small", "none" + rule_id: str # e.g., "CORE:C:0001" + status: str # "pass" or "fail" @dataclass(frozen=True) class CategoryStats: """Per-category rule statistics for the category summary table.""" - code: str # "S", "C", "E", "M", "G" - name: str # "Structure", "Content", etc. + code: str # "S", "C", "D", "E", "M", "G" + name: str # "Structure", "Coherence", "Direction", etc. total: int passed: int failed: int @@ -130,17 +111,17 @@ class GlobalConfig: auto_update_check: bool = True default_agent: str = "" recommended: bool = True + tier: str = "" # "free" | "pro" — overridden by AILS_TIER env var @dataclass class ProjectConfig: # pylint: disable=too-many-instance-attributes - """Project-level configuration (.reporails/config.yml).""" + """Project-level configuration (.ails/config.yml).""" framework_version: str | None = None # Pin version packages: list[str] = field(default_factory=list) # Project rule packages disabled_rules: list[str] = field(default_factory=list) overrides: dict[str, dict[str, str]] = field(default_factory=dict) - experimental: bool | list[str] = False # True, False, or list of rule IDs recommended: bool = True # Include recommended rules (opt out with false) exclude_dirs: list[str] = field(default_factory=list) # Directory names to exclude default_agent: str = "" # Default agent when --agent not specified (e.g., "claude") @@ -212,14 +193,6 @@ class PendingSemantic: rules: tuple[str, ...] # Rule IDs (e.g., "C6", "C10") -@dataclass(frozen=True) -class SkippedExperimental: - """Summary of skipped experimental rules.""" - - rule_count: int # Number of experimental rules skipped - rules: tuple[str, ...] # Rule IDs (e.g., "CORE:C:0004") - - @dataclass(frozen=True) class ValidationResult: # pylint: disable=too-many-instance-attributes """Complete validation output.""" @@ -234,12 +207,12 @@ class ValidationResult: # pylint: disable=too-many-instance-attributes feature_summary: str # Human-readable friction: FrictionEstimate # Per-category breakdown - has_orphan_features: bool = False # Features above base level (display as L3+) category_summary: tuple[CategoryStats, ...] = () + # Per-rule pass/fail (for violation matrix construction) + rule_results: tuple[RuleResult, ...] = () # Evaluation completeness is_partial: bool = True # True for CLI (pattern-only), False for MCP (includes semantic) pending_semantic: PendingSemantic | None = None # Summary of pending semantic rules - skipped_experimental: SkippedExperimental | None = None # Summary of skipped experimental rules @dataclass(frozen=True) diff --git a/src/reporails_cli/core/rule_builder.py b/src/reporails_cli/core/rule_builder.py index eca55c53..e229e350 100644 --- a/src/reporails_cli/core/rule_builder.py +++ b/src/reporails_cli/core/rule_builder.py @@ -7,10 +7,12 @@ import yaml -from reporails_cli.core.bootstrap import get_rules_path +from reporails_cli.core.bootstrap import get_framework_root from reporails_cli.core.models import ( Category, Check, + Execution, + FileMatch, PatternConfidence, Rule, RuleType, @@ -50,8 +52,15 @@ def _load_source_weights( extra_source_dirs: list[Path] | None = None, ) -> dict[str, float]: """Load source weights from sources.yml in rules dir and extra packages.""" - base = rules_dir or get_rules_path() - weights = _parse_sources_yml(base / "docs" / "sources.yml") + base = rules_dir or get_framework_root() + candidates = [ + base / "docs" / "sources.yml", # installed mode: docs/ alongside rules + base.parent / "docs" / "sources.yml", # legacy: rules_dir is rules/, docs at parent + base / "sources.yml", # bundled: sources.yml at package root + base.parent / "sources.yml", # bundled: rules_dir is rules/, sources.yml at parent + ] + sources_path = next((p for p in candidates if p.exists()), candidates[0]) + weights = _parse_sources_yml(sources_path) if extra_source_dirs: for pkg_dir in extra_source_dirs: @@ -75,49 +84,75 @@ def derive_tier(backed_by: list[str], source_weights: dict[str, float] | None = return Tier.CORE if max_weight >= CORE_WEIGHT_THRESHOLD else Tier.EXPERIMENTAL -def build_rule(frontmatter: dict[str, Any], md_path: Path, yml_path: Path | None) -> Rule: - """Build Rule from parsed frontmatter. Raises KeyError/ValueError on bad input.""" - # Parse checks (enriched format with type, check, args) - checks = [] - for item in frontmatter.get("checks", []): - check = Check( +def _load_checks(frontmatter: dict[str, Any]) -> list[Check]: + """Load check entries from frontmatter (pre-populated by registry from checks.yml).""" + return [ + Check( id=item.get("id", ""), - severity=Severity(item.get("severity", "medium")), type=item.get("type", "deterministic"), - name=item.get("name", ""), check=item.get("check"), args=item.get("args"), - negate=item.get("negate", False), + query=item.get("query"), + expect=item.get("expect", "present"), metadata_keys=item.get("metadata_keys", []), ) - checks.append(check) + for item in frontmatter.get("checks", []) + ] + + +def _parse_severity(frontmatter: dict[str, Any]) -> Severity: + """Parse rule-level severity from frontmatter or legacy check-level severity.""" + raw = frontmatter.get("severity") + if not raw: + raw_checks = frontmatter.get("checks", []) + if raw_checks: + raw = raw_checks[0].get("severity") + return Severity(raw) if raw else Severity.MEDIUM + + +def _parse_match(frontmatter: dict[str, Any]) -> FileMatch | None: + """Parse property-based file targeting from frontmatter.""" + raw = frontmatter.get("match") + if isinstance(raw, dict): + return FileMatch( + type=raw.get("type"), + scope=raw.get("scope"), + format=raw.get("format"), + content_format=raw.get("content_format"), + cardinality=raw.get("cardinality"), + lifecycle=raw.get("lifecycle"), + maintainer=raw.get("maintainer"), + vcs=raw.get("vcs"), + loading=raw.get("loading"), + precedence=raw.get("precedence"), + ) + if raw is not None: + # Empty match (match: {}) parsed as None by YAML — treat as match-all + return FileMatch() + return None - # Parse backed_by — plain string list (source IDs) - backed_by: list[str] = [entry for entry in frontmatter.get("backed_by", []) if isinstance(entry, str)] - # Parse pattern_confidence +def build_rule(frontmatter: dict[str, Any], md_path: Path, yml_path: Path | None) -> Rule: + """Build Rule from parsed frontmatter. Raises KeyError/ValueError on bad input.""" raw_confidence = frontmatter.get("pattern_confidence") - pattern_confidence = PatternConfidence(raw_confidence) if raw_confidence else None + raw_execution = frontmatter.get("execution", "local") return Rule( id=frontmatter["id"], title=frontmatter["title"], category=Category(frontmatter["category"]), type=RuleType(frontmatter["type"]), - level=frontmatter.get("level", "L2"), + severity=_parse_severity(frontmatter), slug=frontmatter.get("slug", ""), - targets=frontmatter.get("targets", ""), + execution=Execution(raw_execution), + match=_parse_match(frontmatter), supersedes=frontmatter.get("supersedes"), - checks=checks, + checks=_load_checks(frontmatter), sources=frontmatter.get("sources", []), see_also=frontmatter.get("see_also", []), - backed_by=backed_by, - pattern_confidence=pattern_confidence, - question=frontmatter.get("question"), - criteria=frontmatter.get("criteria"), - choices=frontmatter.get("choices"), - pass_value=frontmatter.get("pass_value"), - examples=frontmatter.get("examples"), + backed_by=[e for e in frontmatter.get("backed_by", []) if isinstance(e, str)], + concern=frontmatter.get("concern"), + pattern_confidence=PatternConfidence(raw_confidence) if raw_confidence else None, md_path=md_path, yml_path=yml_path, ) @@ -133,6 +168,6 @@ def get_rules_by_category(rules: dict[str, Rule], category: Category) -> dict[st return {k: v for k, v in rules.items() if v.category == category} -def get_rule_yml_paths(rules: dict[str, Rule]) -> list[Path]: - """Get .yml paths for rules that have them.""" +def get_checks_paths(rules: dict[str, Rule]) -> list[Path]: + """Get checks.yml paths for rules that have them.""" return [r.yml_path for r in rules.values() if r.yml_path is not None] diff --git a/src/reporails_cli/core/rule_runner.py b/src/reporails_cli/core/rule_runner.py new file mode 100644 index 00000000..9b6ed69c --- /dev/null +++ b/src/reporails_cli/core/rule_runner.py @@ -0,0 +1,135 @@ +"""Rule runner — iterate YAML rule definitions and dispatch checks. + +Replaces the old engine.py pipeline with a simplified runner that +dispatches mechanical and deterministic checks, producing LocalFinding +instances directly. + +Content-quality checks (type=content_query) run separately via +run_content_quality_checks() against the RulesetMap. +""" + +from __future__ import annotations + +import contextlib +import logging +from pathlib import Path + +from reporails_cli.core.models import LocalFinding, Rule, RuleType + +logger = logging.getLogger(__name__) + +_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} + + +def run_m_probes( + project_dir: Path, + instruction_files: list[Path], + agent: str = "", +) -> list[LocalFinding]: + """Run M-probe checks (mechanical + deterministic) against instruction files.""" + from reporails_cli.core.classification import classify_files, load_file_types + from reporails_cli.core.mechanical.runner import run_mechanical_checks + from reporails_cli.core.regex import get_checks_paths, run_checks + from reporails_cli.core.registry import load_rules + + rules = load_rules(project_root=project_dir, scan_root=project_dir, agent=agent) + + # Classify files for mechanical check targeting + file_types = load_file_types(agent or "generic") + classified = classify_files(project_dir, instruction_files, file_types) + + findings: list[LocalFinding] = [] + + # Run mechanical checks -> Violation objects -> convert to LocalFinding + # Skip server-execution rules — they have no local checks + from reporails_cli.core.models import Execution + + mechanical_rules = { + k: v for k, v in rules.items() if v.type == RuleType.MECHANICAL and v.execution == Execution.LOCAL + } + mech_violations = run_mechanical_checks(mechanical_rules, project_dir, classified) + for v in mech_violations: + file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location + line = 0 + if ":" in v.location: + with contextlib.suppress(ValueError): + line = int(v.location.rsplit(":", 1)[1]) + findings.append( + LocalFinding( + file=file_path, + line=line, + severity=v.severity.value, + rule=v.rule_id, + message=v.message, + source="m_probe", + check_id=v.check_id or "", + ) + ) + + # Run deterministic checks — group by match.type so each rule only + # targets files of the correct type (e.g., scoped_rule rules skip CLAUDE.md) + det_rules = {rid: r for rid, r in rules.items() if r.type == RuleType.DETERMINISTIC} + + # Build file lists per match type from classified files + files_by_type: dict[str, list[Path]] = {} + for cf in classified: + files_by_type.setdefault(cf.file_type, []).append(cf.path) + + # Group rules by their match.type (None = wildcard, targets all files) + rules_by_target: dict[str | None, dict[str, Rule]] = {} + for rid, r in det_rules.items(): + match_type = None + if r.match and r.match.type: + match_type = r.match.type if isinstance(r.match.type, str) else None + rules_by_target.setdefault(match_type, {})[rid] = r + + for match_type, group_rules in rules_by_target.items(): + yml_paths = get_checks_paths(group_rules) + if not yml_paths: + continue + + target_files = instruction_files if match_type is None else files_by_type.get(match_type, []) + + if not target_files: + continue + + det_findings = run_checks( + yml_paths, + project_dir, + instruction_files=target_files, + ) + findings.extend(det_findings) + + # Sort by severity then line number + findings.sort(key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.line)) + return findings + + +def run_content_quality_checks( + ruleset_map: object, + project_dir: Path, + instruction_files: list[Path] | None = None, + agent: str = "", +) -> list[LocalFinding]: + """Run content-quality checks (type=content_query) against RulesetMap atoms. + + Atom queries are dispatched against files matching each rule's `match` + field, using classified file properties from the agent config. + """ + from reporails_cli.core.classification import classify_files, load_file_types + from reporails_cli.core.content_checker import run_content_checks + from reporails_cli.core.mapper.mapper import RulesetMap as _RulesetMap + from reporails_cli.core.registry import load_rules + + if not isinstance(ruleset_map, _RulesetMap): + return [] + + rules = load_rules(project_root=project_dir, scan_root=project_dir, agent=agent) + + # Classify files so content_checker can respect rule.match targeting + classified = [] + if instruction_files: + file_types = load_file_types(agent or "generic") + classified = classify_files(project_dir, instruction_files, file_types) + + return run_content_checks(ruleset_map, rules, classified) diff --git a/src/reporails_cli/core/sarif.py b/src/reporails_cli/core/sarif.py deleted file mode 100644 index 87c22281..00000000 --- a/src/reporails_cli/core/sarif.py +++ /dev/null @@ -1,258 +0,0 @@ -"""SARIF parsing - converts regex engine output to domain objects. - -All functions are pure (no I/O). -""" - -from __future__ import annotations - -import re -from typing import Any - -from reporails_cli.core.models import Rule, Severity, Violation - -# Matches the start of a rule coordinate: NAMESPACE.CATEGORY.SLOT -# Namespace: uppercase letters + optional underscore (CORE, CLAUDE, RRAILS_CLAUDE, etc.) -# Category: single uppercase letter (S, C, E, M, G) -# Slot: exactly 4 digits -_COORDINATE_RE = re.compile(r"^[A-Z][A-Z_]*\.[A-Z]\.\d{4}") - - -def _find_coordinate_start(parts: list[str]) -> int: - """Find the index where the rule coordinate starts in dot-split parts. - - Rule IDs may be prefixed with path components when template-resolved - yml files are used (e.g., tmp.tmpXXXXXX.CORE.S.0001...). This function - locates the actual coordinate start by matching the NAMESPACE.CATEGORY.SLOT - pattern. - - Args: - parts: Dot-split segments of the SARIF ruleId - - Returns: - Index of the first coordinate segment, or 0 if no prefix detected - """ - for i in range(len(parts) - 2): - candidate = ".".join(parts[i : i + 3]) - if _COORDINATE_RE.match(candidate): - return i - return 0 - - -def extract_rule_id(sarif_rule_id: str) -> str: - """Extract rule coordinate from SARIF ruleId. - - SARIF rule IDs use dots (colons are invalid in YAML rule IDs). - Dot-format: REGISTRY.CATEGORY.SLOT.check.NNNN -> REGISTRY:CATEGORY:SLOT - - Rule IDs may be prefixed with path components when template resolution - is involved. This function strips the prefix by locating the coordinate - pattern. - - Example: CORE.S.0001.check.0001 -> CORE:S:0001 - Example: tmp.tmpXXX.CORE.S.0001.check.0001 -> CORE:S:0001 - Example: RRAILS_CLAUDE.S.0002.check.0001 -> RRAILS_CLAUDE:S:0002 - - Args: - sarif_rule_id: Full ruleId from SARIF output (dot-separated) - - Returns: - Rule coordinate in colon format (e.g., CORE:S:0001) - """ - parts = sarif_rule_id.split(".") - start = _find_coordinate_start(parts) - if start + 3 <= len(parts): - return ":".join(parts[start : start + 3]) - return sarif_rule_id - - -def extract_check_id(sarif_rule_id: str) -> str | None: - """Extract check ID suffix from SARIF ruleId. - - SARIF rule IDs use dots. Returns colon-format for internal use. - Handles path prefix in the same way as extract_rule_id. - - Example: CORE.S.0001.check.0001 -> check:0001 - Example: tmp.tmpXXX.CORE.S.0001.check.0001 -> check:0001 - - Args: - sarif_rule_id: Full ruleId from SARIF output (dot-separated) - - Returns: - Check suffix in colon format (e.g., "check:0001") or None - """ - parts = sarif_rule_id.split(".") - start = _find_coordinate_start(parts) - suffix_start = start + 3 - if suffix_start < len(parts): - return ":".join(parts[suffix_start:]) - return None - - -def get_location(result: dict[str, Any]) -> str: - """Extract location string from SARIF result. - - Args: - result: SARIF result object - - Returns: - Location string in format "file:line" - """ - locations = result.get("locations", []) - if not locations: - return "unknown" - - loc = locations[0].get("physicalLocation", {}) - artifact = loc.get("artifactLocation", {}).get("uri", "unknown") - region = loc.get("region", {}) - line = region.get("startLine", 0) - return f"{artifact}:{line}" - - -def get_severity(rule: Rule | None, check_id: str | None) -> Severity: - """Get severity for a violation. - - Looks up severity from rule's checks list by check ID suffix, falls back to MEDIUM. - - Args: - rule: Rule object (may be None) - check_id: Check ID suffix from SARIF (e.g., "check:0001") - - Returns: - Severity level - """ - if rule is None: - return Severity.MEDIUM - - # Try to find matching check by ID suffix - for check in rule.checks: - if check_id and check.id.endswith(check_id): - return check.severity - - # Fall back to first check's severity - if rule.checks: - return rule.checks[0].severity - - return Severity.MEDIUM - - -def parse_sarif( # pylint: disable=too-many-locals - sarif: dict[str, Any], - rules: dict[str, Rule], -) -> list[Violation]: - """Parse SARIF output into Violation objects.""" - violations = [] - - for run in sarif.get("runs", []): - # Build map of rule levels from tool definitions - rule_levels: dict[str, str] = {} - tool = run.get("tool", {}).get("driver", {}) - for rule_def in tool.get("rules", []): - rule_id = rule_def.get("id", "") - level = rule_def.get("defaultConfiguration", {}).get("level", "warning") - rule_levels[rule_id] = level - - for result in run.get("results", []): - sarif_rule_id = result.get("ruleId", "") - - # Skip INFO/note level findings - rule_level = rule_levels.get(sarif_rule_id, "warning") - if rule_level in ("note", "none"): - continue - - rule_id = extract_rule_id(sarif_rule_id) - check_id = extract_check_id(sarif_rule_id) - message = result.get("message", {}).get("text", "") - location = get_location(result) - - # Get rule metadata — skip results not in the provided rules dict - rule = rules.get(rule_id) - if rule is None: - continue - title = rule.title - severity = get_severity(rule, check_id) - - violations.append( - Violation( - rule_id=rule_id, - rule_title=title, - location=location, - message=message, - severity=severity, - check_id=check_id, - ) - ) - - return violations - - -def dedupe_violations(violations: list[Violation]) -> list[Violation]: - """Deduplicate violations by (file, rule_id, check_id). - - Keeps first occurrence of each unique (file, rule_id, check_id) tuple. - Multi-check rules produce distinct findings per check. - - Args: - violations: List of violations (may have duplicates) - - Returns: - Deduplicated list of violations - """ - seen: set[tuple[str, str, str | None]] = set() - result: list[Violation] = [] - - for v in violations: - file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location - key = (file_path, v.rule_id, v.check_id) - - if key not in seen: - seen.add(key) - result.append(v) - - return result - - -def distribute_sarif_by_rule( - sarif: dict[str, Any], - rules: dict[str, Rule], -) -> dict[str, list[dict[str, Any]]]: - """Group raw SARIF results by extracted rule_id. - - Iterates over all SARIF results, extracts the rule coordinate from each - ruleId, and buckets results by rule_id. Only results whose rule_id appears - in the provided rules dict are included. - - Args: - sarif: Raw SARIF output from regex engine. - rules: Dict of rules to filter by (rule_id -> Rule). - - Returns: - Dict mapping rule_id to list of raw SARIF result dicts. - """ - by_rule: dict[str, list[dict[str, Any]]] = {} - - for run in sarif.get("runs", []): - # Build rule level lookup for INFO filtering - rule_levels: dict[str, str] = {} - tool = run.get("tool", {}).get("driver", {}) - for rule_def in tool.get("rules", []): - rid = rule_def.get("id", "") - level = rule_def.get("defaultConfiguration", {}).get("level", "warning") - rule_levels[rid] = level - - for result in run.get("results", []): - sarif_rule_id = result.get("ruleId", "") - - # Skip INFO/note level findings (same as parse_sarif) - rule_level = rule_levels.get(sarif_rule_id, "warning") - if rule_level in ("note", "none"): - continue - - rule_id = extract_rule_id(sarif_rule_id) - if rule_id not in rules: - continue - - if rule_id not in by_rule: - by_rule[rule_id] = [] - by_rule[rule_id].append(result) - - return by_rule diff --git a/src/reporails_cli/core/scorer.py b/src/reporails_cli/core/scorer.py deleted file mode 100644 index 2fda76ab..00000000 --- a/src/reporails_cli/core/scorer.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Scoring functions for reporails. All pure functions.""" - -from __future__ import annotations - -from reporails_cli.core.models import FrictionEstimate, Severity, Violation -from reporails_cli.core.sarif import dedupe_violations - -# Severity weights for scoring (higher = more impact) -SEVERITY_WEIGHTS: dict[Severity, float] = { - Severity.CRITICAL: 5.5, - Severity.HIGH: 4.0, - Severity.MEDIUM: 2.5, - Severity.LOW: 1.0, -} - -# Default weight for rules (used when calculating total possible points) -DEFAULT_RULE_WEIGHT: float = 2.5 - - -def calculate_score(rules_checked: int, violations: list[Violation]) -> float: - """Calculate display score on 0-10 scale. - - Score = (earned_points / possible_points) x 10 - - Lost points are capped per rule - multiple violations of the same rule - don't deduct more than the rule's weight (DEFAULT_RULE_WEIGHT). - - Args: - rules_checked: Total number of rules checked - violations: List of violations found - - Returns: - Score between 0.0 and 10.0 - """ - if rules_checked == 0: - return 10.0 # No rules = perfect - - # Total possible points - possible = rules_checked * DEFAULT_RULE_WEIGHT - - # Group violations by rule_id, cap lost points per rule - unique_violations = dedupe_violations(violations) - by_rule: dict[str, float] = {} - for v in unique_violations: - weight = SEVERITY_WEIGHTS.get(v.severity, DEFAULT_RULE_WEIGHT) - # Accumulate but cap at rule weight - current = by_rule.get(v.rule_id, 0.0) - by_rule[v.rule_id] = min(current + weight, DEFAULT_RULE_WEIGHT) - - lost = sum(by_rule.values()) - - # Earned = possible - lost (floor at 0) - earned = max(0.0, possible - lost) - - # Score on 0-10 scale - score = (earned / possible) * 10 - return round(score, 1) - - -def get_severity_weight(severity: Severity) -> float: - """Get weight for a severity level. - - Args: - severity: Severity level - - Returns: - Weight value - """ - return SEVERITY_WEIGHTS.get(severity, DEFAULT_RULE_WEIGHT) - - -def estimate_friction(violations: list[Violation]) -> FrictionEstimate: - """Estimate friction from violations. - - Friction levels based on violation severity: - - extreme: Any critical violation - - high: 2+ high severity OR 5+ total violations - - medium: 1 high severity OR 3-4 violations - - small: 1-2 violations (medium/low severity) - - none: No violations - - Args: - violations: List of violations - - Returns: - FrictionEstimate with level - """ - unique = dedupe_violations(violations) - - if not unique: - return FrictionEstimate(level="none") - - # Count by severity - critical_count = sum(1 for v in unique if v.severity == Severity.CRITICAL) - high_count = sum(1 for v in unique if v.severity == Severity.HIGH) - total_count = len(unique) - - # Determine level - if critical_count > 0: - level = "extreme" - elif high_count >= 2 or total_count >= 5: - level = "high" - elif high_count >= 1 or total_count >= 3: - level = "medium" - else: - level = "small" - - return FrictionEstimate(level=level) - - -def has_critical_violations(violations: list[Violation]) -> bool: - """Check if any violation is critical. - - Args: - violations: List of violations - - Returns: - True if any violation has CRITICAL severity - """ - return any(v.severity == Severity.CRITICAL for v in violations) diff --git a/src/reporails_cli/core/semantic.py b/src/reporails_cli/core/semantic.py deleted file mode 100644 index fe7de230..00000000 --- a/src/reporails_cli/core/semantic.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Semantic rule request building - creates JudgmentRequests for LLM evaluation. - -Semantic rules require regex pattern matches before LLM evaluation. -No match = rule passes (nothing to evaluate). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from reporails_cli.core.models import JudgmentRequest, Rule, RuleType, Severity -from reporails_cli.core.sarif import extract_rule_id, get_location - - -def build_request_from_sarif_result( - rule: Rule, - sarif_result: dict[str, Any], - target: Path, -) -> JudgmentRequest | None: - """Build a JudgmentRequest from a single SARIF result for a semantic rule. - - Args: - rule: Semantic rule definition. - sarif_result: Single SARIF result dict. - target: Project root for snippet extraction. - - Returns: - JudgmentRequest, or None if content missing or rule lacks required fields. - """ - location = get_location(sarif_result) - # Prefer full file content for semantic evaluation (capped at 8KB) - content = extract_full_content(sarif_result, target) - if not content: - # Fall back to snippet if full content unavailable - content = extract_snippet(sarif_result, target) - if not content: - return None - return build_request(rule, content, location) - - -def build_semantic_requests( - sarif: dict[str, Any], - rules: dict[str, Rule], - target: Path, -) -> list[JudgmentRequest]: - requests: list[JudgmentRequest] = [] - semantic_rules = {k: v for k, v in rules.items() if v.type == RuleType.SEMANTIC} - - if not semantic_rules: - return requests - - for run in sarif.get("runs", []): - for result in run.get("results", []): - sarif_rule_id = result.get("ruleId", "") - rule_id = extract_rule_id(sarif_rule_id) - - rule = semantic_rules.get(rule_id) - if not rule: - continue - - request = build_request_from_sarif_result(rule, result, target) - if request: - requests.append(request) - - return requests - - -def extract_full_content(result: dict[str, Any], target: Path, max_chars: int = 8000) -> str | None: - """Extract full file content for semantic evaluation. - - Reads the entire file referenced by the SARIF result location, capped at - max_chars to keep context manageable for LLM evaluation. - - Args: - result: SARIF result dict with location info. - target: Project root directory. - max_chars: Maximum characters to return (default 8KB). - - Returns: - File content string, or None if file cannot be read. - """ - location = get_location(result) - if ":" not in location: - return None - - file_path = location.rsplit(":", 1)[0] - full_path = target / file_path - try: - content = full_path.read_text(encoding="utf-8") - return content[:max_chars] - except (OSError, UnicodeDecodeError): - return None - - -def extract_snippet(result: dict[str, Any], target: Path) -> str | None: - """Extract matched content snippet from SARIF result. - - SARIF provides the matched region. Use that instead of reading whole file. - Falls back to context lines around match if snippet not in SARIF. - """ - # Try to get snippet from SARIF - locations = result.get("locations", []) - if locations: - physical = locations[0].get("physicalLocation", {}) - region = physical.get("region", {}) - - # SARIF may include the snippet directly - snippet: str | None = region.get("snippet", {}).get("text") - if snippet: - return snippet - - # Fallback: read lines around the match - location = get_location(result) - if ":" not in location: - return None - - file_path, line_str = location.rsplit(":", 1) - try: - line_num = int(line_str) - except ValueError: - return None - - full_path = target / file_path - try: - lines = full_path.read_text(encoding="utf-8").splitlines() - except (OSError, UnicodeDecodeError): - return None - - # Get 5 lines of context (2 before, match, 2 after) - start = max(0, line_num - 3) - end = min(len(lines), line_num + 2) - context_lines = lines[start:end] - - return "\n".join(context_lines) - - -def build_request( - rule: Rule, - content: str, - location: str, -) -> JudgmentRequest | None: - """Build a single JudgmentRequest from a rule. - - Args: - rule: Semantic rule definition - content: File content to evaluate - location: File path for location - - Returns: - JudgmentRequest, or None if rule lacks required fields - """ - if not rule.question: - return None - - # Parse criteria - criteria = _parse_criteria(rule.criteria) - - # Parse choices - choices = _parse_choices(rule.choices) - - # Get examples - examples = rule.examples or {"good": [], "bad": []} - if not isinstance(examples, dict): - examples = {"good": [], "bad": []} - - # Get pass value - pass_value = rule.pass_value or "pass" - - # Get severity from first check, or default - severity = Severity.MEDIUM - if rule.checks: - severity = rule.checks[0].severity - - return JudgmentRequest( - rule_id=rule.id, - rule_title=rule.title, - content=content, - location=location, - question=rule.question, - criteria=criteria, - examples=examples, - choices=choices, - pass_value=pass_value, - severity=severity, - points_if_fail=-10, # Default penalty - ) - - -def _parse_criteria(criteria: list[dict[str, str]] | str | None) -> dict[str, str]: - """Parse criteria field to dict format. - - Args: - criteria: Criteria in various formats - - Returns: - Dict mapping criterion key to check description - """ - if criteria is None: - return {"pass_condition": "Evaluate based on context"} - - if isinstance(criteria, str): - return {"pass_condition": criteria} - - if isinstance(criteria, list): - result: dict[str, str] = {} - for item in criteria: - if isinstance(item, dict): - key = item.get("key", f"criterion_{len(result)}") - check = item.get("check", str(item)) - result[key] = check - return result if result else {"pass_condition": "Evaluate based on context"} - - return {"pass_condition": "Evaluate based on context"} - - -def _parse_choices(choices: list[dict[str, str]] | list[str] | None) -> list[str]: - """Parse choices field to list format. - - Args: - choices: Choices in various formats - - Returns: - List of choice values - """ - if choices is None: - return ["pass", "fail"] - - if not choices: - return ["pass", "fail"] - - result: list[str] = [] - for choice in choices: - if isinstance(choice, dict): - value = choice.get("value", str(choice)) - result.append(str(value)) - else: - result.append(str(choice)) - - return result if result else ["pass", "fail"] diff --git a/src/reporails_cli/core/stopwords.py b/src/reporails_cli/core/stopwords.py new file mode 100644 index 00000000..ea637449 --- /dev/null +++ b/src/reporails_cli/core/stopwords.py @@ -0,0 +1,282 @@ +"""Stopwords vocabulary — pattern decomposition and extraction.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +_GUARD_PATTERNS = frozenset( + { + r"\A[\s\S]+", + r"\A[\S\s]+", + r"\A[\s\S]*", + } +) + + +@dataclass +class PatternParts: + """Decomposed regex pattern: flags + prefix + group(terms) + suffix.""" + + flags: str + prefix: str + group_open: str # "(?:" or "(" + terms: list[str] + suffix: str + + +def _strip_flags(pattern: str) -> tuple[str, str]: + """Strip leading inline flags (?i), (?s), etc.""" + flags = "" + rest = pattern + while rest.startswith("(?"): + m = re.match(r"\(\?([ismx]+)\)", rest) + if m: + flags += m.group(0) + rest = rest[m.end() :] + else: + break + return flags, rest + + +def _match_close_paren(text: str, start: int) -> int: + """Find matching close paren, returning index of ')' or -1.""" + depth = 1 + j = start + in_cc = False + while j < len(text): + ch = text[j] + if ch == "\\" and j + 1 < len(text): + j += 2 + continue + if ch == "[" and not in_cc: + in_cc = True + elif ch == "]" and in_cc: + in_cc = False + elif not in_cc: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return j + j += 1 + return -1 + + +def _find_outer_group(text: str) -> tuple[str, str, int, int, str] | None: + """Find the outermost alternation group.""" + i = 0 + while i < len(text): + c = text[i] + if c == "\\" and i + 1 < len(text): + i += 2 + continue + if c == "(": + prefix = text[:i] + if text[i : i + 3] == "(?:": + group_open = "(?:" + content_start = i + 3 + elif text[i : i + 2] in ("(?=", "(?!", "(?<"): + i += 1 + continue + else: + group_open = "(" + content_start = i + 1 + + close = _match_close_paren(text, content_start) + if close == -1: + return None + return prefix, group_open, content_start, close, text[close + 1 :] + i += 1 + return None + + +def _split_alternation(content: str) -> list[str]: + """Split on | at depth 0, respecting groups and char classes.""" + terms: list[str] = [] + buf: list[str] = [] + depth = 0 + in_cc = False + i = 0 + while i < len(content): + c = content[i] + if c == "\\" and i + 1 < len(content): + buf.append(c) + buf.append(content[i + 1]) + i += 2 + continue + if c == "[" and not in_cc: + in_cc = True + elif c == "]" and in_cc: + in_cc = False + elif not in_cc: + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + elif c == "|" and depth == 0: + terms.append("".join(buf)) + buf = [] + i += 1 + continue + buf.append(c) + i += 1 + if buf: + terms.append("".join(buf)) + return terms + + +def decompose(pattern: str) -> PatternParts | None: + """Decompose a regex into wrapper + alternation terms.""" + flags, rest = _strip_flags(pattern) + result = _find_outer_group(rest) + if result is None: + return None + prefix, group_open, cs, ce, suffix = result + content = rest[cs:ce] + terms = _split_alternation(content) + if len(terms) < 2: + return None + return PatternParts( + flags=flags, + prefix=prefix, + group_open=group_open, + terms=terms, + suffix=suffix, + ) + + +def recompose(parts: PatternParts, terms: list[str] | None = None) -> str: + """Rebuild pattern from decomposed parts, optionally with new terms.""" + t = terms if terms is not None else parts.terms + alt = "|".join(t) + return f"{parts.flags}{parts.prefix}{parts.group_open}{alt}){parts.suffix}" + + +def is_guard(pattern: str) -> bool: + """True if pattern is a guard/catch-all (e.g., (?s)\\A[\\s\\S]+).""" + _, rest = _strip_flags(pattern) + rest = rest.strip() + return any(rest == g or rest.startswith(g) for g in _GUARD_PATTERNS) + + +@dataclass +class _PatternInfo: + """Info about one extractable pattern within a check.""" + + target: str # "pattern-regex", "pattern-not-regex", "args.pattern" + terms: list[str] + + +def _try_decompose(pattern: str, target: str) -> _PatternInfo | None: + """Try to decompose a pattern; return PatternInfo or None.""" + if not pattern or is_guard(pattern): + return None + d = decompose(pattern) + return _PatternInfo(target, d.terms) if d else None + + +def _analyze_patterns_array(patterns: list[dict[str, Any]]) -> list[_PatternInfo]: + """Extract alternation info from a patterns: array.""" + infos: list[_PatternInfo] = [] + for entry in patterns: + for fld in ("pattern-regex", "pattern-not-regex"): + if fld in entry: + info = _try_decompose(entry[fld], fld) + if info: + infos.append(info) + return infos + + +def _analyze_check(check: dict[str, Any]) -> list[_PatternInfo]: + """Find extractable alternation patterns in a check definition.""" + ctype = check.get("type", "") + + if ctype == "mechanical" and check.get("check") == "content_absent": + pat = (check.get("args") or {}).get("pattern", "") + info = _try_decompose(pat, "args.pattern") + return [info] if info else [] + + if ctype != "deterministic": + return [] + + if "pattern-regex" in check and "patterns" not in check: + info = _try_decompose(check["pattern-regex"], "pattern-regex") + return [info] if info else [] + + if "patterns" in check: + return _analyze_patterns_array(check["patterns"]) + + return [] + + +def extract_vocab(rule_dir: Path) -> dict[str, Any] | None: + """Extract vocab.yml content from a rule's checks.yml.""" + checks_path = rule_dir / "checks.yml" + if not checks_path.exists(): + return None + try: + data = yaml.safe_load(checks_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError): + return None + + checks = (data.get("checks") or []) if isinstance(data, dict) else [] + vocab: dict[str, Any] = {} + + for check in checks: + cid = check.get("id", "") + suffix = cid.rsplit(".", 1)[-1] if "." in cid else cid + if not suffix: + continue + + infos = _analyze_check(check) + if not infos: + continue + + if len(infos) == 1: + vocab[suffix] = infos[0].terms + else: + vocab[suffix] = {info.target: info.terms for info in infos} + + return vocab if vocab else None + + +@dataclass +class ExtractResult: + """Result of extracting vocab from one rule directory.""" + + rule_dir: Path + vocab: dict[str, Any] | None + message: str + + +def extract_all(rules_root: Path) -> list[ExtractResult]: + """Extract vocab from all rules under rules_root.""" + results: list[ExtractResult] = [] + + for checks_path in sorted(rules_root.rglob("checks.yml")): + if "tests" in checks_path.parts: + continue + rule_dir = checks_path.parent + vocab = extract_vocab(rule_dir) + if vocab: + results.append(ExtractResult(rule_dir, vocab, f"{len(vocab)} check(s)")) + else: + results.append(ExtractResult(rule_dir, None, "no extractable vocab")) + + return results + + +def write_vocab(rule_dir: Path, vocab: dict[str, Any]) -> Path: + """Write vocab.yml to a rule directory.""" + vocab_path = rule_dir / "vocab.yml" + vocab_path.write_text( + yaml.dump(vocab, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + return vocab_path diff --git a/src/reporails_cli/core/stopwords_sync.py b/src/reporails_cli/core/stopwords_sync.py new file mode 100644 index 00000000..de5a30bb --- /dev/null +++ b/src/reporails_cli/core/stopwords_sync.py @@ -0,0 +1,293 @@ +"""Stopwords sync — compile vocab.yml terms into checks.yml patterns.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from reporails_cli.core.stopwords import decompose, is_guard, recompose + + +def _find_check_by_suffix( + checks: list[dict[str, Any]], + suffix: str, +) -> dict[str, Any] | None: + """Find a check whose ID ends with the given suffix.""" + for check in checks: + cid = check.get("id", "") + check_suffix = cid.rsplit(".", 1)[-1] if "." in cid else cid + if check_suffix == suffix: + return check + return None + + +def _update_pattern(existing: str, new_terms: list[str]) -> str | None: + """Replace alternation terms in an existing pattern, preserving wrapper.""" + parts = decompose(existing) + if parts is None: + return None + return recompose(parts, new_terms) + + +def _try_update_field(container: dict[str, Any], key: str, terms: list[str]) -> bool: + """Try to update a pattern field in a dict. Returns True if modified.""" + existing = container.get(key, "") + if not existing: + return False + updated = _update_pattern(existing, terms) + if updated and updated != existing: + container[key] = updated + return True + return False + + +def _sync_patterns_array(patterns: list[dict[str, Any]], terms: list[str]) -> bool: + """Sync terms into the first non-guard decomposable entry.""" + for entry in patterns: + for fld in ("pattern-regex", "pattern-not-regex"): + if fld not in entry or is_guard(entry[fld]): + continue + if decompose(entry[fld]): + return _try_update_field(entry, fld, terms) + return False + + +def _sync_flat(check: dict[str, Any], terms: list[str]) -> bool: + """Auto-detect target field and sync terms. Returns True if modified.""" + ctype = check.get("type", "") + + if ctype == "mechanical" and check.get("check") == "content_absent": + args = check.get("args") or {} + if _try_update_field(args, "pattern", terms): + check["args"] = args + return True + return False + + if ctype != "deterministic": + return False + + if "pattern-regex" in check and "patterns" not in check: + return _try_update_field(check, "pattern-regex", terms) + + if "patterns" in check: + return _sync_patterns_array(check["patterns"], terms) + + return False + + +def _sync_targeted(check: dict[str, Any], terms: list[str], target: str) -> bool: + """Sync terms into a specific pattern field. Returns True if modified.""" + ctype = check.get("type", "") + + if target == "args.pattern" and ctype == "mechanical": + args = check.get("args") or {} + if _try_update_field(args, "pattern", terms): + check["args"] = args + return True + return False + + if target == "pattern-regex" and "pattern-regex" in check and "patterns" not in check: + return _try_update_field(check, "pattern-regex", terms) + + if "patterns" in check: + for entry in check["patterns"]: + if target in entry and not is_guard(entry[target]): + return _try_update_field(entry, target, terms) + + return False + + +def _dispatch_sync(check: dict[str, Any], value: Any) -> bool: + """Dispatch sync for a single vocab entry. Returns True if modified.""" + if isinstance(value, list): + return _sync_flat(check, value) + if isinstance(value, dict): + changed = False + for field_name, field_terms in value.items(): + if isinstance(field_terms, list) and _sync_targeted(check, field_terms, field_name): + changed = True + return changed + return False + + +def _load_yaml(path: Path) -> tuple[Any, str | None]: + """Load a YAML file. Returns (data, error_message).""" + if not path.exists(): + return None, f"no {path.name}" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return data, None + except (yaml.YAMLError, OSError) as e: + return None, f"failed to read {path.name}: {e}" + + +@dataclass +class SyncResult: + """Result of syncing vocab into checks.yml for one rule.""" + + rule_dir: Path + updated: int = 0 + skipped: int = 0 + messages: list[str] = field(default_factory=list) + + +def sync_vocab(rule_dir: Path, *, dry_run: bool = False) -> SyncResult: + """Sync vocab.yml terms into checks.yml patterns for a single rule.""" + result = SyncResult(rule_dir=rule_dir) + checks_path = rule_dir / "checks.yml" + + vocab, err = _load_yaml(rule_dir / "vocab.yml") + if err: + result.messages.append(err) + return result + if not isinstance(vocab, dict): + result.messages.append("vocab.yml is not a mapping") + return result + + checks_data, err = _load_yaml(checks_path) + if err: + result.messages.append(err) + return result + + checks = (checks_data.get("checks") or []) if isinstance(checks_data, dict) else [] + if not checks: + result.messages.append("checks.yml has no checks") + return result + + modified = False + for suffix, value in vocab.items(): + check = _find_check_by_suffix(checks, suffix) + if check is None: + result.skipped += 1 + result.messages.append(f"no check matching suffix '{suffix}'") + continue + + if _dispatch_sync(check, value): + result.updated += 1 + modified = True + else: + result.skipped += 1 + + if modified and not dry_run: + checks_path.write_text( + yaml.dump(checks_data, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + return result + + +def sync_all(rules_root: Path, *, dry_run: bool = False) -> list[SyncResult]: + """Sync all vocab.yml files under rules_root.""" + results: list[SyncResult] = [] + + for vocab_path in sorted(rules_root.rglob("vocab.yml")): + if "tests" in vocab_path.parts: + continue + rule_dir = vocab_path.parent + results.append(sync_vocab(rule_dir, dry_run=dry_run)) + + return results + + +def _decompose_terms(pattern: str) -> list[str] | None: + """Decompose a pattern and return its terms, or None.""" + d = decompose(pattern) + return d.terms if d else None + + +def _auto_detect_terms(check: dict[str, Any]) -> list[str] | None: + """Auto-detect and extract terms from the first decomposable pattern.""" + ctype = check.get("type", "") + if ctype == "mechanical": + pat = (check.get("args") or {}).get("pattern", "") + return _decompose_terms(pat) if pat else None + + if "pattern-regex" in check and "patterns" not in check: + return _decompose_terms(check["pattern-regex"]) + + if "patterns" in check: + for entry in check["patterns"]: + for fld in ("pattern-regex", "pattern-not-regex"): + if fld in entry and not is_guard(entry[fld]): + terms = _decompose_terms(entry[fld]) + if terms: + return terms + return None + + +def _extract_current_terms(check: dict[str, Any], target: str) -> list[str] | None: + """Extract current alternation terms from a check's pattern field.""" + if target == "auto": + return _auto_detect_terms(check) + + if target == "args.pattern": + pat = (check.get("args") or {}).get("pattern", "") + return _decompose_terms(pat) if pat else None + + if target == "pattern-regex" and "pattern-regex" in check and "patterns" not in check: + return _decompose_terms(check["pattern-regex"]) + + if "patterns" in check: + for entry in check["patterns"]: + if target in entry and not is_guard(entry[target]): + return _decompose_terms(entry[target]) + + return None + + +@dataclass +class StalenessResult: + """Result of checking vocab.yml vs checks.yml staleness.""" + + rule_dir: Path + stale: bool + stale_checks: list[str] = field(default_factory=list) + + +def check_staleness(rule_dir: Path) -> StalenessResult | None: + """Check if checks.yml patterns are stale. Returns None if no vocab.yml.""" + vocab_path = rule_dir / "vocab.yml" + if not vocab_path.exists(): + return None + + checks_path = rule_dir / "checks.yml" + if not checks_path.exists(): + return StalenessResult(rule_dir=rule_dir, stale=True, stale_checks=["checks.yml missing"]) + + try: + vocab = yaml.safe_load(vocab_path.read_text(encoding="utf-8")) + checks_data = yaml.safe_load(checks_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError): + return StalenessResult(rule_dir=rule_dir, stale=True, stale_checks=["parse error"]) + + if not isinstance(vocab, dict) or not isinstance(checks_data, dict): + return StalenessResult(rule_dir=rule_dir, stale=True, stale_checks=["invalid format"]) + + checks = checks_data.get("checks") or [] + stale_list: list[str] = [] + + for suffix, value in vocab.items(): + check = _find_check_by_suffix(checks, suffix) + if check is None: + stale_list.append(suffix) + continue + + if isinstance(value, list): + pairs = [("auto", value)] + elif isinstance(value, dict): + pairs = [(k, v) for k, v in value.items() if isinstance(v, list)] + else: + stale_list.append(suffix) + continue + + for target, expected in pairs: + actual = _extract_current_terms(check, target) + if actual is None or sorted(actual) != sorted(expected): + stale_list.append(suffix) + break + + return StalenessResult(rule_dir=rule_dir, stale=bool(stale_list), stale_checks=stale_list) diff --git a/src/reporails_cli/core/templates.py b/src/reporails_cli/core/templates.py deleted file mode 100644 index 7f090eb5..00000000 --- a/src/reporails_cli/core/templates.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Template resolution for rule YAML files. - -Handles {{placeholder}} substitution in .yml rule configs. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -# Template placeholder pattern -TEMPLATE_PATTERN = re.compile(r"\{\{(\w+)\}\}") - - -def _glob_to_regex(glob_pattern: str, for_yaml: bool = True) -> str: - """Convert a glob pattern to a regex pattern. - - Handles common glob syntax: - - ** -> .* (match any path) - - * -> [^/]* (match any chars except /) - - . -> \\. (escape literal dot) - - Other special regex chars are escaped - - Args: - glob_pattern: Glob pattern like "**/CLAUDE.md" - for_yaml: If True, double-escape backslashes for YAML double-quoted strings - - Returns: - Regex pattern like ".*CLAUDE\\.md" - """ - # Remove leading **/ (matches any directory prefix) - pattern = glob_pattern - if pattern.startswith("**/"): - pattern = pattern[3:] - - # Escape regex special chars except * and ? - result = "" - i = 0 - while i < len(pattern): - c = pattern[i] - if c == "*": - if i + 1 < len(pattern) and pattern[i + 1] == "*": - # ** matches anything including / - result += ".*" - i += 2 - # Skip trailing / after ** - if i < len(pattern) and pattern[i] == "/": - i += 1 - else: - # * matches anything except / - result += "[^/]*" - i += 1 - elif c == "?": - result += "." - i += 1 - elif c in ".^$+{}[]|()": - # Escape for regex, double-escape for YAML if needed - escape = "\\\\" if for_yaml else "\\" - result += escape + c - i += 1 - else: - result += c - i += 1 - - return result - - -def has_templates(yml_path: Path) -> bool: - """Check if yml file contains template placeholders. - - Args: - yml_path: Path to yml file - - Returns: - True if templates found - """ - try: - content = yml_path.read_text(encoding="utf-8") - return bool(TEMPLATE_PATTERN.search(content)) - except OSError: - return False - - -def resolve_templates_str(content: str, context: dict[str, str | list[str]]) -> str: - """Resolve template placeholders in yml content string. - - Same as resolve_templates but takes content directly instead of reading - from a file path. Avoids double-read when caller already has the content. - - Args: - content: Raw yml content with {{placeholder}} tokens - context: Dict mapping placeholder names to string or list values - - Returns: - Resolved yml content - """ - for key, value in context.items(): - placeholder = "{{" + key + "}}" - if placeholder not in content: - continue - - if isinstance(value, list): - lines = content.split("\n") - new_lines: list[str] = [] - for line in lines: - if placeholder in line: - stripped = line.lstrip() - indent = len(line) - len(stripped) - indent_str = " " * indent - if stripped.startswith("- "): - new_lines.extend(f'{indent_str}- "{item}"' for item in value) - elif "pattern-regex:" in line or "pattern-not-regex:" in line: - regex_patterns = [_glob_to_regex(g) for g in value] - combined = "(" + "|".join(regex_patterns) + ")" - new_lines.append(line.replace(placeholder, combined)) - else: - first_item = value[0] if value else "" - new_lines.append(line.replace(placeholder, first_item)) - else: - new_lines.append(line) - content = "\n".join(new_lines) - else: - content = content.replace(placeholder, str(value)) - - return content - - -def resolve_templates(yml_path: Path, context: dict[str, str | list[str]]) -> str: - """Resolve template placeholders in yml content. - - Replaces {{placeholder}} with values from context. - Context-aware resolution: - - In array context (paths.include), expands list to multiple items - - In pattern-regex context, converts globs to regex and joins with | - - For string values, does simple substitution - - Args: - yml_path: Path to yml file - context: Dict mapping placeholder names to string or list values - - Returns: - Resolved yml content - """ - content = yml_path.read_text(encoding="utf-8") - return resolve_templates_str(content, context) diff --git a/src/reporails_cli/core/updater.py b/src/reporails_cli/core/updater.py index f492e6d0..88239a93 100644 --- a/src/reporails_cli/core/updater.py +++ b/src/reporails_cli/core/updater.py @@ -28,9 +28,9 @@ # Schema versions this CLI can consume (match on major.minor, ignore patch). # Only schemas the CLI reads directly -- others are ignored. REQUIRED_SCHEMAS: dict[str, str] = { - "rule": "0.1", + "rule": "0.2", "levels": "0.1", - "agent": "0.2", + "agent": "0.3", } diff --git a/src/reporails_cli/core/utils.py b/src/reporails_cli/core/utils.py index 8467259c..1b29354a 100644 --- a/src/reporails_cli/core/utils.py +++ b/src/reporails_cli/core/utils.py @@ -24,6 +24,27 @@ def fast_yaml_load(content: str) -> Any: return yaml.load(content, Loader=_YamlLoader) +# File-level YAML cache — avoids re-parsing the same file from multiple call sites +# (e.g., registry loads checks.yml for Rule construction, compiler re-reads for regex). +_yaml_file_cache: dict[str, Any] = {} + + +def load_yaml_file(path: Path) -> Any: + """Load and cache a YAML file using the fastest available loader.""" + key = str(path) + cached = _yaml_file_cache.get(key) + if cached is not None: + return cached + data = fast_yaml_load(path.read_text(encoding="utf-8")) + _yaml_file_cache[key] = data + return data + + +def clear_yaml_cache() -> None: + """Clear the YAML file cache. Called alongside rule cache clearing.""" + _yaml_file_cache.clear() + + def parse_frontmatter(content: str) -> dict[str, Any]: """Parse YAML frontmatter from markdown content. diff --git a/src/reporails_cli/formatters/github.py b/src/reporails_cli/formatters/github.py index d91ac54a..f9ed1924 100644 --- a/src/reporails_cli/formatters/github.py +++ b/src/reporails_cli/formatters/github.py @@ -96,3 +96,24 @@ def format_result( parts.append(json.dumps(data)) return "\n".join(parts) + + +def format_combined_annotations(result: Any) -> str: + """Emit GitHub workflow commands from CombinedResult findings.""" + from reporails_cli.core.merger import CombinedResult + + if not isinstance(result, CombinedResult): + return "" + + lines: list[str] = [] + for f in result.findings: + command = "error" if f.severity == "error" else "warning" + title = _escape_workflow_property(f"[{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}") + + # JSON summary + data = json_formatter.format_combined_result(result) + lines.append(json.dumps(data)) + return "\n".join(lines) diff --git a/src/reporails_cli/formatters/json.py b/src/reporails_cli/formatters/json.py index d42cff88..5d91239d 100644 --- a/src/reporails_cli/formatters/json.py +++ b/src/reporails_cli/formatters/json.py @@ -12,8 +12,8 @@ from reporails_cli.core.models import ( CategoryStats, PendingSemantic, + RuleResult, ScanDelta, - SkippedExperimental, ValidationResult, ) @@ -29,6 +29,11 @@ def _format_pending_semantic(pending: PendingSemantic | None) -> dict[str, Any] } +def _format_rule_results(results: tuple[RuleResult, ...]) -> list[dict[str, str]]: + """Format per-rule pass/fail for JSON output.""" + return [{"rule_id": r.rule_id, "status": r.status} for r in results] + + def _format_category_summary(summary: tuple[CategoryStats, ...]) -> list[dict[str, Any]]: """Format category summary for JSON output.""" return [ @@ -44,16 +49,6 @@ def _format_category_summary(summary: tuple[CategoryStats, ...]) -> list[dict[st ] -def _format_skipped_experimental(skipped: SkippedExperimental | None) -> dict[str, Any] | None: - """Format skipped experimental rules for JSON output.""" - if skipped is None: - return None - return { - "rule_count": skipped.rule_count, - "rules": list(skipped.rules), - } - - def format_result( result: ValidationResult, delta: ScanDelta | None = None, @@ -73,7 +68,6 @@ def format_result( "score": result.score, "level": result.level.value, "capability": LEVEL_LABELS.get(result.level, "Unknown"), - "has_orphan_features": result.has_orphan_features, "feature_summary": result.feature_summary, "summary": { "rules_checked": result.rules_checked, @@ -107,6 +101,7 @@ def format_result( ], "friction": result.friction.level if result.friction else "none", "category_summary": _format_category_summary(result.category_summary), + "rule_results": _format_rule_results(result.rule_results), # Evaluation completeness "evaluation": "awaiting_semantic" if result.is_partial else "complete", "is_partial": result.is_partial, @@ -116,9 +111,6 @@ def format_result( pending = _format_pending_semantic(result.pending_semantic) if pending is not None: data["pending_semantic"] = pending - skipped = _format_skipped_experimental(result.skipped_experimental) - if skipped is not None: - data["skipped_experimental"] = skipped # Add delta fields (null if no previous run or unchanged) if delta is not None: @@ -166,16 +158,19 @@ def format_rule(rule_id: str, rule_data: dict[str, Any]) -> dict[str, Any]: Returns: Dict with rule details """ - return { + result: dict[str, Any] = { "id": rule_id, "title": rule_data.get("title", ""), "category": rule_data.get("category", ""), "type": rule_data.get("type", ""), - "level": rule_data.get("level", ""), - "description": rule_data.get("description", ""), - "checks": rule_data.get("checks", rule_data.get("antipatterns", [])), - "see_also": rule_data.get("see_also", []), } + match = rule_data.get("match") + if match: + result["scope"] = match + result["description"] = rule_data.get("description", "") + result["checks"] = rule_data.get("checks", rule_data.get("antipatterns", [])) + result["see_also"] = rule_data.get("see_also", []) + return result def format_heal_result( @@ -206,3 +201,66 @@ def format_heal_result( result["violations"] = violations result["summary"]["violations_count"] = len(violations) return result + + +def format_combined_result(result: Any) -> dict[str, Any]: + """Format CombinedResult as JSON dict. + + Args: + result: CombinedResult from merger + + Returns: + Dict with findings, stats, compliance + """ + from dataclasses import asdict + + from reporails_cli.core.merger import CombinedResult + + if not isinstance(result, CombinedResult): + return {"error": "Invalid result type"} + + # Group findings by file for agent consumption — agents work file-by-file + by_file: dict[str, list[dict[str, Any]]] = {} + for f in result.findings: + entry: dict[str, Any] = { + "line": f.line, + "severity": f.severity, + "rule": f.rule, + "message": f.message, + } + if f.fix: + entry["fix"] = f.fix + by_file.setdefault(f.file, []).append(entry) + + data: dict[str, Any] = { + "offline": result.offline, + "files": { + fp: {"findings": findings, "count": len(findings)} + for fp, findings in sorted(by_file.items(), key=lambda x: -len(x[1])) + }, + "stats": asdict(result.stats), + } + if result.cross_file: + data["cross_file"] = [ + { + "file_1": cf.file_1, + "file_2": cf.file_2, + "line_1": cf.line_1, + "line_2": cf.line_2, + "type": cf.finding_type, + } + for cf in result.cross_file + ] + if result.hints: + data["hints"] = [ + { + "file": h.file, + "type": h.diagnostic_type, + "count": h.count, + "summary": h.summary, + } + for h in result.hints + ] + if result.quality is not None and result.quality.compliance_band: + data["compliance_band"] = result.quality.compliance_band + return data diff --git a/src/reporails_cli/formatters/mcp.py b/src/reporails_cli/formatters/mcp.py index 17004fd6..b1460e94 100644 --- a/src/reporails_cli/formatters/mcp.py +++ b/src/reporails_cli/formatters/mcp.py @@ -1,6 +1,8 @@ -"""MCP output formatter. +"""MCP output formatter — compact format optimized for LLM context windows. -Wraps canonical JSON format with MCP-specific transformations if needed. +Builds responses directly from domain models instead of delegating to the +CLI JSON formatter. Violations are grouped by file with positional arrays, +judgment requests use short keys, and whitespace is eliminated by the server. """ from __future__ import annotations @@ -8,64 +10,146 @@ from typing import TYPE_CHECKING, Any from reporails_cli.core.models import ScanDelta, ValidationResult -from reporails_cli.formatters import json as json_formatter if TYPE_CHECKING: from reporails_cli.core.fixers import FixResult from reporails_cli.core.models import JudgmentRequest, Violation +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_MSG_MAX = 80 + + +def _line_ref(location: str, file_key: str) -> str: + """Extract ':line' portion relative to the file group key. + + >>> _line_ref("CLAUDE.md:45", "CLAUDE.md") + ':45' + >>> _line_ref("CLAUDE.md", "CLAUDE.md") + '' + """ + if location.startswith(file_key) and len(location) > len(file_key) and location[len(file_key)] == ":": + return location[len(file_key) :] + return "" + + +def _file_from_location(location: str) -> str: + """Extract file path from a location string (drop :line suffix).""" + # location may be "path/to/file.md:42" or just "path/to/file.md" + parts = location.rsplit(":", 1) + if len(parts) == 2 and parts[1].isdigit(): + return parts[0] + return location + + +def _truncate(text: str, limit: int = _MSG_MAX) -> str: + return (text[: limit - 1] + "\u2026") if len(text) > limit else text + + +# --------------------------------------------------------------------------- +# Violations — grouped by file, positional arrays +# --------------------------------------------------------------------------- + + +def _group_violations(violations: tuple[Violation, ...]) -> dict[str, list[list[str]]]: + """Group violations by file as ``{file: [[rule_id, line_ref, severity, message], ...]}``.""" + grouped: dict[str, list[list[str]]] = {} + for v in violations: + fkey = _file_from_location(v.location) + entry = [ + v.rule_id, + _line_ref(v.location, fkey), + v.severity.value, + _truncate(v.message), + ] + grouped.setdefault(fkey, []).append(entry) + return grouped + + +# --------------------------------------------------------------------------- +# Judgment requests — compact dicts with short keys +# --------------------------------------------------------------------------- + +# Default criteria values that can be elided +_DEFAULT_CRITERIA: dict[str, str] = {} + + +def _compact_judgment(jr: JudgmentRequest) -> dict[str, Any]: + d: dict[str, Any] = { + "id": jr.rule_id, + "loc": _file_from_location(jr.location), + "q": jr.question, + } + if jr.criteria and jr.criteria != _DEFAULT_CRITERIA: + d["criteria"] = jr.criteria + return d + + +# --------------------------------------------------------------------------- +# Delta — only non-null fields with short keys +# --------------------------------------------------------------------------- + + +def _compact_delta(delta: ScanDelta | None) -> dict[str, Any] | None: + if delta is None: + return None + d: dict[str, Any] = {} + if delta.score_delta is not None: + d["score_delta"] = delta.score_delta + if delta.level_previous is not None: + d["level_prev"] = delta.level_previous + if delta.level_improved is not None: + d["level_up"] = delta.level_improved + if delta.violations_delta is not None: + d["viol_delta"] = delta.violations_delta + return d or None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + def format_result( result: ValidationResult, delta: ScanDelta | None = None, ) -> dict[str, Any]: + """Format validation result as compact MCP response. + + Returns: + Dict with score, level, checked, failed, friction, violations (grouped + by file), judgment_requests (compact), and optional delta. """ - Format validation result for MCP response. + data: dict[str, Any] = { + "score": result.score, + "level": result.level.value, + "checked": result.rules_checked, + "failed": result.rules_failed, + "friction": result.friction.level if result.friction else "none", + } - Adds instructions for Claude to evaluate JudgmentRequests inline. + violations = _group_violations(result.violations) + if violations: + data["violations"] = violations - Args: - result: ValidationResult from engine - delta: Optional ScanDelta for comparison with previous run + jrs = [_compact_judgment(jr) for jr in result.judgment_requests] + if jrs: + data["judgment_requests"] = jrs - Returns: - Dict suitable for MCP tool response - """ - data = json_formatter.format_result(result, delta) - - # Add structured workflow for semantic evaluation - if data.get("judgment_requests"): - data["_semantic_workflow"] = { - "action": "evaluate_and_judge", - "steps": [ - "For each judgment_request: read the content field and evaluate against question + criteria", - "Collect verdicts", - "Call the judge tool with verdicts to cache results", - "Report only failures to user; state pass count at end", - ], - "verdict_format": "RULE_ID:FILENAME:pass|fail:brief_reason (under 40 chars)", - "example_call": { - "tool": "judge", - "arguments": { - "path": ".", - "verdicts": ["CORE:C:0017:CLAUDE.md:pass:Repo-specific paths"], - }, - }, - } + d = _compact_delta(delta) + if d: + data["delta"] = d return data def format_score(result: ValidationResult) -> dict[str, Any]: - """ - Format quick score response for MCP. - - Args: - result: ValidationResult from engine + """Format quick score response for MCP.""" + from reporails_cli.formatters import json as json_formatter - Returns: - Simplified dict with just score info - """ return json_formatter.format_score(result) @@ -75,18 +159,11 @@ def format_heal_result( *, non_fixable: list[Violation] | None = None, ) -> dict[str, Any]: - """ - Format heal result for MCP response. - - Returns auto-fixes applied, non-fixable violations, and remaining semantic judgment requests. - - Args: - fixes: List of FixResult from auto-fix phase - judgment_requests: Remaining semantic JudgmentRequests - non_fixable: Optional list of violations without auto-fixers + """Format heal result as compact MCP response. Returns: - Dict with auto_fixed, violations, and judgment_requests + Dict with auto_fixed, compact judgment_requests, and optional + non-fixable violations. """ data: dict[str, Any] = { "auto_fixed": [ @@ -97,58 +174,63 @@ def format_heal_result( } for f in fixes ], - "judgment_requests": [ - { - "rule_id": jr.rule_id, - "rule_title": jr.rule_title, - "question": jr.question, - "content": jr.content, - "location": jr.location, - "criteria": jr.criteria, - "examples": jr.examples, - "choices": jr.choices, - "pass_value": jr.pass_value, - } - for jr in judgment_requests - ], } + jrs = [_compact_judgment(jr) for jr in judgment_requests] + if jrs: + data["judgment_requests"] = jrs + if non_fixable: data["violations"] = [ { "rule_id": v.rule_id, - "rule_title": v.rule_title, "location": v.location, - "message": v.message, + "message": _truncate(v.message), "severity": v.severity.value, } for v in non_fixable ] - if judgment_requests: - data["_semantic_workflow"] = { - "action": "evaluate_and_judge", - "steps": [ - "For each judgment_request: read the content field and evaluate against question + criteria", - "Collect verdicts", - "Call the judge tool with verdicts to cache results", - "Report only failures to user; state pass count at end", - ], - "verdict_format": "RULE_ID:FILENAME:pass|fail:brief_reason (under 40 chars)", - } - return data -def format_rule(rule_id: str, rule_data: dict[str, Any]) -> dict[str, Any]: - """ - Format rule explanation for MCP. - - Args: - rule_id: Rule identifier - rule_data: Rule metadata +def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str: + """Format rule explanation as readable text for MCP. - Returns: - Dict with rule details + Unlike validate/score/heal (which return compact JSON for agent parsing), + explain returns human-readable text since its purpose is explanation. """ - return json_formatter.format_rule(rule_id, rule_data) + title = rule_data.get("title", "") + severity = rule_data.get("severity", "medium") + + parts = [f"{rule_id} — {title}"] + + meta = [f"severity: {severity}"] + scope = rule_data.get("match", {}) + if scope and scope.get("type"): + meta.append(f"scope: {scope['type']}") + parts.append(" | ".join(meta)) + parts.append("") + + desc = rule_data.get("description", "") + if desc: + # Strip the markdown heading (already shown as title) + lines = desc.strip().splitlines() + body = "\n".join( + line for line in lines if not (line.startswith("# ") and title.lower() in line.lower()) + ).strip() + if body: + parts.append(body) + parts.append("") + + checks = rule_data.get("checks", []) + if checks: + parts.append("Checks:") + parts.extend(f" {c.get('id', '?')} ({c.get('type', '?')})" for c in checks) + + see_also = rule_data.get("see_also", []) + if see_also: + parts.append("") + parts.append("See also: " + ", ".join(see_also)) + + return "\n".join(parts) diff --git a/src/reporails_cli/formatters/text/__init__.py b/src/reporails_cli/formatters/text/__init__.py index 2113635e..46ff822c 100644 --- a/src/reporails_cli/formatters/text/__init__.py +++ b/src/reporails_cli/formatters/text/__init__.py @@ -1,45 +1,12 @@ """Terminal text output formatters. Public API for formatting validation results as terminal text. +Archived: full.py, box.py, violations.py, compact.py moved to _archived/formatters/. +Active: rules.py (explain command), components.py (shared helpers), chars.py (character sets). """ -from reporails_cli.core.models import ScanDelta as _ScanDelta -from reporails_cli.formatters.text.compact import format_compact, format_score -from reporails_cli.formatters.text.components import format_legend - -# Re-export internal functions used by tests (return str only, dropping markup_extra) -from reporails_cli.formatters.text.components import ( - format_level_delta as _format_level_delta_raw, -) -from reporails_cli.formatters.text.components import ( - format_score_delta as _format_score_delta_raw, -) -from reporails_cli.formatters.text.components import ( - format_violations_delta as _format_violations_delta_raw, -) -from reporails_cli.formatters.text.full import format_result from reporails_cli.formatters.text.rules import format_rule - -def _format_score_delta(delta: "_ScanDelta | None", ascii_mode: bool | None = None) -> str: - return _format_score_delta_raw(delta, ascii_mode)[0] - - -def _format_level_delta(delta: "_ScanDelta | None", ascii_mode: bool | None = None) -> str: - return _format_level_delta_raw(delta, ascii_mode)[0] - - -def _format_violations_delta(delta: "_ScanDelta | None", ascii_mode: bool | None = None) -> str: - return _format_violations_delta_raw(delta, ascii_mode)[0] - - __all__ = [ - "_format_level_delta", - "_format_score_delta", - "_format_violations_delta", - "format_compact", - "format_legend", - "format_result", "format_rule", - "format_score", ] diff --git a/src/reporails_cli/formatters/text/box.py b/src/reporails_cli/formatters/text/box.py index 083bec98..d246b61a 100644 --- a/src/reporails_cli/formatters/text/box.py +++ b/src/reporails_cli/formatters/text/box.py @@ -227,9 +227,7 @@ def format_assessment_box( level_delta_rich, _ = format_level_delta(delta, ascii_mode, colored) viol_delta_rich, _ = format_violations_delta(delta, ascii_mode, colored) - # Orphan features → L3+ display - has_orphan = data.get("has_orphan_features", False) - level_display = f"{level}+" if has_orphan else level + level_display = level # Build individual lines top_border = chars["tl"] + chars["h"] * box_width + chars["tr"] diff --git a/src/reporails_cli/formatters/text/compact.py b/src/reporails_cli/formatters/text/compact.py index bbce9f3e..cd65625b 100644 --- a/src/reporails_cli/formatters/text/compact.py +++ b/src/reporails_cli/formatters/text/compact.py @@ -9,7 +9,7 @@ from typing import Any from reporails_cli.core.levels import get_level_labels -from reporails_cli.core.models import ScanDelta, ValidationResult +from reporails_cli.core.models import Level, ScanDelta, ValidationResult from reporails_cli.formatters import json as json_formatter from reporails_cli.formatters.text.chars import get_chars from reporails_cli.formatters.text.components import ( @@ -35,6 +35,11 @@ def format_compact( score = data.get("score", 0.0) level = data.get("level", "L1") level_label = get_level_labels().get(result.level, "Unknown") + + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + return f"{level_label} ({level}) — no instruction files found" + violations = data.get("violations", []) is_partial = data.get("is_partial", True) @@ -109,6 +114,11 @@ def format_compact( def format_score(result: ValidationResult, _ascii_mode: bool | None = None) -> str: """Format quick score summary for terminal.""" level_label = get_level_labels().get(result.level, "Unknown") + + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + return f"ails: {level_label} ({result.level.value}) — no instruction files found" + violation_count = len(result.violations) partial = " (awaiting semantic)" if result.is_partial else "" diff --git a/src/reporails_cli/formatters/text/full.py b/src/reporails_cli/formatters/text/full.py index f470dca1..9398e510 100644 --- a/src/reporails_cli/formatters/text/full.py +++ b/src/reporails_cli/formatters/text/full.py @@ -6,7 +6,8 @@ from __future__ import annotations -from reporails_cli.core.models import ScanDelta, ValidationResult +from reporails_cli.core.levels import get_level_labels +from reporails_cli.core.models import Level, ScanDelta, ValidationResult from reporails_cli.formatters import json as json_formatter from reporails_cli.formatters.text.box import format_assessment_box from reporails_cli.formatters.text.violations import format_violations_section @@ -41,6 +42,11 @@ def format_result( surface: dict[str, object] | None = None, ) -> str: """Format validation result for terminal output.""" + # L0: no instruction files — show level only, no score + if result.level == Level.L0: + level_label = get_level_labels().get(result.level, "Unknown") + return f"{level_label} ({result.level.value}) — no instruction files found" + data = json_formatter.format_result(result, delta) # Copy violations so injections don't affect scorecard counts diff --git a/src/reporails_cli/formatters/text/heal.py b/src/reporails_cli/formatters/text/heal.py deleted file mode 100644 index cd32c737..00000000 --- a/src/reporails_cli/formatters/text/heal.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Heal command output formatting — autoheal summary rendering.""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.fixers import FixResult -from reporails_cli.core.models import JudgmentRequest, Violation - - -def format_heal_summary( - fixes: list[FixResult], - non_fixable: list[Violation], - requests: list[JudgmentRequest], - *, - ascii_mode: bool = False, -) -> str: - """Format the autoheal summary with applied fixes, remaining violations, and pending rules.""" - check = "+" if ascii_mode else "\u2713" - cross = "x" if ascii_mode else "\u2717" - question = "?" - - sections: list[str] = [] - - # Applied fixes - if fixes: - lines = [ - f"Applied {len(fixes)} fix(es):", - *[f" {check} {fix.rule_id} \u2014 {fix.description}" for fix in fixes], - ] - sections.append("\n".join(lines)) - - # Remaining violations - if non_fixable: - lines = [ - f"{len(non_fixable)} remaining violation(s):", - *[f" {cross} {v.rule_id} \u2014 {v.message} ({v.location})" for v in non_fixable], - ] - sections.append("\n".join(lines)) - - # Pending semantic rules - if requests: - lines = [ - f"{len(requests)} semantic rule(s) pending evaluation:", - *[f" {question} {jr.rule_id} \u2014 {jr.rule_title} ({jr.location})" for jr in requests], - ] - sections.append("\n".join(lines)) - - if not sections: - return "Nothing to heal. All rules pass or are cached." - - return "\n\n".join(sections) - - -def extract_violation_snippet( - location: str, - scan_root: Path, - context_lines: int = 2, -) -> str | None: - """Read file and return lines around the violation with a >> marker. - - Returns None on read error or if the location has no line number. - """ - parts = location.rsplit(":", 1) - if len(parts) != 2 or not parts[1].isdigit(): - return None - - file_part, line_str = parts - target_line = int(line_str) - file_path = Path(file_part) - if not file_path.is_absolute(): - file_path = scan_root / file_path - - try: - all_lines = file_path.read_text(encoding="utf-8").splitlines() - except OSError: - return None - - if target_line < 1 or target_line > len(all_lines): - return None - - start = max(0, target_line - 1 - context_lines) - end = min(len(all_lines), target_line + context_lines) - snippet_lines: list[str] = [] - for i in range(start, end): - line_num = i + 1 - marker = ">>" if line_num == target_line else " " - snippet_lines.append(f"{marker} {line_num:>4} | {all_lines[i]}") - return "\n".join(snippet_lines) diff --git a/src/reporails_cli/formatters/text/rules.py b/src/reporails_cli/formatters/text/rules.py index a88dceb2..93a7f924 100644 --- a/src/reporails_cli/formatters/text/rules.py +++ b/src/reporails_cli/formatters/text/rules.py @@ -22,9 +22,6 @@ def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str: lines.append(f"Category: {rule_data['category']}") if rule_data.get("type"): lines.append(f"Type: {rule_data['type']}") - if rule_data.get("level"): - lines.append(f"Required Level: {rule_data['level']}") - lines.append("") if rule_data.get("description"): diff --git a/src/reporails_cli/formatters/text/violations.py b/src/reporails_cli/formatters/text/violations.py index 473f3d8c..a2939c57 100644 --- a/src/reporails_cli/formatters/text/violations.py +++ b/src/reporails_cli/formatters/text/violations.py @@ -131,7 +131,7 @@ def file_sort_key(item: tuple[str, list[dict[str, Any]]]) -> tuple[int, int]: if len(msg) > max_msg_len: truncated = msg[: max_msg_len - 3] last_space = truncated.rfind(" ") - if last_space > max_msg_len // 2: + if last_space > (max_msg_len - 3) // 2: truncated = truncated[:last_space] msg = truncated.rstrip() + "..." msg = msg.ljust(max_msg_len) diff --git a/src/reporails_cli/interfaces/cli/auth_command.py b/src/reporails_cli/interfaces/cli/auth_command.py new file mode 100644 index 00000000..32bc4729 --- /dev/null +++ b/src/reporails_cli/interfaces/cli/auth_command.py @@ -0,0 +1,265 @@ +"""ails auth — authenticate with the Reporails platform. + +Supports GitHub Device Flow for terminal-based authentication. +The API key is stored in ~/.reporails/credentials.yml (not in the project). +""" + +from __future__ import annotations + +import time +from pathlib import Path + +import typer +import yaml +from rich.console import Console + +console = Console(emoji=False, highlight=False) +auth_app = typer.Typer( + name="auth", + help="Authenticate with the Reporails platform.", + no_args_is_help=True, +) + +# GitHub OAuth App Client ID — public, embedded in CLI. +# This is NOT a secret. GitHub Device Flow requires the client ID +# to be available client-side. +GITHUB_CLIENT_ID = "" # Set when GitHub OAuth App is created + +# Reporails platform URL — configurable for local dev +DEFAULT_PLATFORM_URL = "https://reporails.com" + + +def _credentials_path() -> Path: + """Path to credentials file.""" + return Path.home() / ".reporails" / "credentials.yml" + + +def _read_credentials() -> dict[str, str]: + """Read stored credentials.""" + path = _credentials_path() + if not path.exists(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _write_credentials(api_key: str, github_login: str, tier: str) -> None: + """Store credentials securely.""" + path = _credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.dump( + {"api_key": api_key, "github_login": github_login, "tier": tier}, + default_flow_style=False, + ), + encoding="utf-8", + ) + # Restrict permissions to owner only + path.chmod(0o600) + + +def _clear_credentials() -> None: + """Remove stored credentials.""" + path = _credentials_path() + if path.exists(): + path.unlink() + + +def _get_platform_url() -> str: + """Get platform URL from env or default.""" + import os + + return os.environ.get("AILS_PLATFORM_URL", DEFAULT_PLATFORM_URL).rstrip("/") + + +def _resolve_client_id(base_url: str) -> str: + """Resolve the GitHub OAuth client ID, trying embedded constant then platform.""" + import httpx + + client_id = GITHUB_CLIENT_ID + if not client_id: + try: + resp = httpx.get(f"{base_url}/api/auth/client-id", timeout=5.0) + resp.raise_for_status() + client_id = resp.json().get("client_id", "") + except Exception: + pass + return client_id + + +def _poll_github_token(client_id: str, device_code: str, interval: int) -> str | None: + """Poll GitHub for an access token via device flow. Returns token or None on timeout.""" + import httpx + + deadline = time.time() + 900 # 15 min timeout + while time.time() < deadline: + time.sleep(interval) + try: + poll = httpx.post( + "https://github.com/login/oauth/access_token", + data={ + "client_id": client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + headers={"Accept": "application/json"}, + timeout=10.0, + ) + result = poll.json() + except Exception: + continue + + if "access_token" in result: + return str(result["access_token"]) + if result.get("error") == "authorization_pending": + continue + if result.get("error") == "slow_down": + interval += 5 + continue + + console.print(f" [red]Auth failed:[/] {result.get('error', 'unknown error')}") + raise typer.Exit(1) + return None + + +def _handle_exchange_response(payload: dict[str, str]) -> None: + """Handle the API key exchange response — waitlist, already enrolled, or success.""" + if payload.get("error") == "waitlist": + username = payload.get("github_login", "") + console.print(f" [yellow]Beta is full.[/] You're on the list, @{username}.") + console.print(" We'll email you at launch.\n") + console.print(" [dim]In the meantime: npx ails check . for free diagnostics[/dim]\n") + raise typer.Exit(0) + + if payload.get("already_enrolled"): + username = payload.get("github_login", "") + tier = payload.get("tier", "beta") + creds = _read_credentials() + if creds.get("api_key"): + console.print(f" Already enrolled as [bold]@{username}[/] ({tier} tier).") + console.print(" Your existing key is still active.\n") + else: + console.print(f" [yellow]You're enrolled as @{username} ({tier} tier),[/]") + console.print(" but your local key is missing. Contact support or re-register.\n") + raise typer.Exit(0) + + api_key = payload.get("api_key") + if not api_key: + console.print(f" [red]Unexpected response from server:[/] {payload}") + raise typer.Exit(1) + + username = payload.get("github_login", "") + tier = payload.get("tier", "beta") + _write_credentials(api_key, username, tier) + + console.print(" [green]Welcome to the beta![/] Full diagnostics unlocked.") + console.print(f" Authenticated as [bold]@{username}[/]\n") + + +@auth_app.command("login") +def login( + platform_url: str = typer.Option( + "", + "--platform-url", + help="Platform URL (default: https://reporails.com)", + hidden=True, + ), +) -> None: + """Authenticate with GitHub via Device Flow.""" + import httpx + + base_url = platform_url or _get_platform_url() + client_id = _resolve_client_id(base_url) + + if not client_id: + console.print( + " [red]GitHub OAuth not configured.[/] Set GITHUB_CLIENT_ID in the CLI or configure the platform.", + ) + raise typer.Exit(1) + + # Check if already authenticated + creds = _read_credentials() + if creds.get("api_key"): + console.print( + f"\n Already authenticated as [bold]@{creds.get('github_login', '?')}[/] " + f"({creds.get('tier', 'free').title()} tier).\n" + " Run [bold]ails auth logout[/] first to re-authenticate.\n", + ) + raise typer.Exit(0) + + # Step 1: Request device code from GitHub + try: + resp = httpx.post( + "https://github.com/login/device/code", + data={"client_id": client_id, "scope": "read:user user:email"}, + headers={"Accept": "application/json"}, + timeout=10.0, + ) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + console.print(f" [red]Failed to start GitHub auth:[/] {exc}") + raise typer.Exit(1) from exc + + device_code = data["device_code"] + user_code = data["user_code"] + interval = data.get("interval", 5) + + console.print(f"\n Your code: [bold yellow]{user_code}[/]") + console.print(" Visit: [link]https://github.com/login/device[/]") + console.print(" Waiting for authorisation...\n") + + # Step 2: Poll for token + github_token = _poll_github_token(client_id, device_code, interval) + if not github_token: + console.print(" [red]Timed out waiting for authorisation.[/]") + raise typer.Exit(1) + + # Step 3: Exchange GitHub token for Reporails API key + try: + exchange = httpx.post( + f"{base_url}/api/auth/cli-exchange", + json={"github_token": github_token}, + timeout=10.0, + ) + exchange.raise_for_status() + payload = exchange.json() + except Exception as exc: + console.print(f" [red]Failed to exchange token:[/] {exc}") + raise typer.Exit(1) from exc + + _handle_exchange_response(payload) + + +@auth_app.command("status") +def status() -> None: + """Show current authentication status.""" + creds = _read_credentials() + if not creds.get("api_key"): + console.print("\n Not authenticated. Run [bold]ails auth login[/] to sign in.\n") + raise typer.Exit(0) + + api_key = creds["api_key"] + # Show prefix only, never the full key + prefix = api_key[:16] + "..." if len(api_key) > 16 else api_key + + console.print(f"\n Authenticated as [bold]@{creds.get('github_login', '?')}[/]") + console.print(f" Tier: [bold]{creds.get('tier', '?')}[/]") + console.print(f" Key: {prefix}") + console.print(f" File: {_credentials_path()}\n") + + +@auth_app.command("logout") +def logout() -> None: + """Clear stored credentials.""" + creds = _read_credentials() + if not creds.get("api_key"): + console.print("\n Not authenticated.\n") + raise typer.Exit(0) + + username = creds.get("github_login", "?") + _clear_credentials() + console.print(f"\n [green]Logged out.[/] Credentials for @{username} removed.\n") diff --git a/src/reporails_cli/interfaces/cli/commands.py b/src/reporails_cli/interfaces/cli/commands.py index 4fdf691c..9e4e8c93 100644 --- a/src/reporails_cli/interfaces/cli/commands.py +++ b/src/reporails_cli/interfaces/cli/commands.py @@ -1,4 +1,4 @@ -"""CLI commands — map, sync, update, dismiss, judge, version.""" +"""CLI commands — map, version.""" from __future__ import annotations @@ -8,14 +8,13 @@ import typer -from reporails_cli.core.agents import detect_agents, get_all_instruction_files -from reporails_cli.core.cache import ProjectCache, cache_judgments, content_hash +from reporails_cli.core.agents import detect_agents from reporails_cli.core.discover import generate_backbone_yaml, save_backbone -from reporails_cli.interfaces.cli.helpers import _handle_update_check, app, console +from reporails_cli.interfaces.cli.helpers import app, console -@app.command(rich_help_panel="Development") -def map( # pylint: disable=too-many-locals +@app.command(hidden=True) +def map( path: str = typer.Argument(".", help="Project root to analyze"), output: str = typer.Option( "text", @@ -27,7 +26,7 @@ def map( # pylint: disable=too-many-locals False, "--save", "-s", - help="Save backbone.yml to .reporails/ directory", + help="Save backbone.yml to .ails/ directory", ), ) -> None: """Detect agents and project layout.""" @@ -44,43 +43,16 @@ def map( # pylint: disable=too-many-locals backbone_yaml = generate_backbone_yaml(target, agents) if output == "yaml": - console.print(backbone_yaml) + print(backbone_yaml, end="") elif output == "json": import yaml as yaml_lib data = yaml_lib.safe_load(backbone_yaml) - console.print(json.dumps(data, indent=2)) + print(json.dumps(data, indent=2)) else: - console.print(f"[bold]Project Map[/bold] - {target.name}") - console.print("=" * 50) - console.print() - - # Agents - for agent in agents: - root_files = [f for f in agent.instruction_files if f.parent == target] - main_file = str(root_files[0].relative_to(target)) if root_files else "?" - console.print(f"[bold]{agent.agent_type.name}[/bold]") - console.print(f" main: {main_file}") - for label, dir_path in agent.detected_directories.items(): - console.print(f" {label}: {dir_path}") - if agent.config_files: - console.print(f" config: {agent.config_files[0].relative_to(target)}") - console.print() - - # Structure - from reporails_cli.core.discover import detect_project_structure - - structure = detect_project_structure(target) - if structure: - console.print("[bold]Structure:[/bold]") - for key, value in structure.items(): - if isinstance(value, list): - console.print(f" {key}: {', '.join(value)}") - else: - console.print(f" {key}: {value}") - console.print() + from reporails_cli.interfaces.cli.helpers import _print_map_text - console.print(f"[dim]Completed in {elapsed_ms:.0f}ms[/dim]") + _print_map_text(target, agents, elapsed_ms) if save: backbone_path = save_backbone(target, backbone_yaml) @@ -88,196 +60,6 @@ def map( # pylint: disable=too-many-locals console.print(f"[green]Saved:[/green] {backbone_path}") -@app.command(rich_help_panel="Development") -def sync( - rules_dir: str = typer.Argument( - "checks", - help="Local rules directory to sync .md files to", - ), -) -> None: - """Sync rule definitions from framework repo (dev command).""" - from reporails_cli.core.init import sync_rules_to_local - - target = Path(rules_dir).resolve() - - if not target.exists(): - console.print(f"[red]Error:[/red] Directory not found: {target}") - raise typer.Exit(1) - - console.print(f"Syncing .md files from framework repo to {target}...") - - try: - count = sync_rules_to_local(target) - console.print(f"[green]Synced {count} rule definition(s)[/green]") - except RuntimeError as e: - console.print(f"[red]Error:[/red] Failed to sync rules: {e}") - raise typer.Exit(1) from None - - -@app.command(rich_help_panel="Configuration") -def update( - version: str = typer.Option( - None, - "--version", - "-v", - help="Target version (e.g., v0.1.1). Defaults to latest.", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Force update even if already at target version", - ), - recommended: bool = typer.Option( - False, - "--recommended", - help="Update recommended rules only (skips framework)", - ), - cli: bool = typer.Option( - False, - "--cli", - help="Upgrade the CLI package itself (not just rules)", - ), - check: bool = typer.Option( - False, - "--check", - "-c", - help="Check for updates without installing", - ), -) -> None: - """Update rules framework (or CLI with --cli).""" - from reporails_cli.core.init import update_rules - - # Handle --recommended: re-fetch recommended package only - if recommended: - from reporails_cli.core.init import update_recommended - - with console.status("[bold]Updating recommended rules...[/bold]"): - rec_result = update_recommended(force=force) - - if rec_result.updated: - prev = rec_result.previous_version or "none" - console.print(f"[green]Updated:[/green] recommended {prev} -> {rec_result.new_version}") - else: - console.print(rec_result.message) - return - - if cli: - from reporails_cli.core.self_update import upgrade_cli - - with console.status("[bold]Upgrading CLI...[/bold]"): - cli_result = upgrade_cli(target_version=version) - - if cli_result.updated: - console.print(f"[green]CLI upgraded:[/green] {cli_result.previous_version} -> {cli_result.new_version}") - console.print(f"[dim]Method: {cli_result.method.value}[/dim]") - console.print("[dim]Run 'ails install' to update your MCP server config.[/dim]") - else: - console.print(cli_result.message) - return - - if check: - _handle_update_check(console) - return - - # Perform update (rules + recommended) - from reporails_cli.core.init import update_recommended as _update_rec - - with console.status("[bold]Updating rules framework...[/bold]"): - result = update_rules(version=version, force=force) - - if result.updated: - console.print(f"[green]Updated:[/green] framework {result.previous_version or 'none'} -> {result.new_version}") - console.print(f"[dim]{result.rule_count} files installed[/dim]") - else: - console.print(result.message) - - # Also update recommended (unless --version was specified, which targets rules only) - if not version: - with console.status("[bold]Updating recommended rules...[/bold]"): - rec_result = _update_rec(force=force) - - if rec_result.updated: - prev = rec_result.previous_version or "none" - console.print(f"[green]Updated:[/green] recommended {prev} -> {rec_result.new_version}") - elif rec_result.message and rec_result.new_version != "unknown": - console.print(f"[dim]{rec_result.message}[/dim]") - - -@app.command(hidden=True) -def dismiss( - rule_id: str = typer.Argument(..., help="Rule ID to dismiss (e.g., C6, M4)"), - file: str = typer.Argument(None, help="Specific file to dismiss for (default: all instruction files)"), - path: str = typer.Option(".", help="Project root"), -) -> None: - """Dismiss a semantic rule finding (mark as pass in judgment cache).""" - target = Path(path).resolve() - - if not target.exists(): - console.print(f"[red]Error:[/red] Path not found: {target}") - raise typer.Exit(1) - - rule_id_upper = rule_id.upper() - - from reporails_cli.core.engine import _find_project_root - - project_root = _find_project_root(target) - cache = ProjectCache(project_root) - - files = [Path(file)] if file else [f.relative_to(target) for f in get_all_instruction_files(target)] - - if not files: - console.print("[yellow]No instruction files found.[/yellow]") - raise typer.Exit(1) - - dismissed = 0 - for f in files: - full_path = target / f if not f.is_absolute() else f - if not full_path.exists(): - console.print(f"[yellow]Skipping:[/yellow] {f} (not found)") - continue - - file_path = str(f) - try: - file_hash = content_hash(full_path) - except OSError: - console.print(f"[yellow]Skipping:[/yellow] {f} (could not read)") - continue - - existing = cache.get_cached_judgment(file_path, file_hash) or {} - existing[rule_id_upper] = { - "verdict": "pass", - "reason": "Dismissed via ails dismiss", - } - cache.set_cached_judgment(file_path, file_hash, existing) - dismissed += 1 - - console.print(f"[green]Dismissed[/green] {rule_id_upper} for {dismissed} file(s)") - - -@app.command(hidden=True) -def judge( - path: str = typer.Argument(".", help="Project root"), - verdicts: list[str] = typer.Argument( # noqa: B008 - None, - help="Verdict strings: RULE:FILE:verdict:reason (e.g., 'C6:CLAUDE.md:pass:Criteria met')", - ), -) -> None: - """Cache semantic rule verdicts (batch, rule_id:location:verdict:reason format).""" - target = Path(path).resolve() - - if not target.exists(): - console.print(f"[red]Error:[/red] Path not found: {target}") - raise typer.Exit(1) - - if not verdicts: - console.print("[yellow]No verdicts provided.[/yellow]") - raise typer.Exit(1) - - recorded = cache_judgments(target, verdicts) - print(json.dumps({"recorded": recorded})) - - @app.command("version", rich_help_panel="Configuration") def show_version() -> None: """Show CLI and framework versions.""" diff --git a/src/reporails_cli/interfaces/cli/config_command.py b/src/reporails_cli/interfaces/cli/config_command.py index 317f1bb4..beb4bbc9 100644 --- a/src/reporails_cli/interfaces/cli/config_command.py +++ b/src/reporails_cli/interfaces/cli/config_command.py @@ -15,7 +15,7 @@ config_app = typer.Typer( name="config", - help="Get and set project configuration (.reporails/config.yml).", + help="Get and set project configuration (.ails/config.yml).", no_args_is_help=True, ) @@ -24,17 +24,17 @@ "default_agent": str, "exclude_dirs": list, "disabled_rules": list, - "experimental": bool, "recommended": bool, "framework_version": str, + "tier": str, } # Subset of keys allowed in global config (~/.reporails/config.yml) -GLOBAL_KEYS = {"default_agent", "recommended"} +GLOBAL_KEYS = {"default_agent", "recommended", "tier"} def _project_config_path(path: Path) -> Path: - return path / ".reporails" / "config.yml" + return path / ".ails" / "config.yml" def _global_config_path() -> Path: @@ -178,7 +178,7 @@ def config_list( merged[key] = (global_data[key], " (global)") if not merged: - console.print("[dim]No configuration set. Config file: .reporails/config.yml[/dim]") + console.print("[dim]No configuration set. Config file: .ails/config.yml[/dim]") return for key, (val, source) in sorted(merged.items()): diff --git a/src/reporails_cli/interfaces/cli/daemon_cmd.py b/src/reporails_cli/interfaces/cli/daemon_cmd.py new file mode 100644 index 00000000..1db05523 --- /dev/null +++ b/src/reporails_cli/interfaces/cli/daemon_cmd.py @@ -0,0 +1,68 @@ +"""CLI subcommand: ails daemon start|stop|status.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from reporails_cli.interfaces.cli.helpers import console + +daemon_app = typer.Typer(name="daemon", help="Manage the mapper daemon.") + + +@daemon_app.command() +def start( + path: str = typer.Argument(".", help="Project root"), +) -> None: + """Start the mapper daemon (keeps models loaded in background).""" + from reporails_cli.core.mapper.daemon import is_daemon_running, start_daemon + + cache_dir = Path(path).resolve() / ".ails" / ".cache" + + if is_daemon_running(cache_dir): + console.print("[dim]Daemon already running.[/dim]") + return + + console.print("Starting mapper daemon...") + pid = start_daemon(cache_dir) + if is_daemon_running(cache_dir): + console.print(f"[green]Daemon started[/green] (PID {pid})") + else: + console.print("[red]Failed to start daemon.[/red]") + raise typer.Exit(1) + + +@daemon_app.command() +def stop( + path: str = typer.Argument(".", help="Project root"), +) -> None: + """Stop the mapper daemon.""" + from reporails_cli.core.mapper.daemon import stop_daemon + + cache_dir = Path(path).resolve() / ".ails" / ".cache" + if stop_daemon(cache_dir): + console.print("[green]Daemon stopped.[/green]") + else: + console.print("[dim]Daemon not running.[/dim]") + + +@daemon_app.command() +def status( + path: str = typer.Argument(".", help="Project root"), +) -> None: + """Show daemon status.""" + from reporails_cli.core.mapper.daemon import is_daemon_running + from reporails_cli.core.mapper.daemon_client import ping + + cache_dir = Path(path).resolve() / ".ails" / ".cache" + + if not is_daemon_running(cache_dir): + console.print("Daemon: [dim]not running[/dim]") + return + + resp = ping(cache_dir) + if resp and resp.get("ok"): + console.print(f"Daemon: [green]running[/green] (PID {resp.get('pid', '?')})") + else: + console.print("Daemon: [yellow]PID file exists but unresponsive[/yellow]") diff --git a/src/reporails_cli/interfaces/cli/display.py b/src/reporails_cli/interfaces/cli/display.py new file mode 100644 index 00000000..2e8742d2 --- /dev/null +++ b/src/reporails_cli/interfaces/cli/display.py @@ -0,0 +1,3 @@ +"""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 9d2693d4..a0133f51 100644 --- a/src/reporails_cli/interfaces/cli/heal.py +++ b/src/reporails_cli/interfaces/cli/heal.py @@ -1,195 +1,180 @@ -"""Autoheal command — ails heal. +"""Heal command — apply auto-fixes to instruction file issues. -Silently applies all auto-fixes and reports results. -Non-fixable violations are listed for the coding agent to handle. +Combines additive fixers (missing sections) with mechanical fixers +(formatting, bold, italic, ordering) that operate on atom-level data. """ from __future__ import annotations import json +import sys +import time from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any import typer -from reporails_cli.core.engine import run_validation_sync -from reporails_cli.core.fixers import apply_auto_fixes, partition_violations -from reporails_cli.formatters import json as json_formatter -from reporails_cli.formatters.text.heal import format_heal_summary -from reporails_cli.interfaces.cli.helpers import ( - _resolve_recommended_rules, - _resolve_rules_paths, - _validate_agent, - app, - console, -) - -if TYPE_CHECKING: - from reporails_cli.core.fixers import FixResult - from reporails_cli.core.models import JudgmentRequest, Violation - -VALID_HEAL_FORMATS = {"text", "json"} - - -def _serialize_heal_json( - fixes: list[FixResult], - non_fixable: list[Violation], - requests: list[JudgmentRequest], - target: Path, -) -> dict[str, Any]: - """Serialize heal results to JSON-compatible dict.""" - auto_fixed_data = [ - { - "rule_id": fix.rule_id, - "file_path": str(Path(fix.file_path).relative_to(target)) - if Path(fix.file_path).is_relative_to(target) - else fix.file_path, - "description": fix.description, - } - for fix in fixes - ] - - violation_data = [ - { - "rule_id": v.rule_id, - "rule_title": v.rule_title, - "location": v.location, - "message": v.message, - "severity": v.severity.value, - } - for v in non_fixable - ] - - judgment_data = [ - { - "rule_id": jr.rule_id, - "rule_title": jr.rule_title, - "question": jr.question, - "content": jr.content, - "location": jr.location, - "criteria": jr.criteria, - "examples": jr.examples, - "choices": jr.choices, - "pass_value": jr.pass_value, - } - for jr in requests - ] - - return json_formatter.format_heal_result( - auto_fixed_data, - judgment_data, - violations=violation_data, - ) +from reporails_cli.interfaces.cli.main import app # type: ignore[attr-defined] @app.command(rich_help_panel="Commands") -def heal( # pylint: disable=too-many-locals +def heal( # noqa: C901 path: str = typer.Argument(".", help="Project root to heal"), - rules: list[str] = typer.Option( # noqa: B008 - None, - "--rules", - "-r", - help="Directory containing rules (repeatable).", - ), - exclude_dir: list[str] = typer.Option( # noqa: B008 - None, - "--exclude-dir", - "-x", - help="Directory name to exclude from scanning (repeatable).", - ), - refresh: bool = typer.Option( - False, - "--refresh", - help="Refresh file map cache (re-scan for instruction files)", - ), - ascii: bool = typer.Option( - False, - "--ascii", - "-a", - help="Use ASCII characters only (no Unicode)", - ), - agent: str = typer.Option( - "", - "--agent", - help="Agent type for rule overrides and template vars (e.g., claude, cursor, codex)", - ), - experimental: bool = typer.Option( - False, - "--experimental", - help="Include experimental rules", - ), - format: str = typer.Option( - "text", - "--format", - "-f", - help="Output format: text or json", - ), + format: str = typer.Option(None, "--format", "-f", help="Output format: text, json"), + agent: str = typer.Option("", "--agent", help="Agent type"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show fixes without applying"), + exclude_dirs: list[str] = typer.Option(None, "--exclude-dirs", help="Directories to exclude"), # noqa: B008 ) -> None: - """Apply all auto-fixes and report remaining violations.""" - # Validate format - if format not in VALID_HEAL_FORMATS: - console.print(f"[red]Error:[/red] Unknown format: {format}") - console.print(f"Valid formats: {', '.join(sorted(VALID_HEAL_FORMATS))}") - raise typer.Exit(2) - - # Normalize and validate agent - agent = _validate_agent(agent, console) - + """Auto-fix instruction file issues. + + Applies formatting fixes (backticks, bold→italic, constraint wrapping, + charge ordering) and structural fixes (missing sections). Use --dry-run + to preview changes without writing. + """ + from rich.console import Console + + from reporails_cli.core.agents import detect_agents, get_all_instruction_files + from reporails_cli.core.config import get_project_config + from reporails_cli.core.fixers import apply_auto_fixes as _apply_auto_fixes + from reporails_cli.core.models import Severity, Violation + from reporails_cli.core.rule_runner import run_m_probes + from reporails_cli.interfaces.cli.helpers import _default_format, _handle_no_instruction_files + from reporails_cli.interfaces.cli.main import _suppress_ml_noise + + console = Console(stderr=True) target = Path(path).resolve() if not target.exists(): console.print(f"[red]Error:[/red] Path not found: {target}") raise typer.Exit(2) - # Resolve rules paths - rules_paths = _resolve_rules_paths(rules, console) - - # Load project config - from reporails_cli.core.bootstrap import get_project_config - - project_config = get_project_config(target) - - # Resolve effective agent: CLI flag > config default_agent > auto-detect > generic - if not agent and project_config.default_agent: - agent = _validate_agent(project_config.default_agent, console) - - if not agent: - from reporails_cli.core.agents import auto_detect_agent, detect_agents - - all_detected = detect_agents(target) - auto = auto_detect_agent(all_detected) - if auto: - agent = auto + output_format = format or _default_format() + + # 1. Detect agents and discover files + detected = detect_agents(target) + config = get_project_config(target) + agent_arg = agent or config.default_agent + excl = exclude_dirs if exclude_dirs is not None else config.exclude_dirs + from reporails_cli.interfaces.cli.helpers import _resolve_agent_filters, _validate_agent + + if agent_arg: + _validate_agent(agent_arg, console) + effective_agent, _assumed, _mixed, filtered = _resolve_agent_filters(agent_arg, detected, target, excl) + instruction_files = get_all_instruction_files(target, agents=filtered) + if not instruction_files: + _handle_no_instruction_files(effective_agent, output_format, console) + return + + show_progress = sys.stdout.isatty() and output_format != "json" + start_time = time.perf_counter() + + # 2. Run mapper for mechanical fixes + _suppress_ml_noise() + ruleset_map = None + cache_dir = target / ".ails" / ".cache" + + if show_progress: + console.print("[bold]Mapping instruction files...[/bold]") + + try: + from reporails_cli.core.mapper.daemon_client import ensure_daemon, map_ruleset_via_daemon + + ensure_daemon(cache_dir) + ruleset_map = map_ruleset_via_daemon(list(instruction_files), target, cache_dir) + if ruleset_map is None: + from reporails_cli.interfaces.cli.main import _map_in_process + + ruleset_map = _map_in_process(instruction_files, cache_dir) + except (ImportError, RuntimeError): + if show_progress: + console.print("[dim]Mapper unavailable — mechanical fixes skipped.[/dim]") + + # 3. Apply mechanical fixes (atom-level) + mechanical_results: list[dict[str, Any]] = [] + if ruleset_map is not None: + if show_progress: + console.print("[bold]Applying mechanical fixes...[/bold]") + from reporails_cli.core.mechanical_fixers import apply_mechanical_fixes + + mech_fixes = apply_mechanical_fixes(ruleset_map, target, dry_run=dry_run) + mechanical_results.extend( + {"rule_id": mf.fix_type, "file_path": mf.file_path, "line": mf.line, "description": mf.description} + for mf in mech_fixes + ) + + # 4. Run M probes for additive fixes + if show_progress: + console.print("[bold]Running structural checks...[/bold]") + m_findings = run_m_probes(target, instruction_files, agent=effective_agent) + + # Convert findings to Violations for additive fixers + sev_map = {"critical": Severity.CRITICAL, "high": Severity.HIGH, "medium": Severity.MEDIUM, "low": Severity.LOW} + violations = [ + Violation( + rule_id=f.rule, + rule_title="", + severity=sev_map.get(f.severity, Severity.MEDIUM), + message=f.message, + location=f"{f.file}:{f.line}" if f.line else f.file, + ) + for f in m_findings + ] - # Merge exclude_dirs - merged_excludes: list[str] | None = None - all_excludes = set(project_config.exclude_dirs) | set(exclude_dir or []) - if all_excludes: - merged_excludes = sorted(all_excludes) + # 5. Apply additive fixes (missing sections) + additive_results: list[dict[str, Any]] = [] + if not dry_run: + add_fixes = _apply_auto_fixes(violations, target) + additive_results.extend( + {"rule_id": af.rule_id, "file_path": af.file_path, "description": af.description} for af in add_fixes + ) + + elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1) + all_fixes = mechanical_results + additive_results + + # 6. Output + if output_format == "json": + data = { + "auto_fixed": all_fixes, + "summary": { + "auto_fixed_count": len(all_fixes), + "mechanical_count": len(mechanical_results), + "additive_count": len(additive_results), + "dry_run": dry_run, + "elapsed_ms": elapsed_ms, + }, + } + print(json.dumps(data, indent=2)) + else: + _print_text_result(all_fixes, dry_run, elapsed_ms, console) - rules_paths = _resolve_recommended_rules(rules_paths, project_config, format, console) - # Run validation - result = run_validation_sync( - target, - rules_paths=rules_paths, - use_cache=not refresh, - agent=agent, - include_experimental=experimental, - exclude_dirs=merged_excludes, - record_analytics=False, +def _print_text_result( + fixes: list[dict[str, Any]], + dry_run: bool, + elapsed_ms: float, + console: Any, +) -> None: + """Print human-readable heal results.""" + prefix = "[dim]would fix[/dim]" if dry_run else "[green]fixed[/green]" + + if not fixes: + console.print("[green]No fixable issues found.[/green]") + console.print(f"[dim]{elapsed_ms:.0f}ms[/dim]") + return + + # Group by file + by_file: dict[str, list[dict[str, Any]]] = {} + for f in fixes: + by_file.setdefault(f.get("file_path", "?"), []).append(f) + + for filepath, file_fixes in sorted(by_file.items()): + console.print(f"\n[bold]{filepath}[/bold]") + for fix in file_fixes: + line = fix.get("line", "") + line_str = f"L{line} " if line else "" + console.print(f" {prefix} {line_str}{fix['description']}") + + console.print( + f"\n[bold]{len(fixes)}[/bold] fix{'es' if len(fixes) != 1 else ''} " + + ("(dry run)" if dry_run else "applied") + + f" in {elapsed_ms:.0f}ms" ) - - # Partition violations and apply fixes - fixable, non_fixable = partition_violations(list(result.violations)) - requests = list(result.judgment_requests) - fixes = apply_auto_fixes(fixable, target) - - # Output - if format == "json": - output = _serialize_heal_json(fixes, non_fixable, requests, target) - print(json.dumps(output, indent=2)) - else: - summary = format_heal_summary(fixes, non_fixable, requests, ascii_mode=ascii) - console.print(summary) - if fixes or non_fixable or requests: - console.print("\n[dim]Run 'ails check' to see your updated score.[/dim]") diff --git a/src/reporails_cli/interfaces/cli/helpers.py b/src/reporails_cli/interfaces/cli/helpers.py index 96fcc8c0..de063fcc 100644 --- a/src/reporails_cli/interfaces/cli/helpers.py +++ b/src/reporails_cli/interfaces/cli/helpers.py @@ -5,17 +5,13 @@ import json import os import sys +from collections.abc import Mapping from pathlib import Path from typing import Any import typer from rich.console import Console -from reporails_cli.core.models import ScanDelta, ValidationResult -from reporails_cli.formatters import github as github_formatter -from reporails_cli.formatters import json as json_formatter -from reporails_cli.formatters import text as text_formatter - app = typer.Typer( name="ails", help="Validate and score AI instruction files - what ails your repo?", @@ -59,7 +55,7 @@ def _resolve_recommended_rules( ) if use_recommended and not has_recommended and not is_recommended_installed(): - show = sys.stdout.isatty() and format not in ("json", "brief", "compact", "github") + show = sys.stdout.isatty() and format not in ("json", "brief", "compact", "github", "agent") try: if show: with con.status("[bold]Downloading recommended rules...[/bold]"): @@ -99,17 +95,18 @@ def _handle_update_check(con: Console) -> None: con.print("\n[green]You are up to date.[/green]" if up_to_date else "\n[cyan]Run 'ails update' to update[/cyan]") -VALID_FORMATS = {"text", "json", "compact", "brief", "github"} +VALID_FORMATS = {"text", "json", "compact", "brief", "github", "agent"} def _validate_agent(agent: str, con: Console) -> str: """Normalize and validate --agent value. Returns normalized agent or exits.""" - from reporails_cli.core.agents import KNOWN_AGENTS as _KNOWN + from reporails_cli.core.agents import get_known_agents as _get_known agent = agent.lower().strip() - if agent and agent not in _KNOWN: + known = _get_known() + if agent and agent not in known: con.print(f"[red]Error:[/red] Unknown agent: {agent}") - con.print(f"Known agents: {', '.join(sorted(_KNOWN))}") + con.print(f"Known agents: {', '.join(sorted(known))}") raise typer.Exit(2) return agent @@ -162,16 +159,43 @@ def _print_unknown_rule(rule_id: str, loaded_rules: dict[str, Any]) -> None: console.print(f" {ns}: {', '.join(ids[:5])}{tail}") +def _resolve_agent_filters( + agent: str, + all_detected: list[Any], + target: Path, + exclude_dirs: list[str] | None, +) -> tuple[str, bool, bool, list[Any]]: + """Resolve agent selection and filter detected agents. Returns (agent, assumed, mixed, filtered).""" + from reporails_cli.core.agents import ( + detect_single_agent, + filter_agents_by_exclude_dirs, + filter_agents_by_id, + resolve_agent, + ) + + agent, assumed, mixed = resolve_agent(agent, all_detected) + effective = agent if agent else "generic" + if mixed: + filtered = [a for a in all_detected if a.agent_type.id != "generic"] + else: + filtered = filter_agents_by_id(all_detected, effective) + if not filtered and agent: + single = detect_single_agent(target, agent) + if single: + filtered = [single] + return effective, assumed, mixed, filter_agents_by_exclude_dirs(filtered, target, exclude_dirs) + + def _handle_no_instruction_files(effective_agent: str, output_format: str, con: Console) -> None: """Print appropriate message when no instruction files are found, then exit.""" if output_format in ("json", "github"): - print(json.dumps({"violations": [], "score": 0, "level": "L1"})) + print(json.dumps({"violations": [], "score": 0, "level": "L0"})) else: - from reporails_cli.core.agents import KNOWN_AGENTS + from reporails_cli.core.agents import get_known_agents - at = KNOWN_AGENTS.get(effective_agent) + 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: L1 (Absent)\n\n[dim]Create a {hint} to get started.[/dim]") + 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: @@ -186,114 +210,47 @@ def _resolve_rules_paths(rules: list[str] | None, con: Console) -> list[Path] | return resolved -def _compute_file_checks( - rules_paths: list[Path] | None, - target: Path, - instruction_files: list[Path], - agent: str, - experimental: bool, -) -> tuple[dict[str, list[str]], list[str], dict[str, str]]: - """Compute per-file check ID lists (regex + mechanical) and rule titles.""" - from reporails_cli.core.engine import build_template_context - from reporails_cli.core.models import RuleType - from reporails_cli.core.regex import checks_per_file, get_rule_yml_paths - from reporails_cli.core.registry import load_rules - - rules = load_rules( - rules_paths, include_experimental=experimental, project_root=target, agent=agent, scan_root=target - ) - yml_paths = get_rule_yml_paths( - {k: v for k, v in rules.items() if v.type in (RuleType.DETERMINISTIC, RuleType.SEMANTIC)} +def _print_section(title: str, data: Mapping[str, object]) -> None: + """Print a labeled section, skipping null values.""" + non_null = {k: v for k, v in data.items() if v is not None} + if not non_null: + return + console.print(f"[bold]{title}:[/bold]") + for key, value in non_null.items(): + if isinstance(value, list): + console.print(f" {key}: {', '.join(str(v) for v in value)}") + else: + console.print(f" {key}: {value}") + console.print() + + +def _print_map_text(target: Path, agents: list[Any], elapsed_ms: float) -> None: + """Print human-readable map output.""" + from reporails_cli.core.discover import ( + _detect_classification, + _detect_commands, + _detect_meta, + _detect_paths, ) - context = build_template_context(agent, instruction_files, rules_paths) - mechanical_ids = [c.id for r in rules.values() if r.type == RuleType.MECHANICAL for c in r.checks] - titles = {r.id: r.title for r in rules.values()} - return checks_per_file(yml_paths, target, context, instruction_files), mechanical_ids, titles - - -def _check_id_to_rule_id(check_id: str) -> str: # CORE.S.0001.check.0001 -> CORE:S:0001 - parts = check_id.replace(".", ":").split(":") - return ":".join(parts[:3]) if len(parts) >= 3 else check_id - -def _print_verbose( # pylint: disable=too-many-arguments,too-many-locals - rules_paths: list[Path] | None, - instruction_files: list[Path], - result: ValidationResult, - agent: str, - elapsed_ms: float, - target: Path, - experimental: bool, - con: Console, -) -> None: - """Print verbose scan diagnostics with per-file check lists.""" - con.print() - con.print("[dim]Verbose:[/dim]") - if rules_paths: - for rp in rules_paths: - con.print(f"[dim] source: {str(rp).replace(str(Path.home()), '~')}[/dim]") - con.print(f"[dim] agent: {agent}, {result.rules_checked} rules, {elapsed_ms:.0f}ms[/dim]") - file_checks, mechanical_ids, titles = _compute_file_checks( - rules_paths, - target, - instruction_files, - agent, - experimental, - ) - failed_by_file: dict[str, set[str]] = {} - for v in result.violations: - viol_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location - failed_by_file.setdefault(viol_path, set()).add(v.rule_id) - con.print("[dim] files:[/dim]") - for ifile in sorted(instruction_files, key=lambda p: str(p)): - try: - rel = str(ifile.relative_to(target)) - except ValueError: - rel = str(ifile) - regex_ids = file_checks.get(rel, []) - all_rule_ids = sorted({_check_id_to_rule_id(cid) for cid in regex_ids + mechanical_ids}) - failed = failed_by_file.get(rel, set()) - con.print(f"[dim] {rel} ({len(all_rule_ids)} rules):[/dim]") - for rid in all_rule_ids: - status_tag = "[red]FAIL[/red]" if rid in failed else "[green]PASS[/green]" - con.print(f"[dim] {status_tag} {rid} {titles.get(rid, '')}[/dim]") - - -def _format_output( # pylint: disable=too-many-locals - result: ValidationResult, - delta: ScanDelta, - output_format: str, - ascii: bool, - quiet_semantic: bool, - elapsed_ms: float, - con: Console, - surface: dict[str, Any] | None = None, -) -> None: - """Dispatch output formatting based on the chosen format.""" - if output_format == "json": - data = json_formatter.format_result(result, delta) - data["elapsed_ms"] = round(elapsed_ms, 1) - print(json.dumps(data, indent=2)) - elif output_format == "compact": - output = text_formatter.format_compact(result, ascii_mode=ascii, delta=delta) - print(output) - elif output_format == "github": - output = github_formatter.format_result(result, delta) - print(output) - elif output_format == "brief": - data = json_formatter.format_result(result, delta) - n_viols = len(data.get("violations", [])) - mark = ( - ("ok" if ascii else "\u2713") if n_viols == 0 else f"{'x' if ascii else chr(0x2717)} {n_viols} violations" - ) - print(f"ails: {data.get('score', 0):.1f}/10 ({data.get('level', '?')}) {mark}") - else: - output = text_formatter.format_result( - result, - ascii_mode=ascii, - quiet_semantic=quiet_semantic, - delta=delta, - elapsed_ms=elapsed_ms, - surface=surface, - ) - con.print(output) + console.print(f"[bold]Project Map[/bold] - {target.name}") + console.print("=" * 50) + console.print() + + for agent in agents: + root_files = [f for f in agent.instruction_files if f.parent == target] + main_file = str(root_files[0].relative_to(target)) if root_files else "?" + console.print(f"[bold]{agent.agent_type.name}[/bold]") + console.print(f" main: {main_file}") + for label, dir_path in agent.detected_directories.items(): + console.print(f" {label}: {dir_path}") + if agent.config_files: + console.print(f" config: {agent.config_files[0].relative_to(target)}") + console.print() + + _print_section("Classification", _detect_classification(target)) + _print_section("Paths", _detect_paths(target)) + _print_section("Commands", _detect_commands(target)) + _print_section("Meta", _detect_meta(target)) + + console.print(f"[dim]Completed in {elapsed_ms:.0f}ms[/dim]") diff --git a/src/reporails_cli/interfaces/cli/main.py b/src/reporails_cli/interfaces/cli/main.py index 0ce8551e..00712b2a 100644 --- a/src/reporails_cli/interfaces/cli/main.py +++ b/src/reporails_cli/interfaces/cli/main.py @@ -1,37 +1,56 @@ -# pylint: disable=too-many-lines """Typer CLI for reporails - validate and score AI instruction files.""" from __future__ import annotations -import sys -import time -from contextlib import nullcontext -from pathlib import Path +# ───────────────────────────────────────────────────────────────────── +# CRITICAL: torch import blocker MUST run before any import that could +# transitively reach thinc/spacy or sentence-transformers. See the +# module docstring of `_torch_blocker` for the full story — short +# version: spaCy's thinc backend does `try: import torch` as a side +# effect that costs ~20s on cold start. We don't use torch anywhere on +# the CLI critical path; ONNX Runtime + tokenizers handle everything. +from reporails_cli.core import _torch_blocker -import typer +_torch_blocker.install() +# ───────────────────────────────────────────────────────────────────── -from reporails_cli.core.agents import get_all_instruction_files -from reporails_cli.core.cache import get_previous_scan -from reporails_cli.core.engine import run_validation_sync -from reporails_cli.core.models import ScanDelta -from reporails_cli.core.registry import infer_agent_from_rule_id, load_rules -from reporails_cli.formatters import text as text_formatter -from reporails_cli.interfaces.cli.helpers import ( +import json # noqa: E402 +import sys # noqa: E402 +import time # noqa: E402 +from pathlib import Path # noqa: E402 +from typing import Any # noqa: E402 + +import typer # noqa: E402 + +from reporails_cli.core.models import FileMatch, LocalFinding # noqa: E402 +from reporails_cli.core.registry import infer_agent_from_rule_id, load_rules # noqa: E402 +from reporails_cli.formatters import text as text_formatter # noqa: E402 +from reporails_cli.interfaces.cli.helpers import ( # noqa: E402 _default_format, - _format_output, _handle_no_instruction_files, _print_unknown_rule, - _print_verbose, - _resolve_recommended_rules, - _resolve_rules_paths, + _resolve_agent_filters, _show_agent_auto_detect_hint, _validate_agent, - _validate_format, app, console, ) +def _serialize_match(match: FileMatch | None) -> dict[str, object]: + """Serialize FileMatch to dict, including all non-None properties.""" + if match is None: + return {} + result: dict[str, object] = {} + if match.type is not None: + result["type"] = match.type + for prop in ("format", "scope", "cardinality", "lifecycle", "maintainer", "vcs", "loading", "precedence"): + val = getattr(match, prop) + if val is not None: + result[prop] = val + return result + + def _explain_rules_paths(rules: list[str] | None) -> list[Path] | None: """Resolve rules paths for explain command, auto-including recommended.""" if rules: @@ -44,202 +63,914 @@ def _explain_rules_paths(rules: list[str] | None) -> list[Path] | None: @app.command(rich_help_panel="Commands") -def check( # pylint: disable=too-many-arguments,too-many-locals,too-many-statements +def check( # noqa: C901 # pylint: disable=too-many-locals path: str = typer.Argument(".", help="File or directory to validate"), - format: str = typer.Option( - None, - "--format", - "-f", - help="Output format: text, json, github (auto-detects: text for terminal, json for pipes/CI)", - ), - rules: list[str] = typer.Option( # noqa: B008 - None, - "--rules", - "-r", - help="Directory containing rules (repeatable). First = primary framework. Defaults to ~/.reporails/rules/.", - ), - exclude_dir: list[str] = typer.Option( # noqa: B008 - None, - "--exclude-dir", - "-x", - help="Directory name to exclude from scanning (repeatable). Merges with config exclude_dirs.", - ), - refresh: bool = typer.Option( - False, - "--refresh", - help="Refresh file map cache (re-scan for instruction files)", - ), - ascii: bool = typer.Option( - False, - "--ascii", - "-a", - help="Use ASCII characters only (no Unicode box drawing)", - ), - strict: bool = typer.Option( - False, - "--strict", - help="Exit with code 1 if violations found (for CI pipelines)", - ), - quiet_semantic: bool = typer.Option( - False, - "--quiet-semantic", - "-q", - help="Suppress 'semantic rules skipped' message (for agent/MCP contexts)", - ), - legend: bool = typer.Option( - False, - "--legend", - "-l", - help="Show severity legend only", - ), - agent: str = typer.Option( - "", - "--agent", - help="Agent type for rule overrides and template vars (e.g., claude, cursor, codex)", - ), - experimental: bool = typer.Option( - False, - "--experimental", - help="Include experimental rules (methodology-backed, lower confidence)", - ), - verbose: bool = typer.Option( - False, - "--verbose", - "-v", - help="Show rule sources, file count, and scan details", - ), - no_update_check: bool = typer.Option( - False, - "--no-update-check", - help="Skip pre-run update check prompt", - ), + format: str = typer.Option(None, "--format", "-f", help="Output format: text, json, github"), + agent: str = typer.Option("", "--agent", help="Agent type (e.g., claude, copilot)"), + exclude_dirs: list[str] = typer.Option(None, "--exclude-dirs", help="Directories to exclude"), # noqa: B008 + ascii: bool = typer.Option(False, "--ascii", "-a", help="ASCII characters only"), + strict: bool = typer.Option(False, "--strict", help="Exit code 1 if violations found"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show details"), ) -> None: """Validate AI instruction files against reporails rules.""" - if legend: - legend_text = text_formatter.format_legend(ascii_mode=ascii) - print(f"Severity Legend: {legend_text}") - return + from contextlib import nullcontext + + from reporails_cli.core.agents import detect_agents, get_all_instruction_files + from reporails_cli.core.api_client import AilsClient + from reporails_cli.core.client_checks import run_client_checks + from reporails_cli.core.config import get_project_config + from reporails_cli.core.merger import merge_results + from reporails_cli.core.rule_runner import run_content_quality_checks, run_m_probes + from reporails_cli.formatters import json as json_formatter - agent = _validate_agent(agent, console) - _validate_format(format, console) target = Path(path).resolve() if not target.exists(): console.print(f"[red]Error:[/red] Path not found: {target}") - console.print("Run 'ails check .' to validate the current directory.") raise typer.Exit(2) - # Resolve --rules paths - rules_paths = _resolve_rules_paths(rules, console) + output_format = format or _default_format() + + # 1. Detect agents and resolve which one to use + detected = detect_agents(target) + config = get_project_config(target) + agent_arg = agent or config.default_agent + excl = exclude_dirs if exclude_dirs is not None else config.exclude_dirs + if agent_arg: + _validate_agent(agent_arg, console) + effective_agent, assumed, mixed, filtered = _resolve_agent_filters( + agent_arg, + detected, + target, + excl, + ) + instruction_files = get_all_instruction_files(target, agents=filtered) + if not instruction_files: + _handle_no_instruction_files(effective_agent, output_format, console) + return + + # 1a. EAGERLY start the mapper daemon BEFORE any other expensive work. + _cache_dir = target / ".ails" / ".cache" + _suppress_ml_noise() + try: + from reporails_cli.core.mapper.daemon_client import ensure_daemon - # Load project config for exclude_dirs and recommended opt-out - from reporails_cli.core.bootstrap import get_project_config + ensure_daemon(_cache_dir) + except (ImportError, OSError): + pass - project_config = get_project_config(target) + show_progress = sys.stdout.isatty() and output_format not in ("json", "github") - # Resolve effective agent: CLI flag > config default_agent > engine defaults to generic - if not agent and project_config.default_agent: - agent = _validate_agent(project_config.default_agent, console) + spinner = console.status("[bold]Discovering files...[/bold]") if show_progress else nullcontext() - # Merge exclude_dirs: config + CLI flags - all_excludes = set(project_config.exclude_dirs) | set(exclude_dir or []) - merged_excludes: list[str] | None = sorted(all_excludes) if all_excludes else None + start_time = time.perf_counter() - # Auto-include recommended rules if needed - rules_paths = _resolve_recommended_rules(rules_paths, project_config, format, console) + with spinner: + # 2. Build map (needed before M probes to enable content_query checks) + ruleset_map = None + try: + if show_progress: + spinner.update("[bold]Mapping...[/bold]") # type: ignore[union-attr] - # Pre-run update check (interactive TTY only, text format) - if format not in ("json", "brief", "compact", "github"): - from reporails_cli.core.update_check import prompt_for_updates + from reporails_cli.core.mapper.daemon_client import map_ruleset_via_daemon - prompt_for_updates(console, no_update_check=no_update_check) + ruleset_map = map_ruleset_via_daemon(list(instruction_files), target, _cache_dir) - # Early check for missing instruction files - output_format = format if format else _default_format() - from reporails_cli.core.agents import ( - detect_agents, - filter_agents_by_exclude_dirs, - filter_agents_by_id, - resolve_agent, - ) + if ruleset_map is None: + # Daemon unreachable (fork failed, Windows, etc.) — fall back + # to in-process mapping, which still benefits from MapCache. + if show_progress: + spinner.update("[bold]Loading models...[/bold]") # type: ignore[union-attr] + ruleset_map = _map_in_process(instruction_files, _cache_dir) + except (ImportError, RuntimeError): + if verbose: + console.print("[dim]Mapper unavailable. Content checks skipped.[/dim]") - all_detected_agents = detect_agents(target) + # 3. Run M probes (mechanical + structural deterministic) + if show_progress: + spinner.update("[bold]Running M probes...[/bold]") # type: ignore[union-attr] + m_findings = run_m_probes(target, instruction_files, agent=effective_agent) - # Resolution chain: CLI flag > config > auto-detect > generic - agent, assumed, mixed_signals = resolve_agent(agent, all_detected_agents) - effective_agent = agent if agent else "generic" - filtered_agents = filter_agents_by_id(all_detected_agents, effective_agent) - filtered_agents = filter_agents_by_exclude_dirs(filtered_agents, target, merged_excludes) - instruction_files = get_all_instruction_files(target, agents=filtered_agents) - if not instruction_files: - _handle_no_instruction_files(effective_agent, output_format, console) + # 4. Run content-quality checks + client checks on map + content_findings: list[LocalFinding] = [] + client_findings: list[LocalFinding] = [] + if ruleset_map is not None: + if show_progress: + spinner.update("[bold]Running content checks...[/bold]") # type: ignore[union-attr] + content_findings = run_content_quality_checks(ruleset_map, target, instruction_files, agent=effective_agent) + client_findings = run_client_checks(ruleset_map) + + # 5. Server call (stub — returns None offline) + if show_progress: + spinner.update("[bold]Checking server...[/bold]") # type: ignore[union-attr] + lint_result = AilsClient().lint(ruleset_map) if ruleset_map is not None else None + server_report = lint_result.report if lint_result else None + hints = lint_result.hints if lint_result else () + + # 5b. Memory index validation (client-side, reads local filesystem) + memory_findings: list[LocalFinding] = [] + if ruleset_map is not None: + from reporails_cli.core.memory_checks import validate_memory_files + + memory_file_paths = [f.path for f in ruleset_map.files] + memory_findings = validate_memory_files(memory_file_paths) + + # 6. Merge results (content_findings + client_findings + memory_findings go together) + all_client_findings = content_findings + client_findings + memory_findings + result = merge_results(m_findings, all_client_findings, server_report, hints=hints, project_root=target) + elapsed_ms = (time.perf_counter() - start_time) * 1000 + + # 7. Format and display + if output_format == "json": + data = json_formatter.format_combined_result(result) + data["elapsed_ms"] = round(elapsed_ms, 1) + print(json.dumps(data, indent=2)) + elif output_format == "github": + from reporails_cli.formatters import github as github_formatter + + print(github_formatter.format_combined_annotations(result)) + else: + _print_text_result(result, elapsed_ms, ascii, verbose, ruleset_map=ruleset_map) + + _show_agent_auto_detect_hint(effective_agent, output_format, assumed, mixed, detected) + + if strict and result.findings: + raise typer.Exit(1) + + +def _suppress_ml_noise() -> None: + """Suppress sentence-transformers/HF stderr noise.""" + import logging as _logging + import os as _os + + _os.environ["TRANSFORMERS_VERBOSITY"] = "error" + _os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1" + _os.environ["TOKENIZERS_PARALLELISM"] = "false" + _os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1" + for lib in ("sentence_transformers", "transformers", "huggingface_hub", "reporails_cli.core.mapper"): + _logging.getLogger(lib).setLevel(_logging.ERROR) + + +def _map_in_process(instruction_files: list[Path], cache_dir: Path) -> Any: + """Run mapper in-process with stderr suppressed. Returns RulesetMap or None. + + Does NOT eagerly call ``get_models().warmup()`` — when the MapCache is + mostly warm, model loads can be skipped entirely, and an eager warmup + here would defeat that fast path. The daemon DOES eagerly warm up + because its models amortize across many requests; the in-process path + is single-use and should stay lazy. + """ + import io as _io + + saved_stderr = sys.stderr + sys.stderr = _io.StringIO() + try: + from reporails_cli.core.mapper import map_ruleset + + return map_ruleset(list(instruction_files), cache_dir=cache_dir) + except (ImportError, RuntimeError): + return None + finally: + sys.stderr = saved_stderr + + +# Diagnostics NOT in this set are displayed as structural findings (top of card). +# This includes: "general" (no atoms), memory-*, description-mismatch, and any +# new diagnostics — they appear as actionable structural items by default. +_AGGREGATE_RULES = { + # Equation diagnostics — per-atom + "CORE:C:0042", + "CORE:E:0004", + "CORE:C:0043", + "CORE:E:0003", + # Equation diagnostics — interaction + "CORE:C:0041", + "CORE:C:0044", + "CORE:C:0046", + "CORE:C:0047", + "CORE:D:0002", + "CORE:C:0051", + "CORE:C:0050", + "CORE:C:0040", + # Client check labels + "format", + "bold", + "orphan", + "heading_instruction", + "ordering", + "scope", + # Classifier confidence + "ambiguous_charge", +} +_AGGREGATE_LABELS: dict[str, str] = { + "CORE:C:0042": "vague", + "CORE:E:0004": "brief", + "CORE:C:0043": "weak", + "CORE:E:0003": "bold issues", + "CORE:C:0041": "diluted", + "CORE:C:0044": "competing", + "CORE:C:0046": "conflicting", + "CORE:C:0047": "buried", + "CORE:D:0002": "unbalanced", + "CORE:C:0051": "weak overall", + "CORE:C:0050": "low coverage", + "CORE:C:0040": "redundant", + "format": "unformatted", + "bold": "bold", + "orphan": "orphan", + "heading_instruction": "heading as instruction", + "ordering": "misordered", + "scope": "broad scope", + "ambiguous_charge": "ambiguous", +} +_AGG_ORDER = [ + "CORE:C:0042", + "CORE:E:0004", + "CORE:C:0043", + "format", + "CORE:E:0003", + "bold", + "ordering", + "orphan", + "heading_instruction", + "scope", + "ambiguous_charge", + "CORE:C:0044", + "CORE:C:0041", + "CORE:C:0047", + "CORE:D:0002", + "CORE:C:0046", + "CORE:C:0051", + "CORE:C:0050", + "CORE:C:0040", +] +_SEV_WEIGHT = {"error": 0, "warning": 1, "info": 2} +_HRULE = "\u2500" * 56 + + +def _get_sev_icons(ascii_mode: bool) -> dict[str, str]: + if ascii_mode: + return {"error": "[red]![/red]", "warning": "[yellow]![/yellow]", "info": "[dim]-[/dim]"} + return {"error": "[red]\u2717[/red]", "warning": "[yellow]\u26a0[/yellow]", "info": "[dim]\u2139[/dim]"} + + +def _classify_file(filepath: str) -> str: + """Classify a file path into a human-readable type tag.""" + p = Path(filepath) + name = p.name + parts = p.parts + + # Skills: .claude/skills//SKILL.md + if "skills" in parts and name == "SKILL.md": + idx = parts.index("skills") + if idx + 1 < len(parts) - 1: + return f"skill:{parts[idx + 1]}" + return "skill" + + # Agents: .claude/agents/.md + if "agents" in parts and name.endswith(".md"): + return f"agent:{p.stem}" + + # Rules: .claude/rules/.md + if "rules" in parts and name.endswith(".md"): + return f"rule:{p.stem}" + + # Config: settings.json, .mcp.json, config.yml + if name in ("settings.json", ".mcp.json", "config.yml", "settings.local.json"): + return "config" + + # Memory + if "memory" in parts: + return "memory" + + # Main instruction files (including tests/CLAUDE.md) + upper = name.upper() + if upper in ("CLAUDE.MD", "AGENTS.MD", ".CURSORRULES", ".WINDSURFRULES", "COPILOT-INSTRUCTIONS.MD"): + return "main" + + return "file" + + +def _file_type_summary(filepaths: set[str]) -> str: + """Build a compact type breakdown like '1 main, 8 rules, 3 skills'.""" + from collections import Counter + + type_counts: Counter[str] = Counter() + for fp in filepaths: + tag = _classify_file(fp) + base = tag.split(":")[0] + type_counts[base] += 1 + + order = ["main", "rule", "skill", "agent", "config", "memory", "file"] + plurals = { + "main": "main", + "rule": "rules", + "skill": "skills", + "agent": "agents", + "config": "configs", + "memory": "memory", + "file": "files", + } + # No special handling needed — tests already classified as main + parts = [] + for t in order: + n = type_counts.get(t, 0) + if n > 0: + label = plurals.get(t, t) if n > 1 else t + parts.append(f"{n} {label}") + return ", ".join(parts) + + +def _per_file_stats(filepath: str, ruleset_map: Any) -> str: + """Compute per-file stats from RulesetMap atoms. Returns compact stat string.""" + if ruleset_map is None: + return "" + try: + # Require at least a filename-length match to avoid "." matching everything + if len(filepath) < 3: + return "" + # Atom file_path may be absolute; finding filepath is relative. + # Normalize both to project-relative for comparison. + from reporails_cli.core.merger import normalize_finding_path + + project_root = Path.cwd() + 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] + except (AttributeError, TypeError): + return "" + if not atoms: + return "" + + n_dir = sum(1 for a in atoms if a.charge_value == +1) + n_con = sum(1 for a in atoms if a.charge_value == -1) + n_amb = sum(1 for a in atoms if a.ambiguous) + n_total = len(atoms) + n_charged = n_dir + n_con + prose_pct = round(100 * (n_total - n_charged) / n_total) if n_total else 0 + + instr_parts = [] + if n_dir: + instr_parts.append(f"{n_dir} dir") + if n_con: + instr_parts.append(f"{n_con} con") + if n_amb: + instr_parts.append(f"{n_amb} amb") + instr_str = " / ".join(instr_parts) if instr_parts else "0 instr" + + return f"{instr_str} \u00b7 {prose_pct}% prose" + + +def _get_group_atoms( + group_key: str, # noqa: ARG001 + group_files: list[tuple[str, list[Any]]], + ruleset_map: Any, +) -> list[Any]: + """Get all atoms belonging to files in this group.""" + if ruleset_map is None: + return [] + try: + from reporails_cli.core.merger import normalize_finding_path + + project_root = Path.cwd() + norm_fps = {normalize_finding_path(fp, project_root) for fp, _ in group_files} + return [a for a in ruleset_map.atoms if normalize_finding_path(a.file_path, project_root) in norm_fps] + except (AttributeError, TypeError): + return [] + + +def _friendly_name(filepath: str, tag: str) -> str: + """Extract a friendly display name from the tag. Falls back to filename.""" + if ":" in tag: + return tag.split(":", 1)[1] + p = Path(filepath) + # Include parent directory for disambiguation when name alone is ambiguous + if p.parent.name and p.parent.name != ".": + return f"{p.parent.name}/{p.name}" + return p.name + + +def _group_stats_line(atoms: list[Any]) -> str: + """Build a stats summary for a group of atoms.""" + n_dir = sum(1 for a in atoms if a.charge_value == +1) + n_con = sum(1 for a in atoms if a.charge_value == -1) + n_total = len(atoms) + prose_pct = round(100 * (n_total - n_dir - n_con) / n_total) if n_total else 0 + instr_parts = [] + if n_dir: + instr_parts.append(f"{n_dir} directive") + if n_con: + 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 _get_term_width() -> int: + """Get terminal width, defaulting to 80.""" + import shutil + + return shutil.get_terminal_size((80, 24)).columns + + +def _truncate(text: str, max_len: int) -> str: + """Truncate text to max_len, adding ellipsis if needed.""" + if len(text) <= max_len: + return text + return text[: max_len - 1] + "\u2026" + + +def _print_file_card( + filepath: str, + findings: list[Any], + sev_icons: dict[str, str], + verbose: bool, + ruleset_map: Any = None, +) -> None: + """Print one file's card: name, stats, structural findings, then quality aggregate.""" + from collections import Counter + + quality_counts: Counter[str] = Counter() + structural: list[Any] = [] + for f in findings: + if f.rule in _AGGREGATE_RULES: + quality_counts[f.rule] += 1 + else: + structural.append(f) + + tag = _classify_file(filepath) + name = _friendly_name(filepath, tag) + stats = _per_file_stats(filepath, ruleset_map) + b = "\u2502" + tw = _get_term_width() + # Prefix: " │ ⚠ L42 " ≈ 18 chars; suffix: " CORE:C:0003" ≈ 17 chars + msg_width = tw - 35 + + # Line 1: friendly name + stats + stats_str = f" [dim]{stats}[/dim]" if stats else "" + console.print(f" [dim]{b}[/dim] [bold]{name}[/bold]{stats_str}") + if verbose: + short = _short_path(filepath) + # Only show path if it differs from the friendly name + if short != name: + console.print(f" [dim]{b} {short}[/dim]") + + # Structural findings first (M1/M2 — actionable) + structural.sort(key=lambda f: _SEV_WEIGHT.get(f.severity, 9)) + limit = 2 if not verbose else 999 + for f in structural[:limit]: + icon = sev_icons.get(f.severity, " ") + raw = f.message or "" + msg = _truncate(raw, msg_width).replace("[", "\\[") + line_ref = f"L{f.line:<4d} " if f.line > 1 else " " + rule_id = f.rule.replace("[", "\\[") + console.print(f" [dim]{b}[/dim] {icon} {line_ref}{msg} [dim]{rule_id}[/dim]") + if len(structural) > limit: + console.print(f" [dim]{b} ... and {len(structural) - limit} more[/dim]") + + # Quality: verbose shows per-finding lines (deduped), compact shows aggregate counts + if verbose: + quality_findings = [f for f in findings if f.rule in _AGGREGATE_RULES] + quality_findings.sort(key=lambda f: (f.line, f.rule)) + # Dedup: group by (line, message, rule) — same diagnostic on same line is noise + seen: dict[tuple[int, str, str], int] = {} + for f in quality_findings: + msg = f.message or _AGGREGATE_LABELS.get(f.rule, f.rule) + key = (f.line, msg, f.rule) + seen[key] = seen.get(key, 0) + 1 + for (line, msg, rule), count in seen.items(): + line_ref = f"L{line:<4d} " if line > 1 else " " + suffix = f" ({count}\u00d7)" if count > 1 else "" + full = f"{msg}{suffix}" + console.print(f" [dim]{b} {line_ref}{_truncate(full, msg_width)} {rule}[/dim]") + else: + parts = [f"{quality_counts[rule]} {_AGGREGATE_LABELS[rule]}" for rule in _AGG_ORDER if rule in quality_counts] + if parts: + agg_line = " \u00b7 ".join(parts) + console.print(f" [dim]{b} {_truncate(agg_line, tw - 8)}[/dim]") + + console.print(f" [dim]{b}[/dim]") + + +def _compute_score(result: Any, has_quality: bool, n_atoms: int = 0) -> float: + """Compute a 0-10 display score from compliance band + finding severity. + + Band sets the base range, severity rates adjust within it. + Rates are relative to instruction count so larger projects + aren't penalized for having more atoms to check. + Does not expose equation internals. + """ + s = result.stats + hint_errors = sum(getattr(h, "error_count", 0) for h in result.hints) if result.hints else 0 + hint_warnings = sum(getattr(h, "warning_count", 0) for h in result.hints) if result.hints else 0 + total_errors = s.errors + hint_errors + total_warnings = s.warnings + hint_warnings + total = total_errors + total_warnings + s.infos + + if total == 0: + return 10.0 + + # Base from compliance band (equation ran) + if has_quality: + band = result.quality.compliance_band + base = 8.5 if band == "HIGH" else 5.5 if band == "MODERATE" else 3.0 + else: + base = 6.0 + + # Rate-based penalties — relative to project size + denom = max(n_atoms, total, 1) + error_rate = total_errors / denom + warning_rate = total_warnings / denom + + # Errors are structural problems: -3 at 10% error rate, caps at -4 + error_penalty = min(4.0, error_rate * 30) + # Warnings are per-atom quality: -1 at 50% warning rate, caps at -2 + warning_penalty = min(2.0, warning_rate * 2) + + score = base - error_penalty - warning_penalty + + return float(round(max(0.0, min(10.0, score)), 1)) + + +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) + empty = bar_width - filled + bar = "\u2593" * filled + "\u2591" * empty + + color = "green" if score >= 7.0 else "yellow" if score >= 4.0 else "red" + console.print(f" Score: [{color} bold]{score:.1f}[/{color} bold] / 10 [dim]{bar}[/dim]") + + +# 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", +} + + +def _finding_category(rule: str) -> str: + """Extract category letter from rule ID or client check label.""" + # CORE:C:0034 → C, CLAUDE:S:0001 → S + 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") + + +def _print_category_bars(findings: tuple[Any, ...], tw: int) -> None: + """Print per-category finding breakdown with colored bars.""" + from collections import Counter + + 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 == 0: + continue + name = _RULE_CATEGORY_MAP.get(cat_key, cat_key) + bar_len = max(1, round(bar_max * count / max_count)) + has_errors = cat_errors.get(cat_key, 0) > 0 + 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}") + console.print() + + +def _print_scorecard( # noqa: C901 + result: Any, has_quality: bool, n_atoms: int = 0, + tier: str = "", elapsed_ms: float = 0, + agent: str = "", type_str: str = "", + n_dir: int = 0, n_con: int = 0, n_amb: int = 0, n_prose: int = 0, +) -> None: + """Print the bottom scorecard — the payoff users scroll to.""" + tw = _get_term_width() + s = result.stats + + hint_errors = sum(getattr(h, "error_count", 0) for h in result.hints) if result.hints else 0 + hint_warnings = sum(getattr(h, "warning_count", 0) for h in result.hints) if result.hints else 0 + total_errors = s.errors + hint_errors + total_warnings = s.warnings + hint_warnings + total = total_errors + total_warnings + s.infos + + console.print(f" [dim]\u2500\u2500 Summary {_HRULE}[/dim]\n") + + # ── Score line ── + score = _compute_score(result, has_quality, n_atoms) + bar_width = min(30, tw - 40) + filled = round(bar_width * score / 10) + bar = "\u2593" * filled + "\u2591" * (bar_width - filled) + color = "green" if score >= 7.0 else "yellow" if score >= 4.0 else "red" + elapsed_s = f" [dim]({elapsed_ms / 1000:.1f}s)[/dim]" if elapsed_ms else "" + console.print(f" Score: [{color} bold]{score:.1f}[/{color} bold] / 10 [dim]{bar}[/dim]{elapsed_s}") + + # ── Agent ── + agent_name = agent.title() if agent else "auto" + console.print(f" Agent: {agent_name}") + + # ── Scope ── + console.print() + console.print(" Scope:") + if type_str: + console.print(f" capabilities: {type_str}") + instr_parts = [] + if n_dir or n_prose: + instr_parts.append(f"{n_dir} directive / {n_prose} prose ({round(100 * n_prose / n_atoms) if n_atoms else 0}%)") + if n_con or n_amb: + con_parts = [f"{n_con} constraint"] + if n_amb: + con_parts.append(f"{n_amb} ambiguous") + instr_parts.append(" / ".join(con_parts)) + if instr_parts: + console.print(f" instructions: {instr_parts[0]}") + for extra in instr_parts[1:]: + console.print(f" {extra}") + + # ── Results ── + console.print() + parts = [] + if total_errors: + parts.append(f"[red]{total_errors} errors[/red]") + parts.append(f"{total_warnings} warnings") + parts.append(f"{s.infos} info") + console.print(f" {total} findings \u00b7 {' \u00b7 '.join(parts)}") + + if result.cross_file: + n_conflicts = sum(1 for cf in result.cross_file if cf.finding_type == "conflict") + n_reps = sum(1 for cf in result.cross_file if cf.finding_type == "repetition") + cf_parts = [] + if n_conflicts: + cf_parts.append(f"{n_conflicts} cross-file conflicts") + if n_reps: + cf_parts.append(f"{n_reps} cross-file repetitions") + if cf_parts: + console.print(f" {' \u00b7 '.join(cf_parts)}") + + if has_quality: + band = result.quality.compliance_band + band_color = "green" if band == "HIGH" else "yellow" if band == "MODERATE" else "red" + console.print(f" Compliance: [{band_color}]{band}[/{band_color}]") + + # ── Beta CTA for unauthenticated users ── + if tier == "free": + console.print() + console.print(" [dim]Full diagnostics free for the first 100 registering users during beta[/dim]") + console.print(" [bold]ails auth login[/bold]") + + console.print() + + +def _print_text_result( # noqa: C901 + result: object, + elapsed_ms: float, + ascii_mode: bool, + verbose: bool, + ruleset_map: object = None, +) -> None: + """Print compact text output: files sorted worst-first, aggregated counts, scorecard at bottom.""" + from reporails_cli.core.merger import CombinedResult + + if not isinstance(result, CombinedResult): return - # Get previous scan BEFORE running validation (for delta comparison) - previous_scan = get_previous_scan(target) + # ── Header ── + # Normalize to relative paths so absolute and relative forms don't double-count + from reporails_cli.core.merger import normalize_finding_path + + project_root = Path.cwd() + all_files: set[str] = set() + if result.findings: + all_files.update(normalize_finding_path(f.file, project_root) for f in result.findings) + try: + from reporails_cli.core.mapper.mapper import RulesetMap + + if isinstance(ruleset_map, RulesetMap): + all_files.update(normalize_finding_path(fr.path, project_root) for fr in ruleset_map.files) + except ImportError: + pass - # Run validation with timing - show_spinner = sys.stdout.isatty() and format not in ("json", "brief", "compact", "github") - if show_spinner: - spinner = console.status("[bold]Loading rules...[/bold]") - progress_cb = lambda phase, _c, _t: spinner.update(f"[bold]{phase}...[/bold]") # noqa: E731 + # Instruction breakdown from atoms + n_dir = n_con = n_amb = n_prose = n_total = 0 + try: + if isinstance(ruleset_map, RulesetMap): + for a in ruleset_map.atoms: + n_total += 1 + if a.charge_value == +1: + n_dir += 1 + elif a.charge_value == -1: + n_con += 1 + if a.ambiguous: + n_amb += 1 + n_prose = n_total - n_dir - n_con + except (ImportError, NameError): + pass + + has_quality = result.quality is not None and bool(result.quality.compliance_band) + # Determine display tier: beta if authenticated with beta key, else free/pro + creds_tier = "" + try: + from reporails_cli.interfaces.cli.auth_command import _read_credentials + + creds_tier = _read_credentials().get("tier", "") + except Exception: + pass + if result.offline: + tier = "offline" + elif creds_tier == "beta": + tier = "Pro (beta)" + elif result.hints: + tier = "free" + elif has_quality: + tier = "Pro" else: - spinner, progress_cb = nullcontext(), None # type: ignore[assignment] - start_time = time.perf_counter() + tier = "free" + + type_str = _file_type_summary(all_files) if all_files else "0 files" + + # Detect primary agent from ruleset_map file records + _detected_agent_name = "" try: - with spinner: - result = run_validation_sync( - target, - rules_paths=rules_paths, - use_cache=not refresh, - agent=agent, - include_experimental=experimental, - exclude_dirs=merged_excludes, - on_progress=progress_cb, - ) - except FileNotFoundError as e: - console.print(f"[red]Error:[/red] File not found during validation: {e.filename or e}") - raise typer.Exit(2) from None - elapsed_ms = (time.perf_counter() - start_time) * 1000 + if isinstance(ruleset_map, RulesetMap): + from collections import Counter as _Ctr - # Compute delta from previous scan - delta = ScanDelta.compute( - current_score=result.score, - current_level=result.level.value, - current_violations=len(result.violations), - previous=previous_scan, - ) + agent_counts = _Ctr(fr.agent for fr in ruleset_map.files if fr.agent != "generic") + if agent_counts: + _detected_agent_name = agent_counts.most_common(1)[0][0] + except (AttributeError, TypeError): + pass + + tier_badge = f" — [bold]{tier}[/bold]" if tier and tier != "free" else "" + console.print(f"\n[bold]Reporails[/bold] — Diagnostics{tier_badge}\n") + + if not result.findings: + mark = "ok" if ascii_mode else "\u2713" + console.print(f" {mark} No findings.") + return - # Build surface summary from agent detection for display - from reporails_cli.formatters.text.components import build_surface_summary + # ── Group files by type ── + sev_icons = _get_sev_icons(ascii_mode) - surface = build_surface_summary(filtered_agents, target) | { - "detected_agents": [a.agent_type.id for a in all_detected_agents], - "effective_agent": effective_agent, - "assumed": assumed, + by_file: dict[str, list[Any]] = {} + for f in result.findings: + by_file.setdefault(f.file, []).append(f) + + # Classify each file and group — skip project-level "." findings + groups: dict[str, list[tuple[str, list[Any]]]] = {} + for filepath, findings in by_file.items(): + if filepath == "." or filepath == ".:0": + continue # Project-level "no matching files" noise + tag = _classify_file(filepath) + group_key = tag.split(":")[0] + groups.setdefault(group_key, []).append((filepath, findings)) + + # Sort files within each group by severity then count + for group_files in groups.values(): + group_files.sort(key=lambda x: (min(_SEV_WEIGHT.get(f.severity, 9) for f in x[1]), -len(x[1]))) + + # Display order and labels + group_order = ["main", "agent", "skill", "rule", "config", "memory"] + group_labels = { + "main": "Main", + "agent": "Agents", + "skill": "Skills", + "rule": "Rules", + "config": "Config", + "memory": "Memory", } - # Format output - _format_output( - result, - delta, - output_format, - ascii, - quiet_semantic, - elapsed_ms, - console, - surface=surface, + max_per_group = 3 if not verbose else 999 + total_remaining = 0 + + for gkey in group_order: + group_files = groups.get(gkey, []) + if not group_files: + continue + + label = group_labels.get(gkey, gkey.title()) + n_group_findings = sum(len(fs) for _, fs in group_files) + b = "\u2502" # left border + + # Group header: top border + stats + group_atoms = _get_group_atoms(gkey, group_files, ruleset_map) + stats_str = "" + if group_atoms: + stats_str = f" [dim]{_group_stats_line(group_atoms)}[/dim]" + console.print(f" [dim]\u250c\u2500[/dim] [bold]{label}[/bold] [dim]({len(group_files)})[/dim]{stats_str}") + + # File cards with left border + for i, (filepath, findings) in enumerate(group_files): + if i >= max_per_group: + remaining = sum(len(fs) for _, fs in group_files[i:]) + total_remaining += remaining + n_more = len(group_files) - i + console.print(f" [dim]{b} ... and {n_more} more ({remaining} findings)[/dim]") + break + _print_file_card(filepath, findings, sev_icons, verbose, ruleset_map=ruleset_map) + + # Bottom border + console.print(f" [dim]\u2514\u2500 {n_group_findings} findings[/dim]\n") + + # ── Hints ── + if result.hints: + agg_hints = _aggregate_hints(result.hints) + if agg_hints: + console.print(f" [dim]\u2500\u2500 Hints {_HRULE}[/dim]\n") + for sev, line in agg_hints: + icon = sev_icons.get(sev, "\u25cf") + console.print(f" {icon} {line}") + console.print("\n [dim]Beta: full diagnostics free \u2192 ails auth login[/dim]") + console.print() + + _print_scorecard( + result, has_quality, n_atoms=n_total, tier=tier, + elapsed_ms=elapsed_ms, agent=_detected_agent_name, + type_str=type_str, n_dir=n_dir, n_con=n_con, + n_amb=n_amb, n_prose=n_prose, ) - # Agent auto-detect messaging - _show_agent_auto_detect_hint(effective_agent, output_format, assumed, mixed_signals, all_detected_agents) - # Verbose diagnostics (text formats only) - if verbose and output_format not in ("json", "brief"): - _print_verbose(rules_paths, instruction_files, result, agent, elapsed_ms, target, experimental, console) +def _short_path(file_path: str) -> str: + """Extract short display path for file headers.""" + p = Path(file_path) + # Home-relative paths (e.g., ~/.claude/projects/.../memory/MEMORY.md) + home = Path.home() + if p.is_absolute(): + try: + rel = str(p.relative_to(home)) + # Shorten deep ~/.claude/projects//memory/X paths + if "memory" in p.parts: + idx = p.parts.index("memory") + return "~/" + str(Path(*p.parts[idx:])) + return "~/" + rel + except ValueError: + pass + parts = p.parts + # Shorten relative ~/.claude/projects//memory/X paths too + if "memory" in parts: + idx = parts.index("memory") + return str(Path(*parts[idx:])) + for i, part in enumerate(parts): + if part in (".claude", "tests"): + return str(Path(*parts[i:])) + if part.endswith(".md") and part[:1].isupper(): + return str(Path(*parts[i:])) + return p.name - # Exit with error only in strict mode - if strict and result.violations: - raise typer.Exit(1) + +_HINT_SEV_ORDER = {"error": 0, "warning": 1, "info": 2} + + +def _aggregate_hints(hints: tuple[Any, ...]) -> list[tuple[str, str]]: + """Aggregate per-file hints into system-wide summary lines with severity. + + Returns list of (severity, message) tuples, sorted worst-first. + """ + by_type: dict[str, list[Any]] = {} + for h in hints: + by_type.setdefault(h.diagnostic_type, []).append(h) + + lines: list[tuple[str, str]] = [] + for dtype, group in sorted(by_type.items(), key=lambda x: -len(x[1])): + n_files = len({h.file for h in group}) + total_count = sum(h.count for h in group) + s = "s" if total_count != 1 else "" + # Worst severity across all hints of this type + worst_sev = min( + (getattr(h, "severity", "warning") for h in group), + key=lambda sv: _HINT_SEV_ORDER.get(sv, 9), + ) + + templates: dict[str, str] = { + "CORE:C:0044": f"{n_files} files have overlapping topics — differentiate with named constructs", + "CORE:C:0046": f"{total_count} contradicting instruction{s} across {n_files} files", + "CORE:C:0041": f"{n_files} files have instructions diluted by surrounding content", + "CORE:C:0051": f"{n_files} files have vague instructions overall", + "CORE:C:0047": f"{total_count} instruction{s} buried in {n_files} files", + "CORE:D:0002": f"Unbalanced topics in {n_files} files", + "CORE:C:0050": f"{n_files} files lack named constructs", + } + lines.append((worst_sev, templates.get(dtype, f"{total_count} {dtype} issue{s} in {n_files} files"))) + + # Sort by severity (errors first) + lines.sort(key=lambda x: _HINT_SEV_ORDER.get(x[0], 9)) + return lines @app.command(rich_help_panel="Commands") @@ -267,10 +998,10 @@ def explain( "title": rule.title, "category": rule.category.value, "type": rule.type.value, - "level": rule.level, "slug": rule.slug, - "targets": rule.targets, - "checks": [{"id": c.id, "type": c.type, "name": c.name, "severity": c.severity.value} for c in rule.checks], + "match": _serialize_match(rule.match), + "severity": rule.severity.value, + "checks": [{"id": c.id, "type": c.type} for c in rule.checks], "see_also": rule.see_also, } @@ -285,18 +1016,26 @@ def explain( console.print(output) +import reporails_cli.interfaces.cli.heal # noqa: E402 # Register heal command + + def main() -> None: """Entry point for CLI.""" app() import reporails_cli.interfaces.cli.commands # noqa: E402 # Register commands -import reporails_cli.interfaces.cli.heal # noqa: E402 # Register heal command import reporails_cli.interfaces.cli.install # noqa: E402 # Register install command import reporails_cli.interfaces.cli.test_command # noqa: F401, E402 # Register test command +from reporails_cli.interfaces.cli.auth_command import auth_app # noqa: E402 from reporails_cli.interfaces.cli.config_command import config_app # noqa: E402 +from reporails_cli.interfaces.cli.daemon_cmd import daemon_app # noqa: E402 +from reporails_cli.interfaces.cli.stopwords_command import stopwords_app # noqa: E402 +app.add_typer(auth_app, rich_help_panel="Commands") app.add_typer(config_app, rich_help_panel="Configuration") +app.add_typer(daemon_app, hidden=True) +app.add_typer(stopwords_app, hidden=True) if __name__ == "__main__": main() diff --git a/src/reporails_cli/interfaces/cli/stopwords_command.py b/src/reporails_cli/interfaces/cli/stopwords_command.py new file mode 100644 index 00000000..dadb7be4 --- /dev/null +++ b/src/reporails_cli/interfaces/cli/stopwords_command.py @@ -0,0 +1,94 @@ +"""CLI command group: ails stopwords — vocab extraction and sync.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from reporails_cli.interfaces.cli.helpers import console + +stopwords_app = typer.Typer( + name="stopwords", + help="Manage term-based regex patterns via vocab.yml.", + no_args_is_help=True, +) + + +@stopwords_app.command("extract") +def stopwords_extract( + rules_root: str = typer.Option( + ".", + "--rules-root", + help="Rules directory root (default: current dir)", + ), +) -> None: + """Extract alternation terms from checks.yml into vocab.yml files.""" + from reporails_cli.core.stopwords import extract_all, write_vocab + + root = Path(rules_root).resolve() + if not root.exists(): + console.print(f"[red]Error:[/red] Path not found: {root}") + raise typer.Exit(2) + + results = extract_all(root) + + written = 0 + skipped = 0 + for er in results: + if er.vocab: + write_vocab(er.rule_dir, er.vocab) + slug = er.rule_dir.name + console.print(f" [green]+[/green] {slug}: {er.message}") + written += 1 + else: + skipped += 1 + + console.print() + console.print(f"Extracted: {written} vocab.yml files written, {skipped} skipped") + + +@stopwords_app.command("sync") +def stopwords_sync( + rules_root: str = typer.Option( + ".", + "--rules-root", + help="Rules directory root (default: current dir)", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would change without writing", + ), +) -> None: + """Compile vocab.yml terms into checks.yml patterns.""" + from reporails_cli.core.stopwords_sync import sync_all + + root = Path(rules_root).resolve() + if not root.exists(): + console.print(f"[red]Error:[/red] Path not found: {root}") + raise typer.Exit(2) + + results = sync_all(root, dry_run=dry_run) + + if not results: + console.print("No vocab.yml files found.") + return + + total_updated = 0 + total_skipped = 0 + for sr in results: + slug = sr.rule_dir.name + if sr.updated: + prefix = "[dim]dry-run:[/dim] " if dry_run else "" + console.print(f" {prefix}[green]+[/green] {slug}: {sr.updated} pattern(s) updated") + total_updated += sr.updated + if sr.skipped: + total_skipped += sr.skipped + for msg in sr.messages: + if "no check matching" in msg or "failed" in msg: + console.print(f" [yellow]![/yellow] {slug}: {msg}") + + console.print() + label = "Would update" if dry_run else "Updated" + console.print(f"{label}: {total_updated} pattern(s) across {len(results)} rule(s), {total_skipped} skipped") diff --git a/src/reporails_cli/interfaces/cli/test_command.py b/src/reporails_cli/interfaces/cli/test_command.py index bd888485..0a4489af 100644 --- a/src/reporails_cli/interfaces/cli/test_command.py +++ b/src/reporails_cli/interfaces/cli/test_command.py @@ -12,7 +12,7 @@ from reporails_cli.interfaces.cli.helpers import app, console -@app.command("test", rich_help_panel="Development") +@app.command("test", hidden=True) def test_rules( # pylint: disable=too-many-arguments,too-many-locals path: str = typer.Argument(None, help="Filter by path prefix (e.g., core/structure/)"), rule: str = typer.Option( @@ -70,6 +70,11 @@ def test_rules( # pylint: disable=too-many-arguments,too-many-locals "--coverage-baseline", help="Check rules against expected-rules baseline JSON file", ), + lint: bool = typer.Option( + False, + "--lint", + help="Run structural integrity checks on rule files", + ), ) -> None: """Validate rules against their own test fixtures.""" root = Path(rules_root).resolve() @@ -79,6 +84,10 @@ def test_rules( # pylint: disable=too-many-arguments,too-many-locals package_roots = [Path(p).resolve() for p in (package or [])] + if lint: + _run_lint(root, path, rule, package_roots, agent) + return + if export_baseline: _run_export_baseline(root, package_roots, agent, export_baseline) return @@ -119,6 +128,44 @@ def test_rules( # pylint: disable=too-many-arguments,too-many-locals raise typer.Exit(1) +def _run_lint( + root: Path, + filter_path: str | None, + filter_rule: str | None, + package_roots: list[Path], + agent: str, +) -> None: + """Run structural integrity checks on rule files.""" + from reporails_cli.core.harness import discover_rules, lint_rules, load_agent_config + + _, excludes = load_agent_config(root, agent) + rules = discover_rules( + root, + filter_path=filter_path, + filter_rule=filter_rule, + package_roots=package_roots, + excludes=excludes, + agent=None, + ) + + if not rules: + console.print("[red]No rules found.[/red]") + raise typer.Exit(1) + + errors = lint_rules(rules) + + console.print(f"Lint: {len(rules)} rule(s) checked") + if not errors: + console.print("[green]0 errors[/green]") + return + + console.print(f"[red]{len(errors)} error(s):[/red]") + for err in errors: + console.print(f" {err.rule_id} [{err.check_name}] {err.message}") + + raise typer.Exit(1) + + def _run_export_baseline( root: Path, package_roots: list[Path], diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index b235665e..8677e322 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -1,38 +1,39 @@ """MCP server for reporails - exposes validation tools to Claude Code.""" -import hashlib -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from mcp.server import Server -from mcp.server.stdio import stdio_server -from mcp.types import TextContent, Tool - -from reporails_cli.core.agents import get_all_instruction_files -from reporails_cli.core.bootstrap import is_initialized -from reporails_cli.core.cache import get_previous_scan -from reporails_cli.core.engine import run_validation -from reporails_cli.core.models import ScanDelta -from reporails_cli.formatters import mcp as mcp_formatter -from reporails_cli.interfaces.mcp.tools import ( - _get_exclude_dirs, - _resolve_recommended_rules_paths, +# ───────────────────────────────────────────────────────────────────── +# CRITICAL: torch import blocker MUST run before any import that could +# transitively reach thinc/spacy. The MCP server is long-lived and +# serves many tool calls; skipping the ~20s torch import makes first +# validate/score calls fast. See `_torch_blocker` docstring for details. +from reporails_cli.core import _torch_blocker + +_torch_blocker.install() +# ───────────────────────────────────────────────────────────────────── + +import hashlib # noqa: E402 +import json # noqa: E402 +from dataclasses import dataclass # noqa: E402 +from pathlib import Path # noqa: E402 +from typing import Any # noqa: E402 + +from mcp.server import Server # noqa: E402 +from mcp.server.stdio import stdio_server # noqa: E402 +from mcp.types import TextContent, Tool # noqa: E402 + +from reporails_cli.core.agents import get_all_instruction_files # noqa: E402 +from reporails_cli.core.bootstrap import is_initialized # noqa: E402 +from reporails_cli.interfaces.mcp.tools import ( # noqa: E402 explain_tool, - heal_tool, - judge_tool, score_tool, + validate_tool, ) # Create MCP server server = Server("ails") # Circuit breaker: content-aware loop detection. -# Tracks instruction file mtimes — if files changed between calls, it's a -# legitimate edit-validate cycle, not a loop. -_MAX_CALLS = 10 # Absolute ceiling per session -_MAX_UNCHANGED = 2 # Consecutive calls without file changes +_MAX_CALLS = 10 +_MAX_UNCHANGED = 2 @dataclass @@ -64,10 +65,9 @@ async def list_tools() -> list[Tool]: Tool( name="validate", description=( - "Returns JSON with score (0-10), level (L1-L6), violations array," - " and judgment_requests for semantic rules you must evaluate." + "Validate AI instruction files. Returns JSON with findings," + " compliance band, and cross-file analysis." " Use when user asks to check, validate, or improve instruction files." - " After evaluating judgment_requests, call judge to cache verdicts." ), inputSchema={ "type": "object", @@ -83,10 +83,7 @@ async def list_tools() -> list[Tool]: Tool( name="score", description=( - "Quick score check without violation details or semantic rules." - " Returns JSON with score, level, violation count, and friction." - " Use for fast health checks or progress monitoring." - " Use validate for full details." + "Quick score check without violation details. Returns JSON with compliance band and violation count." ), inputSchema={ "type": "object", @@ -103,7 +100,7 @@ async def list_tools() -> list[Tool]: name="explain", description=( "Get details about a specific rule by ID." - " Returns JSON with title, category, type, level, description, checks." + " Returns rule title, category, type, description, checks." " Use full coordinate IDs (e.g., CORE:S:0005, CLAUDE:S:0011)." ), inputSchema={ @@ -111,7 +108,7 @@ async def list_tools() -> list[Tool]: "properties": { "rule_id": { "type": "string", - "description": "Rule ID to explain (e.g., S1, C2)", + "description": "Rule ID to explain (e.g., CORE:S:0005)", } }, "required": ["rule_id"], @@ -120,11 +117,9 @@ async def list_tools() -> list[Tool]: Tool( name="heal", description=( - "Auto-fix deterministic violations (adds missing sections) then returns" - " remaining semantic judgment_requests for you to evaluate." - " Use when user asks to fix, heal, or improve instruction files." - " Returns JSON with auto_fixed array and judgment_requests." - " After evaluating judgment_requests, call judge to cache verdicts." + "Auto-fix instruction file issues. Applies formatting, bold→italic," + " constraint wrapping, and charge ordering fixes." + " Use --dry-run to preview." ), inputSchema={ "type": "object", @@ -134,42 +129,20 @@ async def list_tools() -> list[Tool]: "description": "Directory to heal (default: current directory)", "default": ".", }, - }, - }, - ), - Tool( - name="judge", - description=( - "Cache semantic rule verdicts so they persist across validation runs." - " Call after evaluating judgment_requests from validate." - " Verdict format: RULE_ID:FILENAME:pass|fail:reason." - " Keep reason BRIEF (under 40 chars)." - " Examples: 'CORE:C:0017:CLAUDE.md:pass:Repo-specific paths'," - " 'CORE:S:0012:CLAUDE.md:fail:3 inlined procedures'." - ), - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Project root directory", - "default": ".", - }, - "verdicts": { - "type": "array", - "items": {"type": "string"}, - "description": "Verdict strings in rule_id:location:verdict:reason format", + "dry_run": { + "type": "boolean", + "description": "Preview fixes without applying", + "default": False, }, }, - "required": ["verdicts"], }, ), ] -def _json_response(data: dict[str, Any], indent: int = 2) -> list[TextContent]: - """Wrap a dict as a JSON TextContent response.""" - return [TextContent(type="text", text=json.dumps(data, indent=indent))] +def _json_response(data: dict[str, Any]) -> list[TextContent]: + """Wrap a dict as a compact JSON TextContent response.""" + return [TextContent(type="text", text=json.dumps(data, separators=(",", ":")))] async def _handle_validate(arguments: dict[str, Any]) -> list[TextContent]: @@ -177,7 +150,6 @@ async def _handle_validate(arguments: dict[str, Any]) -> list[TextContent]: path = arguments.get("path", ".") target = Path(path).resolve() - # Circuit breaker: update state, then check thresholds path_key = str(target) state = _validate_states.get(path_key, _CircuitState()) mtime_hash = _compute_mtime_hash(target) @@ -212,41 +184,25 @@ async def _handle_validate(arguments: dict[str, Any]) -> list[TextContent]: if not target.is_dir(): return _json_response({"error": "not_a_directory", "message": f"Not a directory: {target}"}) - previous_scan = get_previous_scan(target) - try: - rules_paths = _resolve_recommended_rules_paths(target) - exclude_dirs = _get_exclude_dirs(target) - result = run_validation(target, agent="claude", rules_paths=rules_paths, exclude_dirs=exclude_dirs) - except (FileNotFoundError, ValueError, RuntimeError) as e: - return _json_response({"error": type(e).__name__, "message": str(e)}) - - delta = ScanDelta.compute( - current_score=result.score, - current_level=result.level.value, - current_violations=len(result.violations), - previous=previous_scan, - ) - return _json_response(mcp_formatter.format_result(result, delta=delta)) + return _json_response(validate_tool(path)) async def _handle_score(arguments: dict[str, Any]) -> list[TextContent]: - """Handle the 'score' tool call.""" + """Handle 'score'.""" return _json_response(score_tool(arguments.get("path", "."))) -async def _handle_explain(arguments: dict[str, Any]) -> list[TextContent]: - """Handle the 'explain' tool call.""" - return _json_response(explain_tool(arguments.get("rule_id", ""))) - - async def _handle_heal(arguments: dict[str, Any]) -> list[TextContent]: - """Handle the 'heal' tool call.""" - return _json_response(heal_tool(arguments.get("path", "."))) + """Handle 'heal'.""" + from reporails_cli.interfaces.mcp.tools import heal_tool + return _json_response(heal_tool(arguments.get("path", "."), arguments.get("dry_run", False))) -async def _handle_judge(arguments: dict[str, Any]) -> list[TextContent]: - """Handle the 'judge' tool call.""" - return _json_response(judge_tool(arguments.get("path", "."), arguments.get("verdicts", []))) + +async def _handle_explain(arguments: dict[str, Any]) -> list[TextContent]: + """Handle 'explain' — readable text, not JSON.""" + result = explain_tool(arguments.get("rule_id", "")) + return _json_response(result) if isinstance(result, dict) else [TextContent(type="text", text=result)] @server.call_tool() # type: ignore[untyped-decorator] @@ -257,7 +213,6 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: "explain": _handle_explain, "score": _handle_score, "heal": _handle_heal, - "judge": _handle_judge, } handler = handlers.get(name) if handler is None: diff --git a/src/reporails_cli/interfaces/mcp/tools.py b/src/reporails_cli/interfaces/mcp/tools.py index 0fb83799..c73557ae 100644 --- a/src/reporails_cli/interfaces/mcp/tools.py +++ b/src/reporails_cli/interfaces/mcp/tools.py @@ -3,267 +3,165 @@ from pathlib import Path from typing import Any -from reporails_cli.core.bootstrap import get_project_config, is_initialized -from reporails_cli.core.engine import run_validation +from reporails_cli.core.bootstrap import is_initialized +from reporails_cli.core.models import FileMatch from reporails_cli.core.registry import infer_agent_from_rule_id, load_rules from reporails_cli.formatters import mcp as mcp_formatter -from reporails_cli.formatters import text as text_formatter -def _resolve_recommended_rules_paths(target: Path) -> list[Path] | None: - """Build rules_paths including recommended package if enabled for target project.""" - import logging - - from reporails_cli.core.bootstrap import get_project_config, get_recommended_package_path - from reporails_cli.core.init import download_recommended, is_recommended_installed - from reporails_cli.core.registry import get_rules_dir - - logger = logging.getLogger("reporails") +def _serialize_match(match: FileMatch | None) -> dict[str, object]: + """Serialize FileMatch to dict, including all non-None properties.""" + if match is None: + return {} + result: dict[str, object] = {} + if match.type is not None: + result["type"] = match.type + for prop in ("format", "scope", "cardinality", "lifecycle", "maintainer", "vcs", "loading", "precedence"): + val = getattr(match, prop) + if val is not None: + result[prop] = val + return result + + +def _run_pipeline(target: Path) -> dict[str, Any]: + """Run the full check pipeline and return CombinedResult as dict.""" + from reporails_cli.core.agents import detect_agents, get_all_instruction_files, resolve_agent + from reporails_cli.core.api_client import AilsClient + from reporails_cli.core.client_checks import run_client_checks + from reporails_cli.core.config import get_project_config + from reporails_cli.core.merger import merge_results + from reporails_cli.core.rule_runner import run_content_quality_checks, run_m_probes + from reporails_cli.formatters import json as json_formatter + + config = get_project_config(target) + detected = detect_agents(target) + effective_agent, _, _ = resolve_agent(config.default_agent, detected) + instruction_files = get_all_instruction_files(target, agents=detected) + if not instruction_files: + return {"error": "No instruction files found"} + + # Build map first + ruleset_map = None + try: + from reporails_cli.core.mapper import map_ruleset - project_config = get_project_config(target) - if not project_config.recommended: - return None + cache_dir = target / ".ails" / ".cache" + ruleset_map = map_ruleset(list(instruction_files), cache_dir=cache_dir) + except (ImportError, RuntimeError): + pass - if not is_recommended_installed(): - try: - download_recommended() - except Exception as e: - logger.warning("Failed to download recommended rules: %s: %s", type(e).__name__, e) - return None + # M probes (mechanical + deterministic) + m_findings = run_m_probes(target, instruction_files, agent=effective_agent) - rec_path = get_recommended_package_path() - if rec_path.is_dir(): - return [get_rules_dir(), rec_path] - return None + # Content quality + client checks on map + content_findings = ( + run_content_quality_checks(ruleset_map, target, instruction_files, agent=effective_agent) if ruleset_map else [] + ) + client_findings = run_client_checks(ruleset_map) if ruleset_map else [] - -def _get_exclude_dirs(target: Path) -> list[str] | None: - """Load exclude_dirs from project config.""" - dirs = get_project_config(target).exclude_dirs - return sorted(dirs) if dirs else None + lint_result = AilsClient().lint(ruleset_map) if ruleset_map else None + server_report = lint_result.report if lint_result else None + hints = lint_result.hints if lint_result else () + result = merge_results( + m_findings, content_findings + client_findings, server_report, hints=hints, project_root=target + ) + return json_formatter.format_combined_result(result) def validate_tool(path: str = ".") -> dict[str, Any]: - """ - Validate AI instruction files at path. - - Returns violations, score, level, and JudgmentRequests for semantic rules. - - Args: - path: Directory to validate (default: current directory) - - Returns: - Validation result dict - """ + """Validate AI instruction files at path.""" if not is_initialized(): return {"error": "Reporails not initialized. Run 'ails install' first."} - target = Path(path).resolve() - if not target.exists(): return {"error": f"Path not found: {target}"} if not target.is_dir(): return {"error": f"Path is not a directory: {target}"} - try: - rules_paths = _resolve_recommended_rules_paths(target) - exclude_dirs = _get_exclude_dirs(target) - result = run_validation(target, agent="claude", rules_paths=rules_paths, exclude_dirs=exclude_dirs) - return mcp_formatter.format_result(result) + return _run_pipeline(target) except (FileNotFoundError, ValueError, RuntimeError) as e: return {"error": str(e)} -def validate_tool_text(path: str = ".") -> str: - """ - Validate AI instruction files at path, returning text format. - - Returns human-readable text report with score, violations, and friction. - - Args: - path: Directory to validate (default: current directory) - - Returns: - Text-formatted validation report - """ - if not is_initialized(): - return "Error: Reporails not initialized. Run 'ails install' first." - - target = Path(path).resolve() - - if not target.exists(): - return f"Error: Path not found: {target}" - if not target.is_dir(): - return f"Error: Path is not a directory: {target}" - - try: - rules_paths = _resolve_recommended_rules_paths(target) - exclude_dirs = _get_exclude_dirs(target) - result = run_validation(target, agent="claude", rules_paths=rules_paths, exclude_dirs=exclude_dirs) - return text_formatter.format_result(result, ascii_mode=True) - except (FileNotFoundError, ValueError, RuntimeError) as e: - return f"Error: {e}" - - def score_tool(path: str = ".") -> dict[str, Any]: - """ - Quick score check for AI instruction files. - - Args: - path: Directory to score (default: current directory) - - Returns: - Score summary dict - """ + """Quick score check for AI instruction files.""" if not is_initialized(): return {"error": "Reporails not initialized. Run 'ails install' first."} - target = Path(path).resolve() - if not target.exists(): return {"error": f"Path not found: {target}"} if not target.is_dir(): return {"error": f"Path is not a directory: {target}"} - try: - rules_paths = _resolve_recommended_rules_paths(target) - exclude_dirs = _get_exclude_dirs(target) - result = run_validation(target, agent="claude", rules_paths=rules_paths, exclude_dirs=exclude_dirs) - return mcp_formatter.format_score(result) + result = _run_pipeline(target) + if "error" in result: + return result + stats = result.get("stats", {}) + return { + "compliance_band": result.get("compliance_band", "offline"), + "total_findings": stats.get("total_findings", 0), + "errors": stats.get("errors", 0), + "warnings": stats.get("warnings", 0), + "offline": result.get("offline", True), + } except (FileNotFoundError, ValueError, RuntimeError) as e: return {"error": str(e)} -def judge_tool(path: str = ".", verdicts: list[str] | None = None) -> dict[str, Any]: - """ - Cache semantic judgment verdicts so they persist across validation runs. - - Args: - path: Project root directory (default: current directory) - verdicts: Verdict strings in rule_id:location:verdict:reason format - - Returns: - Dict with recorded count or error - """ - if not verdicts: - return {"error": "No verdicts provided"} - +def heal_tool(path: str = ".", dry_run: bool = False) -> dict[str, Any]: + """Auto-fix instruction file issues at path.""" + if not is_initialized(): + return {"error": "Reporails not initialized. Run 'ails install' first."} target = Path(path).resolve() if not target.exists(): return {"error": f"Path not found: {target}"} if not target.is_dir(): return {"error": f"Path is not a directory: {target}"} - try: - recorded_verdicts, failed = cache_judgments_with_details(target, verdicts) - result: dict[str, Any] = {"recorded": len(recorded_verdicts)} - if recorded_verdicts: - result["verdicts"] = recorded_verdicts - if failed: - result["failed"] = failed - return result - except (ValueError, OSError) as e: - return {"error": str(e)} + from reporails_cli.core.agents import detect_agents, get_all_instruction_files, resolve_agent + from reporails_cli.core.config import get_project_config + config = get_project_config(target) + detected = detect_agents(target) + _agent, _, _ = resolve_agent(config.default_agent, detected) + instruction_files = get_all_instruction_files(target, agents=detected) + if not instruction_files: + return {"auto_fixed": [], "summary": {"auto_fixed_count": 0}} -def cache_judgments_with_details(target: Path, verdicts: list[str]) -> tuple[list[dict[str, str]], list[str]]: - """Cache judgments and return (recorded_verdict_details, failed_reasons).""" - from reporails_cli.core.cache import _parse_verdict_string, cache_judgments - - # Pre-validate verdicts to report failures - failed: list[str] = [] - valid_verdicts: list[str] = [] - parsed: list[dict[str, str]] = [] - for v in verdicts: - rule_id, location, verdict, reason = _parse_verdict_string(v) - if not rule_id or not location: - failed.append(f"Invalid format (need RULE:FILE:verdict:reason): {v}") - continue - if verdict not in ("pass", "fail"): - failed.append(f"Invalid verdict value (need pass/fail): {v}") - continue - file_path = location.rsplit(":", 1)[0] if ":" in location else location - full_path = (target / file_path).resolve() - if not full_path.is_relative_to(target): - failed.append(f"Path traversal blocked: {file_path}") - continue - if not full_path.exists(): - failed.append(f"File not found: {file_path}") - continue - valid_verdicts.append(v) - # Truncate reason for display (full reason still cached via cache_judgments) - brief = (reason[:37] + "...") if len(reason) > 40 else reason - parsed.append({"rule": rule_id, "file": file_path, "verdict": verdict, "reason": brief}) - - if valid_verdicts: - cache_judgments(target, valid_verdicts) - return parsed, failed - - -def heal_tool(path: str = ".") -> dict[str, Any]: - """ - Auto-fix deterministic violations and return remaining semantic requests. - - Applies safe, additive fixes (append missing sections) then returns - a summary of fixes applied plus any pending judgment requests. - - Args: - path: Directory to heal (default: current directory) - - Returns: - Dict with auto_fixed list and remaining judgment_requests - """ - if not is_initialized(): - return {"error": "Reporails not initialized. Run 'ails check' first."} - - target = Path(path).resolve() - if not target.exists(): - return {"error": f"Path not found: {target}"} - if not target.is_dir(): - return {"error": f"Path is not a directory: {target}"} - - try: - from reporails_cli.core.fixers import apply_auto_fixes, partition_violations + ruleset_map = None + try: + from reporails_cli.core.mapper import map_ruleset - rules_paths = _resolve_recommended_rules_paths(target) - exclude_dirs = _get_exclude_dirs(target) - result = run_validation(target, agent="claude", rules_paths=rules_paths, exclude_dirs=exclude_dirs) + cache_dir = target / ".ails" / ".cache" + ruleset_map = map_ruleset(list(instruction_files), cache_dir=cache_dir) + except (ImportError, RuntimeError): + pass - # Partition violations into fixable and non-fixable - fixable, non_fixable = partition_violations(list(result.violations)) + fixes: list[dict[str, str]] = [] + if ruleset_map is not None: + from reporails_cli.core.mechanical_fixers import apply_mechanical_fixes - # Phase 1: Auto-fix - fixes = apply_auto_fixes(fixable, target) + mech = apply_mechanical_fixes(ruleset_map, target, dry_run=dry_run) + fixes.extend({"rule_id": m.fix_type, "file_path": m.file_path, "description": m.description} for m in mech) - # Phase 2: Return fixes, non-fixable violations, and semantic requests - return mcp_formatter.format_heal_result(fixes, list(result.judgment_requests), non_fixable=non_fixable) + return {"auto_fixed": fixes, "summary": {"auto_fixed_count": len(fixes), "dry_run": dry_run}} except (FileNotFoundError, ValueError, RuntimeError) as e: return {"error": str(e)} -def explain_tool(rule_id: str, rules_paths: list[Path] | None = None) -> dict[str, Any]: - """ - Get detailed info about a specific rule. - - Args: - rule_id: Rule identifier (e.g., S1, C2) - rules_paths: Optional rules directories (for CLI/testing; MCP auto-resolves) - - Returns: - Rule details dict - """ +def explain_tool(rule_id: str, rules_paths: list[Path] | None = None) -> str | dict[str, Any]: + """Get detailed info about a specific rule.""" if rules_paths is None: from reporails_cli.core.bootstrap import get_recommended_package_path from reporails_cli.core.registry import get_rules_dir - # Auto-resolve: include recommended if available rec_path = get_recommended_package_path() if rec_path.is_dir(): rules_paths = [get_rules_dir(), rec_path] rule_id_upper = rule_id.upper() - agent = infer_agent_from_rule_id(rule_id_upper) # auto-load agent-namespaced rules - rules = load_rules(rules_paths, include_experimental=True, agent=agent) + agent = infer_agent_from_rule_id(rule_id_upper) + rules = load_rules(rules_paths, agent=agent) if rule_id_upper not in rules: return { @@ -276,22 +174,20 @@ def explain_tool(rule_id: str, rules_paths: list[Path] | None = None) -> dict[st "title": rule.title, "category": rule.category.value, "type": rule.type.value, - "level": rule.level, "slug": rule.slug, - "targets": rule.targets, - "checks": [{"id": c.id, "type": c.type, "name": c.name, "severity": c.severity.value} for c in rule.checks], + "match": _serialize_match(rule.match), + "severity": rule.severity.value, + "checks": [{"id": c.id, "type": c.type} for c in rule.checks], "see_also": rule.see_also, } - # Read description from markdown file if available if rule.md_path and rule.md_path.exists(): try: content = rule.md_path.read_text(encoding="utf-8") - # Extract content after frontmatter parts = content.split("---", 2) if len(parts) >= 3: rule_data["description"] = parts[2].strip()[:500] except (OSError, ValueError): - pass # Return rule data without description + pass return mcp_formatter.format_rule(rule_id_upper, rule_data) diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index cae52514..409d9716 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -1,10 +1,9 @@ # Tests -Unit and integration tests for reporails CLI. +Unit and integration tests for `reporails` CLI. -- Add unit tests in `unit/` for new functions -- Add integration tests in `integration/` for pipeline changes -- Read existing tests fully before adding new ones to avoid duplication -- Reference from memory instead of re-reading unchanged fixtures -- When requirements are ambiguous, ask for clarification rather than guessing -- Do not modify golden fixtures; update the corresponding expected output alongside instead +- Add unit tests in `tests/unit/` for new functions in `src/reporails_cli/` +- Add integration tests in `tests/integration/` for changes to `src/reporails_cli/core/pipeline.py` or `src/reporails_cli/core/rule_runner.py` +- Read existing `test_*.py` files fully before adding new ones — duplicate tests waste CI time +- **Do not modify golden fixtures in `tests/fixtures/golden/`.* Update the corresponding expected output file alongside instead.* +- When requirements are ambiguous, ask for clarification rather than guessing test behavior diff --git a/tests/conftest.py b/tests/conftest.py index 9f02cac2..681ad5da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ """Pytest fixtures for reporails test suite. -Provides reusable fixtures for testing template resolution, rule validation, -capability detection, and scoring. +Provides reusable fixtures for testing rule validation and scoring. """ from __future__ import annotations @@ -40,27 +39,26 @@ def fixtures_dir() -> Path: @pytest.fixture def dev_rules_dir() -> Path: - """Path to development rules directory (framework repo). + """Path to development rules directory (bundled in-repo). - Skip if not available (CI environments without the monorepo). + Skip if not available (e.g. rules not yet copied into repo). """ - # Walk up from cli/ to find rules/ sibling cli_dir = Path(__file__).resolve().parents[1] # cli/ - rules_dir = cli_dir.parent / "rules" + rules_dir = cli_dir / "framework" / "rules" if not rules_dir.exists() or not (rules_dir / "core").exists(): pytest.skip("Development rules directory not available") return rules_dir @pytest.fixture -def agent_config() -> dict[str, str]: - """Return Claude agent template variables. +def agent_file_types() -> list: + """Return Claude agent file type declarations. Skips when framework is not installed (CI without ~/.reporails/rules/). """ - from reporails_cli.core.bootstrap import get_agent_vars + from reporails_cli.core.bootstrap import get_agent_file_types - result = get_agent_vars("claude") + result = get_agent_file_types("claude") if not result: pytest.skip("Framework not installed (no agent config available)") return result @@ -175,7 +173,7 @@ def level5_project(tmp_path: Path) -> Generator[Path, None, None]: ## Session Start -1. Read `.reporails/backbone.yml` +1. Read `.ails/backbone.yml` 2. Check project status ## Commands @@ -205,18 +203,24 @@ def level5_project(tmp_path: Path) -> Generator[Path, None, None]: - MUST write tests for new features """) - # Create .reporails directory with backbone - reporails_dir = project / ".reporails" - reporails_dir.mkdir() + # Create skills directory (L5 gate: dynamic_context) + skills_dir = project / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + (skills_dir / "example.md").write_text("# Example skill\n") - (reporails_dir / "backbone.yml").write_text("""\ + # Create .ails directory with backbone + ails_dir = project / ".ails" + ails_dir.mkdir() + + (ails_dir / "backbone.yml").write_text("""\ # Auto-generated by ails map. Customize freely. -version: 2 +version: 3 generator: ails map agents: claude: main_instruction_file: CLAUDE.md rules: .claude/rules/ + skills: .claude/skills/ """) yield project @@ -227,9 +231,9 @@ def level5_project(tmp_path: Path) -> Generator[Path, None, None]: @pytest.fixture def valid_rule_yaml() -> str: - """Return a valid OpenGrep rule YAML.""" + """Return a valid checks YAML.""" return """\ -rules: +checks: - id: test-valid-rule message: "Found a TODO comment" severity: WARNING @@ -245,7 +249,7 @@ def valid_rule_yaml() -> str: def valid_rule_with_patterns_yaml() -> str: """Return a valid rule using patterns block.""" return """\ -rules: +checks: - id: test-patterns-rule message: "File missing required section" severity: WARNING @@ -266,7 +270,7 @@ def invalid_toplevel_pattern_not_regex_yaml() -> str: This is the bug we found - pattern-not-regex requires patterns: block. """ return """\ -rules: +checks: - id: test-invalid-toplevel message: "Invalid schema" severity: WARNING @@ -278,38 +282,6 @@ def invalid_toplevel_pattern_not_regex_yaml() -> str: """ -@pytest.fixture -def rule_with_template_yaml() -> str: - """Return a rule with template placeholder.""" - return """\ -rules: - - id: test-template-rule - message: "Found match" - severity: WARNING - languages: [generic] - pattern-regex: "MUST" - paths: - include: - - "{{instruction_files}}" -""" - - -@pytest.fixture -def rule_with_unresolvable_template_yaml() -> str: - """Return a rule with a template that won't resolve.""" - return """\ -rules: - - id: test-unresolvable - message: "Unresolvable template" - severity: WARNING - languages: [generic] - pattern-regex: "test" - paths: - include: - - "{{nonexistent_variable}}" -""" - - # --- Helper Functions --- diff --git a/tests/fixtures/golden/l2_claude/expected.json b/tests/fixtures/golden/l2_claude/expected.json index a5a4ef4e..215d88eb 100644 --- a/tests/fixtures/golden/l2_claude/expected.json +++ b/tests/fixtures/golden/l2_claude/expected.json @@ -2,26 +2,34 @@ "category_summary": [ { "code": "S", - "failed": 13, + "failed": 19, "name": "Structure", - "passed": 8, - "total": 21, + "passed": 11, + "total": 30, "worst_severity": "high" }, { "code": "C", - "failed": 3, - "name": "Content", - "passed": 5, - "total": 8, + "failed": 30, + "name": "Coherence", + "passed": 17, + "total": 47, "worst_severity": "high" }, + { + "code": "D", + "failed": 0, + "name": "Direction", + "passed": 0, + "total": 0, + "worst_severity": null + }, { "code": "E", "failed": 0, "name": "Efficiency", - "passed": 4, - "total": 4, + "passed": 6, + "total": 6, "worst_severity": null }, { @@ -30,15 +38,15 @@ "name": "Maintenance", "passed": 0, "total": 1, - "worst_severity": "medium" + "worst_severity": "high" }, { "code": "G", - "failed": 6, + "failed": 2, "name": "Governance", - "passed": 1, - "total": 7, - "worst_severity": "medium" + "passed": 2, + "total": 4, + "worst_severity": "high" } ], "evaluation": "awaiting_semantic", @@ -57,162 +65,608 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:7", + "pass_value": "pass", + "rule_id": "CORE:C:0001" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:8", + "pass_value": "pass", + "rule_id": "CORE:C:0003" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:11", + "pass_value": "pass", + "rule_id": "CORE:C:0033" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0046" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0047" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0045" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0046" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:17", + "pass_value": "pass", + "rule_id": "CORE:S:0047" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:17", + "pass_value": "pass", + "rule_id": "CORE:S:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", "pass_value": "pass", - "rule_id": "CORE:G:0007" + "rule_id": "CORE:S:0054" } ], "level": "L2", "pending_semantic": { "file_count": 1, - "rule_count": 1, + "rule_count": 11, "rules": [ - "CORE:G:0007" + "CORE:C:0001", + "CORE:C:0003", + "CORE:C:0033", + "CORE:C:0046", + "CORE:C:0047", + "CORE:C:0048", + "CORE:S:0045", + "CORE:S:0046", + "CORE:S:0047", + "CORE:S:0048", + "CORE:S:0054" ] }, - "score": 4.5, + "score": 4.2, "summary": { - "rules_checked": 41, - "rules_failed": 23, - "rules_passed": 18 + "rules_checked": 88, + "rules_failed": 52, + "rules_passed": 36 }, "violations": [ { - "check_id": "CLAUDE.G.0001.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:G:0001", + "check_id": "CORE.C.0002.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0002", + "severity": "low" + }, + { + "check_id": "CORE.C.0004.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0004", + "severity": "medium" + }, + { + "check_id": "CORE.C.0006.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0006", + "severity": "medium" + }, + { + "check_id": "CORE.C.0007.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0007", + "severity": "medium" + }, + { + "check_id": "CORE.C.0009.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0009", "severity": "medium" }, { - "check_id": "CLAUDE.S.0001.skill_dir_exists", - "location": ".claude/skills/**/*.md:0", - "rule_id": "CLAUDE:S:0001", + "check_id": "CORE.C.0012.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0012", + "severity": "high" + }, + { + "check_id": "CORE.C.0014.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0014", "severity": "high" }, { - "check_id": "CLAUDE.S.0002.skill_file_exists", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CLAUDE:S:0002", + "check_id": "CORE.C.0016.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0016", + "severity": "medium" + }, + { + "check_id": "CORE.C.0020.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0020", "severity": "medium" }, { - "check_id": "CLAUDE.S.0003.skill_file_exists", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CLAUDE:S:0003", + "check_id": "CORE.C.0021.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0021", + "severity": "medium" + }, + { + "check_id": "CORE.C.0023.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0023", + "severity": "medium" + }, + { + "check_id": "CORE.C.0024.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0024", "severity": "high" }, { - "check_id": "CLAUDE.S.0004.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0004", + "check_id": "CORE.C.0025.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0025", "severity": "high" }, { - "check_id": "CLAUDE.S.0005.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0005", + "check_id": "CORE.C.0027.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0027", + "severity": "medium" + }, + { + "check_id": "CORE.C.0028.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0028", + "severity": "low" + }, + { + "check_id": "CORE.C.0031.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0031", + "severity": "medium" + }, + { + "check_id": "CORE.C.0034.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0034", "severity": "high" }, { - "check_id": "CLAUDE.S.0006.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0006", + "check_id": "CORE.C.0036.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0036", + "severity": "medium" + }, + { + "check_id": "CORE.C.0037.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0037", + "severity": "medium" + }, + { + "check_id": "CORE.C.0038.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0038", + "severity": "medium" + }, + { + "check_id": "CORE.C.0039.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0039", + "severity": "medium" + }, + { + "check_id": "CORE.C.0040.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0040", + "severity": "medium" + }, + { + "check_id": "CORE.C.0041.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0041", + "severity": "medium" + }, + { + "check_id": "CORE.C.0041.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0041", + "severity": "medium" + }, + { + "check_id": "CORE.C.0042.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0042", + "severity": "medium" + }, + { + "check_id": "CORE.C.0042.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0042", + "severity": "medium" + }, + { + "check_id": "CORE.C.0043.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0043", + "severity": "medium" + }, + { + "check_id": "CORE.C.0043.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0043", + "severity": "medium" + }, + { + "check_id": "CORE.C.0044.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0044", + "severity": "medium" + }, + { + "check_id": "CORE.C.0044.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0044", + "severity": "medium" + }, + { + "check_id": "CORE.C.0045.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0045", + "severity": "medium" + }, + { + "check_id": "CORE.C.0045.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0045", + "severity": "medium" + }, + { + "check_id": "CORE.C.0046.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0046", + "severity": "medium" + }, + { + "check_id": "CORE.C.0047.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0047", + "severity": "medium" + }, + { + "check_id": "CORE.C.0048.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0048", + "severity": "medium" + }, + { + "check_id": "CORE.G.0001.check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:G:0001", "severity": "high" }, { - "check_id": "CLAUDE.S.0007.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0007", + "check_id": "CORE.G.0004.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:G:0004", + "severity": "medium" + }, + { + "check_id": "CORE.M.0001.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:M:0001", "severity": "high" }, { - "check_id": "has_security_content", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0011", + "check_id": "CORE.S.0001.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0001", "severity": "medium" }, { - "check_id": "has_project_description", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0013", + "check_id": "CORE.S.0010.modular", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0010", "severity": "high" }, { - "check_id": "has_code_fences", - "location": "CLAUDE.md:7", - "rule_id": "CORE:C:0016", + "check_id": "CORE.S.0039.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0039", "severity": "medium" }, { - "check_id": "has_command_boundaries", - "location": "CLAUDE.md:1", - "rule_id": "CORE:G:0004", + "check_id": "CORE.S.0039.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0039", "severity": "medium" }, { - "check_id": "CORE.G.0005.file_in_scope", - "location": ".claude/settings.json:0", - "rule_id": "CORE:G:0005", + "check_id": "CORE.S.0040.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0040", "severity": "medium" }, { - "check_id": "CORE.G.0006.managed_config_exists", - "location": "/etc/claude-code/managed-settings.json:0", - "rule_id": "CORE:G:0006", - "severity": "low" + "check_id": "CORE.S.0040.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0040", + "severity": "medium" }, { - "check_id": "extract_settings_refs", - "location": "CLAUDE.md:1", - "rule_id": "CORE:G:0007", + "check_id": "CORE.S.0041.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0041", "severity": "medium" }, { - "check_id": "CORE.G.0008.mcp_config_exists", - "location": ".mcp.json:0", - "rule_id": "CORE:G:0008", + "check_id": "CORE.S.0041.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0041", "severity": "medium" }, { - "check_id": "has_freshness_marker", - "location": "CLAUDE.md:1", - "rule_id": "CORE:M:0001", + "check_id": "CORE.S.0042.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0042", + "severity": "medium" + }, + { + "check_id": "CORE.S.0042.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0042", "severity": "medium" }, { - "check_id": "CORE.S.0005.file_in_scope", - "location": ".claude/rules/**/*.md:0", - "rule_id": "CORE:S:0005", + "check_id": "CORE.S.0043.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0043", "severity": "medium" }, { - "check_id": "CORE.S.0006.file_in_scope", - "location": ".claude/rules/**/*.md:0", - "rule_id": "CORE:S:0006", + "check_id": "CORE.S.0043.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0043", "severity": "medium" }, { - "check_id": "CORE.S.0010.multiple_files_present", + "check_id": "CORE.S.0044.d_shadow", "location": "CLAUDE.md:0", - "rule_id": "CORE:S:0010", + "rule_id": "CORE:S:0044", + "severity": "medium" + }, + { + "check_id": "CORE.S.0044.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0044", "severity": "medium" }, { - "check_id": "CORE.S.0011.file_in_scope", - "location": ".claude/rules/**/*.md:0", - "rule_id": "CORE:S:0011", + "check_id": "CORE.S.0045.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0045", "severity": "medium" }, { - "check_id": "discovery_documented", - "location": "CLAUDE.md:1", - "rule_id": "CORE:S:0012", + "check_id": "CORE.S.0046.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0046", + "severity": "medium" + }, + { + "check_id": "CORE.S.0047.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0047", + "severity": "medium" + }, + { + "check_id": "CORE.S.0048.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0048", + "severity": "medium" + }, + { + "check_id": "CORE.S.0049.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0049", "severity": "medium" }, { - "check_id": "CORE.S.0018.file_in_scope", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CORE:S:0018", + "check_id": "CORE.S.0049.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0049", + "severity": "medium" + }, + { + "check_id": "CORE.S.0050.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0050", + "severity": "medium" + }, + { + "check_id": "CORE.S.0050.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0050", + "severity": "medium" + }, + { + "check_id": "CORE.S.0051.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0051", + "severity": "medium" + }, + { + "check_id": "CORE.S.0051.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0051", + "severity": "medium" + }, + { + "check_id": "CORE.S.0052.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0052", + "severity": "medium" + }, + { + "check_id": "CORE.S.0052.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0052", + "severity": "medium" + }, + { + "check_id": "CORE.S.0053.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0053", + "severity": "medium" + }, + { + "check_id": "CORE.S.0053.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0053", + "severity": "medium" + }, + { + "check_id": "CORE.S.0054.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0054", + "severity": "medium" + }, + { + "check_id": "CORE.S.0055.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0055", + "severity": "medium" + }, + { + "check_id": "CORE.S.0055.file_count_gate", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0055", "severity": "medium" } ] diff --git a/tests/fixtures/golden/l2_generic/expected.json b/tests/fixtures/golden/l2_generic/expected.json index 46b73da8..390436c5 100644 --- a/tests/fixtures/golden/l2_generic/expected.json +++ b/tests/fixtures/golden/l2_generic/expected.json @@ -2,26 +2,34 @@ "category_summary": [ { "code": "S", - "failed": 6, + "failed": 19, "name": "Structure", - "passed": 8, - "total": 14, - "worst_severity": "medium" + "passed": 11, + "total": 30, + "worst_severity": "high" }, { "code": "C", - "failed": 4, - "name": "Content", - "passed": 4, - "total": 8, + "failed": 30, + "name": "Coherence", + "passed": 18, + "total": 48, "worst_severity": "high" }, + { + "code": "D", + "failed": 0, + "name": "Direction", + "passed": 0, + "total": 0, + "worst_severity": null + }, { "code": "E", "failed": 0, "name": "Efficiency", - "passed": 4, - "total": 4, + "passed": 6, + "total": 6, "worst_severity": null }, { @@ -30,15 +38,15 @@ "name": "Maintenance", "passed": 0, "total": 1, - "worst_severity": "medium" + "worst_severity": "high" }, { "code": "G", - "failed": 5, + "failed": 2, "name": "Governance", - "passed": 1, - "total": 6, - "worst_severity": "medium" + "passed": 2, + "total": 4, + "worst_severity": "high" } ], "evaluation": "awaiting_semantic", @@ -57,120 +65,625 @@ "bad": [], "good": [] }, - "location": "AGENTS.md:1", + "location": "AGENTS.md:7", + "pass_value": "pass", + "rule_id": "CORE:C:0001" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:8", + "pass_value": "pass", + "rule_id": "CORE:C:0003" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:9", + "pass_value": "pass", + "rule_id": "CORE:C:0012" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:11", + "pass_value": "pass", + "rule_id": "CORE:C:0033" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0046" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0047" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:17", + "pass_value": "pass", + "rule_id": "CORE:C:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0045" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:5", "pass_value": "pass", - "rule_id": "CORE:G:0007" + "rule_id": "CORE:S:0046" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:17", + "pass_value": "pass", + "rule_id": "CORE:S:0047" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:17", + "pass_value": "pass", + "rule_id": "CORE:S:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "AGENTS.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0054" } ], "level": "L2", "pending_semantic": { "file_count": 1, - "rule_count": 1, + "rule_count": 12, "rules": [ - "CORE:G:0007" + "CORE:C:0001", + "CORE:C:0003", + "CORE:C:0012", + "CORE:C:0033", + "CORE:C:0046", + "CORE:C:0047", + "CORE:C:0048", + "CORE:S:0045", + "CORE:S:0046", + "CORE:S:0047", + "CORE:S:0048", + "CORE:S:0054" ] }, - "score": 5.3, + "score": 4.3, "summary": { - "rules_checked": 33, - "rules_failed": 16, - "rules_passed": 17 + "rules_checked": 89, + "rules_failed": 52, + "rules_passed": 37 }, "violations": [ { - "check_id": "has_security_content", - "location": "AGENTS.md:1", - "rule_id": "CORE:C:0011", + "check_id": "CORE.C.0002.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0002", + "severity": "low" + }, + { + "check_id": "CORE.C.0004.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0004", + "severity": "medium" + }, + { + "check_id": "CORE.C.0006.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0006", "severity": "medium" }, { - "check_id": "has_project_description", - "location": "AGENTS.md:1", - "rule_id": "CORE:C:0013", + "check_id": "CORE.C.0007.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0007", + "severity": "medium" + }, + { + "check_id": "CORE.C.0009.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0009", + "severity": "medium" + }, + { + "check_id": "CORE.C.0011.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0011", "severity": "high" }, { - "check_id": "has_code_fences", - "location": "AGENTS.md:7", + "check_id": "CORE.C.0014.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0014", + "severity": "high" + }, + { + "check_id": "CORE.C.0016.pattern_check", + "location": "AGENTS.md:0", "rule_id": "CORE:C:0016", "severity": "medium" }, { - "check_id": "has_mermaid_blocks", - "location": "AGENTS.md:1", - "rule_id": "CORE:C:0031", + "check_id": "CORE.C.0020.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0020", "severity": "medium" }, { - "check_id": "has_command_boundaries", - "location": "AGENTS.md:1", - "rule_id": "CORE:G:0004", + "check_id": "CORE.C.0021.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0021", "severity": "medium" }, { - "check_id": "CORE.G.0005.file_in_scope", - "location": "{{settings_file}}:0", - "rule_id": "CORE:G:0005", + "check_id": "CORE.C.0023.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0023", "severity": "medium" }, { - "check_id": "CORE.G.0006.managed_config_exists", - "location": "{{managed_config}}:0", - "rule_id": "CORE:G:0006", + "check_id": "CORE.C.0024.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0024", + "severity": "high" + }, + { + "check_id": "CORE.C.0025.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0025", + "severity": "high" + }, + { + "check_id": "CORE.C.0027.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0027", + "severity": "medium" + }, + { + "check_id": "CORE.C.0028.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0028", "severity": "low" }, { - "check_id": "extract_settings_refs", - "location": "AGENTS.md:1", - "rule_id": "CORE:G:0007", + "check_id": "CORE.C.0031.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0031", + "severity": "medium" + }, + { + "check_id": "CORE.C.0034.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0034", + "severity": "high" + }, + { + "check_id": "CORE.C.0036.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0036", "severity": "medium" }, { - "check_id": "CORE.G.0008.mcp_config_exists", - "location": "{{mcp_config}}:0", - "rule_id": "CORE:G:0008", + "check_id": "CORE.C.0037.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0037", "severity": "medium" }, { - "check_id": "has_freshness_marker", - "location": "AGENTS.md:1", - "rule_id": "CORE:M:0001", + "check_id": "CORE.C.0038.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0038", "severity": "medium" }, { - "check_id": "CORE.S.0005.file_in_scope", - "location": "{{supplementary_files}}:0", - "rule_id": "CORE:S:0005", + "check_id": "CORE.C.0039.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0039", "severity": "medium" }, { - "check_id": "CORE.S.0006.file_in_scope", - "location": "{{supplementary_files}}:0", - "rule_id": "CORE:S:0006", + "check_id": "CORE.C.0040.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0040", "severity": "medium" }, { - "check_id": "CORE.S.0010.multiple_files_present", + "check_id": "CORE.C.0041.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0041", + "severity": "medium" + }, + { + "check_id": "CORE.C.0041.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0041", + "severity": "medium" + }, + { + "check_id": "CORE.C.0042.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0042", + "severity": "medium" + }, + { + "check_id": "CORE.C.0042.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0042", + "severity": "medium" + }, + { + "check_id": "CORE.C.0043.config_language", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0043", + "severity": "medium" + }, + { + "check_id": "CORE.C.0043.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0043", + "severity": "medium" + }, + { + "check_id": "CORE.C.0044.config_language", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0044", + "severity": "medium" + }, + { + "check_id": "CORE.C.0044.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0044", + "severity": "medium" + }, + { + "check_id": "CORE.C.0045.config_language", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0045", + "severity": "medium" + }, + { + "check_id": "CORE.C.0045.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0045", + "severity": "medium" + }, + { + "check_id": "CORE.C.0046.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0046", + "severity": "medium" + }, + { + "check_id": "CORE.C.0047.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0047", + "severity": "medium" + }, + { + "check_id": "CORE.C.0048.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:C:0048", + "severity": "medium" + }, + { + "check_id": "CORE.G.0001.check", + "location": "AGENTS.md:0", + "rule_id": "CORE:G:0001", + "severity": "high" + }, + { + "check_id": "CORE.G.0004.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:G:0004", + "severity": "medium" + }, + { + "check_id": "CORE.M.0001.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:M:0001", + "severity": "high" + }, + { + "check_id": "CORE.S.0001.pattern_check", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0001", + "severity": "medium" + }, + { + "check_id": "CORE.S.0010.modular", "location": "AGENTS.md:0", "rule_id": "CORE:S:0010", + "severity": "high" + }, + { + "check_id": "CORE.S.0039.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0039", "severity": "medium" }, { - "check_id": "CORE.S.0011.file_in_scope", - "location": "{{supplementary_files}}:0", - "rule_id": "CORE:S:0011", + "check_id": "CORE.S.0039.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0039", "severity": "medium" }, { - "check_id": "discovery_documented", - "location": "AGENTS.md:1", - "rule_id": "CORE:S:0012", + "check_id": "CORE.S.0040.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0040", "severity": "medium" }, { - "check_id": "CORE.S.0018.file_in_scope", - "location": "{{skill_entry_file}}:0", - "rule_id": "CORE:S:0018", + "check_id": "CORE.S.0040.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0040", + "severity": "medium" + }, + { + "check_id": "CORE.S.0041.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0041", + "severity": "medium" + }, + { + "check_id": "CORE.S.0041.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0041", + "severity": "medium" + }, + { + "check_id": "CORE.S.0042.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0042", + "severity": "medium" + }, + { + "check_id": "CORE.S.0042.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0042", + "severity": "medium" + }, + { + "check_id": "CORE.S.0043.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0043", + "severity": "medium" + }, + { + "check_id": "CORE.S.0043.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0043", + "severity": "medium" + }, + { + "check_id": "CORE.S.0044.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0044", + "severity": "medium" + }, + { + "check_id": "CORE.S.0044.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0044", + "severity": "medium" + }, + { + "check_id": "CORE.S.0045.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0045", + "severity": "medium" + }, + { + "check_id": "CORE.S.0046.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0046", + "severity": "medium" + }, + { + "check_id": "CORE.S.0047.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0047", + "severity": "medium" + }, + { + "check_id": "CORE.S.0048.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0048", + "severity": "medium" + }, + { + "check_id": "CORE.S.0049.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0049", + "severity": "medium" + }, + { + "check_id": "CORE.S.0049.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0049", + "severity": "medium" + }, + { + "check_id": "CORE.S.0050.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0050", + "severity": "medium" + }, + { + "check_id": "CORE.S.0050.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0050", + "severity": "medium" + }, + { + "check_id": "CORE.S.0051.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0051", + "severity": "medium" + }, + { + "check_id": "CORE.S.0051.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0051", + "severity": "medium" + }, + { + "check_id": "CORE.S.0052.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0052", + "severity": "medium" + }, + { + "check_id": "CORE.S.0052.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0052", + "severity": "medium" + }, + { + "check_id": "CORE.S.0053.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0053", + "severity": "medium" + }, + { + "check_id": "CORE.S.0053.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0053", + "severity": "medium" + }, + { + "check_id": "CORE.S.0054.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0054", + "severity": "medium" + }, + { + "check_id": "CORE.S.0055.d_shadow", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0055", + "severity": "medium" + }, + { + "check_id": "CORE.S.0055.file_count_gate", + "location": "AGENTS.md:0", + "rule_id": "CORE:S:0055", "severity": "medium" } ] diff --git a/tests/fixtures/golden/l5_claude/.reporails/backbone.yml b/tests/fixtures/golden/l5_claude/.ails/backbone.yml similarity index 100% rename from tests/fixtures/golden/l5_claude/.reporails/backbone.yml rename to tests/fixtures/golden/l5_claude/.ails/backbone.yml diff --git a/tests/fixtures/golden/l5_claude/CLAUDE.md b/tests/fixtures/golden/l5_claude/CLAUDE.md index 30a6176c..df24a3e3 100644 --- a/tests/fixtures/golden/l5_claude/CLAUDE.md +++ b/tests/fixtures/golden/l5_claude/CLAUDE.md @@ -4,7 +4,7 @@ A governed Python service with full structure. ## Session Start -1. Read `.reporails/backbone.yml` +1. Read `.ails/backbone.yml` 2. Check project status ## Commands diff --git a/tests/fixtures/golden/l5_claude/expected.json b/tests/fixtures/golden/l5_claude/expected.json index 7251a8cb..68cd3a97 100644 --- a/tests/fixtures/golden/l5_claude/expected.json +++ b/tests/fixtures/golden/l5_claude/expected.json @@ -2,27 +2,35 @@ "category_summary": [ { "code": "S", - "failed": 17, + "failed": 6, "name": "Structure", - "passed": 12, - "total": 29, + "passed": 30, + "total": 36, "worst_severity": "high" }, { "code": "C", - "failed": 15, - "name": "Content", - "passed": 14, - "total": 29, + "failed": 25, + "name": "Coherence", + "passed": 22, + "total": 47, "worst_severity": "high" }, + { + "code": "D", + "failed": 0, + "name": "Direction", + "passed": 0, + "total": 0, + "worst_severity": null + }, { "code": "E", - "failed": 1, + "failed": 0, "name": "Efficiency", - "passed": 4, - "total": 5, - "worst_severity": "medium" + "passed": 6, + "total": 6, + "worst_severity": null }, { "code": "M", @@ -30,15 +38,15 @@ "name": "Maintenance", "passed": 0, "total": 1, - "worst_severity": "medium" + "worst_severity": "high" }, { "code": "G", - "failed": 8, + "failed": 1, "name": "Governance", - "passed": 1, - "total": 9, - "worst_severity": "medium" + "passed": 3, + "total": 4, + "worst_severity": "high" } ], "evaluation": "awaiting_semantic", @@ -57,7 +65,7 @@ "bad": [], "good": [] }, - "location": ".claude/rules/security.md:2", + "location": ".claude/rules/security.md:4", "pass_value": "pass", "rule_id": "CORE:C:0001" }, @@ -73,7 +81,7 @@ "bad": [], "good": [] }, - "location": ".claude/rules/testing.md:2", + "location": "CLAUDE.md:8", "pass_value": "pass", "rule_id": "CORE:C:0001" }, @@ -89,9 +97,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:21", + "location": ".claude/rules/testing.md:4", "pass_value": "pass", - "rule_id": "CORE:C:0001" + "rule_id": "CORE:C:0003" }, { "choices": [ @@ -105,9 +113,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:13", "pass_value": "pass", - "rule_id": "CORE:C:0002" + "rule_id": "CORE:C:0003" }, { "choices": [ @@ -121,9 +129,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:12", + "location": "CLAUDE.md:16", "pass_value": "pass", - "rule_id": "CORE:C:0003" + "rule_id": "CORE:C:0033" }, { "choices": [ @@ -137,9 +145,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:18", "pass_value": "pass", - "rule_id": "CORE:C:0009" + "rule_id": "CORE:C:0036" }, { "choices": [ @@ -153,9 +161,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:18", "pass_value": "pass", - "rule_id": "CORE:C:0012" + "rule_id": "CORE:C:0037" }, { "choices": [ @@ -169,9 +177,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:18", "pass_value": "pass", - "rule_id": "CORE:C:0028" + "rule_id": "CORE:C:0038" }, { "choices": [ @@ -185,9 +193,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:18", "pass_value": "pass", - "rule_id": "CORE:E:0003" + "rule_id": "CORE:C:0039" }, { "choices": [ @@ -201,9 +209,9 @@ "bad": [], "good": [] }, - "location": "CLAUDE.md:1", + "location": "CLAUDE.md:18", "pass_value": "pass", - "rule_id": "CORE:G:0007" + "rule_id": "CORE:C:0040" }, { "choices": [ @@ -217,9 +225,9 @@ "bad": [], "good": [] }, - "location": ".claude/rules/security.md:1", + "location": ".claude/rules/security.md:3", "pass_value": "pass", - "rule_id": "CORE:S:0011" + "rule_id": "CORE:C:0046" }, { "choices": [ @@ -233,332 +241,712 @@ "bad": [], "good": [] }, - "location": ".claude/rules/testing.md:1", + "location": ".claude/rules/testing.md:3", "pass_value": "pass", - "rule_id": "CORE:S:0011" - } - ], - "level": "L5", - "pending_semantic": { - "file_count": 3, - "rule_count": 9, - "rules": [ - "CORE:C:0001", - "CORE:C:0002", - "CORE:C:0003", - "CORE:C:0009", - "CORE:C:0012", - "CORE:C:0028", - "CORE:E:0003", - "CORE:G:0007", - "CORE:S:0011" - ] - }, - "score": 4.4, - "summary": { - "rules_checked": 73, - "rules_failed": 42, - "rules_passed": 31 - }, - "violations": [ + "rule_id": "CORE:C:0046" + }, { - "check_id": "CLAUDE.G.0001.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:G:0001", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:C:0046" }, { - "check_id": "CLAUDE.S.0001.skill_dir_exists", - "location": ".claude/skills/**/*.md:0", - "rule_id": "CLAUDE:S:0001", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/security.md:3", + "pass_value": "pass", + "rule_id": "CORE:C:0047" }, { - "check_id": "CLAUDE.S.0002.skill_file_exists", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CLAUDE:S:0002", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/testing.md:3", + "pass_value": "pass", + "rule_id": "CORE:C:0047" }, { - "check_id": "CLAUDE.S.0003.skill_file_exists", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CLAUDE:S:0003", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:C:0047" }, { - "check_id": "CLAUDE.S.0004.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0004", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/security.md:3", + "pass_value": "pass", + "rule_id": "CORE:C:0048" }, { - "check_id": "CLAUDE.S.0005.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0005", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/testing.md:3", + "pass_value": "pass", + "rule_id": "CORE:C:0048" }, { - "check_id": "CLAUDE.S.0006.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0006", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:C:0048" }, { - "check_id": "CLAUDE.S.0007.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CLAUDE:S:0007", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0039" }, { - "check_id": "flag_vague_language", - "location": ".claude/rules/security.md:2", - "rule_id": "CORE:C:0001", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0040" }, { - "check_id": "flag_vague_language", - "location": ".claude/rules/testing.md:2", - "rule_id": "CORE:C:0001", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0041" }, { - "check_id": "flag_vague_language", - "location": "CLAUDE.md:21", - "rule_id": "CORE:C:0001", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0042" }, { - "check_id": "extract_rationale_content", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0002", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0043" }, { - "check_id": "extract_iteration_directives", - "location": "CLAUDE.md:12", - "rule_id": "CORE:C:0003", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0044" }, { - "check_id": "has_workflow_content", - "location": "CLAUDE.md:14", - "rule_id": "CORE:C:0007", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/security.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0045" }, { - "check_id": "has_validation_section", - "location": "CLAUDE.md:8", - "rule_id": "CORE:C:0008", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/testing.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0045" }, { - "check_id": "extract_ask_directives", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0009", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0045" }, { - "check_id": "commands_in_code_blocks", - "location": "CLAUDE.md:12", - "rule_id": "CORE:C:0010", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0046" }, { - "check_id": "has_security_content", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0011", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/security.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0047" }, { - "check_id": "extract_convention_content", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0012", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/testing.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0047" }, { - "check_id": "has_project_description", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0013", - "severity": "high" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0047" }, { - "check_id": "has_code_fences", + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/security.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": ".claude/rules/testing.md:3", + "pass_value": "pass", + "rule_id": "CORE:S:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0048" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0050" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0051" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:22", + "pass_value": "pass", + "rule_id": "CORE:S:0052" + }, + { + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, "location": "CLAUDE.md:7", - "rule_id": "CORE:C:0016", - "severity": "medium" + "pass_value": "pass", + "rule_id": "CORE:S:0053" }, { - "check_id": "has_detailed_commands", - "location": "CLAUDE.md:12", - "rule_id": "CORE:C:0020", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:5", + "pass_value": "pass", + "rule_id": "CORE:S:0054" }, { - "check_id": "has_workflow_sequence", - "location": "CLAUDE.md:12", - "rule_id": "CORE:C:0021", - "severity": "medium" + "choices": [ + "pass", + "fail" + ], + "criteria": { + "pass_condition": "Evaluate based on context" + }, + "examples": { + "bad": [], + "good": [] + }, + "location": "CLAUDE.md:18", + "pass_value": "pass", + "rule_id": "CORE:S:0055" + } + ], + "level": "L5", + "pending_semantic": { + "file_count": 3, + "rule_count": 27, + "rules": [ + "CORE:C:0001", + "CORE:C:0003", + "CORE:C:0033", + "CORE:C:0036", + "CORE:C:0037", + "CORE:C:0038", + "CORE:C:0039", + "CORE:C:0040", + "CORE:C:0046", + "CORE:C:0047", + "CORE:C:0048", + "CORE:S:0039", + "CORE:S:0040", + "CORE:S:0041", + "CORE:S:0042", + "CORE:S:0043", + "CORE:S:0044", + "CORE:S:0045", + "CORE:S:0046", + "CORE:S:0047", + "CORE:S:0048", + "CORE:S:0050", + "CORE:S:0051", + "CORE:S:0052", + "CORE:S:0053", + "CORE:S:0054", + "CORE:S:0055" + ] + }, + "score": 6.6, + "summary": { + "rules_checked": 94, + "rules_failed": 33, + "rules_passed": 61 + }, + "violations": [ + { + "check_id": "CORE.C.0002.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0002", + "severity": "low" }, { - "check_id": "has_domain_terms", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0024", + "check_id": "CORE.C.0004.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0004", "severity": "medium" }, { - "check_id": "extract_reasoning_directives", - "location": "CLAUDE.md:1", - "rule_id": "CORE:C:0028", + "check_id": "CORE.C.0006.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0006", "severity": "medium" }, { - "check_id": "extract_potentially_redundant", - "location": "CLAUDE.md:1", - "rule_id": "CORE:E:0003", + "check_id": "CORE.C.0009.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0009", "severity": "medium" }, { - "check_id": "CORE.G.0001.file_tracked", + "check_id": "CORE.C.0012.pattern_check", "location": "CLAUDE.md:0", - "rule_id": "CORE:G:0001", - "severity": "medium" + "rule_id": "CORE:C:0012", + "severity": "high" + }, + { + "check_id": "CORE.C.0014.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0014", + "severity": "high" }, { - "check_id": "has_permission_section", - "location": "CLAUDE.md:1", - "rule_id": "CORE:G:0003", + "check_id": "CORE.C.0016.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0016", "severity": "medium" }, { - "check_id": "has_command_boundaries", - "location": "CLAUDE.md:1", - "rule_id": "CORE:G:0004", + "check_id": "CORE.C.0020.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0020", "severity": "medium" }, { - "check_id": "CORE.G.0005.file_in_scope", - "location": ".claude/settings.json:0", - "rule_id": "CORE:G:0005", + "check_id": "CORE.C.0023.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0023", "severity": "medium" }, { - "check_id": "CORE.G.0006.managed_config_exists", - "location": "/etc/claude-code/managed-settings.json:0", - "rule_id": "CORE:G:0006", - "severity": "low" + "check_id": "CORE.C.0024.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0024", + "severity": "high" }, { - "check_id": "extract_settings_refs", - "location": "CLAUDE.md:1", - "rule_id": "CORE:G:0007", - "severity": "medium" + "check_id": "CORE.C.0025.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0025", + "severity": "high" }, { - "check_id": "CORE.G.0008.mcp_config_exists", - "location": ".mcp.json:0", - "rule_id": "CORE:G:0008", + "check_id": "CORE.C.0027.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0027", "severity": "medium" }, { - "check_id": "has_freshness_marker", - "location": "CLAUDE.md:1", - "rule_id": "CORE:M:0001", + "check_id": "CORE.C.0028.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0028", + "severity": "low" + }, + { + "check_id": "CORE.C.0031.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0031", "severity": "medium" }, { - "check_id": "has_import_references", - "location": ".claude/rules/security.md:1", - "rule_id": "CORE:S:0001", - "severity": "low" + "check_id": "CORE.C.0034.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0034", + "severity": "high" }, { - "check_id": "has_import_references", - "location": ".claude/rules/testing.md:1", - "rule_id": "CORE:S:0001", - "severity": "low" + "check_id": "d_shadow", + "location": "CLAUDE.md:18", + "rule_id": "CORE:C:0036", + "severity": "medium" }, { - "check_id": "has_frontmatter_block", - "location": ".claude/rules/security.md:1", - "rule_id": "CORE:S:0006", + "check_id": "d_shadow", + "location": "CLAUDE.md:18", + "rule_id": "CORE:C:0037", "severity": "medium" }, { - "check_id": "has_frontmatter_block", - "location": ".claude/rules/testing.md:1", - "rule_id": "CORE:S:0006", + "check_id": "d_shadow", + "location": "CLAUDE.md:18", + "rule_id": "CORE:C:0038", "severity": "medium" }, { - "check_id": "extract_override_content", - "location": ".claude/rules/security.md:1", - "rule_id": "CORE:S:0011", + "check_id": "d_shadow", + "location": "CLAUDE.md:18", + "rule_id": "CORE:C:0039", "severity": "medium" }, { - "check_id": "extract_override_content", - "location": ".claude/rules/testing.md:1", - "rule_id": "CORE:S:0011", + "check_id": "d_shadow", + "location": "CLAUDE.md:18", + "rule_id": "CORE:C:0040", "severity": "medium" }, { - "check_id": "discovery_documented", - "location": "CLAUDE.md:1", - "rule_id": "CORE:S:0012", + "check_id": "CORE.C.0041.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0041", "severity": "medium" }, { - "check_id": "has_overview_section", - "location": "CLAUDE.md:1", - "rule_id": "CORE:S:0016", + "check_id": "CORE.C.0042.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0042", "severity": "medium" }, { - "check_id": "CORE.S.0018.file_in_scope", - "location": ".claude/skills/**/SKILL.md:0", - "rule_id": "CORE:S:0018", + "check_id": "CORE.C.0043.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0043", "severity": "medium" }, { - "check_id": "CORE.S.0021.settings_file_exists", - "location": ".claude/settings.json:0", - "rule_id": "CORE:S:0021", + "check_id": "CORE.C.0044.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0044", "severity": "medium" }, { - "check_id": "CORE.S.0022.local_override_exists", - "location": "CLAUDE.local.md:0", - "rule_id": "CORE:S:0022", + "check_id": "CORE.C.0045.config_language", + "location": "CLAUDE.md:0", + "rule_id": "CORE:C:0045", "severity": "medium" }, { - "check_id": "CORE.S.0024.all_imports_resolve", - "location": ".claude/rules/**/*.md:0", - "rule_id": "CORE:S:0024", + "check_id": "CORE.G.0001.check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:G:0001", "severity": "high" }, { - "check_id": "extract_import_refs", - "location": ".claude/rules/security.md:1", - "rule_id": "CORE:S:0024", + "check_id": "CORE.M.0001.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:M:0001", "severity": "high" }, { - "check_id": "extract_import_refs", - "location": ".claude/rules/testing.md:1", - "rule_id": "CORE:S:0024", + "check_id": "CORE.S.0001.pattern_check", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0001", + "severity": "medium" + }, + { + "check_id": "CORE.S.0005.pattern_check", + "location": "security.md:0", + "rule_id": "CORE:S:0005", "severity": "high" }, { - "check_id": "has_markdown_rule_files", - "location": ".claude/rules/security.md:1", - "rule_id": "CORE:S:0025", + "check_id": "CORE.S.0006.pattern_check", + "location": "security.md:0", + "rule_id": "CORE:S:0006", + "severity": "high" + }, + { + "check_id": "CORE.S.0012.pattern_check", + "location": "security.md:0", + "rule_id": "CORE:S:0012", "severity": "medium" }, { - "check_id": "has_markdown_rule_files", - "location": ".claude/rules/testing.md:1", - "rule_id": "CORE:S:0025", + "check_id": "CORE.S.0013.pattern_check", + "location": "security.md:0", + "rule_id": "CORE:S:0013", + "severity": "medium" + }, + { + "check_id": "CORE.S.0049.d_shadow", + "location": "CLAUDE.md:0", + "rule_id": "CORE:S:0049", "severity": "medium" } ] diff --git a/tests/fixtures/projects/multi_agent_with_config/.reporails/config.yml b/tests/fixtures/projects/multi_agent_with_config/.ails/config.yml similarity index 100% rename from tests/fixtures/projects/multi_agent_with_config/.reporails/config.yml rename to tests/fixtures/projects/multi_agent_with_config/.ails/config.yml diff --git a/tests/integration/test_behavioral.py b/tests/integration/test_behavioral.py index 0f0fda6e..61449a86 100644 --- a/tests/integration/test_behavioral.py +++ b/tests/integration/test_behavioral.py @@ -14,7 +14,7 @@ import pytest from typer.testing import CliRunner -from reporails_cli.core.agents import KNOWN_AGENTS, clear_agent_cache +from reporails_cli.core.agents import clear_agent_cache, get_known_agents from reporails_cli.interfaces.cli.main import app runner = CliRunner() @@ -31,6 +31,13 @@ def _rules_installed() -> bool: reason="Rules framework not installed", ) +_onnx_path = ( + Path(__file__).resolve().parents[2] + / "src" / "reporails_cli" / "bundled" / "models" / "minilm-l6-v2" / "onnx" / "model.onnx" +) +_has_onnx_model = _onnx_path.exists() +requires_model = pytest.mark.skipif(not _has_onnx_model, reason="Bundled ONNX model not available") + # --------------------------------------------------------------------------- # Fixtures @@ -126,49 +133,38 @@ class TestCheckJsonSchema: @requires_rules def test_required_keys_present(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - required = {"score", "level", "violations", "summary", "friction", "category_summary"} + required = {"offline", "files", "stats"} assert required.issubset(data.keys()), f"Missing keys: {required - data.keys()}" @requires_rules - def test_score_is_number_in_range(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) - data = json.loads(result.output) - assert isinstance(data["score"], (int, float)) - assert 0 <= data["score"] <= 10 - - @requires_rules - def test_level_format(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) - data = json.loads(result.output) - assert data["level"].startswith("L"), f"Level should start with L, got: {data['level']}" - - @requires_rules - def test_violations_are_list(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) + def test_files_is_dict_with_findings(self, minimal_project: Path) -> None: + result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) data = json.loads(result.output) - assert isinstance(data["violations"], list) + assert isinstance(data["files"], dict) + for file_data in data["files"].values(): + assert isinstance(file_data["findings"], list) @requires_rules - def test_violation_has_required_fields(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) + def test_finding_has_required_fields(self, minimal_project: Path) -> None: + result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) data = json.loads(result.output) - if data["violations"]: - v = data["violations"][0] - assert "rule_id" in v - assert "location" in v - assert "message" in v - assert "severity" in v + for file_data in data["files"].values(): + for f in file_data["findings"]: + assert "line" in f + assert "severity" in f + assert "rule" in f + assert "message" in f def test_no_files_json_output(self, empty_project: Path) -> None: - result = runner.invoke(app, ["check", str(empty_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(empty_project), "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["violations"] == [] - assert data["level"] == "L1" + assert data["level"] == "L0" # =========================================================================== @@ -181,21 +177,23 @@ class TestCheckScanScope: @requires_rules def test_nested_child_only_scans_child(self, nested_project: Path) -> None: - result = runner.invoke(app, ["check", str(nested_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(nested_project), "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - for v in data["violations"]: - # Location should be relative to scan root or inside child - assert "Parent" not in v.get("message", ""), "Parent content leaked into child scan" + for file_data in data["files"].values(): + for f in file_data["findings"]: + assert "Parent" not in f.get("message", ""), "Parent content leaked into child scan" @requires_rules def test_nested_child_violation_count_reasonable(self, nested_project: Path) -> None: """A single-file project should have a bounded number of violations.""" - result = runner.invoke(app, ["check", str(nested_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(nested_project), "-f", "json"]) data = json.loads(result.output) - # One CLAUDE.md can't have hundreds of violations - assert len(data["violations"]) < 50, f"Suspiciously many violations: {len(data['violations'])}" + # Count total findings across all files + total = sum(len(fd["findings"]) for fd in data["files"].values()) + # One CLAUDE.md can't have hundreds of findings. + assert total < 100, f"Suspiciously many findings: {total}" # =========================================================================== @@ -208,31 +206,22 @@ class TestCheckTextOutput: @requires_rules def test_score_displayed(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["check", str(minimal_project), "-f", "text", "-q", "--no-update-check"]) + result = runner.invoke(app, ["check", str(minimal_project), "-f", "text"]) assert result.exit_code == 0 assert "SCORE:" in result.output or "/ 10" in result.output or "Score:" in result.output @requires_rules def test_violations_grouped_by_file(self, minimal_project: Path) -> None: result = runner.invoke( - app, ["check", str(minimal_project), "--agent", "claude", "-f", "text", "-q", "--no-update-check"] + app, ["check", str(minimal_project), "--agent", "claude", "-f", "text"] ) assert result.exit_code == 0 assert "CLAUDE.md" in result.output - def test_no_files_shows_l1_message(self, empty_project: Path) -> None: - result = runner.invoke(app, ["check", str(empty_project), "-f", "text", "--no-update-check"]) + def test_no_files_shows_l0_message(self, empty_project: Path) -> None: + result = runner.invoke(app, ["check", str(empty_project), "-f", "text"]) assert "No instruction files found" in result.output - assert "L1" in result.output - - @requires_rules - def test_quiet_semantic_suppresses_message(self, minimal_project: Path) -> None: - result_quiet = runner.invoke(app, ["check", str(minimal_project), "-f", "text", "-q", "--no-update-check"]) - result_normal = runner.invoke(app, ["check", str(minimal_project), "-f", "text", "--no-update-check"]) - # If semantic message appears in normal, it should not appear in quiet - if "semantic" in result_normal.output.lower(): - # -q should either suppress or shorten the message - assert result_quiet.output != result_normal.output + assert "L0" in result.output # =========================================================================== @@ -245,12 +234,10 @@ class TestCheckMultiFile: @requires_rules def test_multiple_agents_detected(self, multi_file_project: Path) -> None: - result = runner.invoke(app, ["check", str(multi_file_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(multi_file_project), "-f", "json"]) data = json.loads(result.output) - # Multi-file project should produce valid JSON output with required keys - assert "violations" in data - assert "score" in data - assert "level" in data + # Multi-file project should produce valid JSON output with files key + assert "files" in data assert result.exit_code == 0 @@ -263,112 +250,17 @@ class TestCheckScoreConsistency: """Score must be deterministic — same project, same score.""" @requires_rules - def test_deterministic_score(self, structured_project: Path) -> None: - scores = [] + def test_deterministic_stats(self, structured_project: Path) -> None: + stats_list = [] for _ in range(3): - result = runner.invoke(app, ["check", str(structured_project), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(structured_project), "-f", "json"]) data = json.loads(result.output) - scores.append(data["score"]) + stats_list.append(data["stats"]) - assert len(set(scores)) == 1, f"Score varied across runs: {scores}" - - @requires_rules - def test_score_is_bounded(self, tmp_path: Path) -> None: - """Score must be between 0 and 10 for any project content.""" - bare = tmp_path / "bare" - bare.mkdir() - (bare / "CLAUDE.md").write_text("# Project\n") - - rich = tmp_path / "rich" - rich.mkdir() - (rich / "CLAUDE.md").write_text( - "# Project\n\n## Commands\n\n- `make build`\n\n" - "## Architecture\n\nModular.\n\n" - "## Constraints\n\n- NEVER commit secrets\n" + assert stats_list[0] == stats_list[1] == stats_list[2], ( + f"Stats varied across runs: {stats_list}" ) - for project in [bare, rich]: - result = runner.invoke(app, ["check", str(project), "-f", "json", "--no-update-check"]) - score = json.loads(result.output)["score"] - assert 0 <= score <= 10, f"Score {score} out of bounds for {project.name}" - - -# =========================================================================== -# DISMISS COMMAND -# (Map command covered by smoke tests) -# =========================================================================== - - -class TestDismissCommand: - """ails dismiss must cache a pass verdict for the rule.""" - - def test_dismiss_caches_verdict(self, minimal_project: Path) -> None: - result = runner.invoke(app, ["dismiss", "C6", "--path", str(minimal_project)]) - assert result.exit_code == 0 - assert "Dismissed" in result.output - assert "C6" in result.output - - # Verify cache was written - from reporails_cli.core.cache import ProjectCache, content_hash - - # Find project root the same way dismiss does - from reporails_cli.core.engine_helpers import _find_project_root - - project_root = _find_project_root(minimal_project) - cache = ProjectCache(project_root) - md = minimal_project / "CLAUDE.md" - h = content_hash(md) - judgment = cache.get_cached_judgment(str(md.relative_to(minimal_project)), h) - assert judgment is not None - assert "C6" in judgment - assert judgment["C6"]["verdict"] == "pass" - - # Error paths (missing path, no files) covered by smoke tests - - -# =========================================================================== -# CHECK COMMAND — Flags -# (Version and Explain commands covered by smoke tests) -# =========================================================================== - - -# =========================================================================== -# CHECK COMMAND — Flags -# =========================================================================== - - -class TestCheckFlags: - """CLI flags must produce the documented behavior.""" - - @requires_rules - def test_refresh_flag_accepted(self, minimal_project: Path) -> None: - """--refresh should not error and should produce valid output.""" - result = runner.invoke(app, ["check", str(minimal_project), "--refresh", "-f", "json", "--no-update-check"]) - assert result.exit_code == 0 - data = json.loads(result.output) - assert "score" in data - - # --exclude-dir and --ascii covered by smoke tests - - def test_legend_flag_shows_legend(self) -> None: - """--legend should show severity legend and exit.""" - result = runner.invoke(app, ["check", ".", "--legend"]) - assert result.exit_code == 0 - assert "Legend" in result.output or "Severity" in result.output - - @requires_rules - def test_compact_format(self, minimal_project: Path) -> None: - """-f compact should produce condensed output.""" - result = runner.invoke(app, ["check", str(minimal_project), "-f", "compact", "--no-update-check"]) - assert result.exit_code == 0 - assert len(result.output.strip()) > 0 - - @requires_rules - def test_brief_format(self, minimal_project: Path) -> None: - """-f brief should produce output.""" - result = runner.invoke(app, ["check", str(minimal_project), "-f", "brief", "--no-update-check"]) - assert result.exit_code == 0 - # =========================================================================== # CHECK COMMAND — Agent Flag @@ -381,23 +273,16 @@ class TestCheckAgentFlag: # Hint message tests (no agent, claude, codex, copilot) covered by # smoke TestHintMessages. Keep agent-specific behavior tests below. - def test_no_files_hint_cursor(self, empty_project: Path) -> None: - """--agent cursor should hint .cursorrules.""" - result = runner.invoke( - app, ["check", str(empty_project), "--agent", "cursor", "-f", "text", "--no-update-check"] - ) - assert "Create a .cursorrules to get started" in result.output - def test_no_files_hint_copilot(self, empty_project: Path) -> None: """--agent copilot should hint its instruction file.""" result = runner.invoke( - app, ["check", str(empty_project), "--agent", "copilot", "-f", "text", "--no-update-check"] + app, ["check", str(empty_project), "--agent", "copilot", "-f", "text"] ) assert "Create a .github/copilot-instructions.md to get started" in result.output def test_unknown_agent_errors(self, empty_project: Path) -> None: """Unknown agent must error with exit code 2 and list known agents.""" - result = runner.invoke(app, ["check", str(empty_project), "--agent", "somefuture", "--no-update-check"]) + result = runner.invoke(app, ["check", str(empty_project), "--agent", "somefuture"]) assert result.exit_code == 2 assert "Unknown agent" in result.output assert "claude" in result.output @@ -407,7 +292,7 @@ def test_wrong_agent_no_false_positive(self, tmp_path: Path) -> None: p = tmp_path / "proj" p.mkdir() (p / "AGENTS.md").write_text("# Agents\n\nInstructions.\n") - result = runner.invoke(app, ["check", str(p), "--agent", "claude", "-f", "text", "--no-update-check"]) + result = runner.invoke(app, ["check", str(p), "--agent", "claude", "-f", "text"]) assert "No instruction files found" in result.output @requires_rules @@ -416,11 +301,11 @@ def test_codex_agent_scans_agents_md(self, tmp_path: Path) -> None: p = tmp_path / "proj" p.mkdir() (p / "AGENTS.md").write_text("# Agents\n\nInstructions for Codex.\n") - result = runner.invoke(app, ["check", str(p), "--agent", "codex", "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(p), "--agent", "codex", "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - assert "score" in data - assert data["level"] != "L1", "AGENTS.md should be detected, not L1 Absent" + assert "files" in data + assert "AGENTS.md" in data["files"], "AGENTS.md should be detected" @requires_rules def test_no_agent_core_rules_fire(self, tmp_path: Path) -> None: @@ -428,13 +313,10 @@ def test_no_agent_core_rules_fire(self, tmp_path: Path) -> None: p = tmp_path / "proj" p.mkdir() (p / "AGENTS.md").write_text("# Agents\n\nInstructions.\n") - result = runner.invoke(app, ["check", str(p), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(p), "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - assert data["level"] != "L1" - # Core content/structure rules must fire, not just CORE:S:0001 - checked = data.get("summary", {}).get("rules_checked", 0) - assert checked > 3, f"Only {checked} rules checked — core rules not resolving targets" + assert data["files"], "No files detected — core rules should find instruction files" # =========================================================================== @@ -443,50 +325,40 @@ def test_no_agent_core_rules_fire(self, tmp_path: Path) -> None: class TestAgentCrossValidation: - """KNOWN_AGENTS must stay in sync with the rules framework agent configs.""" + """Agent registry must be built from framework config.yml files.""" @requires_rules - def test_framework_agents_in_known_agents(self) -> None: - """Every agent dir in the rules framework must have an entry in KNOWN_AGENTS.""" - from reporails_cli.core.agents import KNOWN_AGENTS - from reporails_cli.core.bootstrap import get_rules_path - - agents_dir = get_rules_path() / "agents" - if not agents_dir.is_dir(): - pytest.skip("No agents directory in rules framework") - - framework_agents = {d.name for d in agents_dir.iterdir() if d.is_dir() and (d / "config.yml").exists()} - missing = framework_agents - set(KNOWN_AGENTS) - assert not missing, ( - f"Rules framework has agent configs not in KNOWN_AGENTS: {missing}. " - f"Add them to KNOWN_AGENTS in src/reporails_cli/core/agents.py" - ) + 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"): + assert agent_id in agents, f"Agent {agent_id} missing from registry" # =========================================================================== -# CHECK COMMAND — Agent Matrix (derived from KNOWN_AGENTS, not manual) +# CHECK COMMAND — Agent Matrix (derived from config.yml, not manual) # =========================================================================== # Agents whose first instruction pattern is YAML, not markdown — skip file-detection # test since the rules engine expects markdown content. -_YAML_AGENTS = {"aider"} +_YAML_AGENTS: set[str] = set() class TestAgentMatrix: """Every known agent must produce a valid check result against its instruction file. - Parametrized directly from KNOWN_AGENTS so new agents get coverage automatically. + Parametrized from get_known_agents() so new config.yml agents get coverage automatically. """ @requires_rules - @pytest.mark.parametrize("agent_id", sorted(KNOWN_AGENTS)) + @pytest.mark.parametrize("agent_id", sorted(get_known_agents())) def test_agent_check_finds_files(self, agent_id: str, tmp_path: Path) -> None: """ails check --agent X must detect the agent's instruction file.""" if agent_id in _YAML_AGENTS: pytest.skip(f"{agent_id} uses YAML instruction files") - agent_type = KNOWN_AGENTS[agent_id] + agent_type = get_known_agents()[agent_id] # Use first instruction pattern (strip glob wildcards for file creation) filename = agent_type.instruction_patterns[0].replace("**/", "") p = tmp_path / "proj" @@ -495,18 +367,18 @@ def test_agent_check_finds_files(self, agent_id: str, tmp_path: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) target.write_text(f"# {agent_type.name}\n\nInstructions.\n") - result = runner.invoke(app, ["check", str(p), "--agent", agent_id, "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(p), "--agent", agent_id, "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - assert data["level"] != "L1", f"--agent {agent_id} should detect {filename}, got L1 Absent" + assert data["files"], f"--agent {agent_id} should detect {filename}, got empty files" @requires_rules - @pytest.mark.parametrize("agent_id", sorted(KNOWN_AGENTS)) + @pytest.mark.parametrize("agent_id", sorted(get_known_agents())) def test_agent_check_no_crash(self, agent_id: str, tmp_path: Path) -> None: """ails check --agent X must not crash on an empty project.""" p = tmp_path / "proj" p.mkdir() - result = runner.invoke(app, ["check", str(p), "--agent", agent_id, "--no-update-check"]) + result = runner.invoke(app, ["check", str(p), "--agent", agent_id]) assert result.exit_code == 0 @@ -522,10 +394,11 @@ class TestCheckFileTarget: def test_single_file_target(self, minimal_project: Path) -> None: """Pointing at a specific file should work.""" target = minimal_project / "CLAUDE.md" - result = runner.invoke(app, ["check", str(target), "-f", "json", "--no-update-check"]) + result = runner.invoke(app, ["check", str(target), "-f", "json"]) assert result.exit_code == 0 data = json.loads(result.output) - assert "score" in data + # Single file target may return old L0 schema or new files schema + assert "files" in data or "level" in data # =========================================================================== @@ -538,31 +411,32 @@ class TestCheckConfig: @requires_rules def test_disabled_rules_excluded(self, tmp_path: Path) -> None: - """Rules listed in .reporails/config.yml disabled_rules should not fire.""" + """Rules listed in .ails/config.yml disabled_rules should not fire.""" p = tmp_path / "proj" p.mkdir() (p / "CLAUDE.md").write_text("# Project\n\nA project.\n") - # Run without config — get violations - r1 = runner.invoke(app, ["check", str(p), "-f", "json", "--no-update-check"]) + # Run without config — get findings + r1 = runner.invoke(app, ["check", str(p), "-f", "json"]) d1 = json.loads(r1.output) - if not d1["violations"]: - pytest.skip("No violations to disable") + all_findings = [f for fd in d1["files"].values() for f in fd["findings"]] + if not all_findings: + pytest.skip("No findings to disable") # Get a rule ID to disable - rule_to_disable = d1["violations"][0]["rule_id"] + rule_to_disable = all_findings[0]["rule"] # Create config that disables it - config_dir = p / ".reporails" - config_dir.mkdir() + config_dir = p / ".ails" + config_dir.mkdir(exist_ok=True) (config_dir / "config.yml").write_text(f"disabled_rules:\n - {rule_to_disable}\n") # Run with config — that rule should not fire clear_agent_cache() - r2 = runner.invoke(app, ["check", str(p), "-f", "json", "--no-update-check"]) + r2 = runner.invoke(app, ["check", str(p), "-f", "json"]) d2 = json.loads(r2.output) - fired_rules = {v["rule_id"] for v in d2["violations"]} + fired_rules = {f["rule"] for fd in d2["files"].values() for f in fd["findings"]} assert rule_to_disable not in fired_rules, f"{rule_to_disable} fired despite being disabled" @@ -576,6 +450,7 @@ class TestHealCommand: # test_heal_missing_path covered by smoke tests + @requires_model @requires_rules def test_heal_auto_fixes_applied(self, tmp_path: Path) -> None: """Auto-fixers should modify the file and report what they fixed.""" @@ -592,6 +467,7 @@ def test_heal_auto_fixes_applied(self, tmp_path: Path) -> None: if content != original: assert len(content) > len(original) + @requires_model @requires_rules def test_heal_nothing_to_heal(self, tmp_path: Path) -> None: """When all rules pass or are cached, heal should say nothing to do.""" @@ -606,6 +482,7 @@ def test_heal_nothing_to_heal(self, tmp_path: Path) -> None: # Should produce some output (fixes applied, violations listed, or nothing to heal) assert len(result.output.strip()) > 0 + @requires_model @requires_rules def test_heal_json_output(self, tmp_path: Path) -> None: """Invoke with -f json, parse JSON, assert keys.""" @@ -621,6 +498,7 @@ def test_heal_json_output(self, tmp_path: Path) -> None: assert "summary" in data assert "auto_fixed_count" in data["summary"] + @requires_model def test_heal_works_without_tty(self, tmp_path: Path) -> None: """CliRunner is non-TTY — heal should still work (no TTY requirement).""" p = tmp_path / "proj" @@ -630,6 +508,7 @@ def test_heal_works_without_tty(self, tmp_path: Path) -> None: result = runner.invoke(app, ["heal", str(p)]) assert result.exit_code in (0, None) + @requires_model @requires_rules def test_heal_shows_remaining_violations(self, tmp_path: Path) -> None: """Non-fixable violations should be listed in text output.""" @@ -653,23 +532,3 @@ def test_heal_shows_remaining_violations(self, tmp_path: Path) -> None: assert has_content, f"Expected heal output content, got: {output}" -# =========================================================================== -# CHECK COMMAND — Delta Tracking -# =========================================================================== - - -class TestCheckDelta: - """Score/level deltas should appear after the first run.""" - - @requires_rules - def test_second_run_shows_delta(self, minimal_project: Path) -> None: - """Running check twice should show delta on the second run.""" - # First run — no delta - runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) - - # Second run — should have delta fields - r2 = runner.invoke(app, ["check", str(minimal_project), "-f", "json", "--no-update-check"]) - d2 = json.loads(r2.output) - # Delta fields should exist (may be 0 if unchanged) - assert "score_delta" in d2 - assert "violations_delta" in d2 diff --git a/tests/integration/test_capability_detection.py b/tests/integration/test_capability_detection.py index 20de4e9f..1c8fb899 100644 --- a/tests/integration/test_capability_detection.py +++ b/tests/integration/test_capability_detection.py @@ -1,207 +1,98 @@ -"""Capability detection tests - level detection must be deterministic and correct. +"""Project level determination tests - level detection must be deterministic and correct. -The capability level (L1-L6) determines which rules are applied. -Detection must be consistent and based on actual project structure. +The project level (L1-L6) is computed from file type property divergence. +Detection must be consistent for the same set of classified files. """ from __future__ import annotations from pathlib import Path -from reporails_cli.core.models import Level - -# TestFilesystemFeatureDetection removed — covered by unit/test_applicability.py - - -class TestContentFeatureDetection: - """Test Phase 2: content-based feature detection via regex engine.""" - - def test_detect_sections_in_content( - self, - level2_project: Path, - ) -> None: - """Detect markdown sections (## headings) in content.""" - from reporails_cli.core.capability import detect_features_content - from reporails_cli.core.regex import run_capability_detection - - sarif = run_capability_detection(level2_project) - content_features = detect_features_content(sarif) - - assert content_features.has_sections, "Should detect ## headings in CLAUDE.md" - - def test_detect_explicit_constraints( - self, - level2_project: Path, - ) -> None: - """Detect MUST/NEVER constraints in content.""" - from reporails_cli.core.capability import detect_features_content - from reporails_cli.core.regex import run_capability_detection - - sarif = run_capability_detection(level2_project) - content_features = detect_features_content(sarif) - - assert content_features.has_explicit_constraints, "Should detect MUST/NEVER in level2 CLAUDE.md" - - -class TestCapabilityLevelDetermination: - """Test capability level determination from features.""" - - def test_level1_minimal_project(self, level1_project: Path) -> None: - """Level 1 project should be detected as L1 or L2.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(level1_project) - sarif = run_capability_detection(level1_project) - content_features = detect_features_content(sarif) - - result = determine_capability_level(features, content_features) - - # Minimal project should be L1 or L2 - assert result.level in (Level.L1, Level.L2), f"Minimal project should be L1-L2, got {result.level}" - - def test_level2_basic_project(self, level2_project: Path) -> None: - """Level 2 project should be detected as L2 or L3.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(level2_project) - sarif = run_capability_detection(level2_project) - content_features = detect_features_content(sarif) - - result = determine_capability_level(features, content_features) - - # Basic project with sections should be at least L2 - assert result.level.value >= Level.L2.value, f"Project with sections should be at least L2, got {result.level}" - - def test_level3_structured_project(self, level3_project: Path) -> None: - """Level 3 project should be detected as L3 or higher.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(level3_project) - sarif = run_capability_detection(level3_project) - content_features = detect_features_content(sarif) - - result = determine_capability_level(features, content_features) - - # Project with rules dir should be at least L3 - assert result.level.value >= Level.L3.value, ( - f"Project with .claude/rules/ should be at least L3, got {result.level}" - ) - - def test_level5_governed_project(self, level5_project: Path) -> None: - """Level 5 project should be detected as L5 or L6.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(level5_project) - sarif = run_capability_detection(level5_project) - content_features = detect_features_content(sarif) - - result = determine_capability_level(features, content_features) - - # Project with backbone should be at least L5 - assert result.level.value >= Level.L5.value, ( - f"Project with backbone.yml should be at least L5, got {result.level}" - ) - - def test_missing_files_lowers_level(self, tmp_path: Path) -> None: - """Missing expected files should result in lower level, not error.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - # Create minimal structure - (tmp_path / "CLAUDE.md").write_text("# Test\n") - - features = detect_features_filesystem(tmp_path) - sarif = run_capability_detection(tmp_path) - content_features = detect_features_content(sarif) - - # Should not raise error - result = determine_capability_level(features, content_features) - - assert result.level is not None, "Should determine a level even for minimal project" - - -class TestLevelDeterminism: - """Test that level detection is deterministic.""" - - def test_same_project_same_level(self, level3_project: Path) -> None: - """Same project should always detect same level.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - results = [] - for _ in range(3): - features = detect_features_filesystem(level3_project) - sarif = run_capability_detection(level3_project) - content_features = detect_features_content(sarif) - result = determine_capability_level(features, content_features) - results.append(result.level) - - assert len(set(results)) == 1, f"Level detection should be deterministic, got different results: {results}" - - def test_same_features_same_level(self, level3_project: Path) -> None: - """Same features should always map to same level.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - merge_content_features, - ) - from reporails_cli.core.levels import determine_level_from_gates - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(level3_project) - sarif = run_capability_detection(level3_project) - content_features = detect_features_content(sarif) - merge_content_features(features, content_features) - - # Same features should always give same level - levels = [determine_level_from_gates(features) for _ in range(5)] - assert len(set(levels)) == 1, f"Same features should give same level, got: {levels}" - - -class TestCapabilityEdgeCases: - """Edge cases for the capability detection pipeline.""" - - def test_empty_project_is_l0(self, tmp_path: Path) -> None: - """An empty directory should detect as L0 through the full pipeline.""" - from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import ( - detect_features_content, - determine_capability_level, - ) - from reporails_cli.core.regex import run_capability_detection - - features = detect_features_filesystem(tmp_path) - sarif = run_capability_detection(tmp_path) - content_features = detect_features_content(sarif) - - result = determine_capability_level(features, content_features) - - assert result.level == Level.L0, f"Empty directory should be L0, got {result.level}" +import pytest + +from reporails_cli.core.levels import determine_project_level +from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration, Level + + +def _cf( + name: str, + path: str = "CLAUDE.md", + **properties: str, +) -> ClassifiedFile: + return ClassifiedFile(path=Path(path), file_type=name, properties=properties) + + +def _ft( + name: str, + patterns: tuple[str, ...] = ("**/CLAUDE.md",), + **properties: str, +) -> FileTypeDeclaration: + return FileTypeDeclaration(name=name, patterns=patterns, properties=properties) + + +class TestProjectLevelDetermination: + """Test project level determination from file type properties.""" + + def test_no_files_is_l0(self, tmp_path: Path) -> None: + """No files → L0.""" + level, present = determine_project_level(tmp_path, [], []) + assert level == Level.L0 + assert present == set() + + def test_main_only_is_l1(self, tmp_path: Path) -> None: + """Main file with baseline properties → L1.""" + classified = [_cf("main")] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == Level.L1 + + @pytest.mark.parametrize( + "depth, expected_level", + [ + (0, Level.L1), + (1, Level.L2), + (2, Level.L3), + (3, Level.L4), + (4, Level.L5), + (5, Level.L6), + ], + ) + def test_progressive_level(self, tmp_path: Path, depth: int, expected_level: Level) -> None: + """depth N divergences → Level L(N+1).""" + prop_overrides = [ + ("format", "frontmatter"), + ("cardinality", "collection"), + ("precedence", "managed"), + ("loading", "on_demand"), + ("scope", "path_scoped"), + ] + props: dict[str, str] = {} + for i in range(depth): + k, v = prop_overrides[i] + props[k] = v + classified = [_cf("test", **props)] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == expected_level + + def test_max_depth_wins(self, tmp_path: Path) -> None: + """Level is driven by the type with most divergences.""" + classified = [ + _cf("main"), # depth 0 + _cf("scoped_rule", format="frontmatter"), # depth 1 + _cf("skill", format="frontmatter", scope="task_scoped", loading="on_invocation"), # depth 3 + ] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == Level.L4 # max(0, 1, 3) + 1 + + +class TestProjectLevelDeterminism: + """Test that project level determination is deterministic.""" + + def test_same_input_same_output(self, tmp_path: Path) -> None: + """Same files always give same level.""" + classified = [ + _cf("main"), + _cf("scoped_rule", format="frontmatter", scope="path_scoped"), + ] + results = [determine_project_level(tmp_path, [], classified) for _ in range(5)] + levels = [r[0] for r in results] + assert len(set(levels)) == 1 diff --git a/tests/integration/test_cli_e2e.py b/tests/integration/test_cli_e2e.py index d4ba9092..f523078e 100644 --- a/tests/integration/test_cli_e2e.py +++ b/tests/integration/test_cli_e2e.py @@ -1,20 +1,15 @@ """End-to-end CLI tests — exercise ails commands via Typer CliRunner. -Covers the update-related CLI surface: - - ails version (shows recommended line) - - ails check --no-update-check (flag accepted, prompt skipped) +Covers the check CLI surface: - ails check -f json (JSON output parseable) - - ails update --check (framework + recommended sections) - - ails update (default: both rules + recommended) - - ails update --recommended (recommended-only path) - - ails update --version X (rules-only, no recommended) + - ails check --strict (exit 1 on violations) + - ails check -f json (no prompt text in output) """ from __future__ import annotations import json from pathlib import Path -from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner @@ -23,6 +18,21 @@ runner = CliRunner() +_has_onnx_model = ( + Path(__file__).resolve().parents[2] + / "src" + / "reporails_cli" + / "bundled" + / "models" + / "minilm-l6-v2" + / "onnx" + / "model.onnx" +).exists() + +requires_model = pytest.mark.skipif( + not _has_onnx_model, reason="Bundled ONNX model not available" +) + def _rules_installed() -> bool: """Check if rules framework is installed.""" @@ -44,22 +54,6 @@ def _rules_installed() -> bool: class TestCheckCommand: - @requires_rules - def test_no_update_check_flag_accepted(self, level2_project: Path) -> None: - """--no-update-check should be a valid flag that doesn't error.""" - result = runner.invoke( - app, - [ - "check", - str(level2_project), - "--no-update-check", - "-q", - "-f", - "text", - ], - ) - assert result.exit_code == 0, result.output - def test_json_output_parseable(self, level2_project: Path) -> None: """JSON output should be valid JSON with expected keys.""" result = runner.invoke( @@ -69,14 +63,11 @@ def test_json_output_parseable(self, level2_project: Path) -> None: str(level2_project), "-f", "json", - "--no-update-check", ], ) assert result.exit_code == 0, result.output data = json.loads(result.output) - assert "score" in data - assert "level" in data - assert "violations" in data + assert "files" in data and "stats" in data # text_output_has_score, compact_output, missing_path, no_instruction_files # covered by smoke and behavioral tests @@ -91,23 +82,21 @@ def test_strict_mode_exits_1_on_violations(self, level2_project: Path) -> None: "check", str(level2_project), "--strict", - "--no-update-check", "-f", "json", ], ) - # If rules apply and violations exist, exit 1; otherwise check score + # --strict exits 1 when any findings exist data = json.loads(result.output) - if data.get("violations"): + has_findings = bool(data.get("files", {})) + if has_findings: assert result.exit_code == 1 else: - # No violations = clean project, exit 0 is correct assert result.exit_code == 0 + @requires_model def test_pre_run_prompt_skipped_in_json_format(self, level2_project: Path) -> None: - """JSON format should not trigger update prompt (even without --no-update-check).""" - # CliRunner is non-TTY so prompt would be skipped anyway, - # but json format adds another guard + """JSON format should produce clean JSON output with no prompt text.""" result = runner.invoke( app, [ @@ -120,213 +109,4 @@ def test_pre_run_prompt_skipped_in_json_format(self, level2_project: Path) -> No assert result.exit_code == 0, result.output # Output should be valid JSON (no prompt text mixed in) data = json.loads(result.output) - assert "score" in data - - -# --------------------------------------------------------------------------- -# ails update --check -# --------------------------------------------------------------------------- - - -class TestUpdateCheckCommand: - # test_shows_framework_and_recommended_sections covered by smoke tests - - def test_shows_up_to_date_when_current(self) -> None: - """When all components are current, should say up to date.""" - with ( - patch( - "reporails_cli.core.bootstrap.get_installed_version", - return_value="0.4.0", - ), - patch( - "reporails_cli.core.init.get_latest_version", - return_value="0.4.0", - ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.2.0", - ), - patch( - "reporails_cli.core.init.get_latest_recommended_version", - return_value="0.2.0", - ), - ): - result = runner.invoke(app, ["update", "--check"]) - - assert result.exit_code == 0, result.output - assert "up to date" in result.output.lower() - - -# --------------------------------------------------------------------------- -# ails update (default path) -# --------------------------------------------------------------------------- - - -class TestUpdateDefaultCommand: - def test_updates_both_rules_and_recommended(self) -> None: - """Default ails update should attempt both rules and recommended.""" - mock_rules_result = MagicMock( - updated=True, - previous_version="0.2.0", - new_version="0.3.0", - rule_count=100, - message="Updated.", - ) - mock_rec_result = MagicMock( - updated=True, - previous_version="0.0.9", - new_version="0.1.0", - rule_count=10, - message="Updated.", - ) - - with ( - patch("reporails_cli.core.init.update_rules", return_value=mock_rules_result), - patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result), - ): - result = runner.invoke(app, ["update"]) - - assert result.exit_code == 0, result.output - assert "framework" in result.output.lower() - assert "recommended" in result.output.lower() - - def test_version_flag_skips_recommended(self) -> None: - """ails update --version X should only update rules, not recommended.""" - mock_rules_result = MagicMock( - updated=True, - previous_version="0.2.0", - new_version="0.3.0", - rule_count=100, - message="Updated.", - ) - - with ( - patch("reporails_cli.core.init.update_rules", return_value=mock_rules_result), - patch("reporails_cli.core.init.update_recommended"), - ): - result = runner.invoke(app, ["update", "--version", "0.3.0"]) - - assert result.exit_code == 0, result.output - # --version targets rules only; output should not mention recommended update - assert "recommended" not in result.output.lower() or "already" in result.output.lower() - - def test_already_current_shows_message(self) -> None: - """When already at latest, should show already-current messages.""" - mock_rules_result = MagicMock( - updated=False, - previous_version="0.3.0", - new_version="0.3.0", - rule_count=0, - message="Already at version 0.3.0.", - ) - mock_rec_result = MagicMock( - updated=False, - previous_version="0.1.0", - new_version="0.1.0", - rule_count=0, - message="Recommended already at version 0.1.0.", - ) - - with ( - patch("reporails_cli.core.init.update_rules", return_value=mock_rules_result), - patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result), - ): - result = runner.invoke(app, ["update"]) - - assert result.exit_code == 0, result.output - assert "Already at version" in result.output - - -# --------------------------------------------------------------------------- -# ails update --recommended -# --------------------------------------------------------------------------- - - -class TestUpdateRecommendedCommand: - def test_recommended_flag_only_updates_recommended(self) -> None: - """--recommended should only update recommended, not rules.""" - mock_rec_result = MagicMock( - updated=True, - previous_version="0.0.9", - new_version="0.1.0", - rule_count=10, - message="Updated.", - ) - - with ( - patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result), - patch("reporails_cli.core.init.update_rules"), - ): - result = runner.invoke(app, ["update", "--recommended"]) - - assert result.exit_code == 0, result.output - assert "recommended" in result.output.lower() - # --recommended should not mention framework update - assert "framework" not in result.output.lower() or "0.1.0" in result.output - - def test_recommended_already_current(self) -> None: - """When recommended is current, should show message.""" - mock_rec_result = MagicMock( - updated=False, - previous_version="0.1.0", - new_version="0.1.0", - rule_count=0, - message="Recommended already at version 0.1.0.", - ) - - with patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result): - result = runner.invoke(app, ["update", "--recommended"]) - - assert result.exit_code == 0, result.output - assert "already at version" in result.output.lower() - - def test_recommended_force_flag(self) -> None: - """--recommended --force should succeed and report update.""" - mock_rec_result = MagicMock( - updated=True, - previous_version="0.1.0", - new_version="0.1.0", - rule_count=10, - message="Updated.", - ) - - with patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result): - result = runner.invoke(app, ["update", "--recommended", "--force"]) - - assert result.exit_code == 0, result.output - assert "recommended" in result.output.lower() - - -# --------------------------------------------------------------------------- -# ails update --force (default path) -# --------------------------------------------------------------------------- - - -class TestUpdateForceCommand: - def test_force_updates_both(self) -> None: - """--force without --version should force-update both components.""" - mock_rules_result = MagicMock( - updated=True, - previous_version="0.3.0", - new_version="0.3.0", - rule_count=200, - message="Updated.", - ) - mock_rec_result = MagicMock( - updated=True, - previous_version="0.1.0", - new_version="0.1.0", - rule_count=10, - message="Updated.", - ) - - with ( - patch("reporails_cli.core.init.update_rules", return_value=mock_rules_result), - patch("reporails_cli.core.init.update_recommended", return_value=mock_rec_result), - ): - result = runner.invoke(app, ["update", "--force"]) - - assert result.exit_code == 0, result.output - # --force should update both components - assert "framework" in result.output.lower() or "rules" in result.output.lower() - assert "recommended" in result.output.lower() + assert "files" in data and "stats" in data diff --git a/tests/integration/test_golden_snapshot.py b/tests/integration/test_golden_snapshot.py deleted file mode 100644 index 887de4a0..00000000 --- a/tests/integration/test_golden_snapshot.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Golden snapshot tests — assert full pipeline output stability. - -Runs `run_validation` + `format_result` against committed fixture projects -and compares structurally against expected.json golden files. - -Regenerate golden files after intentional changes: - uv run pytest tests/integration/test_golden_snapshot.py --update-golden -v -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest - -from reporails_cli.core.engine import run_validation -from reporails_cli.formatters.json import format_result - - -def _rules_installed() -> bool: - from reporails_cli.core.bootstrap import get_rules_path - - return (get_rules_path() / "core").exists() - - -requires_rules = pytest.mark.skipif( - not _rules_installed(), - reason="Rules framework not installed", -) - -GOLDEN_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "golden" - -# Fields matched exactly — these are deterministic for static input -EXACT_FIELDS = {"score", "level", "friction", "summary", "category_summary"} - -# Fields stripped entirely — non-deterministic or delta-dependent -STRIP_FIELDS = { - "score_delta", - "level_previous", - "level_improved", - "violations_delta", -} - -# Per-violation fields that drift with rule text edits -VIOLATION_SKIP_FIELDS = {"message", "rule_title"} - -# Per-judgment-request fields that drift with rule text edits -JUDGMENT_SKIP_FIELDS = {"content", "question", "rule_title"} - -# Top-level fields with human-readable labels that may change -LABEL_SKIP_FIELDS = {"feature_summary", "capability"} - - -# --------------------------------------------------------------------------- -# Fixture scenarios -# --------------------------------------------------------------------------- - -SCENARIOS = [ - pytest.param("l2_claude", "claude", id="l2-claude"), - pytest.param("l2_generic", "", id="l2-generic"), - pytest.param("l5_claude", "claude", id="l5-claude"), -] - - -# --------------------------------------------------------------------------- -# Structured comparison -# --------------------------------------------------------------------------- - - -def _strip_violation(v: dict[str, Any]) -> dict[str, Any]: - """Keep only stable violation fields.""" - return {k: val for k, val in v.items() if k not in VIOLATION_SKIP_FIELDS} - - -def _strip_judgment(j: dict[str, Any]) -> dict[str, Any]: - """Keep only stable judgment request fields.""" - return {k: val for k, val in j.items() if k not in JUDGMENT_SKIP_FIELDS} - - -def _stable_output(data: dict[str, Any]) -> dict[str, Any]: - """Extract the structurally stable subset of a format_result dict.""" - stable: dict[str, Any] = {} - - # Exact-match scalar/dict fields - for field in EXACT_FIELDS: - if field in data: - stable[field] = data[field] - - # Evaluation completeness - stable["evaluation"] = data.get("evaluation") - stable["is_partial"] = data.get("is_partial") - - # Violations: strip drifty text, sort for determinism - violations = [_strip_violation(v) for v in data.get("violations", [])] - stable["violations"] = sorted( - violations, key=lambda v: (v["rule_id"], v.get("location", ""), v.get("check_id", "")) - ) - - # Judgment requests: strip drifty text, sort for determinism - judgments = [_strip_judgment(j) for j in data.get("judgment_requests", [])] - stable["judgment_requests"] = sorted(judgments, key=lambda j: (j["rule_id"], j.get("location", ""))) - - # Optional sections — include when present in either actual or expected - if "pending_semantic" in data and data["pending_semantic"] is not None: - ps = data["pending_semantic"] - stable["pending_semantic"] = { - "rule_count": ps["rule_count"], - "file_count": ps["file_count"], - "rules": sorted(ps["rules"]), - } - - if "skipped_experimental" in data and data["skipped_experimental"] is not None: - se = data["skipped_experimental"] - stable["skipped_experimental"] = { - "rule_count": se["rule_count"], - "rules": sorted(se["rules"]), - } - - return stable - - -def _diff_golden(actual: dict[str, Any], expected: dict[str, Any]) -> list[str]: - """Compare two stable dicts, returning human-readable diffs.""" - diffs: list[str] = [] - - all_keys = sorted(set(actual) | set(expected)) - for key in all_keys: - if key not in actual: - diffs.append(f"Missing in actual: {key}") - continue - if key not in expected: - diffs.append(f"Extra in actual: {key}") - continue - if actual[key] != expected[key]: - a = json.dumps(actual[key], indent=2, default=str) - e = json.dumps(expected[key], indent=2, default=str) - diffs.append(f"Mismatch in '{key}':\n actual: {a}\n expected: {e}") - - return diffs - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@requires_rules -class TestGoldenSnapshots: - """Run full pipeline against committed fixtures, compare golden output.""" - - @pytest.mark.parametrize("fixture_name,agent", SCENARIOS) - def test_golden_output( - self, - fixture_name: str, - agent: str, - update_golden: bool, - ) -> None: - fixture_dir = GOLDEN_DIR / fixture_name - expected_path = fixture_dir / "expected.json" - - # Run full pipeline - result = run_validation( - fixture_dir, - agent=agent, - use_cache=False, - record_analytics=False, - ) - raw_output = format_result(result, delta=None) - actual_stable = _stable_output(raw_output) - - if update_golden: - expected_path.write_text(json.dumps(actual_stable, indent=2, sort_keys=True) + "\n") - pytest.skip(f"Updated golden file: {expected_path}") - return - - if not expected_path.exists(): - pytest.fail(f"Golden file missing: {expected_path}\nRun with --update-golden to generate it.") - - expected = json.loads(expected_path.read_text()) - diffs = _diff_golden(actual_stable, expected) - - if diffs: - pytest.fail( - f"Golden snapshot mismatch for {fixture_name}:\n" - + "\n".join(diffs) - + "\n\nRun with --update-golden to accept changes." - ) diff --git a/tests/integration/test_mcp_e2e.py b/tests/integration/test_mcp_e2e.py index 57900a7a..1b098fb2 100644 --- a/tests/integration/test_mcp_e2e.py +++ b/tests/integration/test_mcp_e2e.py @@ -2,11 +2,10 @@ Covers: - Tool listing: all expected tools present with correct schemas - - validate: returns JSON with score, violations, judgment workflow - - score: returns JSON with score/level keys + - validate: returns JSON with score, violations + - score: returns JSON with compliance band and finding counts - explain: returns rule details or error for unknown rules - - judge: caches verdicts, returns recorded count - - judge: path traversal blocked, coordinate IDs parsed correctly + - heal: auto-fix instruction file issues - Circuit breaker: content-aware mtime tracking - Unknown tool: returns error """ @@ -21,6 +20,22 @@ import pytest +# --------------------------------------------------------------------------- +# Skip markers +# --------------------------------------------------------------------------- + +_has_onnx_model = ( + Path(__file__).resolve().parents[2] + / "src" + / "reporails_cli" + / "bundled" + / "models" + / "minilm-l6-v2" + / "onnx" + / "model.onnx" +).exists() +requires_model = pytest.mark.skipif(not _has_onnx_model, reason="Bundled ONNX model not available") + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -59,20 +74,12 @@ def _call_tool(name: str, arguments: dict[str, Any]) -> str: class TestListTools: def test_all_tools_present(self) -> None: - """list_tools should return all five tools.""" + """list_tools should return all four tools.""" from reporails_cli.interfaces.mcp.server import list_tools tools = _run_async(list_tools()) names = {t.name for t in tools} - assert names == {"validate", "score", "explain", "judge", "heal"} - - def test_judge_tool_has_required_verdicts(self) -> None: - """judge tool schema should require the verdicts parameter.""" - from reporails_cli.interfaces.mcp.server import list_tools - - tools = _run_async(list_tools()) - judge = next(t for t in tools if t.name == "judge") - assert "verdicts" in judge.inputSchema["required"] + assert names == {"validate", "score", "explain", "heal"} def test_validate_tool_path_optional(self) -> None: """validate tool should not require path (has default).""" @@ -111,28 +118,33 @@ def test_returns_valid_json(self, level2_project: Path) -> None: assert isinstance(data, dict) @requires_rules - def test_json_has_score(self, level2_project: Path) -> None: - """validate JSON must contain a score key.""" + def test_json_has_files_and_stats(self, level2_project: Path) -> None: + """validate JSON must contain files and stats keys.""" text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) - assert "score" in data - assert isinstance(data["score"], (int, float)) + assert "files" in data + assert "stats" in data @requires_rules - def test_json_has_violations_array(self, level2_project: Path) -> None: - """validate JSON must contain violations as a list.""" + def test_json_has_violations_grouped_by_file(self, level2_project: Path) -> None: + """validate JSON must contain violations as a dict grouped by file.""" text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) - assert "violations" in data - assert isinstance(data["violations"], list) + if "violations" in data: + assert isinstance(data["violations"], dict) + for file_key, entries in data["violations"].items(): + assert isinstance(file_key, str) + assert isinstance(entries, list) + for entry in entries: + assert isinstance(entry, list) + assert len(entry) == 4 # [rule_id, line_ref, severity, message] @requires_rules - def test_json_has_level(self, level2_project: Path) -> None: - """validate JSON must contain level.""" + def test_json_has_offline_flag(self, level2_project: Path) -> None: + """validate JSON must contain offline flag.""" text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) - assert "level" in data - assert data["level"].startswith("L") + assert "offline" in data @requires_rules def test_no_shell_out_guidance(self, level2_project: Path) -> None: @@ -141,19 +153,6 @@ def test_no_shell_out_guidance(self, level2_project: Path) -> None: for pattern in _SHELL_OUT_PATTERNS: assert pattern not in text, f"Shell-out pattern {pattern!r} found in validate response" - @requires_rules - def test_semantic_workflow_when_pending(self, level2_project: Path) -> None: - """When semantic rules exist, JSON must contain _semantic_workflow.""" - text = _call_tool("validate", {"path": str(level2_project)}) - data = json.loads(text) - if data.get("judgment_requests"): - assert "_semantic_workflow" in data - workflow = data["_semantic_workflow"] - assert workflow["action"] == "evaluate_and_judge" - assert "steps" in workflow - assert "verdict_format" in workflow - assert "example_call" in workflow - def test_missing_path_returns_error_json(self) -> None: """Non-existent path should return JSON error.""" text = _call_tool("validate", {"path": "/tmp/no-such-path-xyz-mcp-test"}) @@ -169,15 +168,18 @@ def test_uninitialized_returns_error_json(self) -> None: assert data["error"] == "not_initialized" def test_runtime_error_returns_error_json(self, level2_project: Path) -> None: - """RuntimeError from run_validation must return JSON error, not crash.""" - with patch( - "reporails_cli.interfaces.mcp.server.run_validation", - side_effect=RuntimeError("Unsupported operating system"), + """RuntimeError from _run_pipeline must return JSON error, not crash.""" + with ( + patch("reporails_cli.interfaces.mcp.server.is_initialized", return_value=True), + patch( + "reporails_cli.interfaces.mcp.tools._run_pipeline", + side_effect=RuntimeError("Unsupported operating system"), + ), ): text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) assert "error" in data - assert data["error"] == "RuntimeError" + assert data["error"] == "Unsupported operating system" # --------------------------------------------------------------------------- @@ -187,19 +189,18 @@ def test_runtime_error_returns_error_json(self, level2_project: Path) -> None: class TestScoreTool: @requires_rules - def test_returns_json_with_score(self, level2_project: Path) -> None: - """score should return JSON with score and level.""" + def test_returns_json_with_stats(self, level2_project: Path) -> None: + """score should return JSON with findings summary.""" text = _call_tool("score", {"path": str(level2_project)}) data = json.loads(text) - assert "score" in data - assert "level" in data + assert "total_findings" in data or "errors" in data @requires_rules - def test_score_is_numeric(self, level2_project: Path) -> None: - """Score value should be a number.""" + def test_offline_flag_present(self, level2_project: Path) -> None: + """Score result should indicate offline status.""" text = _call_tool("score", {"path": str(level2_project)}) data = json.loads(text) - assert isinstance(data["score"], (int, float)) + assert "offline" in data # --------------------------------------------------------------------------- @@ -209,12 +210,13 @@ def test_score_is_numeric(self, level2_project: Path) -> None: class TestExplainTool: def test_known_rule_returns_details(self, dev_rules_dir: Path) -> None: - """Explaining a known rule should return its details.""" + """Explaining a known rule should return readable text with rule ID and title.""" from reporails_cli.interfaces.mcp.tools import explain_tool - data = explain_tool("CORE:S:0001", rules_paths=[dev_rules_dir]) - assert "error" not in data - assert "title" in data or "rule_id" in data + result = explain_tool("CORE:S:0002", rules_paths=[dev_rules_dir]) + assert isinstance(result, str) + assert "CORE:S:0002" in result + assert "Section Headers Present" in result def test_unknown_rule_returns_error(self) -> None: """Explaining an unknown rule should return an error.""" @@ -223,145 +225,6 @@ def test_unknown_rule_returns_error(self) -> None: assert "error" in data -# --------------------------------------------------------------------------- -# judge tool -# --------------------------------------------------------------------------- - - -class TestJudgeTool: - def test_records_verdicts(self, level2_project: Path) -> None: - """judge should record verdicts and return count.""" - verdicts = ["CORE:S:0001:CLAUDE.md:pass:File size OK"] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert "recorded" in data - assert data["recorded"] == 1 - - def test_records_multiple_verdicts(self, level2_project: Path) -> None: - """judge should handle multiple verdicts.""" - verdicts = [ - "CORE:S:0001:CLAUDE.md:pass:File size OK", - "CORE:C:0002:CLAUDE.md:fail:Missing section", - ] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert data["recorded"] == 2 - - def test_coordinate_rule_id(self, level2_project: Path) -> None: - """Coordinate-format rule IDs (CORE:S:0001) should be parsed correctly.""" - verdicts = ["CORE:S:0001:CLAUDE.md:pass:Criteria met"] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert data["recorded"] == 1 - - def test_persists_to_cache(self, level2_project: Path) -> None: - """Verdicts should be persisted in the judgment cache file.""" - from reporails_cli.core.cache import ProjectCache - - verdicts = ["CORE:S:0001:CLAUDE.md:pass:Looks good"] - _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - - cache = ProjectCache(level2_project) - cache_data = cache.load_judgment_cache() - judgments = cache_data.get("judgments", {}) - assert "CLAUDE.md" in judgments - assert "CORE:S:0001" in judgments["CLAUDE.md"].get("results", {}) - - def test_empty_verdicts_returns_error(self) -> None: - """Empty verdicts list should return an error.""" - text = _call_tool("judge", {"path": ".", "verdicts": []}) - data = json.loads(text) - assert "error" in data - - def test_no_verdicts_key_returns_error(self) -> None: - """Missing verdicts argument should return an error.""" - text = _call_tool("judge", {"path": "."}) - data = json.loads(text) - assert "error" in data - - def test_invalid_verdict_format_records_zero(self, level2_project: Path) -> None: - """Malformed verdict strings should not be recorded.""" - verdicts = ["garbage"] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert data["recorded"] == 0 - - def test_nonexistent_file_in_verdict_records_zero(self, level2_project: Path) -> None: - """Verdict referencing a nonexistent file should not be recorded.""" - verdicts = ["CORE:S:0001:no-such-file.md:pass:OK"] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert data["recorded"] == 0 - - def test_path_traversal_blocked(self, tmp_path: Path) -> None: - """Verdicts referencing files outside the project must be rejected.""" - project = tmp_path / "project" - sibling = tmp_path / "sibling" - project.mkdir() - sibling.mkdir() - (project / "CLAUDE.md").write_text("# Project\n") - (sibling / "secrets.md").write_text("API_KEY=sk-secret\n") - - text = _call_tool( - "judge", - { - "path": str(project), - "verdicts": ["CORE:S:0001:../sibling/secrets.md:pass:Should be blocked"], - }, - ) - data = json.loads(text) - assert data["recorded"] == 0 - - def test_invalid_verdict_value_rejected(self, level2_project: Path) -> None: - """Verdict must be 'pass' or 'fail'; other values are rejected.""" - verdicts = ["CORE:S:0001:CLAUDE.md:maybe:unsure"] - text = _call_tool( - "judge", - { - "path": str(level2_project), - "verdicts": verdicts, - }, - ) - data = json.loads(text) - assert data["recorded"] == 0 - - # --------------------------------------------------------------------------- # Unknown tool # --------------------------------------------------------------------------- @@ -405,7 +268,7 @@ def test_first_call_succeeds(self, level2_project: Path) -> None: text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) assert "error" not in data - assert "score" in data + assert "files" in data @requires_rules def test_second_call_succeeds(self, level2_project: Path) -> None: @@ -414,7 +277,7 @@ def test_second_call_succeeds(self, level2_project: Path) -> None: text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) assert "error" not in data - assert "score" in data + assert "files" in data @requires_rules def test_third_unchanged_triggers_breaker(self, level2_project: Path) -> None: @@ -437,7 +300,7 @@ def test_edit_between_calls_resets_breaker(self, level2_project: Path) -> None: text = _call_tool("validate", {"path": str(level2_project)}) data = json.loads(text) assert "error" not in data - assert "score" in data + assert "files" in data @requires_rules def test_breaker_message_says_do_not_call_again(self, level2_project: Path) -> None: @@ -454,7 +317,7 @@ def test_different_paths_independent(self, level2_project: Path, tmp_path: Path) other = tmp_path / "other" other.mkdir() (other / "CLAUDE.md").write_text("# Other project\n") - (other / ".reporails").mkdir() + (other / ".ails").mkdir() # Call level2_project twice (at threshold) _call_tool("validate", {"path": str(level2_project)}) @@ -497,29 +360,6 @@ def test_absolute_ceiling(self, level2_project: Path) -> None: # --------------------------------------------------------------------------- -class TestJudgeToolHelper: - """Test the judge_tool helper function directly.""" - - def test_returns_recorded_count_and_details(self, level2_project: Path) -> None: - from reporails_cli.interfaces.mcp.tools import judge_tool - - result = judge_tool(str(level2_project), ["CORE:S:0001:CLAUDE.md:pass:OK"]) - assert result["recorded"] == 1 - assert result["verdicts"] == [{"rule": "CORE:S:0001", "file": "CLAUDE.md", "verdict": "pass", "reason": "OK"}] - - def test_none_verdicts_returns_error(self) -> None: - from reporails_cli.interfaces.mcp.tools import judge_tool - - result = judge_tool(".", None) - assert "error" in result - - def test_missing_path_returns_error(self) -> None: - from reporails_cli.interfaces.mcp.tools import judge_tool - - result = judge_tool("/tmp/no-such-path-xyz-mcp-test", ["CORE:S:0001:x.md:pass:OK"]) - assert "error" in result - - class TestScoreToolHelper: """Test the score_tool helper function directly.""" @@ -528,8 +368,7 @@ def test_returns_score_dict(self, level2_project: Path) -> None: from reporails_cli.interfaces.mcp.tools import score_tool result = score_tool(str(level2_project)) - assert "score" in result - assert "level" in result + assert "total_findings" in result or "offline" in result assert "error" not in result def test_missing_path_returns_error(self) -> None: @@ -642,32 +481,3 @@ def test_garbage_level(self) -> None: assert d is not None -# --------------------------------------------------------------------------- -# Cache atomicity -# --------------------------------------------------------------------------- - - -class TestCacheAtomicity: - """Judgment cache writes must use atomic temp+rename.""" - - def test_write_creates_no_partial_json(self, level2_project: Path) -> None: - """After caching verdicts, the cache file must be valid JSON.""" - from reporails_cli.interfaces.mcp.tools import judge_tool - - judge_tool(str(level2_project), ["CORE:S:0001:CLAUDE.md:pass:OK"]) - - cache_path = level2_project / ".reporails" / ".cache" / "judgment-cache.json" - assert cache_path.exists() - data = json.loads(cache_path.read_text()) - assert "version" in data - assert "judgments" in data - - def test_no_temp_file_left_behind(self, level2_project: Path) -> None: - """Atomic write must not leave .tmp files after completion.""" - from reporails_cli.interfaces.mcp.tools import judge_tool - - judge_tool(str(level2_project), ["CORE:S:0001:CLAUDE.md:pass:OK"]) - - cache_dir = level2_project / ".reporails" / ".cache" - tmp_files = list(cache_dir.glob("*.tmp")) - assert tmp_files == [] diff --git a/tests/integration/test_metadata_propagation.py b/tests/integration/test_metadata_propagation.py deleted file mode 100644 index 17aaaf5f..00000000 --- a/tests/integration/test_metadata_propagation.py +++ /dev/null @@ -1,825 +0,0 @@ -"""Integration tests for M→(M↔D)*n→S pipeline — metadata propagation and timing. - -Exercises the full pipeline path through execute_rule_checks with synthetic -rules, real PipelineState, and crafted SARIF data. Every test asserts a -per-rule timing ceiling to catch algorithmic regressions. - -M gate leads (file existence), then M and D interleave freely, then S. - -Sequence coverage: - M→D→M, M→D→M→S, M→D→D→M→S, M→D→M→D→S, M→M→D→M→D→S -""" - -from __future__ import annotations - -import time -from pathlib import Path - -from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity -from reporails_cli.core.pipeline import PipelineState, TargetMeta -from reporails_cli.core.pipeline_exec import execute_rule_checks - -# Per-rule execution ceiling in milliseconds. -# Synthetic rules with tiny files should complete in <2ms. -# 50ms gives 25x headroom for CI variance while catching O(N) blowups. -RULE_EXEC_CEILING_MS = 50 - - -def _timed_execute(rule, state, scan_root, tvars, instruction_files): - """Wrap execute_rule_checks with timing assertion.""" - start = time.perf_counter() - result = execute_rule_checks(rule, state, scan_root, tvars, instruction_files) - elapsed_ms = (time.perf_counter() - start) * 1000 - assert elapsed_ms < RULE_EXEC_CEILING_MS, ( - f"Rule {rule.id} took {elapsed_ms:.1f}ms (ceiling: {RULE_EXEC_CEILING_MS}ms)" - ) - return result - - -def _make_d_to_m_rule( - rule_id: str, - metadata_key: str, - m_probe: str, - m_args: dict | None = None, -) -> Rule: - """Build a synthetic rule with D→M metadata propagation. - - Creates a two-check rule: deterministic extracts into metadata_key, - mechanical consumes it via the named probe. - """ - return Rule( - id=rule_id, - title=f"D→M test rule {rule_id}", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - targets="{{instruction_files}}", - checks=[ - Check( - id=f"{rule_id}:check:0001", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=[metadata_key], - ), - Check( - id=f"{rule_id}:check:0002", - severity=Severity.HIGH, - type="mechanical", - check=m_probe, - args=m_args or {}, - metadata_keys=[metadata_key], - ), - ], - ) - - -def _sarif_results(rule_id: str, check_num: str, matches: list[tuple[str, str, int]]) -> dict: - """Build SARIF results for a rule's check. - - Args: - rule_id: e.g. "CORE:C:0060" - check_num: e.g. "0001" - matches: list of (message_text, file_uri, line_number) tuples - """ - sarif_rule_id = rule_id.replace(":", ".") + f".check.{check_num}" - return { - rule_id: [ - { - "ruleId": sarif_rule_id, - "message": {"text": msg}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": uri}, - "region": {"startLine": line}, - } - } - ], - } - for msg, uri, line in matches - ] - } - - -class TestDToMCountAtMost: - """D extracts items → M checks count_at_most → fires when too many.""" - - def test_violation_when_count_exceeds_threshold(self, tmp_path: Path) -> None: - """3 D matches with threshold=0 → M violation fires.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = _make_d_to_m_rule( - "CORE:C:0060", - metadata_key="style_rules", - m_probe="count_at_most", - m_args={"threshold": 0}, - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0060", - "0001", - [ - ("indent with 4 spaces", "CLAUDE.md", 3), - ("use tabs for indent", "CLAUDE.md", 7), - ("trailing comma required", "CLAUDE.md", 12), - ], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - # D produced 3 violations - d_findings = [f for f in state.findings if "check:0001" in (f.check_id or "")] - assert len(d_findings) == 3 - - # M consumed the 3-item list → exceeds threshold 0 → violation - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0060:check:0002"] - assert len(m_findings) == 1 - assert "exceeds" in m_findings[0].message - - # Annotations are on the target - assert state.targets["CLAUDE.md"].annotations["style_rules"] == [ - "indent with 4 spaces", - "use tabs for indent", - "trailing comma required", - ] - - def test_pass_when_count_within_threshold(self, tmp_path: Path) -> None: - """1 D match with threshold=5 → M passes (no violation).""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = _make_d_to_m_rule( - "CORE:C:0061", - metadata_key="items", - m_probe="count_at_most", - m_args={"threshold": 5}, - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0061", - "0001", - [("single item", "CLAUDE.md", 1)], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0061:check:0002"] - assert len(m_findings) == 0 - - def test_no_d_matches_m_sees_empty(self, tmp_path: Path) -> None: - """No SARIF results → D writes nothing → M sees no metadata → passes (0 <= threshold).""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = _make_d_to_m_rule( - "CORE:C:0062", - metadata_key="items", - m_probe="count_at_most", - m_args={"threshold": 0}, - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - # No D results → no annotations → M gets empty args → count=0 ≤ threshold=0 → pass - assert not any(f.check_id == "CORE:C:0062:check:0002" for f in state.findings) - assert state.targets["CLAUDE.md"].annotations == {} - - -class TestDToMCountAtLeast: - """D extracts items → M checks count_at_least → fires when too few.""" - - def test_violation_when_below_minimum(self, tmp_path: Path) -> None: - """1 D match with threshold=3 → count_at_least fires.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = _make_d_to_m_rule( - "CORE:C:0063", - metadata_key="prohibitions", - m_probe="count_at_least", - m_args={"threshold": 3}, - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0063", - "0001", - [("NEVER do X", "CLAUDE.md", 5)], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0063:check:0002"] - assert len(m_findings) == 1 - assert "below" in m_findings[0].message - - def test_pass_when_meets_minimum(self, tmp_path: Path) -> None: - """3 D matches with threshold=2 → count_at_least passes.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = _make_d_to_m_rule( - "CORE:C:0064", - metadata_key="prohibitions", - m_probe="count_at_least", - m_args={"threshold": 2}, - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0064", - "0001", - [ - ("NEVER do X", "CLAUDE.md", 5), - ("DO NOT do Y", "CLAUDE.md", 8), - ("MUST NOT do Z", "CLAUDE.md", 11), - ], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0064:check:0002"] - assert len(m_findings) == 0 - - -class TestDToMImportTargetsExist: - """D extracts @import paths → M resolves them against filesystem.""" - - def test_all_imports_resolve(self, tmp_path: Path) -> None: - """All extracted import paths exist → pass.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n@rules.md\n@config.md\n") - (tmp_path / "rules.md").write_text("# Rules") - (tmp_path / "config.md").write_text("# Config") - rule = _make_d_to_m_rule( - "CORE:C:0065", - metadata_key="import_paths", - m_probe="check_import_targets_exist", - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0065", - "0001", - [ - ("@rules.md", "CLAUDE.md", 2), - ("@config.md", "CLAUDE.md", 3), - ], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0065:check:0002"] - assert len(m_findings) == 0 - - def test_missing_import_fires_violation(self, tmp_path: Path) -> None: - """One import doesn't resolve → violation.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n@rules.md\n@missing.md\n") - (tmp_path / "rules.md").write_text("# Rules") - rule = _make_d_to_m_rule( - "CORE:C:0066", - metadata_key="import_paths", - m_probe="check_import_targets_exist", - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0066", - "0001", - [ - ("@rules.md", "CLAUDE.md", 2), - ("@missing.md", "CLAUDE.md", 3), - ], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0066:check:0002"] - assert len(m_findings) == 1 - assert "missing.md" in m_findings[0].message - - -class TestMultipleMetadataKeys: - """A single D check can write to multiple metadata keys.""" - - def test_multiple_keys_propagated(self, tmp_path: Path) -> None: - """D check with two metadata_keys writes same data to both annotations.""" - (tmp_path / "CLAUDE.md").write_text("# Hello\n") - rule = Rule( - id="CORE:C:0070", - title="Multi-key test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - targets="{{instruction_files}}", - checks=[ - Check( - id="CORE:C:0070:check:0001", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["key_alpha", "key_beta"], - ), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = _sarif_results( - "CORE:C:0070", - "0001", - [("data point", "CLAUDE.md", 1)], - ) - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - _timed_execute(rule, state, tmp_path, tvars, None) - - annotations = state.targets["CLAUDE.md"].annotations - assert annotations["key_alpha"] == ["data point"] - assert annotations["key_beta"] == ["data point"] - - -# --------------------------------------------------------------------------- -# Multi-gate sequence tests — representative signal catalog patterns -# --------------------------------------------------------------------------- - - -# Helper to build SARIF for a specific check within a rule -def _check_sarif(rule_id: str, check_num: str, messages: list[str], uri: str = "CLAUDE.md") -> list[dict]: - """Build raw SARIF result list for one check.""" - sarif_rule_id = rule_id.replace(":", ".") + f".check.{check_num}" - return [ - { - "ruleId": sarif_rule_id, - "message": {"text": msg}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": uri}, - "region": {"startLine": i + 1}, - } - } - ], - } - for i, msg in enumerate(messages) - ] - - -class TestSequenceMDMS: - """M→D→M→S: CODING_STYLE_ABSENT pattern. - - 1. M: file_exists (gate) - 2. D: extract inline style rules → metadata_keys=[inline_style_rules] - 3. M: count_at_most(threshold=0) ← reads inline_style_rules - 4. S: semantic evaluation (produces JudgmentRequest) - """ - - def test_m_d_m_s_violation_fires(self, tmp_path: Path) -> None: - """D finds style rules → M count_at_most(0) fires → S still produces JudgmentRequest.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Project\nindent with 4 spaces\nSome content.\n") - rule = Rule( - id="CORE:C:0080", - title="M→D→M→S test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Are inline style rules harmful?", - criteria=[{"key": "check1", "check": "Has inline style rules"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0080:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0080:check:0002", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["inline_style_rules"], - ), - Check( - id="CORE:C:0080:check:0003", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 0}, - metadata_keys=["inline_style_rules"], - ), - Check(id="CORE:C:0080:check:0004", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - # Semantic handler iterates ALL SARIF results for the rule to build requests. - # Provide only the D results — semantic uses them as candidates. - d_results = _check_sarif("CORE:C:0080", "0002", ["indent with 4 spaces"]) - state._sarif_by_rule = {"CORE:C:0080": d_results} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # M (file_exists) passed — no violation for check:0001 - assert not any(f.check_id == "CORE:C:0080:check:0001" for f in state.findings) - # D produced violation + wrote annotations - assert any("check:0002" in (f.check_id or "") for f in state.findings) - assert state.targets["CLAUDE.md"].annotations["inline_style_rules"] == ["indent with 4 spaces"] - # M (count_at_most 0) consumed 1-item list → violation - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0080:check:0003"] - assert len(m_findings) == 1 - # S produced JudgmentRequest (one per SARIF result available) - assert len(jrs) == 1 - - def test_m_d_m_s_no_style_rules_m_passes(self, tmp_path: Path) -> None: - """D finds nothing → M count_at_most sees empty → passes → S still fires on SARIF.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Clean project\nNo style rules here.\n") - rule = Rule( - id="CORE:C:0081", - title="M→D→M→S clean test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Is this clean?", - criteria=[{"key": "check1", "check": "No inline styles"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0081:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0081:check:0002", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["inline_style_rules"], - ), - Check( - id="CORE:C:0081:check:0003", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 0}, - metadata_keys=["inline_style_rules"], - ), - Check(id="CORE:C:0081:check:0004", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - # Only semantic SARIF — D finds nothing - s_results = _check_sarif("CORE:C:0081", "0004", ["semantic content"]) - state._sarif_by_rule = {"CORE:C:0081": s_results} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # No D violations, no M violations - assert not any("check:0002" in (f.check_id or "") for f in state.findings) - assert not any(f.check_id == "CORE:C:0081:check:0003" for f in state.findings) - # S still fires (semantic SARIF exists) - assert len(jrs) == 1 - - -class TestSequenceMDDMS: - """M→D→D→M→S: CONTENT_NON_REDUNDANT pattern. - - 1. M: file_exists - 2. D: extract potentially redundant content → metadata_keys=[redundant_candidates] - 3. D: second deterministic check (another pattern) - 4. M: count_at_most ← reads redundant_candidates - 5. S: semantic evaluation - """ - - def test_m_d_d_m_s_two_d_checks_both_contribute(self, tmp_path: Path) -> None: - """Two D checks: first writes metadata, second is a plain violation; M reads first's metadata.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Project\nnpm install\npip install\nContent.\n") - rule = Rule( - id="CORE:C:0082", - title="M→D→D→M→S test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Is content non-redundant?", - criteria=[{"key": "check1", "check": "No redundancy"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0082:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0082:check:0002", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["redundant_candidates"], - ), - Check( - id="CORE:C:0082:check:0003", - severity=Severity.MEDIUM, - type="deterministic", - ), - Check( - id="CORE:C:0082:check:0004", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 2}, - metadata_keys=["redundant_candidates"], - ), - Check(id="CORE:C:0082:check:0005", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - d1 = _check_sarif("CORE:C:0082", "0002", ["npm install", "pip install", "poetry add"]) - d2 = _check_sarif("CORE:C:0082", "0003", ["some other pattern"]) - state._sarif_by_rule = {"CORE:C:0082": d1 + d2} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # D1 wrote 3 items to redundant_candidates - assert state.targets["CLAUDE.md"].annotations["redundant_candidates"] == [ - "npm install", - "pip install", - "poetry add", - ] - # D2 produced its own violation (no metadata_keys) - d2_findings = [f for f in state.findings if "check:0003" in (f.check_id or "")] - assert len(d2_findings) == 1 - # M (count_at_most 2) consumed 3-item list → violation (3 > 2) - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0082:check:0004"] - assert len(m_findings) == 1 - # S produced JudgmentRequests (semantic handler iterates all SARIF for rule) - assert len(jrs) == 1 - - -class TestSequenceMDMDS: - """M→D→M→D→S: CONTENT_ACTIONABLE pattern. - - 1. M: file_exists - 2. D: extract instruction sentences → metadata_keys=[instruction_sentences] - 3. M: count_at_least(threshold=1) ← reads instruction_sentences - 4. D: flag vague language → metadata_keys=[vague_flags] - 5. S: semantic evaluation - """ - - def test_m_d_m_d_s_full_chain(self, tmp_path: Path) -> None: - """Full 5-step chain: each gate runs in order, metadata propagates correctly.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Project\nAlways use strict mode.\nEnsure quality.\n") - rule = Rule( - id="CORE:C:0083", - title="M→D→M→D→S test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Are instructions specific?", - criteria=[{"key": "check1", "check": "Specific instructions"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0083:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0083:check:0002", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["instruction_sentences"], - ), - Check( - id="CORE:C:0083:check:0003", - severity=Severity.MEDIUM, - type="mechanical", - check="count_at_least", - args={"threshold": 1}, - metadata_keys=["instruction_sentences"], - ), - Check( - id="CORE:C:0083:check:0004", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["vague_flags"], - ), - Check(id="CORE:C:0083:check:0005", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - d1 = _check_sarif("CORE:C:0083", "0002", ["Always use strict mode"]) - d2 = _check_sarif("CORE:C:0083", "0004", ["ensure quality"]) - state._sarif_by_rule = {"CORE:C:0083": d1 + d2} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # D1 wrote instruction_sentences - assert state.targets["CLAUDE.md"].annotations["instruction_sentences"] == ["Always use strict mode"] - # M (count_at_least 1) sees 1 item → passes - assert not any(f.check_id == "CORE:C:0083:check:0003" for f in state.findings) - # D2 wrote vague_flags - assert state.targets["CLAUDE.md"].annotations["vague_flags"] == ["ensure quality"] - # S produced JudgmentRequests (one per SARIF result for the rule) - assert len(jrs) == 1 - - def test_m_d_m_d_s_no_instructions_m_fires(self, tmp_path: Path) -> None: - """D1 finds nothing → M count_at_least(1) fires → rest continues.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Empty project\nNo instructions.\n") - rule = Rule( - id="CORE:C:0084", - title="M→D→M→D→S empty test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Is this actionable?", - criteria=[{"key": "check1", "check": "Actionable"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0084:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0084:check:0002", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["instruction_sentences"], - ), - Check( - id="CORE:C:0084:check:0003", - severity=Severity.HIGH, - type="mechanical", - check="count_at_least", - args={"threshold": 1}, - metadata_keys=["instruction_sentences"], - ), - Check( - id="CORE:C:0084:check:0004", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["vague_flags"], - ), - Check(id="CORE:C:0084:check:0005", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - # No D results at all - state._sarif_by_rule = {} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # M (count_at_least 1) with no metadata → empty → fires - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0084:check:0003"] - assert len(m_findings) == 1 - assert "below" in m_findings[0].message - # S short-circuits (no SARIF candidates at all) - assert jrs == [] - - -class TestSequenceMMDMDS: - """M→M→D→M→D→S: Deep sequence with two leading M checks. - - 1. M: file_exists (gate) - 2. M: line_count (structural check) - 3. D: extract content → metadata_keys=[extracted_items] - 4. M: count_at_most ← reads extracted_items - 5. D: second extraction (plain violation) - 6. S: semantic evaluation - """ - - def test_m_m_d_m_d_s_full_deep_chain(self, tmp_path: Path) -> None: - """6-step chain: two M gates, D→M metadata, second D, then S.""" - (tmp_path / ".git").mkdir() - content = "# Project\n" + "\n".join(f"Line {i}" for i in range(20)) + "\n" - (tmp_path / "CLAUDE.md").write_text(content) - rule = Rule( - id="CORE:C:0085", - title="M→M→D→M→D→S test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Is content well-structured?", - criteria=[{"key": "check1", "check": "Well-structured"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0085:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0085:check:0002", - severity=Severity.LOW, - type="mechanical", - check="line_count", - args={"max": 500}, - ), - Check( - id="CORE:C:0085:check:0003", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["extracted_items"], - ), - Check( - id="CORE:C:0085:check:0004", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 1}, - metadata_keys=["extracted_items"], - ), - Check( - id="CORE:C:0085:check:0005", - severity=Severity.MEDIUM, - type="deterministic", - ), - Check(id="CORE:C:0085:check:0006", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - d1 = _check_sarif("CORE:C:0085", "0003", ["item A", "item B", "item C"]) - d2 = _check_sarif("CORE:C:0085", "0005", ["second det match"]) - state._sarif_by_rule = {"CORE:C:0085": d1 + d2} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # M1 (file_exists) passed - assert not any(f.check_id == "CORE:C:0085:check:0001" for f in state.findings) - # M2 (line_count max=500) passed (21 lines) - assert not any(f.check_id == "CORE:C:0085:check:0002" for f in state.findings) - # D1 wrote 3 items - assert state.targets["CLAUDE.md"].annotations["extracted_items"] == ["item A", "item B", "item C"] - # M3 (count_at_most 1) consumed 3-item list → violation - m_findings = [f for f in state.findings if f.check_id == "CORE:C:0085:check:0004"] - assert len(m_findings) == 1 - # D2 produced its own violation - d2_findings = [f for f in state.findings if "check:0005" in (f.check_id or "")] - assert len(d2_findings) == 1 - # S produced JudgmentRequests (one per SARIF result for the rule) - assert len(jrs) == 1 - - def test_m_m_d_m_d_s_line_count_fails(self, tmp_path: Path) -> None: - """M2 (line_count) fails → violation recorded, but rest of chain continues.""" - (tmp_path / ".git").mkdir() - content = "# Big\n" + "\n".join(f"Line {i}" for i in range(100)) + "\n" - (tmp_path / "CLAUDE.md").write_text(content) - rule = Rule( - id="CORE:C:0086", - title="M→M fail→D→M→D→S test", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - targets="{{instruction_files}}", - question="Is content ok?", - criteria=[{"key": "check1", "check": "OK"}], - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0086:check:0001", severity=Severity.LOW, type="mechanical", check="file_exists"), - Check( - id="CORE:C:0086:check:0002", - severity=Severity.MEDIUM, - type="mechanical", - check="line_count", - args={"max": 10}, - ), - Check( - id="CORE:C:0086:check:0003", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["items"], - ), - Check( - id="CORE:C:0086:check:0004", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 5}, - metadata_keys=["items"], - ), - Check( - id="CORE:C:0086:check:0005", - severity=Severity.MEDIUM, - type="deterministic", - ), - Check(id="CORE:C:0086:check:0006", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - d1 = _check_sarif("CORE:C:0086", "0003", ["a", "b"]) - state._sarif_by_rule = {"CORE:C:0086": d1} - tvars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - jrs = _timed_execute(rule, state, tmp_path, tvars, None) - - # M2 (line_count max=10) failed → violation - m2_findings = [f for f in state.findings if f.check_id == "CORE:C:0086:check:0002"] - assert len(m2_findings) == 1 - assert "exceeds" in m2_findings[0].message - # D→M chain still ran despite M2 failure (non-blocking) - assert state.targets["CLAUDE.md"].annotations["items"] == ["a", "b"] - # M4 (count_at_most 5) passed (2 ≤ 5) - assert not any(f.check_id == "CORE:C:0086:check:0004" for f in state.findings) - # S produced JudgmentRequests (from D SARIF results as candidates) - assert len(jrs) == 1 diff --git a/tests/integration/test_pipeline_smoke.py b/tests/integration/test_pipeline_smoke.py deleted file mode 100644 index 6d6b2c67..00000000 --- a/tests/integration/test_pipeline_smoke.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Pipeline smoke test — runs full validation with all three gate types. - -This is the integration test that catches composition bugs invisible to -unit tests (which mock the regex engine) and the rule harness (which tests one -rule at a time). It exercises: - - template resolution → regex execution → SARIF parsing → rule matching - -across multiple rules simultaneously, verifying mechanical, deterministic, -and semantic gates all produce output. - -Regression coverage: -- SARIF ruleId format (rule IDs must be valid coordinates) -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from reporails_cli.core.engine import run_validation -from reporails_cli.core.models import RuleType - - -@pytest.fixture -def vague_project(tmp_path: Path) -> Path: - """Fixture project that triggers all three gate types. - - Mechanical: missing cross-agent file, not git-tracked - Deterministic: vague qualifiers, style conventions - Semantic: specificity-over-vagueness (vague content → LLM judgment) - """ - project = tmp_path / "vague_project" - project.mkdir() - - (project / "CLAUDE.md").write_text("""\ -# My App - -A web application. - -## Guidelines - -Write clean code and follow good practices. -Format code properly and use appropriate naming. -Handle errors well and write good tests. -""") - - return project - - -class TestPipelineSmoke: - """Smoke tests for the full validation pipeline.""" - - def test_all_three_gates_produce_output( - self, - vague_project: Path, - dev_rules_dir: Path, - ) -> None: - """Mechanical + deterministic + semantic gates all fire. - - This is the composition test that catches: - - SARIF ruleId format corruption - - Template resolution failures (unresolved {{placeholders}}) - """ - result = run_validation( - vague_project, - rules_paths=[dev_rules_dir], - agent="claude", - use_cache=False, - record_analytics=False, - ) - - # Mechanical violations should exist (not git-tracked, missing files, etc.) - mechanical_violations = [ - v - for v in result.violations - if any(r.type == RuleType.MECHANICAL for r in _get_rules_for_violations(result, v.rule_id)) - ] - assert len(mechanical_violations) > 0, ( - "Expected mechanical violations (e.g., not git-tracked) but got none. Mechanical gate may not be running." - ) - - # Deterministic violations should exist (vague qualifiers in fixture) - deterministic_violations = [ - v - for v in result.violations - if any(r.type == RuleType.DETERMINISTIC for r in _get_rules_for_violations(result, v.rule_id)) - ] - assert len(deterministic_violations) > 0, ( - "Expected deterministic violations (vague qualifiers like 'clean', 'good') " - "but got none. Template resolution or SARIF parsing may be broken." - ) - - # Semantic rules are L2+ and may be experimental; an L1 project - # won't trigger them. Verify the pipeline reports applicable counts. - assert result.rules_checked > 0, "No rules were checked at all." - - def test_multiple_rules_checked( - self, - vague_project: Path, - dev_rules_dir: Path, - ) -> None: - """Multiple rules must be checked.""" - result = run_validation( - vague_project, - rules_paths=[dev_rules_dir], - agent="claude", - use_cache=False, - record_analytics=False, - ) - - # An L1 project should check at least the core L1 rules - assert result.rules_checked >= 8, ( - f"Only {result.rules_checked} rules checked — expected 8+. " - "Template resolution may be writing all rule.yml to the same temp path." - ) - - def test_deterministic_violations_have_correct_rule_ids( - self, - vague_project: Path, - dev_rules_dir: Path, - ) -> None: - """Violation rule_ids must be valid coordinates. - - Rule IDs must be in NAMESPACE:CATEGORY:SLOT format (e.g., CORE:C:0006). - """ - result = run_validation( - vague_project, - rules_paths=[dev_rules_dir], - agent="claude", - use_cache=False, - record_analytics=False, - ) - - for v in result.violations: - parts = v.rule_id.split(":") - assert len(parts) == 3, ( - f"Violation rule_id '{v.rule_id}' is not a valid coordinate. " - "Expected format: NAMESPACE:CATEGORY:SLOT (e.g., CORE:C:0006)." - ) - namespace, category, slot = parts - assert namespace.isupper(), f"Namespace '{namespace}' in '{v.rule_id}' should be uppercase." - assert len(category) == 1 and category.isupper(), ( - f"Category '{category}' in '{v.rule_id}' should be a single uppercase letter." - ) - assert slot.isdigit() and len(slot) == 4, f"Slot '{slot}' in '{v.rule_id}' should be 4 digits." - - -def _get_rules_for_violations(result, rule_id: str) -> list: - """Infer rule type from violation's rule_id category code. - - Structure (S) rules are mechanical; Content/other rules are deterministic. - """ - category_code = rule_id.split(":")[1] if ":" in rule_id else "" - - from reporails_cli.core.models import Category, Rule - - if category_code == "S": - return [ - Rule( - id=rule_id, - title="", - category=Category.STRUCTURE, - type=RuleType.MECHANICAL, - level="L1", - ) - ] - if category_code in ("C", "E", "M", "G"): - return [ - Rule( - id=rule_id, - title="", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L1", - ) - ] - return [] diff --git a/tests/integration/test_scoring.py b/tests/integration/test_scoring.py deleted file mode 100644 index 4bcfd3cd..00000000 --- a/tests/integration/test_scoring.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Scoring integration tests - test scoring through validation pipeline. - -These tests require OpenGrep to be installed and use the full validation pipeline. -""" - -from __future__ import annotations - -from pathlib import Path - - -class TestValidationScoring: - """Test scoring through validation pipeline.""" - - def test_clean_project_scores_above_floor(self, level2_project: Path) -> None: - """Level 2 project should score above the floor (not bottom-tier).""" - from reporails_cli.core.engine import run_validation_sync - - result = run_validation_sync(level2_project, agent="claude") - - # A level 2 project (commands + architecture + constraints) won't ace - # every content rule, but should clear the baseline. Threshold lowered - # for 0.5.0 rules (52 rules vs ~20 previously — many new structure/governance rules). - assert result.score >= 2.0, ( - f"Level 2 project should score 2+, got {result.score}\nViolations {result.violations}" - ) - - def test_score_reproducible_across_runs(self, level3_project: Path) -> None: - """Same project should produce same score across runs.""" - from reporails_cli.core.engine import run_validation_sync - - scores = [] - for _ in range(3): - result = run_validation_sync(level3_project, agent="claude") - scores.append(result.score) - - assert len(set(scores)) == 1, f"Score should be reproducible, got: {scores}" - - def test_score_is_deterministic_for_same_content(self, tmp_path: Path) -> None: - """Same content should produce same score across runs.""" - from reporails_cli.core.engine import run_validation_sync - - # Create project with content - (tmp_path / "CLAUDE.md").write_text("""\ -# Test Project - -A test project. - -## Commands - -- `test` - Run tests - -## Architecture - -Simple architecture. -""") - - result1 = run_validation_sync(tmp_path, agent="claude") - result2 = run_validation_sync(tmp_path, agent="claude") - - # Same content should give same score - assert result1.score == result2.score, f"Same content gave different scores: {result1.score} vs {result2.score}" diff --git a/tests/integration/test_symlink_validation.py b/tests/integration/test_symlink_validation.py index 09080d16..4e2e2160 100644 --- a/tests/integration/test_symlink_validation.py +++ b/tests/integration/test_symlink_validation.py @@ -1,7 +1,4 @@ -"""Integration tests for symlinked instruction file validation. - -Requires OpenGrep to be installed. -""" +"""Integration tests for symlinked instruction file validation.""" from __future__ import annotations @@ -52,36 +49,16 @@ def symlink_project(tmp_path: Path) -> tuple[Path, Path]: class TestSymlinkIntegration: """Integration tests for symlinked instruction file handling.""" - def test_capability_detection_with_external_symlink(self, symlink_project: tuple[Path, Path]) -> None: - """Symlinked CLAUDE.md should have its content features detected.""" + def test_symlink_detection_populates_resolved_symlinks(self, symlink_project: tuple[Path, Path]) -> None: + """Symlinked CLAUDE.md should appear in resolved_symlinks.""" project, _external = symlink_project - from reporails_cli.bundled import get_capability_patterns_path from reporails_cli.core.applicability import detect_features_filesystem - from reporails_cli.core.capability import detect_features_content - from reporails_cli.core.regex import run_validation features = detect_features_filesystem(project) assert features.has_claude_md is True assert len(features.resolved_symlinks) == 1 - # Run capability detection with extra targets - capability_patterns = get_capability_patterns_path() - if not capability_patterns.exists(): - pytest.skip("Capability patterns not available") - - sarif = run_validation( - [capability_patterns], - project, - extra_targets=features.resolved_symlinks, - ) - - content_features = detect_features_content(sarif) - - # The external file has sections and constraints - assert content_features.has_sections is True - assert content_features.has_explicit_constraints is True - def test_rule_validation_with_external_symlink(self, symlink_project: tuple[Path, Path]) -> None: """Symlinked CLAUDE.md with violations should have them detected.""" project, external = symlink_project diff --git a/tests/integration/test_template_resolution.py b/tests/integration/test_template_resolution.py index ca17ada7..af27e1a1 100644 --- a/tests/integration/test_template_resolution.py +++ b/tests/integration/test_template_resolution.py @@ -1,263 +1,58 @@ -"""Template resolution tests - CRITICAL for correctness. +"""File classification integration tests. -Every template variable must resolve before reaching the regex engine. -Unresolved templates cause silent failures that are hard to debug. +Verifies that the classification engine correctly loads file types from +agent configs and produces usable classified file lists for downstream consumers. """ from __future__ import annotations -from pathlib import Path - import pytest -from tests.conftest import create_temp_rule_file - - -class TestTemplateResolution: - """Every template variable must resolve before reaching the regex engine.""" - - def test_instruction_files_resolves_to_glob(self, agent_config: dict[str, str]) -> None: - """{{instruction_files}} must resolve to an actual glob pattern.""" - assert "instruction_files" in agent_config, ( - "Agent config missing 'instruction_files' key - templates using this will fail silently" - ) - value = agent_config["instruction_files"] - assert value, "instruction_files is empty" - # instruction_files can be a string or list of globs - if isinstance(value, list): - for item in value: - assert "{{" not in item, f"instruction_files contains unresolved template: {item}" - assert "*" in item or "/" in item, f"instruction_files item doesn't look like a path pattern: {item}" - else: - assert "{{" not in value, f"instruction_files contains unresolved template: {value}" - assert "*" in value or "/" in value, f"instruction_files doesn't look like a path pattern: {value}" - - def test_rules_dir_resolves(self, agent_config: dict[str, str]) -> None: - """{{rules_dir}} must resolve to a directory path.""" - assert "rules_dir" in agent_config, "Agent config missing 'rules_dir' key" - value = agent_config["rules_dir"] - assert value, "rules_dir is empty" - assert "{{" not in value, f"rules_dir contains unresolved template: {value}" - - def test_resolve_yml_templates_replaces_placeholders( - self, - tmp_path: Path, - rule_with_template_yaml: str, - agent_config: dict[str, str], - ) -> None: - """Template placeholders must be replaced with actual values.""" - from reporails_cli.core.templates import resolve_templates - - rule_path = create_temp_rule_file(tmp_path, rule_with_template_yaml) - resolved = resolve_templates(rule_path, agent_config) - - assert "{{instruction_files}}" not in resolved, ( - f"Template {{{{instruction_files}}}} was not resolved!\nResolved content:\n{resolved}" - ) - # Check that resolved values appear in output (as list items or regex) - value = agent_config["instruction_files"] - if isinstance(value, list): - # At least one item should appear (as list item or in regex pattern) - found = any(item in resolved or item.replace("**/", "") in resolved for item in value) - assert found, f"Expected resolved values from {value} not found in output:\n{resolved}" - else: - assert value in resolved, f"Expected resolved value '{value}' not found in output:\n{resolved}" - - def test_has_templates_detects_placeholders( - self, - tmp_path: Path, - rule_with_template_yaml: str, - valid_rule_yaml: str, - ) -> None: - """has_templates() must correctly identify files with placeholders.""" - from reporails_cli.core.templates import has_templates - - with_template = create_temp_rule_file(tmp_path, rule_with_template_yaml, "with.yml") - without_template = create_temp_rule_file(tmp_path, valid_rule_yaml, "without.yml") - - assert has_templates(with_template), f"Failed to detect template in:\n{rule_with_template_yaml}" - assert not has_templates(without_template), f"False positive - detected template in:\n{valid_rule_yaml}" - - def test_empty_context_skips_resolution( - self, - tmp_path: Path, - rule_with_template_yaml: str, - ) -> None: - """Empty template context should not attempt resolution. - - regression: Empty dict {} is falsy in Python, causing template - resolution to be skipped entirely. - """ - from reporails_cli.core.templates import resolve_templates - - rule_path = create_temp_rule_file(tmp_path, rule_with_template_yaml) - - # With empty context, templates remain unresolved - resolved = resolve_templates(rule_path, {}) - assert "{{instruction_files}}" in resolved, "Empty context should leave templates unresolved" - - def test_run_regex_resolves_templates_before_execution( - self, - tmp_path: Path, - temp_project: Path, - rule_with_template_yaml: str, - agent_config: dict[str, str], - ) -> None: - """run_validation must resolve templates before matching. - - This is the critical integration test - templates must be resolved - before regex patterns are compiled and executed. - """ - from reporails_cli.core.regex import run_validation - - rule_path = create_temp_rule_file(tmp_path, rule_with_template_yaml) - - # Run with template context - should resolve - result = run_validation( - [rule_path], - temp_project, - template_context=agent_config, - ) - - # Should get valid SARIF (not empty due to template error) - assert "runs" in result, f"Expected SARIF output, got: {result}" - # --- NEGATIVE TESTS --- +class TestFileClassificationLoading: + """Test that file_types load correctly from agent configs.""" - def test_unresolved_template_produces_no_matches( - self, - tmp_path: Path, - temp_project: Path, - rule_with_template_yaml: str, - ) -> None: - """Unresolved {{...}} templates should not match any files.""" - from reporails_cli.core.regex import run_validation + def test_load_claude_file_types(self) -> None: + """Claude agent config should have file_types declarations.""" + from reporails_cli.core.bootstrap import get_agent_file_types - rule_path = create_temp_rule_file(tmp_path, rule_with_template_yaml) - - # Run with empty context (simulates missing agent config) - result = run_validation( - [rule_path], - temp_project, - template_context={}, # Empty - no resolution - ) - - # Should return valid structure with no results (unresolved templates match nothing) - runs = result.get("runs", []) - if runs: - results = runs[0].get("results", []) - assert not results, f"Unresolved template should produce no results, but got: {results}" - - def test_unresolvable_template_leaves_placeholder( - self, - tmp_path: Path, - rule_with_unresolvable_template_yaml: str, - agent_config: dict[str, str], - ) -> None: - """Templates without matching context keys remain unresolved.""" - from reporails_cli.core.templates import resolve_templates - - rule_path = create_temp_rule_file(tmp_path, rule_with_unresolvable_template_yaml) - - resolved = resolve_templates(rule_path, agent_config) - - # {{nonexistent_variable}} should remain because it's not in context - assert "{{nonexistent_variable}}" in resolved, "Unresolvable template was incorrectly modified" - - def test_multiple_templates_all_resolve( - self, - tmp_path: Path, - agent_config: dict[str, str], - ) -> None: - """All template placeholders in a file must resolve.""" - multi_template_yaml = """\ -rules: - - id: test-multi - message: "Test" - severity: WARNING - languages: [generic] - pattern-regex: "test" - paths: - include: - - "{{instruction_files}}" - - "{{rules_dir}}/**/*.md" -""" - from reporails_cli.core.templates import resolve_templates - - rule_path = create_temp_rule_file(tmp_path, multi_template_yaml) - resolved = resolve_templates(rule_path, agent_config) - - assert "{{instruction_files}}" not in resolved - assert "{{rules_dir}}" not in resolved - # Check instruction_files resolved (may be list) - value = agent_config["instruction_files"] - if isinstance(value, list): - found = any(item in resolved for item in value) - assert found, f"Expected one of {value} in resolved output" - else: - assert value in resolved - assert agent_config["rules_dir"] in resolved - - -class TestTemplateContextLoading: - """Test that template context is correctly loaded from agent config.""" + file_types = get_agent_file_types("claude") + if not file_types: + pytest.skip("Framework not installed (no agent config available)") + type_names = {ft.name for ft in file_types} + assert "main" in type_names, "Claude config must declare 'main' file type" - def test_get_agent_vars_returns_dict(self) -> None: - """get_agent_vars must return a dict (even if empty).""" - from reporails_cli.core.bootstrap import get_agent_vars + def test_load_unknown_agent_returns_empty(self) -> None: + """Unknown agent should return empty list.""" + from reporails_cli.core.bootstrap import get_agent_file_types - result = get_agent_vars("claude") - assert isinstance(result, dict), f"Expected dict, got {type(result)}" + result = get_agent_file_types("nonexistent_agent_xyz") + assert result == [] - def test_get_agent_vars_claude_has_instruction_files(self) -> None: - """Claude agent config must include instruction_files.""" - from reporails_cli.core.bootstrap import get_agent_vars + def test_file_types_have_patterns(self) -> None: + """Each file type must have at least one pattern.""" + from reporails_cli.core.bootstrap import get_agent_file_types - result = get_agent_vars("claude") - if not result: + file_types = get_agent_file_types("claude") + if not file_types: pytest.skip("Framework not installed (no agent config available)") - assert "instruction_files" in result, ( - "Claude agent config missing 'instruction_files' - this will cause template resolution to fail silently" - ) + for ft in file_types: + assert ft.patterns, f"File type '{ft.name}' has no patterns" - def test_get_agent_vars_unknown_agent_returns_empty(self) -> None: - """Unknown agent should return empty dict, not error.""" - from reporails_cli.core.bootstrap import get_agent_vars + def test_main_type_is_required(self) -> None: + """The 'main' file type should be marked as required.""" + from reporails_cli.core.bootstrap import get_agent_file_types - result = get_agent_vars("nonexistent_agent_xyz") - assert result == {}, f"Expected empty dict for unknown agent, got: {result}" + file_types = get_agent_file_types("claude") + if not file_types: + pytest.skip("Framework not installed (no agent config available)") + main_types = [ft for ft in file_types if ft.name == "main"] + assert main_types, "No 'main' file type found" + assert main_types[0].required, "'main' file type should be required" def test_empty_string_agent_returns_empty(self) -> None: - """Empty string agent should return empty dict.""" - from reporails_cli.core.bootstrap import get_agent_vars - - result = get_agent_vars("") - assert result == {}, f"Expected empty dict for empty agent, got: {result}" - - -class TestEngineTemplateIntegration: - """Test that engine.py correctly passes template context to regex engine.""" - - def test_engine_passes_template_context( - self, - level2_project: Path, - dev_rules_dir: Path, - ) -> None: - """run_validation must pass template_context to the regex engine. - - regression: If agent="" then template_context={} which is falsy, - causing templates to not be resolved. - """ - from reporails_cli.core.engine import run_validation_sync - - # Run validation with explicit agent - result = run_validation_sync( - level2_project, - agent="claude", - rules_paths=[dev_rules_dir], - ) + """Empty string agent should return empty list.""" + from reporails_cli.core.bootstrap import get_agent_file_types - # Should complete without error - assert result.score >= 0, "Validation should complete successfully" - # Score should be reasonable (not 0 due to all rules failing) - assert result.rules_checked > 0, "No rules were checked - possibly all failed due to template issues" + result = get_agent_file_types("") + assert result == [] diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index 79318a1f..6613397e 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -180,7 +180,7 @@ def test_no_agent_namespaced_violations(self, generic_only: Path) -> None: """Without --agent, no agent-specific rules should fire.""" data = _check_json(generic_only) namespaces = _violation_namespaces(data) - for ns in ("CLAUDE", "CODEX", "COPILOT", "CURSOR", "WINDSURF"): + for ns in ("CLAUDE", "CODEX", "COPILOT", "CURSOR", "GEMINI"): assert ns not in namespaces, f"Agent-specific namespace {ns} fired without --agent" @requires_rules @@ -204,7 +204,7 @@ def test_no_agent_fewer_rules_than_any_agent(self, claude_only: Path) -> None: def test_files_detected(self, generic_only: Path) -> None: """Without --agent, auto-detect must find AGENTS.md and not report L1.""" data = _check_json(generic_only) - assert data["level"] != "L1", "AGENTS.md should be detected, got L1 Absent" + assert data["level"] != "L0", "AGENTS.md should be detected, got L0 Absent" # =========================================================================== @@ -219,17 +219,17 @@ class TestAgentFileTargeting: @requires_rules def test_claude_finds_claude_md(self, claude_only: Path) -> None: data = _check_json(claude_only, agent="claude") - assert data["level"] != "L1", "--agent claude should detect CLAUDE.md" + assert data["level"] != "L0", "--agent claude should detect CLAUDE.md" @requires_rules def test_codex_finds_agents_md(self, codex_only: Path) -> None: data = _check_json(codex_only, agent="codex") - assert data["level"] != "L1", "--agent codex should detect AGENTS.md" + assert data["level"] != "L0", "--agent codex should detect AGENTS.md" @requires_rules def test_copilot_finds_its_file(self, copilot_only: Path) -> None: data = _check_json(copilot_only, agent="copilot") - assert data["level"] != "L1", "--agent copilot should detect copilot-instructions.md" + assert data["level"] != "L0", "--agent copilot should detect copilot-instructions.md" def test_wrong_agent_claude_on_codex(self, codex_only: Path) -> None: """--agent claude on a codex-only project must find no files.""" @@ -268,9 +268,7 @@ def test_claude_no_other_agent_rules(self, claude_only: Path) -> None: """--agent claude must produce violations, none from other agent namespaces.""" data = _check_json(claude_only, agent="claude") assert len(data["violations"]) > 0, "Claude fixture must produce violations" - foreign = { - r for r in _violation_rule_ids(data) if r.split(":")[0] in ("CODEX", "COPILOT", "CURSOR", "WINDSURF") - } + foreign = {r for r in _violation_rule_ids(data) if r.split(":")[0] in ("CODEX", "COPILOT", "CURSOR", "GEMINI")} assert not foreign, f"Foreign agent rules fired under --agent claude: {foreign}" @requires_rules @@ -319,20 +317,20 @@ class TestMultiAgentProject: """Multi-agent projects must scope correctly per --agent flag.""" @requires_rules - def test_no_agent_scans_generic_only(self, multi_agent: Path) -> None: - """Without --agent, AGENTS.md (generic) is scanned — not all agents' files.""" + 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.""" data = _check_json(multi_agent) - assert data["level"] != "L1", "Multi-agent project should not be L1" + assert data["level"] != "L0", "Multi-agent project should not be L0" assert len(data["violations"]) > 0, "Multi-agent fixture must produce violations" files = _violation_files(data) - assert "AGENTS.md" in files, f"No-agent should scan AGENTS.md (generic), got: {files}" - assert "CLAUDE.md" not in files, f"No-agent should not scan CLAUDE.md, got: {files}" + # Mixed signals: claude + copilot → scan all instruction files with core rules + assert "CLAUDE.md" in files, f"Mixed signals should scan CLAUDE.md, got: {files}" @requires_rules def test_agent_claude_scopes_to_claude_md(self, multi_agent: Path) -> None: """--agent claude on multi-agent project should scope to CLAUDE.md.""" data = _check_json(multi_agent, agent="claude") - assert data["level"] != "L1" + assert data["level"] != "L0" assert len(data["violations"]) > 0, "Claude on multi-agent must produce violations" namespaces = _violation_namespaces(data) foreign = namespaces - {"CORE", "RRAILS", "CLAUDE"} @@ -342,7 +340,7 @@ def test_agent_claude_scopes_to_claude_md(self, multi_agent: Path) -> None: def test_agent_codex_scopes_to_agents_md(self, multi_agent: Path) -> None: """--agent codex on multi-agent project should scope to AGENTS.md.""" data = _check_json(multi_agent, agent="codex") - assert data["level"] != "L1" + assert data["level"] != "L0" assert len(data["violations"]) > 0, "Codex on multi-agent must produce violations" namespaces = _violation_namespaces(data) foreign = namespaces - {"CORE", "RRAILS", "CODEX"} @@ -426,7 +424,7 @@ def test_empty_string_no_agent_rules(self, generic_only: Path) -> None: """--agent '' must not load agent-specific rules.""" data = _check_json(generic_only, agent="") namespaces = _violation_namespaces(data) - for ns in ("CLAUDE", "CODEX", "COPILOT", "CURSOR", "WINDSURF"): + for ns in ("CLAUDE", "CODEX", "COPILOT", "CURSOR", "GEMINI"): assert ns not in namespaces, f"Agent namespace {ns} fired with --agent '': {namespaces}" def test_empty_string_hint_agents_md(self, empty_dir: Path) -> None: @@ -467,7 +465,7 @@ def test_nested_both_files_scanned(self, nested_claude: Path) -> None: def test_nested_level_above_l1(self, nested_claude: Path) -> None: """Project with nested CLAUDE.md files must not be L1.""" data = _check_json(nested_claude, agent="claude") - assert data["level"] != "L1" + assert data["level"] != "L0" # =========================================================================== @@ -484,10 +482,10 @@ def test_config_only_no_files_detected(self, config_only: Path) -> None: output = _check_text(config_only) assert "No instruction files found" in output - def test_config_only_level_l1(self, config_only: Path) -> None: - """Config-only project should be L1 (Absent).""" + def test_config_only_level_l0(self, config_only: Path) -> None: + """Config-only project should be L0 (Absent).""" data = _check_json(config_only) - assert data["level"] == "L1" + assert data["level"] == "L0" def test_config_only_claude_agent_no_files(self, config_only: Path) -> None: """--agent claude on config-only project should find no instruction files.""" @@ -615,7 +613,7 @@ def test_uppercase_agent_normalized(self, claude_only: Path) -> None: ) assert result.exit_code == 0, f"Uppercase agent failed: {result.output}" data = json.loads(result.output) - assert data["level"] != "L1", "--agent CLAUDE should detect CLAUDE.md after normalization" + assert data["level"] != "L0", "--agent CLAUDE should detect CLAUDE.md after normalization" # =========================================================================== @@ -665,14 +663,14 @@ def test_invalid_format_shows_valid_list(self, claude_only: Path) -> None: # =========================================================================== # Default Agent Config # -# Users can set default_agent in .reporails/config.yml so they don't need +# Users can set default_agent in .ails/config.yml so they don't need # --agent on every invocation. CLI flag always overrides config. # =========================================================================== @pytest.mark.e2e class TestDefaultAgentConfig: - """default_agent in .reporails/config.yml must control agent scoping.""" + """default_agent in .ails/config.yml must control agent scoping.""" @requires_rules def test_config_default_agent_scopes_files(self, multi_agent_with_config: Path) -> None: @@ -691,12 +689,12 @@ def test_cli_flag_overrides_config(self, multi_agent_with_config: Path) -> None: assert "CLAUDE.md" not in files, f"--agent codex should not scan CLAUDE.md, got: {files}" @requires_rules - def test_no_config_defaults_to_generic(self, multi_agent: Path) -> None: - """Without config, no --agent must default to generic (AGENTS.md scanned, not CLAUDE.md).""" + 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.""" data = _check_json(multi_agent) files = _violation_files(data) - assert "AGENTS.md" in files, f"Without config, no-agent should scan AGENTS.md (generic), got: {files}" - assert "CLAUDE.md" not in files, f"Without config, CLAUDE.md should not be scanned, got: {files}" + # Mixed signals: claude + copilot → all instruction files scanned with core rules + assert "CLAUDE.md" in files, f"Mixed signals should include CLAUDE.md, got: {files}" # =========================================================================== @@ -917,7 +915,7 @@ def test_project_default_agent_overrides_global(self, tmp_path: Path) -> None: (project / "AGENTS.md").write_text("# Agents\n\nMinimal content.\n") # Project config overrides global - cfg_dir = project / ".reporails" + cfg_dir = project / ".ails" cfg_dir.mkdir() (cfg_dir / "config.yml").write_text("default_agent: codex\n") @@ -1092,7 +1090,9 @@ def test_json_output_valid(self, claude_only: Path) -> None: result = runner.invoke(app, ["map", str(claude_only), "-o", "json"]) assert result.exit_code == 0 data = json.loads(result.output) + assert data["version"] == 3 assert "agents" in data + assert "auto_heal" in data def test_yaml_output_valid(self, claude_only: Path) -> None: import yaml as yaml_lib @@ -1100,17 +1100,22 @@ def test_yaml_output_valid(self, claude_only: Path) -> None: result = runner.invoke(app, ["map", str(claude_only), "-o", "yaml"]) assert result.exit_code == 0 data = yaml_lib.safe_load(result.output) + assert data["version"] == 3 assert "agents" in data def test_save_creates_backbone(self, tmp_path: Path) -> None: + import yaml as yaml_lib + project = tmp_path / "project" project.mkdir() (project / "CLAUDE.md").write_text("# My Project\n") result = runner.invoke(app, ["map", str(project), "--save"]) assert result.exit_code == 0 - backbone = project / ".reporails" / "backbone.yml" + backbone = project / ".ails" / "backbone.yml" assert backbone.exists(), "backbone.yml not created" + data = yaml_lib.safe_load(backbone.read_text()) + assert data["version"] == 3 def test_multi_agent_detected(self, multi_agent: Path) -> None: result = runner.invoke(app, ["map", str(multi_agent), "-o", "json"]) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index bc5cf27b..d7251e7d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -11,7 +11,7 @@ def make_config_file(tmp_path: Path): """Factory: write YAML to a temp config file.""" - def _make(content: str, subdir: str = ".reporails", name: str = "config.yml") -> Path: + def _make(content: str, subdir: str = ".ails", name: str = "config.yml") -> Path: d = tmp_path / subdir d.mkdir(parents=True, exist_ok=True) p = d / name diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index 1edb1e5f..d23596d2 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -9,7 +9,13 @@ import pytest import yaml -from reporails_cli.core.agents import KNOWN_AGENTS, DetectedAgent, auto_detect_agent +from reporails_cli.core.agents import ( + DetectedAgent, + _codex_global_heuristic, + _disambiguate_codex_generic, + auto_detect_agent, + get_known_agents, +) from reporails_cli.core.bootstrap import get_agent_config from reporails_cli.core.models import ( AgentConfig, @@ -133,14 +139,14 @@ def test_core_agent_config(self, tmp_path: Path, make_config_file) -> None: # ============================================================================= -def _make_rule(rule_id: str, checks: list[Check]) -> Rule: +def _make_rule(rule_id: str, checks: list[Check], severity: Severity = Severity.MEDIUM) -> Rule: """Helper to create a Rule with given checks.""" return Rule( id=rule_id, title=f"Rule {rule_id}", category=Category.STRUCTURE, type=RuleType.DETERMINISTIC, - level="L2", + severity=severity, checks=checks, ) @@ -149,18 +155,19 @@ class TestApplyAgentOverrides: """Test agent check-level overrides.""" def test_severity_changed(self) -> None: - checks = [Check(id="E2-check", name="Check", severity=Severity.HIGH)] - rules = {"E2": _make_rule("E2", checks)} + checks = [Check(id="E2-check")] + rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} overrides = {"E2-check": {"severity": "low"}} result = _apply_agent_overrides(rules, overrides) - assert result["E2"].checks[0].severity == Severity.LOW + # Severity override now lifted to rule level + assert result["E2"].severity == Severity.LOW def test_check_disabled(self) -> None: checks = [ - Check(id="E2-check-a", name="Check A", severity=Severity.HIGH), - Check(id="E2-check-b", name="Check B", severity=Severity.MEDIUM), + Check(id="E2-check-a"), + Check(id="E2-check-b"), ] rules = {"E2": _make_rule("E2", checks)} @@ -171,16 +178,16 @@ def test_check_disabled(self) -> None: assert result["E2"].checks[0].id == "E2-check-b" def test_nonexistent_check_is_noop(self) -> None: - checks = [Check(id="E2-check", name="Check", severity=Severity.HIGH)] - rules = {"E2": _make_rule("E2", checks)} + checks = [Check(id="E2-check")] + rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} overrides = {"BOGUS-check": {"severity": "low"}} result = _apply_agent_overrides(rules, overrides) - assert result["E2"].checks[0].severity == Severity.HIGH + assert result["E2"].severity == Severity.HIGH # unchanged def test_all_checks_disabled_leaves_empty_list(self) -> None: - checks = [Check(id="E2-check", name="Check", severity=Severity.HIGH)] + checks = [Check(id="E2-check")] rules = {"E2": _make_rule("E2", checks)} overrides = {"E2-check": {"disabled": True}} @@ -189,18 +196,18 @@ def test_all_checks_disabled_leaves_empty_list(self) -> None: assert result["E2"].checks == [] def test_invalid_severity_skipped(self) -> None: - checks = [Check(id="E2-check", name="Check", severity=Severity.HIGH)] - rules = {"E2": _make_rule("E2", checks)} + checks = [Check(id="E2-check")] + rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} overrides = {"E2-check": {"severity": "bogus"}} result = _apply_agent_overrides(rules, overrides) - # Invalid severity is skipped — original check kept unchanged - assert result["E2"].checks[0].severity == Severity.HIGH + # Invalid severity is skipped — original rule severity unchanged + assert result["E2"].severity == Severity.HIGH def test_multiple_rules_overridden(self) -> None: rules = { - "E2": _make_rule("E2", [Check(id="E2-c1", name="C1", severity=Severity.HIGH)]), - "E5": _make_rule("E5", [Check(id="E5-c1", name="C1", severity=Severity.MEDIUM)]), + "E2": _make_rule("E2", [Check(id="E2-c1")], severity=Severity.HIGH), + "E5": _make_rule("E5", [Check(id="E5-c1")], severity=Severity.MEDIUM), } overrides = { @@ -209,7 +216,7 @@ def test_multiple_rules_overridden(self) -> None: } result = _apply_agent_overrides(rules, overrides) - assert result["E2"].checks[0].severity == Severity.LOW + assert result["E2"].severity == Severity.LOW assert result["E5"].checks == [] @@ -234,11 +241,8 @@ def test_excludes_removes_rules(self, tmp_path: Path) -> None: ) agent_config = AgentConfig(agent="test", excludes=["CORE:S:0001"]) - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="test") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="test") assert "CORE:S:0001" not in rules assert "CORE:S:0002" in rules @@ -254,11 +258,8 @@ def test_excludes_nonexistent_rule_is_noop(self, tmp_path: Path) -> None: ) agent_config = AgentConfig(agent="test", excludes=["NONEXISTENT"]) - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="test") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="test") assert "CORE:S:0001" in rules @@ -272,8 +273,7 @@ def test_no_agent_skips_processing(self, tmp_path: Path) -> None: "targets: '{{instruction_files}}'\nbacked_by:\n - anthropic-docs\n---\n" ) - with patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}): - rules = load_rules([tmp_path], include_experimental=False, agent="") + rules = load_rules([tmp_path], agent="") assert "CORE:S:0001" in rules @@ -299,11 +299,8 @@ def test_glob_excludes_match_namespaced_rules(self, tmp_path: Path) -> None: prefix="COPILOT", excludes=["CLAUDE:*", "CODEX:*"], ) - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="copilot") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="copilot") assert "CORE:S:0001" in rules assert "CLAUDE:S:0001" not in rules @@ -330,11 +327,8 @@ def test_exact_and_glob_excludes_coexist(self, tmp_path: Path) -> None: agent="test", excludes=["CORE:S:0001", "CLAUDE:*"], ) - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="test") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="test") assert "CORE:S:0001" not in rules # exact exclude assert "CORE:S:0002" in rules # not excluded @@ -381,11 +375,8 @@ def test_prefix_from_config_used(self, tmp_path: Path) -> None: # Agent name is "myagent" but prefix is "MYPREFIX" — prefix should win agent_config = AgentConfig(agent="myagent", prefix="MYPREFIX") - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="myagent") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="myagent") assert "CORE:S:0001" in rules assert "MYPREFIX:S:0001" in rules @@ -409,11 +400,8 @@ def test_fallback_to_agent_upper_without_prefix(self, tmp_path: Path) -> None: # No prefix — agent.upper() = "CLAUDE" used for filtering agent_config = AgentConfig(agent="claude") - with ( - patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config), - patch("reporails_cli.core.registry._load_source_weights", return_value={"anthropic-docs": 1.0}), - ): - rules = load_rules([tmp_path], include_experimental=False, agent="claude") + with patch("reporails_cli.core.registry.get_agent_config", return_value=agent_config): + rules = load_rules([tmp_path], agent="claude") assert "CORE:S:0001" in rules assert "CLAUDE:S:0001" in rules @@ -452,7 +440,7 @@ def test_fnmatch_patterns(self, rule_id: str, pattern: str, should_match: bool) def _detected(agent_id: str, files: list[str] | None = None) -> DetectedAgent: """Create a DetectedAgent stub for a known agent.""" return DetectedAgent( - agent_type=KNOWN_AGENTS[agent_id], + agent_type=get_known_agents()[agent_id], instruction_files=[Path(f) for f in files] if files else [], ) @@ -470,7 +458,7 @@ class TestAutoDetectAgent: id="non_generic_plus_generic", ), pytest.param( - [_detected("claude", ["CLAUDE.md"]), _detected("cursor", [".cursorrules"])], + [_detected("claude", ["CLAUDE.md"]), _detected("copilot", [".github/copilot-instructions.md"])], "", id="two_distinctive_ambiguous", ), @@ -494,3 +482,121 @@ class TestAutoDetectAgent: ) def test_auto_detect(self, agents: list[DetectedAgent], expected: str) -> None: assert auto_detect_agent(agents) == expected + + +# ============================================================================= +# Codex/generic disambiguation tests +# ============================================================================= + + +def _make_detected(agent_id: str, instruction_files: list[str], config_files: list[str] | None = None) -> DetectedAgent: + """Create a DetectedAgent with instruction and config files.""" + return DetectedAgent( + agent_type=get_known_agents()[agent_id], + instruction_files=[Path(f) for f in instruction_files], + config_files=[Path(f) for f in (config_files or [])], + ) + + +class TestCodexGenericDisambiguation: + """Three-tier codex/generic disambiguation on AGENTS.md projects.""" + + def test_tier1_override_file_picks_codex(self, tmp_path: Path) -> None: + """AGENTS.override.md present → codex (definitive).""" + detected = [ + _make_detected("codex", ["AGENTS.md", "AGENTS.override.md"]), + _make_detected("generic", ["AGENTS.md"]), + ] + result = _disambiguate_codex_generic(detected, tmp_path) + ids = {a.agent_type.id for a in result} + assert "codex" in ids + assert "generic" not in ids + + def test_tier2_config_toml_picks_codex(self, tmp_path: Path) -> None: + """.codex/config.toml present → codex (definitive).""" + detected = [ + _make_detected("codex", ["AGENTS.md"], [".codex/config.toml"]), + _make_detected("generic", ["AGENTS.md"]), + ] + result = _disambiguate_codex_generic(detected, tmp_path) + ids = {a.agent_type.id for a in result} + assert "codex" in ids + assert "generic" not in ids + + def test_tier3_global_config_plus_gitignore(self, tmp_path: Path) -> None: + """~/.codex/config.toml + .codex in .gitignore → codex (assumed).""" + (tmp_path / ".gitignore").write_text(".codex/\n") + detected = [ + _make_detected("codex", ["AGENTS.md"]), + _make_detected("generic", ["AGENTS.md"]), + ] + with patch("reporails_cli.core.agents.Path.home", return_value=tmp_path / "fakehome"): + # No global config → should NOT pick codex + result = _disambiguate_codex_generic(detected, tmp_path) + assert {a.agent_type.id for a in result} == {"generic"} + + # Create global config → should pick codex + (tmp_path / "fakehome" / ".codex").mkdir(parents=True) + (tmp_path / "fakehome" / ".codex" / "config.toml").write_text("") + result = _disambiguate_codex_generic(detected, tmp_path) + assert {a.agent_type.id for a in result} == {"codex"} + + def test_tier3_override_in_gitignore(self, tmp_path: Path) -> None: + """~/.codex/config.toml + AGENTS.override in .gitignore → codex.""" + (tmp_path / ".gitignore").write_text("AGENTS.override.md\n") + detected = [ + _make_detected("codex", ["AGENTS.md"]), + _make_detected("generic", ["AGENTS.md"]), + ] + with patch("reporails_cli.core.agents.Path.home", return_value=tmp_path / "fakehome"): + (tmp_path / "fakehome" / ".codex").mkdir(parents=True) + (tmp_path / "fakehome" / ".codex" / "config.toml").write_text("") + result = _disambiguate_codex_generic(detected, tmp_path) + assert {a.agent_type.id for a in result} == {"codex"} + + def test_no_signals_picks_generic(self, tmp_path: Path) -> None: + """No codex markers → generic wins, codex dropped.""" + detected = [ + _make_detected("codex", ["AGENTS.md"]), + _make_detected("generic", ["AGENTS.md"]), + ] + result = _disambiguate_codex_generic(detected, tmp_path) + ids = {a.agent_type.id for a in result} + assert "generic" in ids + assert "codex" not in ids + + def test_no_generic_returns_unchanged(self, tmp_path: Path) -> None: + """Without generic in the list, disambiguation is a no-op.""" + detected = [_make_detected("codex", ["AGENTS.md"])] + result = _disambiguate_codex_generic(detected, tmp_path) + assert len(result) == 1 + assert result[0].agent_type.id == "codex" + + def test_no_codex_returns_unchanged(self, tmp_path: Path) -> None: + """Without codex in the list, disambiguation is a no-op.""" + detected = [_make_detected("generic", ["AGENTS.md"])] + result = _disambiguate_codex_generic(detected, tmp_path) + assert len(result) == 1 + assert result[0].agent_type.id == "generic" + + def test_other_agents_preserved(self, tmp_path: Path) -> None: + """Claude and copilot are untouched by disambiguation.""" + detected = [ + _make_detected("claude", ["CLAUDE.md"]), + _make_detected("codex", ["AGENTS.md"]), + _make_detected("generic", ["AGENTS.md"]), + _make_detected("copilot", [".github/copilot-instructions.md"]), + ] + result = _disambiguate_codex_generic(detected, tmp_path) + ids = {a.agent_type.id for a in result} + assert "claude" in ids + assert "copilot" in ids + assert "generic" in ids + assert "codex" not in ids + + def test_tier3_gitignore_without_global_config(self, tmp_path: Path) -> None: + """Gitignore mentions .codex but no global config → generic wins.""" + (tmp_path / ".gitignore").write_text(".codex/\n") + with patch("reporails_cli.core.agents.Path.home", return_value=tmp_path / "emptyhome"): + (tmp_path / "emptyhome").mkdir() + assert not _codex_global_heuristic(tmp_path) diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py new file mode 100644 index 00000000..ec28b461 --- /dev/null +++ b/tests/unit/test_api_client.py @@ -0,0 +1,128 @@ +"""Tests for core/api_client.py — diagnostic API client.""" + +from __future__ import annotations + +from reporails_cli.core.api_client import AilsClient, _strip_and_serialize +from reporails_cli.core.mapper.mapper import Atom, FileRecord, RulesetMap, RulesetSummary + + +def _make_map() -> RulesetMap: + return RulesetMap( + schema_version="1.0.0", + embedding_model="test", + generated_at="2026-01-01T00:00:00Z", + files=(), + atoms=(), + summary=RulesetSummary(n_atoms=0, n_charged=0, n_neutral=0), + ) + + +class TestAilsClient: + def test_lint_returns_none_without_server(self) -> None: + """No local fallback — lint requires the API.""" + client = AilsClient(base_url="") + result = client.lint(_make_map()) + assert result is None + + def test_lint_returns_none_on_unreachable_server(self) -> None: + client = AilsClient(base_url="https://localhost:1") + result = client.lint(_make_map()) + assert result is None + + def test_custom_base_url(self) -> None: + client = AilsClient(base_url="https://custom.example.com") + assert client.base_url == "https://custom.example.com" + + +class TestV2WireFormat: + """Verify _strip_and_serialize emits v2 obfuscated wire format.""" + + @staticmethod + def _make_atom(**overrides: object) -> Atom: + """Build a minimal Atom with defaults.""" + defaults: dict[str, object] = { + "line": 1, "text": "", "kind": "excitation", "charge": "NEUTRAL", + "charge_value": 0, "modality": "none", "specificity": "abstract", + } + defaults.update(overrides) + return Atom(**defaults) # type: ignore[arg-type] + + @staticmethod + def _make_rm(files: tuple[FileRecord, ...], atoms: tuple[Atom, ...]) -> RulesetMap: + return RulesetMap( + schema_version="1.0.0", embedding_model="test", + generated_at="2026-01-01T00:00:00Z", files=files, atoms=atoms, + summary=RulesetSummary(n_atoms=len(atoms), n_charged=0, n_neutral=len(atoms)), + ) + + def test_schema_version_bumped(self) -> None: + rm = self._make_rm((), ()) + assert _strip_and_serialize(rm)["schema_version"] == "2" + + def test_semantic_keys_absent(self) -> None: + """No semantic field names in serialized atoms.""" + fr = FileRecord(path="test.md", content_hash="a") + atom = self._make_atom(file_path="test.md") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + semantic = {"charge", "modality", "specificity", "format", "kind", + "file_path", "cluster_id", "position_index", "token_count", + "scope_conditional", "embedding_b64", "heading_context", + "depth", "ambiguous", "embedded_charge_markers", "inline"} + assert not semantic & set(a.keys()), f"Semantic keys leaked: {semantic & set(a.keys())}" + + def test_short_keys_present(self) -> None: + """All required short keys emitted.""" + fr = FileRecord(path="test.md", content_hash="a") + atom = self._make_atom(file_path="test.md") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + required = {"line", "t", "c", "cv", "m", "s", "sc", "f", "pi", "tc", "fi", "k"} + assert required <= set(a.keys()), f"Missing keys: {required - set(a.keys())}" + + def test_enum_fields_are_integers(self) -> None: + """Enum fields serialized as integers, not strings.""" + fr = FileRecord(path="test.md", content_hash="a") + atom = self._make_atom(file_path="test.md") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + for key in ("t", "c", "m", "s", "f"): + assert isinstance(a[key], int), f"{key} should be int, got {type(a[key])}" + + def test_file_index_reference(self) -> None: + f1 = FileRecord(path="CLAUDE.md", content_hash="a") + f2 = FileRecord(path=".claude/rules/test.md", content_hash="b") + a1 = self._make_atom(file_path="CLAUDE.md") + a2 = self._make_atom(file_path=".claude/rules/test.md") + payload = _strip_and_serialize(self._make_rm((f1, f2), (a1, a2))) + assert payload["atoms"][0]["fi"] == 0 + assert payload["atoms"][1]["fi"] == 1 + + def test_inline_style_integer_codes(self) -> None: + atom = self._make_atom( + named_tokens=["ruff"], italic_tokens=["always"], bold_tokens=["NEVER"], + ) + fr = FileRecord(path="test.md", content_hash="a") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + assert "il" in a + styles = [span["s"] for span in a["il"]] + assert all(isinstance(s, int) for s in styles) + assert len(a["il"]) == 3 + # Each span has term + integer style code + terms = [span["term"] for span in a["il"]] + assert terms == ["ruff", "always", "NEVER"] + + def test_text_fields_stripped(self) -> None: + atom = self._make_atom( + text="sensitive content", plain_text="stripped", + rule="p1_negation", role="constraint", topics=("security",), + ) + fr = FileRecord(path="test.md", content_hash="a") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + for key in ("text", "plain_text", "rule", "role", "topics"): + assert key not in a, f"Sensitive field '{key}' leaked into wire format" + + def test_optional_fields_omitted_when_empty(self) -> None: + atom = self._make_atom() + fr = FileRecord(path="test.md", content_hash="a") + a = _strip_and_serialize(self._make_rm((fr,), (atom,)))["atoms"][0] + # Optional fields not present when default/empty + for key in ("il", "e", "hc", "d", "a", "ecm"): + assert key not in a, f"Optional field '{key}' present when it should be absent" diff --git a/tests/unit/test_applicability.py b/tests/unit/test_applicability.py index 56739909..6194ec13 100644 --- a/tests/unit/test_applicability.py +++ b/tests/unit/test_applicability.py @@ -1,19 +1,18 @@ """Applicability unit tests - feature detection and rule filtering. -Tests the filesystem feature detection pipeline and level-based rule -applicability logic including supersession. +Tests the filesystem feature detection pipeline and target-existence +rule applicability logic including supersession. """ from __future__ import annotations from pathlib import Path -from reporails_cli.core.models import Category, Level, Rule, RuleType +from reporails_cli.core.models import Category, FileMatch, Rule, RuleType def _make_rule( rule_id: str = "S1", - level: str = "L2", supersedes: str | None = None, **kw: object, ) -> Rule: @@ -23,14 +22,8 @@ def _make_rule( "title": "Test", "category": Category.STRUCTURE, "type": RuleType.DETERMINISTIC, - "level": level, "checks": [], - "question": None, - "criteria": None, - "choices": None, - "examples": None, - "pass_value": None, - "targets": "CLAUDE.md", + "match": FileMatch(type="main"), "slug": "test", "see_also": [], "supersedes": supersedes, @@ -84,140 +77,99 @@ def test_claude_rules_dir_is_abstracted(self, tmp_path: Path) -> None: assert features.is_abstracted is True -class TestCountComponents: - """Test _count_components with v1 and v2 backbone formats.""" - - def test_v1_backbone_with_components(self) -> None: - """v1 backbone counts entries in components dict.""" - from reporails_cli.core.applicability import _count_components - - backbone = { - "version": 1, - "components": { - "api": {"path": "src/api"}, - "web": {"path": "src/web"}, - "workers": {"path": "src/workers"}, - }, - } - - assert _count_components(backbone) == 3 - - def test_v2_backbone_extracts_top_level_dirs(self) -> None: - """v2 backbone collects distinct top-level directories from path values.""" - from reporails_cli.core.applicability import _count_components - - backbone = { - "version": 2, - "structure": { - "source": "src/main", - "tests": "tests/unit", - "docs": "docs/specs/arch.md", - }, - } - - count = _count_components(backbone) - - # Should find: src, tests, docs - assert count == 3 - - def test_v1_backbone_empty_components(self) -> None: - """v1 backbone with no components should return 0.""" - from reporails_cli.core.applicability import _count_components +class TestGetApplicableRules: + """Test target-existence rule filtering and supersession.""" - backbone = {"version": 1, "components": {}} + def test_rule_included_when_target_present(self) -> None: + """A rule targeting 'main' is included when 'main' is present.""" + from reporails_cli.core.applicability import get_applicable_rules - assert _count_components(backbone) == 0 + rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type="main"))} - def test_v2_backbone_skips_urls(self) -> None: - """v2 backbone should skip URL-like values (containing ':' or '@').""" - from reporails_cli.core.applicability import _count_components + result = get_applicable_rules(rules, {"main"}) - backbone = { - "version": 2, - "source": "src/main", - "repo_url": "https://github.com/org/repo", - "contact": "user@example.com", - } + assert "S1" in result - count = _count_components(backbone) + def test_rule_excluded_when_target_absent(self) -> None: + """A rule targeting 'main' is excluded when 'main' is not present.""" + from reporails_cli.core.applicability import get_applicable_rules - # Only src/main produces a top-level dir; URLs and emails are skipped - assert count == 1 + rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type="main"))} + result = get_applicable_rules(rules, {"scoped_rule"}) -class TestGetApplicableRules: - """Test level-based rule filtering and supersession.""" + assert "S1" not in result - def test_l2_rule_not_included_at_l1(self) -> None: - """A rule requiring L2 should not be included when project is at L1.""" + def test_wildcard_match_none_fires_when_any_present(self) -> None: + """Rules with match=None fire if any type is present.""" from reporails_cli.core.applicability import get_applicable_rules - rules = {"S1": _make_rule(rule_id="S1", level="L2")} + rules = {"S1": _make_rule(rule_id="S1", match=None)} - result = get_applicable_rules(rules, Level.L1) + result = get_applicable_rules(rules, {"main"}) - assert "S1" not in result + assert "S1" in result - def test_l2_rule_included_at_l3(self) -> None: - """A rule requiring L2 should be included when project is at L3.""" + def test_wildcard_type_none_fires_when_any_present(self) -> None: + """Rules with match.type=None fire if any type is present.""" from reporails_cli.core.applicability import get_applicable_rules - rules = {"S1": _make_rule(rule_id="S1", level="L2")} + rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type=None))} - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"scoped_rule"}) assert "S1" in result - def test_l2_rule_included_at_l2(self) -> None: - """A rule requiring L2 should be included when project is exactly at L2.""" + def test_no_present_types_returns_empty(self) -> None: + """No present types → no rules fire.""" from reporails_cli.core.applicability import get_applicable_rules - rules = {"S1": _make_rule(rule_id="S1", level="L2")} + rules = {"S1": _make_rule(rule_id="S1")} - result = get_applicable_rules(rules, Level.L2) + result = get_applicable_rules(rules, set()) - assert "S1" in result + assert result == {} def test_supersession_drops_superseded_rule(self) -> None: """When rule A supersedes rule B and both are applicable, B is dropped.""" from reporails_cli.core.applicability import get_applicable_rules rules = { - "S1": _make_rule(rule_id="S1", level="L1"), - "S2": _make_rule(rule_id="S2", level="L2", supersedes="S1"), + "S1": _make_rule(rule_id="S1"), + "S2": _make_rule(rule_id="S2", supersedes="S1"), } - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main"}) assert "S2" in result assert "S1" not in result, "Superseded rule S1 should be dropped" - def test_supersession_keeps_both_when_superseder_not_applicable(self) -> None: - """When superseding rule is above project level, superseded rule stays.""" + def test_supersession_keeps_both_when_superseder_target_absent(self) -> None: + """When superseding rule targets absent type, both remain (only applicable supersede).""" from reporails_cli.core.applicability import get_applicable_rules rules = { - "S1": _make_rule(rule_id="S1", level="L1"), - "S2": _make_rule(rule_id="S2", level="L4", supersedes="S1"), + "S1": _make_rule(rule_id="S1", match=FileMatch(type="main")), + "S2": _make_rule(rule_id="S2", match=FileMatch(type="config"), supersedes="S1"), } - # At L2, S2 (L4) is not applicable, so S1 should remain - result = get_applicable_rules(rules, Level.L2) + # Only "main" present, S2 targets "config" which is absent → S2 excluded + result = get_applicable_rules(rules, {"main"}) assert "S1" in result assert "S2" not in result - def test_multiple_rules_at_different_levels(self) -> None: - """Multiple rules with different levels are filtered correctly.""" + def test_multiple_types_filter_correctly(self) -> None: + """Rules targeting different types are filtered by presence.""" from reporails_cli.core.applicability import get_applicable_rules rules = { - "R1": _make_rule(rule_id="R1", level="L1"), - "R2": _make_rule(rule_id="R2", level="L3"), - "R3": _make_rule(rule_id="R3", level="L5"), + "R1": _make_rule(rule_id="R1", match=FileMatch(type="main")), + "R2": _make_rule(rule_id="R2", match=FileMatch(type="scoped_rule")), + "R3": _make_rule(rule_id="R3", match=FileMatch(type="skill")), } - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main", "scoped_rule"}) assert "R1" in result assert "R2" in result @@ -227,32 +179,34 @@ def test_empty_rules_returns_empty(self) -> None: """Empty rules dict returns empty result.""" from reporails_cli.core.applicability import get_applicable_rules - result = get_applicable_rules({}, Level.L3) + result = get_applicable_rules({}, {"main"}) assert result == {} - def test_invalid_level_string_skipped(self) -> None: - """A rule with an invalid level string should be silently skipped.""" + def test_supersedes_nonexistent_rule_ignored(self) -> None: + """A rule that supersedes a rule ID not in the dict should not crash.""" from reporails_cli.core.applicability import get_applicable_rules rules = { - "R1": _make_rule(rule_id="R1", level="L2"), - "BAD": _make_rule(rule_id="BAD", level="invalid"), + "S1": _make_rule(rule_id="S1", supersedes="DOES_NOT_EXIST"), } - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main"}) - assert "R1" in result - assert "BAD" not in result + assert "S1" in result - def test_supersedes_nonexistent_rule_ignored(self) -> None: - """A rule that supersedes a rule ID not in the dict should not crash.""" + def test_multiple_target_types_fire_when_present(self) -> None: + """Rules targeting different types all fire when their types are present.""" from reporails_cli.core.applicability import get_applicable_rules rules = { - "S1": _make_rule(rule_id="S1", level="L1", supersedes="DOES_NOT_EXIST"), + "R1": _make_rule(rule_id="R1", match=FileMatch(type="main")), + "R2": _make_rule(rule_id="R2", match=FileMatch(type="scoped_rule")), + "R3": _make_rule(rule_id="R3", match=FileMatch(type="skill")), } - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main", "scoped_rule", "skill"}) - assert "S1" in result + assert "R1" in result + assert "R2" in result + assert "R3" in result diff --git a/tests/unit/test_backbone_gate.py b/tests/unit/test_backbone_gate.py index 2fe5d104..c9d1da6c 100644 --- a/tests/unit/test_backbone_gate.py +++ b/tests/unit/test_backbone_gate.py @@ -1,7 +1,7 @@ -"""Unit tests ensuring backbone.yml detection reflects actual project state. +"""Unit tests ensuring feature detection reflects actual project state. -The backbone gate must NOT always evaluate to true — it should only be true -when the project already has a backbone.yml before validation runs. +Verifies that detect_features_filesystem populates display-only features +correctly for the feature summary. """ from __future__ import annotations @@ -9,15 +9,13 @@ from pathlib import Path from reporails_cli.core.applicability import detect_features_filesystem -from reporails_cli.core.levels import determine_level_from_gates -from reporails_cli.core.models import DetectedFeatures, Level -class TestBackboneGateNotAlwaysTrue: - """Verify backbone detection reflects actual project state.""" +class TestFeatureDetection: + """Verify filesystem feature detection for display purposes.""" - def test_no_backbone_yields_false_gate(self, tmp_path: Path) -> None: - """Project without .reporails/backbone.yml → has_backbone is False.""" + def test_no_backbone_yields_false(self, tmp_path: Path) -> None: + """Project without .ails/backbone.yml → has_backbone is False.""" project = tmp_path / "project" project.mkdir() (project / "CLAUDE.md").write_text("# Project\n") @@ -32,70 +30,47 @@ def test_real_backbone_detected(self, tmp_path: Path) -> None: project.mkdir() (project / "CLAUDE.md").write_text("# Project\n") - reporails_dir = project / ".reporails" - reporails_dir.mkdir() - (reporails_dir / "backbone.yml").write_text("version: 2\nagents:\n claude:\n rules: .claude/rules/\n") + ails_dir = project / ".ails" + ails_dir.mkdir() + (ails_dir / "backbone.yml").write_text("version: 2\nagents:\n claude:\n rules: .claude/rules/\n") features = detect_features_filesystem(project) assert features.has_backbone is True - def test_backbone_gate_does_not_affect_level_when_absent(self, tmp_path: Path) -> None: - """L4-level project without backbone should NOT reach L5.""" - features = DetectedFeatures( - has_instruction_file=True, - has_explicit_constraints=True, - has_imports=True, - is_abstracted=True, - # No backbone, no shared files, no component count → no L5 - ) - level = determine_level_from_gates(features) - - assert level != Level.L5 - assert level == Level.L4 - - def test_backbone_gate_grants_l5_navigation(self) -> None: - """Backbone contributes L5 (navigation). L6 needs skills/MCP/memory.""" - features = DetectedFeatures( - has_instruction_file=True, - has_explicit_constraints=True, - has_imports=True, - is_abstracted=True, - has_backbone=True, # L5: navigation - ) - level = determine_level_from_gates(features) - - assert level == Level.L5 - - def test_backbone_plus_skills_grants_l6(self) -> None: - """Full project with backbone (L5) + skills (L6) → L6.""" - features = DetectedFeatures( - has_instruction_file=True, - has_explicit_constraints=True, - has_imports=True, - is_abstracted=True, - has_backbone=True, # L5: navigation - has_skills_dir=True, # L6: dynamic_context - ) - level = determine_level_from_gates(features) - - assert level == Level.L6 + def test_abstracted_structure_detected(self, tmp_path: Path) -> None: + """Project with .claude/rules/ → is_abstracted is True.""" + project = tmp_path / "project" + project.mkdir() + (project / "CLAUDE.md").write_text("# Project\n") + rules_dir = project / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "test.md").write_text("# Rule\n") - def test_placeholder_backbone_not_detected_before_creation(self, tmp_path: Path) -> None: - """Feature detection runs before backbone auto-creation. + features = detect_features_filesystem(project) + + assert features.is_abstracted is True + + def test_shared_files_detected(self, tmp_path: Path) -> None: + """Project with shared/ directory → has_shared_files is True.""" + project = tmp_path / "project" + project.mkdir() + (project / "CLAUDE.md").write_text("# Project\n") + (project / "shared").mkdir() - The .reporails/ directory should not exist before detection, - so has_backbone must be False. - """ + features = detect_features_filesystem(project) + + assert features.has_shared_files is True + + def test_placeholder_backbone_not_detected_before_creation(self, tmp_path: Path) -> None: + """Feature detection runs before backbone auto-creation.""" project = tmp_path / "project" project.mkdir() (project / "CLAUDE.md").write_text("# Project\n") - # Ensure no .reporails directory - assert not (project / ".reporails").exists() + assert not (project / ".ails").exists() features = detect_features_filesystem(project) assert features.has_backbone is False - # .reporails still doesn't exist (engine creates it, not detect_features) - assert not (project / ".reporails" / "backbone.yml").exists() + assert not (project / ".ails" / "backbone.yml").exists() diff --git a/tests/unit/test_classification.py b/tests/unit/test_classification.py new file mode 100644 index 00000000..6e143ad8 --- /dev/null +++ b/tests/unit/test_classification.py @@ -0,0 +1,361 @@ +"""Tests for the classification engine — content format detection and file matching.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from reporails_cli.core.classification import ( + classify_files, + detect_content_format, + match_files, +) +from reporails_cli.core.models import ClassifiedFile, FileMatch, FileTypeDeclaration + +# ═══════════════════════════════════════════════════════════════════════ +# detect_content_format — individual format detection +# ═══════════════════════════════════════════════════════════════════════ + + +class TestDetectContentFormat: + """Tests for detect_content_format() region detection.""" + + @pytest.mark.parametrize( + "text, expected_format", + [ + ("# Title\n\nSome text.", "heading"), + ("## Section\nContent here.", "heading"), + ("###### Deep heading\n", "heading"), + ], + ids=["h1", "h2", "h6"], + ) + def test_heading_detection(self, text: str, expected_format: str): + result = detect_content_format(text) + assert expected_format in result + + def test_heading_requires_space_after_hash(self): + """#hashtag is not a heading.""" + result = detect_content_format("#hashtag not a heading\n") + assert "heading" not in result + + @pytest.mark.parametrize( + "text, expected_format", + [ + ("```python\nprint('hi')\n```\n", "code_block"), + ("```\nplain code\n```\n", "code_block"), + ("~~~\nalt fence\n~~~\n", "code_block"), + ], + ids=["fenced-python", "fenced-plain", "tilde-fence"], + ) + def test_code_block_detection(self, text: str, expected_format: str): + result = detect_content_format(text) + assert expected_format in result + + @pytest.mark.parametrize( + "text, expected_format", + [ + ("```mermaid\ngraph TD\nA-->B\n```\n", "data_block"), + ("```yaml\nkey: value\n```\n", "data_block"), + ("```json\n{}\n```\n", "data_block"), + ("```toml\n[section]\n```\n", "data_block"), + ("```xml\n\n```\n", "data_block"), + ("```csv\na,b\n1,2\n```\n", "data_block"), + ("```yml\nfoo: bar\n```\n", "data_block"), + ], + ids=["mermaid", "yaml", "json", "toml", "xml", "csv", "yml"], + ) + def test_data_block_detection(self, text: str, expected_format: str): + result = detect_content_format(text) + assert expected_format in result + assert "code_block" not in result # data_block, not code_block + + def test_table_detection(self): + text = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |\n" + result = detect_content_format(text) + assert "table" in result + + def test_table_needs_separator_row(self): + """A single pipe line without separator is not a table.""" + text = "| not a table |\nsome other line\n" + result = detect_content_format(text) + assert "table" not in result + + @pytest.mark.parametrize( + "text", + [ + "- item one\n- item two\n", + "* star item\n", + "+ plus item\n", + " - indented item\n", + ], + ids=["dash", "star", "plus", "indented"], + ) + def test_unordered_list_detection(self, text: str): + result = detect_content_format(text) + assert "list" in result + + def test_ordered_list_detection(self): + text = "1. First step\n2. Second step\n" + result = detect_content_format(text) + assert "list" in result + + def test_prose_detection(self): + text = "This is a paragraph of natural language that is long enough.\n" + result = detect_content_format(text) + assert "prose" in result + + def test_prose_requires_nontrivial_length(self): + """Lines <= 10 chars don't count as prose.""" + text = "short\n" + result = detect_content_format(text) + assert "prose" not in result + + def test_prose_ignores_special_line_starts(self): + """Lines starting with #, |, -, etc. are not prose.""" + text = "# heading\n- list\n| table |\n" + result = detect_content_format(text) + assert "prose" not in result + + # ── Frontmatter stripping ───────────────────────────────────────── + + def test_frontmatter_stripped_before_analysis(self): + """Frontmatter YAML should not count as any content format.""" + text = "---\ntitle: Test\ndescription: A long description field here\n---\n" + result = detect_content_format(text) + assert result == [] + + def test_frontmatter_stripped_content_after(self): + text = "---\nid: test\n---\n\n# Real Content\n\nA paragraph of real text here.\n" + result = detect_content_format(text) + assert "heading" in result + assert "prose" in result + + # ── Empty / edge cases ──────────────────────────────────────────── + + def test_empty_string(self): + assert detect_content_format("") == [] + + def test_whitespace_only(self): + assert detect_content_format(" \n\n \n") == [] + + # ── Mixed content ───────────────────────────────────────────────── + + def test_mixed_content_detects_all_formats(self): + text = ( + "# Architecture\n\n" + "The system has three components that work together.\n\n" + "```python\ndef main(): pass\n```\n\n" + "```mermaid\ngraph TD\nA-->B\n```\n\n" + "| Name | Type |\n| --- | --- |\n| foo | int |\n\n" + "- item one\n- item two\n" + ) + result = detect_content_format(text) + assert set(result) == {"heading", "prose", "code_block", "data_block", "table", "list"} + + def test_result_is_sorted(self): + text = "# Heading\n\nProse text that is long enough.\n- list item\n" + result = detect_content_format(text) + assert result == sorted(result) + + # ── Code-block-aware detection ──────────────────────────────────── + + def test_table_inside_code_block_not_detected(self): + """Tables inside fenced code blocks are examples, not real tables.""" + text = ( + "Some explanation text for the reader.\n\n```markdown\n| Col A | Col B |\n| --- | --- |\n| 1 | 2 |\n```\n" + ) + result = detect_content_format(text) + assert "table" not in result + assert "code_block" in result + + def test_list_inside_code_block_not_detected(self): + """Lists inside fenced code blocks are examples, not real lists.""" + text = "Here is how to format a list:\n\n```markdown\n- item one\n- item two\n```\n" + result = detect_content_format(text) + assert "list" not in result + assert "code_block" in result + + def test_prose_inside_code_block_not_detected(self): + """Long lines inside code blocks are code, not prose.""" + text = "```python\ndef this_is_a_very_long_function_name_not_prose():\n pass\n```\n" + result = detect_content_format(text) + assert "prose" not in result + assert "code_block" in result + + def test_content_outside_code_block_still_detected(self): + """Content before/after code blocks should still be detected.""" + text = "# Title\n\nReal prose outside the code block.\n\n```python\ndef main(): pass\n```\n\n- real list item\n" + result = detect_content_format(text) + assert "heading" in result + assert "prose" in result + assert "list" in result + assert "code_block" in result + + # ── New inline/block modes ──────────────────────────────────────── + + def test_blockquote_detection(self): + text = "> This is a blockquote\n> with multiple lines\n" + result = detect_content_format(text) + assert "blockquote" in result + + def test_bold_detection(self): + text = "This has **bold text** in it for emphasis.\n" + result = detect_content_format(text) + assert "bold" in result + + def test_bold_inside_code_block_not_detected(self): + text = "```\n**not bold** because inside code\n```\n" + result = detect_content_format(text) + assert "bold" not in result + + def test_inline_code_detection(self): + text = "Use `some_function()` to call it properly.\n" + result = detect_content_format(text) + assert "inline_code" in result + + def test_inline_code_inside_code_block_not_detected(self): + text = "```python\nx = `not inline code`\n```\n" + result = detect_content_format(text) + assert "inline_code" not in result + + def test_link_detection(self): + text = "See [the docs](https://example.com) for details.\n" + result = detect_content_format(text) + assert "link" in result + + def test_link_ref_detection(self): + text = "See [the docs][1] for more information here.\n" + result = detect_content_format(text) + assert "link" in result + + def test_link_inside_code_block_not_detected(self): + text = "```\n[not a link](https://example.com)\n```\n" + result = detect_content_format(text) + assert "link" not in result + + +# ═══════════════════════════════════════════════════════════════════════ +# classify_files — content_format auto-detection for freeform files +# ═══════════════════════════════════════════════════════════════════════ + + +class TestClassifyFilesContentFormat: + """Tests for content_format auto-detection in classify_files().""" + + def _freeform_type(self) -> FileTypeDeclaration: + return FileTypeDeclaration( + name="main", + patterns=("CLAUDE.md",), + properties={"format": "freeform", "scope": "project"}, + ) + + def _schema_type(self) -> FileTypeDeclaration: + return FileTypeDeclaration( + name="config", + patterns=("settings.json",), + properties={"format": "schema"}, + ) + + def test_freeform_gets_content_format(self, tmp_path: Path): + md = tmp_path / "CLAUDE.md" + md.write_text("# Title\n\nSome real paragraph content here.\n") + result = classify_files(tmp_path, [md], [self._freeform_type()]) + assert len(result) == 1 + cf = result[0].properties.get("content_format") + assert isinstance(cf, list) + assert "heading" in cf + assert "prose" in cf + + def test_schema_format_skips_content_format(self, tmp_path: Path): + f = tmp_path / "settings.json" + f.write_text('{"key": "value"}\n') + result = classify_files(tmp_path, [f], [self._schema_type()]) + assert len(result) == 1 + assert "content_format" not in result[0].properties + + def test_freeform_empty_file_no_content_format(self, tmp_path: Path): + md = tmp_path / "CLAUDE.md" + md.write_text("") + result = classify_files(tmp_path, [md], [self._freeform_type()]) + assert len(result) == 1 + assert "content_format" not in result[0].properties + + def test_explicit_content_format_not_overwritten(self, tmp_path: Path): + """If content_format is already set in properties, don't overwrite.""" + ft = FileTypeDeclaration( + name="main", + patterns=("CLAUDE.md",), + properties={"format": "freeform", "content_format": ["prose"]}, + ) + md = tmp_path / "CLAUDE.md" + md.write_text("# Heading\n\n```python\ncode\n```\n") + result = classify_files(tmp_path, [md], [ft]) + assert result[0].properties["content_format"] == ["prose"] + + def test_freeform_list_format(self, tmp_path: Path): + """format: [freeform, ...] should also trigger detection.""" + ft = FileTypeDeclaration( + name="main", + patterns=("CLAUDE.md",), + properties={"format": ["freeform", "frontmatter"]}, + ) + md = tmp_path / "CLAUDE.md" + md.write_text("# Title\n\nLong enough prose content here.\n") + result = classify_files(tmp_path, [md], [ft]) + assert "content_format" in result[0].properties + + +# ═══════════════════════════════════════════════════════════════════════ +# match_files — content_format property matching +# ═══════════════════════════════════════════════════════════════════════ + + +class TestMatchFilesContentFormat: + """Tests for content_format matching in _file_matches / match_files.""" + + def _cf(self, content_format: list[str]) -> ClassifiedFile: + return ClassifiedFile( + path=Path("/fake/CLAUDE.md"), + file_type="main", + properties={"format": "freeform", "content_format": content_format}, + ) + + def test_content_format_wildcard(self): + """content_format=None matches everything.""" + files = [self._cf(["prose", "heading"])] + result = match_files(files, FileMatch(type="main")) + assert len(result) == 1 + + def test_content_format_match_overlap(self): + """Rule targets code_block, file has code_block among others.""" + files = [self._cf(["code_block", "heading", "prose"])] + result = match_files(files, FileMatch(type="main", content_format=["code_block"])) + assert len(result) == 1 + + def test_content_format_no_overlap(self): + """Rule targets data_block, file only has prose + heading.""" + files = [self._cf(["heading", "prose"])] + result = match_files(files, FileMatch(type="main", content_format=["data_block"])) + assert len(result) == 0 + + def test_content_format_multi_match(self): + """Rule targets multiple formats, file has one of them.""" + files = [self._cf(["prose", "list"])] + result = match_files( + files, + FileMatch(type="main", content_format=["code_block", "list"]), + ) + assert len(result) == 1 + + def test_file_without_content_format_no_match(self): + """File with no content_format property doesn't match explicit criteria.""" + files = [ + ClassifiedFile( + path=Path("/fake/config.json"), + file_type="config", + properties={"format": "schema"}, + ) + ] + result = match_files(files, FileMatch(type="config", content_format=["prose"])) + assert len(result) == 0 diff --git a/tests/unit/test_client_checks.py b/tests/unit/test_client_checks.py new file mode 100644 index 00000000..98df8bc6 --- /dev/null +++ b/tests/unit/test_client_checks.py @@ -0,0 +1,101 @@ +"""Tests for core/client_checks.py — D-level checks.""" + +from __future__ import annotations + +from reporails_cli.core.client_checks import run_client_checks +from reporails_cli.core.mapper.mapper import Atom, FileRecord, RulesetMap, RulesetSummary + + +def _make_map(atoms: list[Atom]) -> RulesetMap: + """Build a minimal RulesetMap from atoms.""" + return RulesetMap( + schema_version="1.0.0", + embedding_model="test", + generated_at="2026-01-01T00:00:00Z", + files=(FileRecord(path="test.md", content_hash="sha256:abc"),), + atoms=tuple(atoms), + summary=RulesetSummary(n_atoms=len(atoms), n_charged=0, n_neutral=0), + ) + + +def _atom(line: int, charge_value: int, cluster_id: int = 0, position_index: int = 0, **kwargs: object) -> Atom: + """Build a minimal Atom for testing.""" + charge = {-1: "CONSTRAINT", 0: "NEUTRAL", 1: "DIRECTIVE"}[charge_value] + return Atom( + line=line, + text=kwargs.get("text", f"test atom at line {line}"), # type: ignore[arg-type] + kind="excitation", + charge=charge, + charge_value=charge_value, + modality="direct", + specificity="named", + position_index=position_index, + cluster_id=cluster_id, + file_path="test.md", + **{k: v for k, v in kwargs.items() if k != "text"}, # type: ignore[arg-type] + ) + + +class TestChargeOrdering: + def test_inverted_ordering_detected(self) -> None: + atoms = [ + _atom(10, -1, cluster_id=1, position_index=0), # constraint first + _atom(20, +1, cluster_id=1, position_index=1), # directive second + ] + findings = run_client_checks(_make_map(atoms)) + ordering = [f for f in findings if f.rule == "ordering"] + assert len(ordering) == 1 + assert "before directive" in ordering[0].message + + def test_correct_ordering_no_finding(self) -> None: + atoms = [ + _atom(10, +1, cluster_id=1, position_index=0), # directive first + _atom(20, -1, cluster_id=1, position_index=1), # constraint second + ] + findings = run_client_checks(_make_map(atoms)) + ordering = [f for f in findings if f.rule == "ordering"] + assert len(ordering) == 0 + + +class TestOrphanAtoms: + def test_directive_only_cluster_not_orphan(self) -> None: + """Directive-only is valid — no orphan finding. + + The golden pattern (+1, 0, -1) only requires a prohibition when there's + a behavior to suppress. 'Use ruff' stands alone without a constraint. + """ + atoms = [_atom(10, +1, cluster_id=1, position_index=0)] + findings = run_client_checks(_make_map(atoms)) + orphans = [f for f in findings if f.rule == "orphan"] + assert len(orphans) == 0 + + def test_constraint_only_cluster(self) -> None: + atoms = [_atom(10, -1, cluster_id=1, position_index=0)] + findings = run_client_checks(_make_map(atoms)) + orphans = [f for f in findings if f.rule == "orphan"] + assert len(orphans) == 1 + assert "prohibition" in orphans[0].message + + def test_balanced_cluster_no_orphan(self) -> None: + atoms = [ + _atom(10, +1, cluster_id=1, position_index=0), + _atom(20, -1, cluster_id=1, position_index=1), + ] + findings = run_client_checks(_make_map(atoms)) + orphans = [f for f in findings if f.rule == "orphan"] + assert len(orphans) == 0 + + +class TestUnformattedCode: + def test_unformatted_tokens_detected(self) -> None: + atoms = [_atom(10, +1, unformatted_code=["pytest"])] + findings = run_client_checks(_make_map(atoms)) + fmt = [f for f in findings if f.rule == "format"] + assert len(fmt) == 1 + assert "pytest" in fmt[0].message + + def test_no_unformatted_no_finding(self) -> None: + atoms = [_atom(10, +1, unformatted_code=[])] + findings = run_client_checks(_make_map(atoms)) + fmt = [f for f in findings if f.rule == "format"] + assert len(fmt) == 0 diff --git a/tests/unit/test_config_command.py b/tests/unit/test_config_command.py index 8f93eacc..6f535507 100644 --- a/tests/unit/test_config_command.py +++ b/tests/unit/test_config_command.py @@ -116,7 +116,7 @@ class TestListMerge: def test_shows_global_fallback_annotated(self, tmp_path: Path) -> None: # Project has exclude_dirs but no default_agent project = tmp_path / "project" - cfg_dir = project / ".reporails" + cfg_dir = project / ".ails" cfg_dir.mkdir(parents=True) (cfg_dir / "config.yml").write_text("exclude_dirs:\n - vendor\n") @@ -134,7 +134,7 @@ def test_shows_global_fallback_annotated(self, tmp_path: Path) -> None: def test_project_value_not_annotated(self, tmp_path: Path) -> None: project = tmp_path / "project" - cfg_dir = project / ".reporails" + cfg_dir = project / ".ails" cfg_dir.mkdir(parents=True) (cfg_dir / "config.yml").write_text("default_agent: cursor\n") diff --git a/tests/unit/test_discover.py b/tests/unit/test_discover.py new file mode 100644 index 00000000..4ce28a3b --- /dev/null +++ b/tests/unit/test_discover.py @@ -0,0 +1,375 @@ +"""Unit tests for discover.py — backbone v3 detection functions.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from reporails_cli.core.discover import ( + _detect_classification, + _detect_commands, + _detect_meta, + _detect_paths, + generate_backbone_placeholder, + generate_backbone_yaml, +) + +# --------------------------------------------------------------------------- +# Classification +# --------------------------------------------------------------------------- + + +class TestDetectClassification: + def test_python_cli_project(self, tmp_path: Path) -> None: + """pyproject.toml with scripts → type=cli, language=[python].""" + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "myapp"\nversion = "1.0"\n' + 'dependencies = ["click"]\n' + '[project.scripts]\nmyapp = "myapp:main"\n' + ) + result = _detect_classification(tmp_path) + assert result["type"] == "cli" + assert result["language"] == ["python"] + assert result["runtime"] == "cpython" + + def test_python_library_project(self, tmp_path: Path) -> None: + """pyproject.toml without scripts → type=library.""" + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "pyproject.toml").write_text('[project]\nname = "mylib"\nversion = "1.0"\n') + result = _detect_classification(tmp_path) + assert result["type"] == "library" + assert result["language"] == ["python"] + + def test_node_project(self, tmp_path: Path) -> None: + """package.json → language=[javascript].""" + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "package.json").write_text(json.dumps({"name": "myapp", "version": "1.0"})) + result = _detect_classification(tmp_path) + assert result["language"] == ["javascript"] + assert result["runtime"] == "node" + + def test_typescript_detection(self, tmp_path: Path) -> None: + """package.json + tsconfig.json → language=[typescript].""" + (tmp_path / "src").mkdir() + (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) + (tmp_path / "tsconfig.json").write_text("{}") + result = _detect_classification(tmp_path) + assert result["language"] == ["typescript"] + + def test_bun_runtime_detection(self, tmp_path: Path) -> None: + """bun.lockb → runtime=bun.""" + (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) + (tmp_path / "bun.lockb").write_bytes(b"") + result = _detect_classification(tmp_path) + assert result["runtime"] == "bun" + + def test_deno_runtime_detection(self, tmp_path: Path) -> None: + """deno.lock → runtime=deno.""" + (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) + (tmp_path / "deno.lock").write_text("{}") + result = _detect_classification(tmp_path) + assert result["runtime"] == "deno" + + def test_multi_language(self, tmp_path: Path) -> None: + """Both pyproject.toml + package.json → multi-language.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\n') + (tmp_path / "package.json").write_text(json.dumps({"name": "frontend"})) + result = _detect_classification(tmp_path) + assert "python" in result["language"] + assert "javascript" in result["language"] + + def test_empty_project(self, tmp_path: Path) -> None: + """No manifests → all null.""" + result = _detect_classification(tmp_path) + assert result["type"] is None + assert result["language"] is None + assert result["framework"] is None + assert result["runtime"] is None + + def test_framework_detection_fastapi(self, tmp_path: Path) -> None: + """FastAPI in dependencies → framework=fastapi.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "api"\ndependencies = ["fastapi>=0.100"]\n') + result = _detect_classification(tmp_path) + assert result["framework"] == "fastapi" + + def test_framework_detection_express(self, tmp_path: Path) -> None: + """Express in dependencies → framework=express.""" + (tmp_path / "package.json").write_text(json.dumps({"name": "api", "dependencies": {"express": "^4.0"}})) + result = _detect_classification(tmp_path) + assert result["framework"] == "express" + + def test_monorepo_npm_workspaces(self, tmp_path: Path) -> None: + """package.json with workspaces → type=monorepo.""" + (tmp_path / "package.json").write_text(json.dumps({"name": "mono", "workspaces": ["packages/*"]})) + result = _detect_classification(tmp_path) + assert result["type"] == "monorepo" + + def test_app_directory(self, tmp_path: Path) -> None: + """app/ directory → type=app.""" + (tmp_path / "app").mkdir() + (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) + result = _detect_classification(tmp_path) + assert result["type"] == "app" + + def test_rust_project(self, tmp_path: Path) -> None: + """Cargo.toml → language=[rust].""" + (tmp_path / "Cargo.toml").write_text('[package]\nname = "mylib"\nversion = "0.1.0"\n') + result = _detect_classification(tmp_path) + assert result["language"] == ["rust"] + + def test_go_project(self, tmp_path: Path) -> None: + """go.mod → language=[go].""" + (tmp_path / "go.mod").write_text("module example.com/mymod\n\ngo 1.21\n") + result = _detect_classification(tmp_path) + assert result["language"] == ["go"] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +class TestDetectCommands: + def test_makefile_targets(self, tmp_path: Path) -> None: + """Makefile with standard targets.""" + (tmp_path / "Makefile").write_text( + "build:\n\tgo build\n\ntest:\n\tgo test ./...\n\nlint:\n\tgolangci-lint run\n" + ) + result = _detect_commands(tmp_path) + assert result["build"] == "make build" + assert result["test"] == "make test" + assert result["lint"] == "make lint" + + def test_pyproject_poe_tasks(self, tmp_path: Path) -> None: + """Poe tasks in pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.poe.tasks]\ntest = "pytest"\nlint = "ruff check"\nformat = "ruff format"\n' + ) + result = _detect_commands(tmp_path) + assert result["test"] == "poe test" + assert result["lint"] == "poe lint" + assert result["format"] == "poe format" + + def test_package_json_scripts(self, tmp_path: Path) -> None: + """npm scripts in package.json.""" + (tmp_path / "package.json").write_text( + json.dumps( + { + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "eslint .", + "format": "prettier --write .", + } + } + ) + ) + result = _detect_commands(tmp_path) + assert result["build"] == "npm run build" + assert result["test"] == "npm run test" + assert result["lint"] == "npm run lint" + assert result["format"] == "npm run format" + + def test_infer_pytest_from_config(self, tmp_path: Path) -> None: + """pytest config in pyproject.toml → test=pytest.""" + (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-v'\n") + result = _detect_commands(tmp_path) + assert result["test"] == "pytest" + + def test_infer_ruff_from_config(self, tmp_path: Path) -> None: + """ruff.toml exists → lint=ruff check, format=ruff format.""" + (tmp_path / "ruff.toml").write_text("[lint]\nselect = ['E']\n") + result = _detect_commands(tmp_path) + assert result["lint"] == "ruff check" + assert result["format"] == "ruff format" + + def test_empty_project(self, tmp_path: Path) -> None: + """No task runners or configs → all null.""" + result = _detect_commands(tmp_path) + assert all(v is None for v in result.values()) + + def test_poe_takes_priority_over_npm(self, tmp_path: Path) -> None: + """Poe tasks win over npm scripts.""" + (tmp_path / "pyproject.toml").write_text("[tool.poe.tasks]\ntest = 'pytest'\n") + (tmp_path / "package.json").write_text(json.dumps({"scripts": {"test": "jest"}})) + result = _detect_commands(tmp_path) + assert result["test"] == "poe test" + + +# --------------------------------------------------------------------------- +# Meta +# --------------------------------------------------------------------------- + + +class TestDetectMeta: + def test_finds_version_and_changelog(self, tmp_path: Path) -> None: + """VERSION + CHANGELOG.md detected.""" + (tmp_path / "VERSION").write_text("1.0.0\n") + (tmp_path / "CHANGELOG.md").write_text("# Changelog\n") + result = _detect_meta(tmp_path) + assert result["version_file"] == "VERSION" + assert result["changelog"] == "CHANGELOG.md" + + def test_manifest_priority(self, tmp_path: Path) -> None: + """pyproject.toml wins over package.json.""" + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n") + (tmp_path / "package.json").write_text(json.dumps({"name": "x"})) + result = _detect_meta(tmp_path) + assert result["manifest"] == "pyproject.toml" + + def test_version_from_manifest(self, tmp_path: Path) -> None: + """No VERSION file → falls back to manifest version field.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\nversion = "2.0"\n') + result = _detect_meta(tmp_path) + assert result["version_file"] == "pyproject.toml" + + def test_ci_github_actions(self, tmp_path: Path) -> None: + """GitHub Actions workflows detected.""" + (tmp_path / ".github" / "workflows").mkdir(parents=True) + result = _detect_meta(tmp_path) + assert result["ci"] == ".github/workflows/" + + def test_ci_gitlab(self, tmp_path: Path) -> None: + """.gitlab-ci.yml detected.""" + (tmp_path / ".gitlab-ci.yml").write_text("stages: [build]\n") + result = _detect_meta(tmp_path) + assert result["ci"] == ".gitlab-ci.yml" + + def test_unreleased_changelog(self, tmp_path: Path) -> None: + """UNRELEASED.md detected as changelog (second priority).""" + (tmp_path / "UNRELEASED.md").write_text("# Unreleased\n") + result = _detect_meta(tmp_path) + assert result["changelog"] == "UNRELEASED.md" + + def test_empty_project(self, tmp_path: Path) -> None: + """No files → all null.""" + result = _detect_meta(tmp_path) + assert all(v is None for v in result.values()) + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + + +class TestDetectPaths: + def test_standard_layout(self, tmp_path: Path) -> None: + """src/ + tests/ + docs/ detected.""" + (tmp_path / "src" / "mypackage").mkdir(parents=True) + (tmp_path / "tests").mkdir() + (tmp_path / "docs").mkdir() + result = _detect_paths(tmp_path) + assert result["src"] == "src/mypackage/" + assert result["tests"] == "tests/" + assert result["docs"] == "docs/" + + def test_src_no_deep_resolve_with_multiple_children(self, tmp_path: Path) -> None: + """src/ with multiple children → src/ not resolved deeper.""" + (tmp_path / "src" / "a").mkdir(parents=True) + (tmp_path / "src" / "b").mkdir() + result = _detect_paths(tmp_path) + assert result["src"] == "src/" + + def test_lib_as_source(self, tmp_path: Path) -> None: + """lib/ detected as src when no src/ exists.""" + (tmp_path / "lib").mkdir() + result = _detect_paths(tmp_path) + assert result["src"] == "lib/" + + def test_scripts_detection(self, tmp_path: Path) -> None: + """scripts/ detected.""" + (tmp_path / "scripts").mkdir() + result = _detect_paths(tmp_path) + assert result["scripts"] == "scripts/" + + def test_empty_project(self, tmp_path: Path) -> None: + """No directories → all null.""" + result = _detect_paths(tmp_path) + assert all(v is None for v in result.values()) + + +# --------------------------------------------------------------------------- +# Full backbone generation +# --------------------------------------------------------------------------- + + +class TestGenerateBackboneV3: + def test_version_3(self, tmp_path: Path) -> None: + """Generated backbone has version 3.""" + from reporails_cli.core.agents import DetectedAgent, get_known_agents + + agent = DetectedAgent( + agent_type=get_known_agents()["claude"], + instruction_files=[tmp_path / "CLAUDE.md"], + ) + output = generate_backbone_yaml(tmp_path, [agent]) + data = yaml.safe_load(output) + assert data["version"] == 3 + + def test_all_v3_sections_present(self, tmp_path: Path) -> None: + """v3 backbone has identity, agents, paths (three dimensions).""" + (tmp_path / "CLAUDE.md").write_text("# Test\n") + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n') + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + + from reporails_cli.core.agents import DetectedAgent, get_known_agents + + agent = DetectedAgent( + agent_type=get_known_agents()["claude"], + instruction_files=[tmp_path / "CLAUDE.md"], + ) + output = generate_backbone_yaml(tmp_path, [agent]) + data = yaml.safe_load(output) + + assert data["version"] == 3 + assert "auto_heal" in data + assert data["auto_heal"] is True + assert "directive" in data + assert "identity" in data + assert "agents" in data + assert "paths" in data + + def test_null_leaves_stripped(self, tmp_path: Path) -> None: + """Null values are stripped from YAML output.""" + output = generate_backbone_yaml(tmp_path, []) + data = yaml.safe_load(output) + # Empty project — no classification keys with null values should appear + assert "identity" not in data or all(v is not None for v in (data.get("identity") or {}).values()) + + def test_header_comment(self, tmp_path: Path) -> None: + """Output starts with header comment referencing v3.""" + output = generate_backbone_yaml(tmp_path, []) + assert output.startswith("# Auto-generated by ails map") + assert "backbone v3" in output + + def test_agents_section_populated(self, tmp_path: Path) -> None: + """Agents section populated from detected agents.""" + (tmp_path / "CLAUDE.md").write_text("# Test\n") + + from reporails_cli.core.agents import DetectedAgent, get_known_agents + + agent = DetectedAgent( + agent_type=get_known_agents()["claude"], + instruction_files=[tmp_path / "CLAUDE.md"], + detected_directories={"rules": ".claude/rules/"}, + ) + output = generate_backbone_yaml(tmp_path, [agent]) + data = yaml.safe_load(output) + + assert "claude" in data["agents"] + assert data["agents"]["claude"]["main_instruction_file"] == "CLAUDE.md" + assert data["agents"]["claude"]["rules"] == ".claude/rules/" + + +class TestPlaceholder: + def test_version_3(self) -> None: + """Placeholder is version 3.""" + content = generate_backbone_placeholder() + assert "version: 3" in content diff --git a/tests/unit/test_download_rules_staging.py b/tests/unit/test_download_rules_staging.py index a5d4ed0d..3fc32699 100644 --- a/tests/unit/test_download_rules_staging.py +++ b/tests/unit/test_download_rules_staging.py @@ -25,7 +25,7 @@ def _make_rules_tarball(manifest_content: str | None = None) -> bytes: """Build an in-memory tarball mimicking a rules release.""" buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: - rule_data = b"rules:\n - id: test\n message: test\n" + rule_data = b"checks:\n - id: test\n message: test\n" info = tarfile.TarInfo(name="core/test-rule.yml") info.size = len(rule_data) tar.addfile(info, io.BytesIO(rule_data)) @@ -46,9 +46,9 @@ def _make_rules_tarball(manifest_content: str | None = None) -> bytes: COMPATIBLE_MANIFEST = """\ schemas: - rule: "0.1.0" + rule: "0.2.0" levels: "0.1.0" - agent: "0.2.0" + agent: "0.3.0" """ INCOMPATIBLE_MANIFEST = """\ diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py deleted file mode 100644 index 12119d2b..00000000 --- a/tests/unit/test_engine.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Unit tests for engine module.""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.engine import _compute_category_summary, _find_project_root -from reporails_cli.core.models import Category, Rule, RuleType, Severity, Violation - - -def _rule(rule_id: str, category: Category) -> Rule: - return Rule( - id=rule_id, - title=f"Rule {rule_id}", - category=category, - type=RuleType.DETERMINISTIC, - level="L2", - ) - - -def _violation(rule_id: str, severity: Severity = Severity.MEDIUM) -> Violation: - return Violation( - rule_id=rule_id, - rule_title="Test", - location="test.md:1", - message="msg", - severity=severity, - ) - - -class TestComputeCategorySummary: - def test_all_passing(self) -> None: - rules = { - "S1": _rule("S1", Category.STRUCTURE), - "C1": _rule("C1", Category.CONTENT), - } - stats = _compute_category_summary(rules, []) - - s_stat = next(s for s in stats if s.code == "S") - c_stat = next(s for s in stats if s.code == "C") - assert s_stat.total == 1 and s_stat.failed == 0 and s_stat.passed == 1 - assert c_stat.total == 1 and c_stat.failed == 0 and c_stat.passed == 1 - - def test_mixed_violations(self) -> None: - rules = { - "S1": _rule("S1", Category.STRUCTURE), - "S2": _rule("S2", Category.STRUCTURE), - "C1": _rule("C1", Category.CONTENT), - "E1": _rule("E1", Category.EFFICIENCY), - } - violations = [ - _violation("S1", Severity.HIGH), - _violation("C1", Severity.LOW), - ] - stats = _compute_category_summary(rules, violations) - - s_stat = next(s for s in stats if s.code == "S") - c_stat = next(s for s in stats if s.code == "C") - e_stat = next(s for s in stats if s.code == "E") - assert s_stat.failed == 1 and s_stat.passed == 1 and s_stat.worst_severity == "high" - assert c_stat.failed == 1 and c_stat.passed == 0 and c_stat.worst_severity == "low" - assert e_stat.failed == 0 and e_stat.passed == 1 and e_stat.worst_severity is None - - def test_empty_rules(self) -> None: - stats = _compute_category_summary({}, []) - for s in stats: - assert s.total == 0 and s.failed == 0 and s.passed == 0 - - def test_worst_severity_across_violations(self) -> None: - rules = {"S1": _rule("S1", Category.STRUCTURE), "S2": _rule("S2", Category.STRUCTURE)} - violations = [ - _violation("S1", Severity.LOW), - _violation("S2", Severity.CRITICAL), - ] - stats = _compute_category_summary(rules, violations) - s_stat = next(s for s in stats if s.code == "S") - assert s_stat.worst_severity == "critical" - - -class TestFindProjectRoot: - """Tests for _find_project_root() project root detection.""" - - def test_finds_git_root(self, tmp_path: Path) -> None: - """Direct directory with .git/ returns itself.""" - (tmp_path / ".git").mkdir() - assert _find_project_root(tmp_path) == tmp_path - - def test_walks_up_to_git_root(self, tmp_path: Path) -> None: - """Subdirectory walks up to find .git/ in parent.""" - (tmp_path / ".git").mkdir() - subdir = tmp_path / "packages" / "child" - subdir.mkdir(parents=True) - assert _find_project_root(subdir) == tmp_path - - def test_no_git_falls_back_to_target(self, tmp_path: Path) -> None: - """No .git/ anywhere falls back to original target.""" - subdir = tmp_path / "some" / "deep" / "path" - subdir.mkdir(parents=True) - assert _find_project_root(subdir) == subdir - - def test_git_file_for_submodule(self, tmp_path: Path) -> None: - """.git as a file (submodule) is detected.""" - (tmp_path / ".git").write_text("gitdir: ../.git/modules/sub") - subdir = tmp_path / "src" - subdir.mkdir() - assert _find_project_root(subdir) == tmp_path - - def test_file_target_uses_parent(self, tmp_path: Path) -> None: - """When target is a file, walks from its parent directory.""" - (tmp_path / ".git").mkdir() - target_file = tmp_path / "src" / "main.py" - target_file.parent.mkdir() - target_file.touch() - assert _find_project_root(target_file) == tmp_path - - def test_child_backbone_preferred_over_parent_coordination(self, tmp_path: Path) -> None: - """Child with its own backbone is a standalone project — don't walk past it.""" - # Parent coordination root - (tmp_path / ".git").mkdir() - backbone_dir = tmp_path / ".reporails" - backbone_dir.mkdir() - (backbone_dir / "backbone.yml").write_text( - "version: 2\nchildren:\n child:\n backbone: child/.reporails/backbone.yml\n" - ) - # Child with its own backbone - child = tmp_path / "child" - child.mkdir() - (child / ".git").mkdir() - child_backbone = child / ".reporails" - child_backbone.mkdir() - (child_backbone / "backbone.yml").write_text("version: 3\ndepends_on:\n rules: {}\n") - assert _find_project_root(child) == child - - def test_backbone_with_repos_preferred(self, tmp_path: Path) -> None: - """Backbone with repos key is recognized as coordination root.""" - (tmp_path / ".git").mkdir() - backbone_dir = tmp_path / ".reporails" - backbone_dir.mkdir() - (backbone_dir / "backbone.yml").write_text("version: 2\nrepos:\n rules:\n path: rules\n") - subdir = tmp_path / "rules" - subdir.mkdir() - (subdir / ".git").mkdir() - assert _find_project_root(subdir) == tmp_path - - def test_child_git_used_when_no_coordination_backbone(self, tmp_path: Path) -> None: - """Without coordination backbone, falls back to nearest .git.""" - child = tmp_path / "child" - child.mkdir() - (child / ".git").mkdir() - subdir = child / "src" - subdir.mkdir() - assert _find_project_root(subdir) == child - - -class TestProgressCallback: - """Tests for on_progress callback in run_validation.""" - - def test_receives_three_phases_in_order(self, level2_project: Path) -> None: - """Callback fires for all three phases with correct (name, current, total) tuples.""" - from reporails_cli.core.engine import run_validation_sync - - phases: list[tuple[str, int, int]] = [] - - def recorder(phase: str, current: int, total: int) -> None: - phases.append((phase, current, total)) - - run_validation_sync(level2_project, agent="claude", on_progress=recorder) - - assert phases == [ - ("Loading rules", 1, 3), - ("Checking files", 2, 3), - ("Scoring", 3, 3), - ] - - def test_none_callback_no_error(self, level2_project: Path) -> None: - """Default on_progress=None does not raise.""" - from reporails_cli.core.engine import run_validation_sync - - result = run_validation_sync(level2_project, agent="claude", on_progress=None) - assert result.score >= 0 diff --git a/tests/unit/test_exit_codes.py b/tests/unit/test_exit_codes.py index 5c9bf3c4..4b2cf477 100644 --- a/tests/unit/test_exit_codes.py +++ b/tests/unit/test_exit_codes.py @@ -25,7 +25,7 @@ def test_no_findings_returns_empty_results( from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: wont-match message: "Won't find this" severity: WARNING @@ -52,7 +52,7 @@ def test_findings_returned_correctly( from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: finds-hello message: "Found hello" severity: WARNING @@ -78,7 +78,7 @@ def test_invalid_rule_handled_gracefully( from reporails_cli.core.regex import run_validation bad_yaml = """\ -rules: +checks: - id: bad-rule # Missing message, severity, pattern """ @@ -98,7 +98,7 @@ def test_no_files_matched_is_not_error( from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: rust-only message: "Rust check" severity: WARNING @@ -128,7 +128,7 @@ def test_sarif_has_correct_structure( from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-structure message: "Found TODO" severity: WARNING @@ -169,7 +169,7 @@ def test_sarif_rule_definitions_present( from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-defs message: "Match" severity: WARNING diff --git a/tests/unit/test_fixers.py b/tests/unit/test_fixers.py deleted file mode 100644 index 1848963c..00000000 --- a/tests/unit/test_fixers.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Unit tests for auto-fixers. - -Tests cover: -- Each fixer adds its section when missing -- Each fixer is idempotent (no-op when section already present) -- apply_auto_fixes filters to fixable violations only -""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.fixers import ( - apply_auto_fixes, - apply_single_fix, - fix_add_commands, - fix_add_constraints, - fix_add_sections, - fix_add_structure, - fix_add_testing, - partition_violations, -) -from reporails_cli.core.models import Severity, Violation - - -def _make_violation( - rule_id: str, - file_path: str, - message: str = "Missing section", -) -> Violation: - return Violation( - rule_id=rule_id, - rule_title="Test Rule", - location=f"{file_path}:1", - message=message, - severity=Severity.HIGH, - ) - - -# --------------------------------------------------------------------------- -# fix_add_constraints (CORE:C:0010) -# --------------------------------------------------------------------------- - - -class TestFixAddConstraints: - def test_adds_section_when_missing(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nSome content.\n") - v = _make_violation("CORE:C:0010", str(md)) - - result = fix_add_constraints(v, tmp_path) - - assert result is not None - assert "Constraints" in result.description - content = md.read_text() - assert "## Constraints" in content - assert "TODO: Add project constraints" in content - - def test_idempotent_with_constraints_heading(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Constraints\n\n- No secrets.\n") - v = _make_violation("CORE:C:0010", str(md)) - - result = fix_add_constraints(v, tmp_path) - assert result is None - - def test_idempotent_with_pitfalls_heading(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Pitfalls\n\n- Watch out.\n") - v = _make_violation("CORE:C:0010", str(md)) - - result = fix_add_constraints(v, tmp_path) - assert result is None - - def test_returns_none_for_missing_file(self, tmp_path: Path) -> None: - v = _make_violation("CORE:C:0010", str(tmp_path / "nonexistent.md")) - result = fix_add_constraints(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# fix_add_commands (CORE:C:0003) -# --------------------------------------------------------------------------- - - -class TestFixAddCommands: - def test_adds_section_when_missing(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nSome content.\n") - v = _make_violation("CORE:C:0003", str(md)) - - result = fix_add_commands(v, tmp_path) - - assert result is not None - assert "Commands" in result.description - content = md.read_text() - assert "## Commands" in content - assert "```bash" in content - - def test_idempotent_with_inline_command(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nRun `npm install` to get started.\n") - v = _make_violation("CORE:C:0003", str(md)) - - result = fix_add_commands(v, tmp_path) - assert result is None - - def test_idempotent_with_shell_prompt(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n$ npm install\n") - v = _make_violation("CORE:C:0003", str(md)) - - result = fix_add_commands(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# fix_add_testing (CORE:C:0004) -# --------------------------------------------------------------------------- - - -class TestFixAddTesting: - def test_adds_section_when_missing(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nSome content.\n") - v = _make_violation("CORE:C:0004", str(md)) - - result = fix_add_testing(v, tmp_path) - - assert result is not None - assert "Testing" in result.description - content = md.read_text() - assert "## Testing" in content - assert "TODO: Add test commands" in content - - def test_idempotent_with_test_heading(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Testing\n\n- Run pytest.\n") - v = _make_violation("CORE:C:0004", str(md)) - - result = fix_add_testing(v, tmp_path) - assert result is None - - def test_idempotent_with_framework_mention(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nWe use pytest for all tests.\n") - v = _make_violation("CORE:C:0004", str(md)) - - result = fix_add_testing(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# fix_add_sections (CORE:C:0015) -# --------------------------------------------------------------------------- - - -class TestFixAddSections: - def test_adds_headings_when_none_exist(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nJust one big block of text with no sections.\n") - v = _make_violation("CORE:C:0015", str(md)) - - result = fix_add_sections(v, tmp_path) - - assert result is not None - assert "Overview" in result.description - content = md.read_text() - assert "## Overview" in content - assert "## Getting Started" in content - - def test_idempotent_with_existing_headings(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Setup\n\nSome setup info.\n") - v = _make_violation("CORE:C:0015", str(md)) - - result = fix_add_sections(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# fix_add_structure (CORE:C:0002) -# --------------------------------------------------------------------------- - - -class TestFixAddStructure: - def test_adds_section_when_missing(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nSome content.\n") - v = _make_violation("CORE:C:0002", str(md)) - - result = fix_add_structure(v, tmp_path) - - assert result is not None - assert "Project Structure" in result.description - content = md.read_text() - assert "## Project Structure" in content - assert "```" in content - - def test_idempotent_with_structure_heading(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Project Structure\n\n```\nsrc/\n```\n") - v = _make_violation("CORE:C:0002", str(md)) - - result = fix_add_structure(v, tmp_path) - assert result is None - - def test_idempotent_with_path_references(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nCode is in src/ and tests are in tests/.\n") - v = _make_violation("CORE:C:0002", str(md)) - - result = fix_add_structure(v, tmp_path) - assert result is None - - def test_idempotent_with_directory_heading(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\n## Directory Layout\n\nStuff here.\n") - v = _make_violation("CORE:C:0002", str(md)) - - result = fix_add_structure(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# apply_auto_fixes -# --------------------------------------------------------------------------- - - -class TestApplyAutoFixes: - def test_applies_fixable_violations(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nMinimal content.\n") - - violations = [ - _make_violation("CORE:C:0010", str(md)), - _make_violation("CORE:C:0004", str(md)), - ] - - results = apply_auto_fixes(violations, tmp_path) - - assert len(results) == 2 - content = md.read_text() - assert "## Constraints" in content - assert "## Testing" in content - - def test_skips_unfixable_violations(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n") - - violations = [ - _make_violation("CORE:S:0001", str(md)), # No fixer registered - _make_violation("CORE:C:0010", str(md)), - ] - - results = apply_auto_fixes(violations, tmp_path) - - assert len(results) == 1 - assert results[0].rule_id == "CORE:C:0010" - - def test_empty_violations(self, tmp_path: Path) -> None: - results = apply_auto_fixes([], tmp_path) - assert results == [] - - def test_relative_path_in_location(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n") - v = _make_violation("CORE:C:0010", "CLAUDE.md") - - results = apply_auto_fixes([v], tmp_path) - - assert len(results) == 1 - assert results[0].file_path == "CLAUDE.md" - - -# --------------------------------------------------------------------------- -# partition_violations -# --------------------------------------------------------------------------- - - -class TestPartitionViolations: - def test_splits_fixable_and_non_fixable(self, tmp_path: Path) -> None: - fixable_v = _make_violation("CORE:C:0003", str(tmp_path / "CLAUDE.md")) - non_fixable_v = _make_violation("CORE:S:0003", str(tmp_path / "CLAUDE.md")) - - fixable, non_fixable = partition_violations([fixable_v, non_fixable_v]) - - assert len(fixable) == 1 - assert fixable[0].rule_id == "CORE:C:0003" - assert len(non_fixable) == 1 - assert non_fixable[0].rule_id == "CORE:S:0003" - - def test_all_fixable(self, tmp_path: Path) -> None: - violations = [ - _make_violation("CORE:C:0003", str(tmp_path / "CLAUDE.md")), - _make_violation("CORE:C:0010", str(tmp_path / "CLAUDE.md")), - ] - fixable, non_fixable = partition_violations(violations) - assert len(fixable) == 2 - assert len(non_fixable) == 0 - - def test_all_non_fixable(self, tmp_path: Path) -> None: - violations = [ - _make_violation("CORE:S:0003", str(tmp_path / "CLAUDE.md")), - _make_violation("CORE:S:0005", str(tmp_path / "CLAUDE.md")), - ] - fixable, non_fixable = partition_violations(violations) - assert len(fixable) == 0 - assert len(non_fixable) == 2 - - -# --------------------------------------------------------------------------- -# apply_single_fix -# --------------------------------------------------------------------------- - - -class TestApplySingleFix: - def test_applies_fix(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n\nSome content.\n") - v = _make_violation("CORE:C:0010", str(md)) - - result = apply_single_fix(v, tmp_path) - - assert result is not None - assert "Constraints" in result.description - - def test_returns_none_for_unknown_rule(self, tmp_path: Path) -> None: - v = _make_violation("CORE:S:0003", str(tmp_path / "CLAUDE.md")) - result = apply_single_fix(v, tmp_path) - assert result is None - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- - - -class TestFixerEdgeCases: - def test_fix_empty_file(self, tmp_path: Path) -> None: - """fix_add_constraints on a 0-byte file should not crash.""" - md = tmp_path / "CLAUDE.md" - md.write_text("") - v = _make_violation("CORE:C:0010", str(md)) - - result = fix_add_constraints(v, tmp_path) - - # Empty file has no constraints heading, so fixer adds the section - assert result is not None - content = md.read_text() - assert "## Constraints" in content - - def test_double_application_idempotent(self, tmp_path: Path) -> None: - """Applying fix_add_constraints twice: second call returns None.""" - md = tmp_path / "CLAUDE.md" - md.write_text("# My Project\n") - v = _make_violation("CORE:C:0010", str(md)) - - first = fix_add_constraints(v, tmp_path) - assert first is not None - - second = fix_add_constraints(v, tmp_path) - assert second is None - - def test_apply_auto_fixes_missing_file_skips_gracefully(self, tmp_path: Path) -> None: - """Violations pointing to a nonexistent file should not crash.""" - violations = [ - _make_violation("CORE:C:0010", str(tmp_path / "does_not_exist.md")), - _make_violation("CORE:C:0003", str(tmp_path / "also_missing.md")), - ] - - results = apply_auto_fixes(violations, tmp_path) - - assert results == [] diff --git a/tests/unit/test_gates.py b/tests/unit/test_gates.py index d8e510c4..ebeee647 100644 --- a/tests/unit/test_gates.py +++ b/tests/unit/test_gates.py @@ -1,242 +1,217 @@ -"""Unit tests for capability-based level detection. +"""Unit tests for project level determination and target existence gating. -Tests the capability detection, level determination, and orphan feature -logic in reporails_cli.core.levels. +Tests determine_project_level() — computes project level from file type +property divergence, and returns the set of present file types. """ from __future__ import annotations -from unittest.mock import patch +from pathlib import Path import pytest -from reporails_cli.core.levels import ( - _detect_capability, - _level_has_capability, - detect_orphan_features, - determine_level_from_gates, -) -from reporails_cli.core.models import DetectedFeatures, Level - -# Test data matching framework registry/levels.yml -_TEST_LEVEL_CAPS: dict[str, list[str]] = { - "L0": [], - "L1": ["instruction_file"], - "L2": ["project_constraints", "size_controlled"], - "L3": ["external_references", "multiple_files"], - "L4": ["path_scoping"], - "L5": ["structural_integrity", "org_policy", "navigation"], - "L6": ["dynamic_context", "extensibility", "state_persistence"], -} - - -@pytest.fixture(autouse=True) -def _mock_level_caps() -> None: # type: ignore[misc] - """Ensure tests use known capability mapping regardless of framework state.""" - with patch( - "reporails_cli.core.levels._load_level_capabilities", - return_value=_TEST_LEVEL_CAPS, - ): - yield - - -class TestDetectCapability: - """Test _detect_capability for individual capabilities.""" - - @pytest.mark.parametrize( - "feature_kwargs, capability, expected", - [ - ({"has_instruction_file": True}, "instruction_file", True), - ({}, "instruction_file", False), - ({"has_explicit_constraints": True}, "project_constraints", True), - ({"is_size_controlled": True}, "size_controlled", True), - ({"has_imports": True}, "external_references", True), - ({"has_multiple_instruction_files": True}, "multiple_files", True), - ({"is_abstracted": True}, "path_scoping", True), - ({"has_path_scoped_rules": True}, "path_scoping", True), - ({"has_backbone": True}, "navigation", True), - ({"component_count": 3}, "navigation", True), - ({"has_shared_files": True}, "org_policy", True), - ({"has_skills_dir": True}, "dynamic_context", True), - ({"has_mcp_config": True}, "extensibility", True), - ], - ids=lambda x: str(x) if not isinstance(x, dict) else next(iter(x.keys()), "empty"), - ) - def test_capability_detected(self, feature_kwargs: dict, capability: str, expected: bool) -> None: - features = DetectedFeatures(**feature_kwargs) - assert _detect_capability(features, capability) is expected - - def test_navigation_below_threshold(self) -> None: - features = DetectedFeatures(component_count=2) - assert _detect_capability(features, "navigation") is False - - def test_structural_integrity_not_detectable(self) -> None: - """structural_integrity is not filesystem-detectable — always False.""" - features = DetectedFeatures() - assert _detect_capability(features, "structural_integrity") is False - - def test_unknown_capability_returns_false(self) -> None: - features = DetectedFeatures() - assert _detect_capability(features, "nonexistent") is False - - def test_skip_content_treats_content_capability_as_detected(self) -> None: - features = DetectedFeatures() # project_constraints not set - assert _detect_capability(features, "project_constraints", skip_content=True) is True - - def test_skip_content_does_not_affect_non_content_capability(self) -> None: - features = DetectedFeatures() - assert _detect_capability(features, "instruction_file", skip_content=True) is False - - -class TestLevelHasCapability: - """Test _level_has_capability — OR within level.""" - - @pytest.mark.parametrize( - "feature_kwargs, level, expected", - [ - ({"has_instruction_file": True}, "L1", True), - ({"is_size_controlled": True}, "L2", True), # OR: just one of two - ({"has_explicit_constraints": True}, "L2", True), # OR: the other one - ({}, "L2", False), # neither L2 capability - ({"has_backbone": True}, "L5", True), # 1 of 3 suffices - ({}, "L6", False), # no L6 capabilities - ({}, "L0", True), # empty level always passes - ], - ids=["L1-pass", "L2-size", "L2-constraints", "L2-neither", "L5-backbone", "L6-none", "L0-empty"], - ) - def test_level_capability_check(self, feature_kwargs: dict, level: str, expected: bool) -> None: - features = DetectedFeatures(**feature_kwargs) - assert _level_has_capability(features, level, _TEST_LEVEL_CAPS) is expected +from reporails_cli.core.levels import _property_depth, _type_exists, determine_project_level +from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration, Level -class TestDetermineLevelFromGates: - """Test determine_level_from_gates — full cumulative walk.""" +def _ft( + name: str, + patterns: tuple[str, ...] = ("**/CLAUDE.md",), + **properties: str, +) -> FileTypeDeclaration: + """Create a FileTypeDeclaration for testing.""" + return FileTypeDeclaration(name=name, patterns=patterns, properties=properties) + + +def _cf( + name: str, + path: str = "CLAUDE.md", + **properties: str, +) -> ClassifiedFile: + """Create a ClassifiedFile for testing.""" + return ClassifiedFile(path=Path(path), file_type=name, properties=properties) + + +# Baseline: format=freeform, cardinality=singleton, precedence=project, +# loading=session_start, scope=global + + +class TestPropertyDepth: + """Test _property_depth — count divergences from baseline.""" + + def test_all_baseline_returns_zero(self) -> None: + props = { + "format": "freeform", + "cardinality": "singleton", + "precedence": "project", + "loading": "session_start", + "scope": "global", + } + assert _property_depth(props) == 0 + + def test_empty_properties_returns_zero(self) -> None: + assert _property_depth({}) == 0 + + def test_one_divergence(self) -> None: + assert _property_depth({"format": "frontmatter"}) == 1 + + def test_two_divergences(self) -> None: + props = {"format": "frontmatter", "scope": "path_scoped"} + assert _property_depth(props) == 2 + + def test_all_divergent(self) -> None: + props = { + "format": "frontmatter", + "cardinality": "collection", + "precedence": "managed", + "loading": "on_demand", + "scope": "path_scoped", + } + assert _property_depth(props) == 5 + + def test_extra_properties_ignored(self) -> None: + """Properties not in baseline don't count.""" + props = {"custom_axis": "whatever", "unknown": "value"} + assert _property_depth(props) == 0 + + +class TestTypeExists: + """Test _type_exists — filesystem pattern matching.""" + + def test_exact_file_exists(self, tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("# Test\n") + assert _type_exists(tmp_path, ("CLAUDE.md",)) is True + + def test_exact_file_missing(self, tmp_path: Path) -> None: + assert _type_exists(tmp_path, ("CLAUDE.md",)) is False + + def test_glob_pattern_matches(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "style.md").write_text("# Style\n") + assert _type_exists(tmp_path, (".claude/rules/**/*.md",)) is True + + def test_glob_pattern_no_match(self, tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + assert _type_exists(tmp_path, (".claude/rules/**/*.md",)) is False + + def test_strips_dotslash_prefix(self, tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("# Test\n") + assert _type_exists(tmp_path, ("./CLAUDE.md",)) is True + + def test_multiple_patterns_any_match(self, tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("# Agents\n") + assert _type_exists(tmp_path, ("CLAUDE.md", "AGENTS.md")) is True + + +class TestDetermineProjectLevel: + """Test determine_project_level — level from property divergence.""" + + def test_no_files_returns_l0(self, tmp_path: Path) -> None: + level, present = determine_project_level(tmp_path, [], []) + assert level == Level.L0 + assert present == set() + + def test_main_only_returns_l1(self, tmp_path: Path) -> None: + """Main file with all-baseline properties → depth 0 → L1.""" + classified = [ + _cf( + "main", + format="freeform", + cardinality="singleton", + precedence="project", + loading="session_start", + scope="global", + ) + ] + level, present = determine_project_level(tmp_path, [], classified) + assert level == Level.L1 + assert present == {"main"} + + def test_one_divergence_returns_l2(self, tmp_path: Path) -> None: + """One property diverges → depth 1 → L2.""" + classified = [_cf("scoped_rule", format="frontmatter")] + level, present = determine_project_level(tmp_path, [], classified) + assert level == Level.L2 + assert present == {"scoped_rule"} + + def test_max_depth_across_types(self, tmp_path: Path) -> None: + """Level is max depth across all present types + 1.""" + classified = [ + _cf("main"), # depth 0 + _cf("scoped_rule", format="frontmatter", scope="path_scoped", loading="on_demand"), # depth 3 + ] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == Level.L4 # max(0, 3) + 1 = 4 + + def test_capped_at_l6(self, tmp_path: Path) -> None: + """Level caps at L6 even with 5+ divergences.""" + classified = [ + _cf( + "extreme", + format="schema_validated", + cardinality="collection", + precedence="managed", + loading="on_demand", + scope="path_scoped", + ) + ] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == Level.L6 # min(5+1, 6) = 6 + + def test_filesystem_fallback(self, tmp_path: Path) -> None: + """Types not in classified_files but present on disk are included.""" + (tmp_path / "CLAUDE.md").write_text("# Test\n") + file_types = [ + _ft( + "main", + patterns=("CLAUDE.md",), + format="freeform", + cardinality="singleton", + precedence="project", + loading="session_start", + scope="global", + ) + ] + level, present = determine_project_level(tmp_path, file_types, []) + assert level == Level.L1 + assert "main" in present + + def test_filesystem_not_double_counted(self, tmp_path: Path) -> None: + """A type already in classified_files is not re-checked on disk.""" + (tmp_path / "CLAUDE.md").write_text("# Test\n") + classified = [_cf("main")] + file_types = [_ft("main", patterns=("CLAUDE.md",), format="frontmatter")] + # classified has depth=0 for "main"; file_types would give depth=1 but + # should be skipped since "main" is already present + level, _ = determine_project_level(tmp_path, file_types, classified) + assert level == Level.L1 # depth 0 from classified, not 1 from file_types @pytest.mark.parametrize( - "feature_kwargs, expected_level", + "depth, expected_level", [ - ({}, Level.L0), - ({"has_instruction_file": True}, Level.L1), - ({"has_instruction_file": True, "has_explicit_constraints": True}, Level.L2), - ({"has_instruction_file": True, "is_size_controlled": True}, Level.L2), # OR path - ({"has_instruction_file": True, "is_size_controlled": True, "has_imports": True}, Level.L3), - ( - { - "has_instruction_file": True, - "has_explicit_constraints": True, - "has_multiple_instruction_files": True, - }, - Level.L3, - ), # L3 via multiple_files - ( - {"has_instruction_file": True, "is_size_controlled": True, "has_imports": True, "is_abstracted": True}, - Level.L4, - ), - ( - { - "has_instruction_file": True, - "has_explicit_constraints": True, - "has_imports": True, - "is_abstracted": True, - "has_backbone": True, - "has_skills_dir": True, - }, - Level.L6, - ), + (0, Level.L1), + (1, Level.L2), + (2, Level.L3), + (3, Level.L4), + (4, Level.L5), + (5, Level.L6), ], - ids=["L0", "L1", "L2-constraints", "L2-size", "L3-imports", "L3-multi", "L4", "L6-full"], ) - def test_cumulative_walk(self, feature_kwargs: dict, expected_level: Level) -> None: - features = DetectedFeatures(**feature_kwargs) - assert determine_level_from_gates(features) == expected_level - - def test_gap_caps_at_lower_level(self) -> None: - """Has L4 + L6 features but missing L5 → caps at L4.""" - features = DetectedFeatures( - has_instruction_file=True, - is_size_controlled=True, - has_imports=True, - is_abstracted=True, - # L5: no shared_files, no backbone, no structural_integrity - has_skills_dir=True, # L6 feature - ) - assert determine_level_from_gates(features) == Level.L4 - - def test_skip_content_optimistic(self) -> None: - """With skip_content, content capabilities treated as detected.""" - features = DetectedFeatures( - has_instruction_file=True, - # no explicit_constraints, no size_controlled - # but skip_content → project_constraints treated as True → L2 passes - has_imports=True, - is_abstracted=True, - # skip_content → path_scoping treated as True → L4 passes - ) - assert determine_level_from_gates(features, skip_content=True) == Level.L4 - - -class TestDetectOrphanFeatures: - """Test detect_orphan_features — capabilities above base level.""" - - def test_l2_with_backbone_is_orphan(self) -> None: - """Backbone = navigation (L5) → orphan above L2.""" - features = DetectedFeatures( - has_instruction_file=True, - has_explicit_constraints=True, - has_backbone=True, - ) - assert detect_orphan_features(features, Level.L2) is True - - def test_l1_with_abstracted_is_orphan(self) -> None: - """Abstracted → path_scoping (L4) → orphan above L1.""" - features = DetectedFeatures( - has_instruction_file=True, - is_abstracted=True, - ) - assert detect_orphan_features(features, Level.L1) is True - - def test_l6_project_no_orphan(self) -> None: - features = DetectedFeatures( - has_instruction_file=True, - has_explicit_constraints=True, - has_imports=True, - is_abstracted=True, - has_backbone=True, - has_skills_dir=True, - ) - assert detect_orphan_features(features, Level.L6) is False - - def test_l1_no_higher_features_no_orphan(self) -> None: - features = DetectedFeatures(has_instruction_file=True) - assert detect_orphan_features(features, Level.L1) is False - - -class TestCapabilityInteraction: - """Test cross-level capability interactions and edge cases.""" - - def test_l5_requires_l4_features(self) -> None: - """L5 features without L4 path_scoping should cap at L3 or lower. - - Has L1-L3 + L5 (backbone) but missing L4 (no path_scoped_rules, - no is_abstracted) → cumulative gate blocks at L4 gap → caps at L3. - """ - features = DetectedFeatures( - has_instruction_file=True, # L1 - has_explicit_constraints=True, # L2 - has_imports=True, # L3 - # L4: is_abstracted=False, has_path_scoped_rules=False → gap - is_abstracted=False, - has_path_scoped_rules=False, - has_backbone=True, # L5: navigation - ) - level = determine_level_from_gates(features) - assert level.value <= Level.L3.value, f"Missing L4 path_scoping should cap level at L3 or below, got {level}" - - def test_state_persistence_detection(self) -> None: - """state_persistence is L6 — verify it returns False for default features.""" - features = DetectedFeatures() - assert _detect_capability(features, "state_persistence") is False - - # With has_memory_dir set, it should be detected - features_with_memory = DetectedFeatures(has_memory_dir=True) - assert _detect_capability(features_with_memory, "state_persistence") is True + def test_depth_to_level_mapping(self, tmp_path: Path, depth: int, expected_level: Level) -> None: + """depth N → Level L(N+1), capped at L6.""" + # Build properties with exactly `depth` divergences + divergent_props: dict[str, str] = {} + prop_overrides = [ + ("format", "frontmatter"), + ("cardinality", "collection"), + ("precedence", "managed"), + ("loading", "on_demand"), + ("scope", "path_scoped"), + ] + for i in range(depth): + k, v = prop_overrides[i] + divergent_props[k] = v + classified = [_cf("test_type", **divergent_props)] + level, _ = determine_project_level(tmp_path, [], classified) + assert level == expected_level diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index b8da0a72..6d16a369 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -4,12 +4,42 @@ from pathlib import Path +from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration + # ── Helpers ────────────────────────────────────────────────────────── +def _ft( + name: str = "main", + patterns: tuple[str, ...] = ("**/*.md",), + required: bool = False, + properties: dict | None = None, +) -> FileTypeDeclaration: + """Create a FileTypeDeclaration for testing.""" + return FileTypeDeclaration( + name=name, + patterns=patterns, + required=required, + properties=properties or {}, + ) + + +def _fts_claude() -> list[FileTypeDeclaration]: + """Return a minimal Claude-like file type set for testing.""" + return [ + _ft("main", ("**/CLAUDE.md",), required=True), + _ft("scoped_rule", (".claude/rules/**/*.md",)), + ] + + +def _fts_with_skills() -> list[FileTypeDeclaration]: + """Return file types including skills.""" + return [*_fts_claude(), _ft("skill", (".claude/skills/**/*.md",))] + + def _make_rule_dir(tmp_path: Path, slug: str, frontmatter: str, body: str = "") -> Path: """Create a minimal rule directory with rule.md.""" - rule_dir = tmp_path / "core" / "structure" / slug + rule_dir = tmp_path / "core" / slug rule_dir.mkdir(parents=True) (rule_dir / "rule.md").write_text(f"---\n{frontmatter}\n---\n\n{body}") return rule_dir @@ -28,17 +58,24 @@ def _make_fixtures(rule_dir: Path, pass_content: str | None, fail_content: str | def _make_agent_config(tmp_path: Path, agent: str = "claude") -> None: - """Create a minimal agent config.yml.""" + """Create a minimal agent config.yml with file_types.""" config_dir = tmp_path / "agents" / agent config_dir.mkdir(parents=True) (config_dir / "config.yml").write_text( - 'agent: claude\nvars:\n instruction_files:\n - "**/*.md"\n main_instruction_file:\n - "**/CLAUDE.md"\n' + "agent: claude\nprefix: CLAUDE\n" + "file_types:\n" + " main:\n" + ' patterns: ["**/CLAUDE.md"]\n' + " required: true\n" + " properties:\n" + " format: freeform\n" + " scope: global\n" ) -def _make_rule_yml(rule_dir: Path, yml_content: str) -> None: - """Create a rule.yml file in a rule directory.""" - (rule_dir / "rule.yml").write_text(yml_content) +def _make_checks_yml(rule_dir: Path, yml_content: str) -> None: + """Create a checks.yml file in a rule directory.""" + (rule_dir / "checks.yml").write_text(yml_content) # ── Discovery tests ───────────────────────────────────────────────── @@ -98,9 +135,13 @@ def test_excludes(self, tmp_path: Path) -> None: def test_discovers_agent_rules(self, tmp_path: Path) -> None: from reporails_cli.core.harness import discover_rules - agent_dir = tmp_path / "agents" / "claude" / "rules" / "test-rule" + # Agent dirs sit alongside core/ and need a config.yml + agent_dir = tmp_path / "claude" agent_dir.mkdir(parents=True) - (agent_dir / "rule.md").write_text( + (agent_dir / "config.yml").write_text("agent: claude\nprefix: CLAUDE\n") + rule_dir = agent_dir / "test-rule" + rule_dir.mkdir() + (rule_dir / "rule.md").write_text( "---\nid: CLAUDE:S:0001\nslug: test-rule\ntitle: Agent Rule\n" "category: structure\ntype: mechanical\nlevel: L1\nchecks: []\n---\n" ) @@ -116,24 +157,32 @@ def test_discovers_agent_rules(self, tmp_path: Path) -> None: class TestLoadAgentConfig: """Tests for load_agent_config().""" - def test_loads_vars_and_excludes(self, tmp_path: Path) -> None: + def test_loads_file_types_and_excludes(self, tmp_path: Path) -> None: from reporails_cli.core.harness import load_agent_config - config_dir = tmp_path / "agents" / "claude" + config_dir = tmp_path / "claude" config_dir.mkdir(parents=True) (config_dir / "config.yml").write_text( - 'agent: claude\nvars:\n instruction_files:\n - "**/*.md"\nexcludes:\n - CORE:S:0010\n' - ) - - vars_, excludes = load_agent_config(tmp_path, "claude") - assert "instruction_files" in vars_ + "agent: claude\nprefix: CLAUDE\n" + "file_types:\n" + " main:\n" + ' patterns: ["**/CLAUDE.md"]\n' + " required: true\n" + " properties:\n" + " format: freeform\n" + "excludes:\n - CORE:S:0010\n" + ) + + file_types, excludes = load_agent_config(tmp_path, "claude") + type_names = {ft.name for ft in file_types} + assert "main" in type_names assert excludes == ["CORE:S:0010"] def test_missing_config_returns_empty(self, tmp_path: Path) -> None: from reporails_cli.core.harness import load_agent_config - vars_, excludes = load_agent_config(tmp_path, "nonexistent") - assert vars_ == {} + file_types, excludes = load_agent_config(tmp_path, "nonexistent") + assert file_types == [] assert excludes == [] @@ -150,7 +199,7 @@ def test_file_exists_pass(self, tmp_path: Path) -> None: tmp_path, "file-exists", "id: CORE:S:0001\nslug: file-exists\ntitle: File Exists\n" - "category: structure\ntype: mechanical\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "category: structure\ntype: mechanical\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.S.0001.file-exists\n type: mechanical\n" " severity: medium\n name: file-exists\n check: file_exists", ) @@ -164,16 +213,15 @@ def test_file_exists_pass(self, tmp_path: Path) -> None: title="File Exists", category="structure", rule_type="mechanical", - level="L1", - targets="{{instruction_files}}", + match={}, checks=[ {"id": "CORE.S.0001.file-exists", "type": "mechanical", "severity": "medium", "check": "file_exists"} ], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"instruction_files": ["**/*.md"]}) + result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED def test_not_implemented(self, tmp_path: Path) -> None: @@ -191,14 +239,13 @@ def test_not_implemented(self, tmp_path: Path) -> None: title="Empty", category="structure", rule_type="mechanical", - level="L1", - targets="", + match={}, checks=[], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {}) + result = run_rule(info, []) assert result.status == HarnessStatus.NOT_IMPLEMENTED def test_no_fixtures(self, tmp_path: Path) -> None: @@ -218,14 +265,13 @@ def test_no_fixtures(self, tmp_path: Path) -> None: title="No Fix", category="structure", rule_type="mechanical", - level="L1", - targets="", + match={}, checks=[{"id": "CORE.S.0098.check", "type": "mechanical", "severity": "medium", "check": "file_exists"}], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {}) + result = run_rule(info, []) assert result.status == HarnessStatus.NO_FIXTURES @@ -242,12 +288,12 @@ def test_pass_fixture_no_violation(self, tmp_path: Path) -> None: tmp_path, "det-rule", "id: CORE:S:0003\nslug: det-rule\ntitle: Det Rule\n" - "category: structure\ntype: deterministic\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "category: structure\ntype: deterministic\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.S.0003.check\n type: deterministic\n severity: medium\n name: wall-of-prose", ) - _make_rule_yml( + _make_checks_yml( rule_dir, - "rules:\n" + "checks:\n" "- id: CORE.S.0003.check\n" " message: Wall of prose\n" " severity: WARNING\n" @@ -269,33 +315,38 @@ def test_pass_fixture_no_violation(self, tmp_path: Path) -> None: title="Det Rule", category="structure", rule_type="deterministic", - level="L1", - targets="{{instruction_files}}", + match={}, checks=[ - {"id": "CORE.S.0003.check", "type": "deterministic", "severity": "medium", "name": "wall-of-prose"} + { + "id": "CORE.S.0003.check", + "type": "deterministic", + "severity": "medium", + "name": "wall-of-prose", + "expect": "absent", + } ], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"instruction_files": ["**/*.md"]}) + result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED - def test_negated_deterministic(self, tmp_path: Path) -> None: - """Negated deterministic: finding = pass, no finding = violation.""" + def test_expect_present_deterministic(self, tmp_path: Path) -> None: + """expect=present deterministic: finding = pass, no finding = violation.""" from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, - "neg-rule", - "id: CORE:C:0001\nslug: neg-rule\ntitle: Neg Rule\n" - "category: content\ntype: deterministic\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "evidence-rule", + "id: CORE:C:0001\nslug: evidence-rule\ntitle: Evidence Rule\n" + "category: content\ntype: deterministic\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.C.0001.check\n type: deterministic\n" - " severity: medium\n negate: true\n name: has-context", + " severity: medium\n expect: present\n name: has-context", ) - _make_rule_yml( + _make_checks_yml( rule_dir, - "rules:\n" + "checks:\n" "- id: CORE.C.0001.check\n" " message: Has project context\n" " severity: WARNING\n" @@ -303,8 +354,8 @@ def test_negated_deterministic(self, tmp_path: Path) -> None: " paths:\n include: ['**/*.md']\n" " pattern-regex: '## (Commands|Architecture)'\n", ) - # Pass: heading present (finding exists, negate → pass) - # Fail: heading absent (no finding, negate → violation) + # Pass: heading present (evidence found → pass) + # Fail: heading absent (no evidence → violation) _make_fixtures( rule_dir, "# Project\n\n## Commands\n\n- Build: `make`\n", @@ -313,26 +364,25 @@ def test_negated_deterministic(self, tmp_path: Path) -> None: info = RuleInfo( rule_id="CORE:C:0001", - slug="neg-rule", - title="Neg Rule", - category="content", + slug="evidence-rule", + title="Evidence Rule", + category="coherence", rule_type="deterministic", - level="L1", - targets="{{instruction_files}}", + match={}, checks=[ { "id": "CORE.C.0001.check", "type": "deterministic", "severity": "medium", - "negate": True, + "expect": "present", "name": "has-context", } ], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"instruction_files": ["**/*.md"]}) + result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED @@ -349,7 +399,7 @@ def test_semantic_always_passes(self, tmp_path: Path) -> None: tmp_path, "sem-rule", "id: CORE:C:0005\nslug: sem-rule\ntitle: Semantic\n" - "category: content\ntype: semantic\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "category: content\ntype: semantic\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.C.0005.sem\n type: semantic\n severity: medium\n name: sem-eval", ) _make_fixtures(rule_dir, "# Good content\n", "# Bad content\n") @@ -358,16 +408,15 @@ def test_semantic_always_passes(self, tmp_path: Path) -> None: rule_id="CORE:C:0005", slug="sem-rule", title="Semantic", - category="content", + category="coherence", rule_type="semantic", - level="L1", - targets="{{instruction_files}}", + match={}, checks=[{"id": "CORE.C.0005.sem", "type": "semantic", "severity": "medium", "name": "sem-eval"}], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"instruction_files": ["**/*.md"]}) + result = run_rule(info, _fts_claude()) # Semantic-only rules have no M/D checks to detect violations in the # fail fixture, so the harness reports FAILED because the fail fixture # produced no violations (a harness failure — the fail case should be @@ -389,7 +438,7 @@ def test_runs_all_discovered_rules(self, tmp_path: Path) -> None: tmp_path, "batch-rule", "id: CORE:S:0001\nslug: batch-rule\ntitle: Batch Rule\n" - "category: structure\ntype: mechanical\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "category: structure\ntype: mechanical\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.S.0001.check\n type: mechanical\n severity: medium\n check: file_exists", ) _make_fixtures(rule_dir, "# Test\n", None) @@ -409,20 +458,20 @@ def test_git_marker_detected(self, tmp_path: Path) -> None: from reporails_cli.core.mechanical.checks import git_tracked (tmp_path / ".git_marker").touch() - result = git_tracked(tmp_path, {}, {}) + result = git_tracked(tmp_path, {}, []) assert result.passed def test_git_dir_detected(self, tmp_path: Path) -> None: from reporails_cli.core.mechanical.checks import git_tracked (tmp_path / ".git").mkdir() - result = git_tracked(tmp_path, {}, {}) + result = git_tracked(tmp_path, {}, []) assert result.passed def test_no_git_fails(self, tmp_path: Path) -> None: from reporails_cli.core.mechanical.checks import git_tracked - result = git_tracked(tmp_path, {}, {}) + result = git_tracked(tmp_path, {}, []) assert not result.passed @@ -440,12 +489,12 @@ def test_json_fixture_discovered_in_deterministic_check(self, tmp_path: Path) -> tmp_path, "json-rule", "id: CORE:S:0099\nslug: json-rule\ntitle: JSON Rule\n" - "category: structure\ntype: deterministic\nlevel: L1\ntargets: '{{settings_file}}'\n" + "category: structure\ntype: deterministic\nlevel: L1\nmatch: {type: config}\n" "checks:\n- id: CORE.S.0099.check\n type: deterministic\n severity: medium\n name: json-check", ) - _make_rule_yml( + _make_checks_yml( rule_dir, - "rules:\n" + "checks:\n" "- id: CORE.S.0099.check\n" " message: Bad setting\n" " severity: WARNING\n" @@ -468,14 +517,14 @@ def test_json_fixture_discovered_in_deterministic_check(self, tmp_path: Path) -> title="JSON Rule", category="structure", rule_type="deterministic", - level="L1", - targets="{{settings_file}}", - checks=[{"id": "CORE.S.0099.check", "type": "deterministic", "severity": "medium"}], + match={"type": "config"}, + checks=[{"id": "CORE.S.0099.check", "type": "deterministic", "severity": "medium", "expect": "absent"}], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"settings_file": ".claude/settings.json"}) + file_types = [*_fts_claude(), _ft("config", (".claude/settings.json",))] + result = run_rule(info, file_types) assert result.status == HarnessStatus.PASSED @@ -490,10 +539,11 @@ def test_falls_back_to_name_field(self, tmp_path: Path) -> None: from reporails_cli.core.harness import _run_mechanical_check (tmp_path / "CLAUDE.md").write_text("# Test\n") + classified = [ClassifiedFile(path=tmp_path / "CLAUDE.md", file_type="main", properties={})] result = _run_mechanical_check( {"name": "file_exists", "args": {"path": "**/*.md"}}, tmp_path, - {"instruction_files": ["**/*.md"]}, + classified, ) assert result.passed @@ -502,10 +552,11 @@ def test_check_key_takes_precedence(self, tmp_path: Path) -> None: from reporails_cli.core.harness import _run_mechanical_check (tmp_path / "CLAUDE.md").write_text("# Test\n") + classified = [ClassifiedFile(path=tmp_path / "CLAUDE.md", file_type="main", properties={})] result = _run_mechanical_check( {"check": "file_exists", "name": "something_else", "args": {"path": "**/*.md"}}, tmp_path, - {"instruction_files": ["**/*.md"]}, + classified, ) assert result.passed @@ -537,12 +588,16 @@ def test_total_size_check_alias(self) -> None: def _make_multi_agent_config(tmp_path: Path, agent: str, prefix: str, instruction_pattern: str) -> None: """Create an agent config with a specific prefix and instruction pattern.""" - config_dir = tmp_path / "agents" / agent + config_dir = tmp_path / agent config_dir.mkdir(parents=True, exist_ok=True) (config_dir / "config.yml").write_text( - f"agent: {agent}\nprefix: {prefix}\nvars:\n" - f' instruction_files:\n - "{instruction_pattern}"\n' - f' main_instruction_file:\n - "{instruction_pattern}"\n' + f"agent: {agent}\nprefix: {prefix}\n" + f"file_types:\n" + f" main:\n" + f' patterns: ["{instruction_pattern}"]\n' + f" required: true\n" + f" properties:\n" + f" format: freeform\n" ) @@ -561,9 +616,9 @@ def test_builds_map_from_configs(self, tmp_path: Path) -> None: def test_skips_agent_without_prefix(self, tmp_path: Path) -> None: from reporails_cli.core.harness import _build_prefix_to_agent_map - config_dir = tmp_path / "agents" / "generic" + config_dir = tmp_path / "generic" config_dir.mkdir(parents=True) - (config_dir / "config.yml").write_text("agent: generic\nvars:\n instruction_files:\n - '**/*.md'\n") + (config_dir / "config.yml").write_text("agent: generic\nfile_types:\n main:\n patterns: ['**/*.md']\n") mapping = _build_prefix_to_agent_map(tmp_path) assert mapping == {} @@ -604,7 +659,7 @@ def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: # Set up claude agent _make_multi_agent_config(tmp_path, "claude", "CLAUDE", "**/CLAUDE.md") - claude_rule_dir = tmp_path / "agents" / "claude" / "rules" / "claude-rule" + claude_rule_dir = tmp_path / "claude" / "claude-rule" claude_rule_dir.mkdir(parents=True) (claude_rule_dir / "rule.md").write_text( "---\nid: CLAUDE:S:0001\nslug: claude-rule\ntitle: Claude Rule\n" @@ -617,7 +672,7 @@ def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: # Set up codex agent _make_multi_agent_config(tmp_path, "codex", "CODEX", "**/AGENTS.md") - codex_rule_dir = tmp_path / "agents" / "codex" / "rules" / "codex-rule" + codex_rule_dir = tmp_path / "codex" / "codex-rule" codex_rule_dir.mkdir(parents=True) (codex_rule_dir / "rule.md").write_text( "---\nid: CODEX:S:0001\nslug: codex-rule\ntitle: Codex Rule\n" @@ -666,8 +721,8 @@ def test_scaffolds_file_for_file_exists(self, tmp_path: Path) -> None: fixture_dir.mkdir() (fixture_dir / "CLAUDE.md").write_text("# Existing\n") - checks = [{"type": "mechanical", "check": "file_exists", "args": {"path": "{{skills_dir}}/**/*.md"}}] - result = _scaffold_fixture(fixture_dir, checks, {"skills_dir": ".claude/skills"}) + checks = [{"type": "mechanical", "check": "file_exists", "args": {"path": ".claude/skills/**/*.md"}}] + result = _scaffold_fixture(fixture_dir, checks, _fts_with_skills()) assert result is not None assert (result / ".claude" / "skills" / "scaffold.md").exists() @@ -685,7 +740,7 @@ def test_scaffolds_git_marker(self, tmp_path: Path) -> None: fixture_dir.mkdir() checks = [{"type": "mechanical", "check": "git_tracked"}] - result = _scaffold_fixture(fixture_dir, checks, {}) + result = _scaffold_fixture(fixture_dir, checks, []) assert result is not None assert (result / ".git_marker").exists() @@ -699,8 +754,8 @@ def test_scaffolds_directory(self, tmp_path: Path) -> None: fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() - checks = [{"type": "mechanical", "check": "directory_exists", "args": {"path": "{{memory_dir}}"}}] - result = _scaffold_fixture(fixture_dir, checks, {"memory_dir": ".claude/memory"}) + checks = [{"type": "mechanical", "check": "directory_exists", "args": {"path": ".claude/memory"}}] + result = _scaffold_fixture(fixture_dir, checks, []) assert result is not None assert (result / ".claude" / "memory").is_dir() @@ -715,7 +770,7 @@ def test_no_scaffold_for_deterministic_only(self, tmp_path: Path) -> None: fixture_dir.mkdir() checks = [{"type": "deterministic", "id": "test", "severity": "medium"}] - result = _scaffold_fixture(fixture_dir, checks, {}) + result = _scaffold_fixture(fixture_dir, checks, []) assert result is None def test_no_scaffold_for_semantic_only(self, tmp_path: Path) -> None: @@ -725,7 +780,7 @@ def test_no_scaffold_for_semantic_only(self, tmp_path: Path) -> None: fixture_dir.mkdir() checks = [{"type": "semantic", "id": "test"}] - result = _scaffold_fixture(fixture_dir, checks, {}) + result = _scaffold_fixture(fixture_dir, checks, []) assert result is None def test_scaffolds_file_removal_for_file_absent(self, tmp_path: Path) -> None: @@ -737,7 +792,7 @@ def test_scaffolds_file_removal_for_file_absent(self, tmp_path: Path) -> None: (fixture_dir / "README.md").write_text("# README") checks = [{"type": "mechanical", "check": "file_absent", "args": {"pattern": "README.md"}}] - result = _scaffold_fixture(fixture_dir, checks, {}) + result = _scaffold_fixture(fixture_dir, checks, []) assert result is not None assert not (result / "README.md").exists() @@ -766,7 +821,7 @@ def test_filename_mismatch_renames_file(self, tmp_path: Path) -> None: "args": {"pattern": r"(?i)^(CLAUDE|AGENTS)\.md$", "path": "**/*.md"}, } ] - result = _scaffold_fail_fixture(fixture_dir, checks, {"instruction_files": ["**/*.md"]}) + result = _scaffold_fail_fixture(fixture_dir, checks, _fts_claude()) assert result is not None # Original file should be renamed to invalid name (preserving extension) @@ -784,7 +839,7 @@ def test_glob_count_deficit_reduces_files(self, tmp_path: Path) -> None: (fixture_dir / "b.md").write_text("b") checks = [{"type": "mechanical", "check": "glob_count", "args": {"pattern": "**/*.md", "min": 2}}] - result = _scaffold_fail_fixture(fixture_dir, checks, {}) + result = _scaffold_fail_fixture(fixture_dir, checks, []) assert result is not None md_files = list(result.glob("**/*.md")) @@ -800,7 +855,7 @@ def test_file_present_creates_forbidden_file(self, tmp_path: Path) -> None: fixture_dir.mkdir() checks = [{"type": "mechanical", "check": "file_absent", "args": {"pattern": "README.md"}}] - result = _scaffold_fail_fixture(fixture_dir, checks, {}) + result = _scaffold_fail_fixture(fixture_dir, checks, []) assert result is not None assert (result / "README.md").exists() @@ -815,7 +870,7 @@ def test_no_scaffold_for_unsupported_checks(self, tmp_path: Path) -> None: fixture_dir.mkdir() checks = [{"type": "mechanical", "check": "file_exists", "args": {"path": "**/*.md"}}] - result = _scaffold_fail_fixture(fixture_dir, checks, {}) + result = _scaffold_fail_fixture(fixture_dir, checks, []) assert result is None @@ -829,7 +884,7 @@ def test_filename_matches_pattern_with_scaffold(self, tmp_path: Path) -> None: tmp_path, "fname-rule", "id: CORE:S:0004\nslug: fname-rule\ntitle: Filename Rule\n" - "category: structure\ntype: mechanical\nlevel: L1\ntargets: '{{instruction_files}}'\n" + "category: structure\ntype: mechanical\nlevel: L1\nmatch: {}\n" "checks:\n- id: CORE.S.0004.fname\n type: mechanical\n severity: medium\n" " check: filename_matches_pattern\n args:\n pattern: '(?i)^(CLAUDE|AGENTS)\\.md$'", ) @@ -848,8 +903,7 @@ def test_filename_matches_pattern_with_scaffold(self, tmp_path: Path) -> None: title="Filename Rule", category="structure", rule_type="mechanical", - level="L1", - targets="{{instruction_files}}", + match={}, checks=[ { "id": "CORE.S.0004.fname", @@ -860,10 +914,10 @@ def test_filename_matches_pattern_with_scaffold(self, tmp_path: Path) -> None: } ], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {"instruction_files": ["**/*.md"]}) + result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED def test_file_absent_with_scaffold(self, tmp_path: Path) -> None: @@ -892,8 +946,7 @@ def test_file_absent_with_scaffold(self, tmp_path: Path) -> None: title="Absent Rule", category="structure", rule_type="mechanical", - level="L1", - targets="", + match={}, checks=[ { "id": "CORE.S.0005.absent", @@ -904,8 +957,8 @@ def test_file_absent_with_scaffold(self, tmp_path: Path) -> None: } ], rule_dir=rule_dir, - rule_yml=rule_dir / "rule.yml", + checks_yml=rule_dir / "checks.yml", ) - result = run_rule(info, {}) + result = run_rule(info, []) assert result.status == HarnessStatus.PASSED diff --git a/tests/unit/test_heal.py b/tests/unit/test_heal.py deleted file mode 100644 index 26eaf342..00000000 --- a/tests/unit/test_heal.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Unit tests for heal command formatting and verdict caching. - -Tests cover: -- format_heal_summary rendering (autoheal output) -- cache_verdict integration with cache_judgments -- cache_violation_dismissal -- extract_violation_snippet -- Non-interactive JSON output (format_heal_result) -""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.cache import ( - ProjectCache, - cache_verdict, - cache_violation_dismissal, - content_hash, - structural_hash, -) -from reporails_cli.core.fixers import FixResult -from reporails_cli.core.models import JudgmentRequest, Severity, Violation -from reporails_cli.formatters.text.heal import extract_violation_snippet, format_heal_summary - - -def _make_request( - rule_id: str = "CORE:C:0019", - rule_title: str = "Navigability Aid", - location: str = "CLAUDE.md:1", - question: str = "Does this file include navigation aids?", - severity: Severity = Severity.MEDIUM, -) -> JudgmentRequest: - return JudgmentRequest( - rule_id=rule_id, - rule_title=rule_title, - content="# My Project\n\nSome content here.\n\n## Section 1\n\nMore content.", - location=location, - question=question, - criteria={"toc": "Has table of contents", "headers": "Section headers provide navigation"}, - examples={"good": ["# Project\n## TOC"], "bad": ["wall of text"]}, - choices=["pass", "fail"], - pass_value="pass", - severity=severity, - points_if_fail=-10, - ) - - -# --------------------------------------------------------------------------- -# Violation helpers -# --------------------------------------------------------------------------- - - -def _make_violation( - rule_id: str = "CORE:S:0003", - rule_title: str = "Wall of Prose", - location: str = "CLAUDE.md:17", - message: str = "Paragraph exceeds 5 lines without structure", - severity: Severity = Severity.MEDIUM, -) -> Violation: - return Violation( - rule_id=rule_id, - rule_title=rule_title, - location=location, - message=message, - severity=severity, - ) - - -# --------------------------------------------------------------------------- -# format_heal_summary -# --------------------------------------------------------------------------- - - -class TestFormatHealSummary: - def test_applied_fixes_shown(self) -> None: - fixes = [ - FixResult( - rule_id="CORE:C:0003", file_path="CLAUDE.md", description="Added ## Commands section to CLAUDE.md" - ), - ] - summary = format_heal_summary(fixes, [], []) - assert "Applied 1 fix(es):" in summary - assert "CORE:C:0003" in summary - assert "Added ## Commands section" in summary - - def test_remaining_violations_shown(self) -> None: - violations = [_make_violation()] - summary = format_heal_summary([], violations, []) - assert "1 remaining violation(s):" in summary - assert "CORE:S:0003" in summary - assert "CLAUDE.md:17" in summary - - def test_pending_semantic_shown(self) -> None: - requests = [_make_request()] - summary = format_heal_summary([], [], requests) - assert "1 semantic rule(s) pending evaluation:" in summary - assert "CORE:C:0019" in summary - assert "Navigability Aid" in summary - - def test_nothing_to_heal(self) -> None: - summary = format_heal_summary([], [], []) - assert "Nothing to heal" in summary - - def test_all_sections(self) -> None: - fixes = [FixResult(rule_id="CORE:C:0003", file_path="CLAUDE.md", description="Added ## Commands")] - violations = [_make_violation()] - requests = [_make_request()] - summary = format_heal_summary(fixes, violations, requests) - assert "Applied 1 fix(es):" in summary - assert "1 remaining violation(s):" in summary - assert "1 semantic rule(s) pending evaluation:" in summary - - def test_ascii_mode_uses_plus(self) -> None: - fixes = [FixResult(rule_id="CORE:C:0003", file_path="CLAUDE.md", description="Added ## Commands")] - summary = format_heal_summary(fixes, [], [], ascii_mode=True) - assert "+" in summary - assert "\u2713" not in summary # No Unicode checkmark - - -# --------------------------------------------------------------------------- -# cache_verdict -# --------------------------------------------------------------------------- - - -class TestCacheVerdict: - def test_caches_pass_verdict(self, tmp_path: Path) -> None: - """Passing verdict is cached correctly.""" - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent here.\n") - - jr = _make_request(location=f"{md}:1") - cache_verdict(tmp_path, jr, "pass", "Looks good") - - cache = ProjectCache(tmp_path) - file_hash = content_hash(md) - struct_hash = structural_hash(md) - cached = cache.get_cached_judgment("CLAUDE.md", file_hash, structural_hash=struct_hash) - assert cached is not None - assert "CORE:C:0019" in cached - assert cached["CORE:C:0019"]["verdict"] == "pass" - - def test_caches_fail_verdict(self, tmp_path: Path) -> None: - """Failing verdict with reason is cached correctly.""" - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent here.\n") - - jr = _make_request(location=f"{md}:1") - cache_verdict(tmp_path, jr, "fail", "Missing TOC") - - cache = ProjectCache(tmp_path) - file_hash = content_hash(md) - struct_hash = structural_hash(md) - cached = cache.get_cached_judgment("CLAUDE.md", file_hash, structural_hash=struct_hash) - assert cached is not None - assert cached["CORE:C:0019"]["verdict"] == "fail" - assert cached["CORE:C:0019"]["reason"] == "Missing TOC" - - def test_dismiss_caches_as_pass(self, tmp_path: Path) -> None: - """Dismiss action caches as pass verdict.""" - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent.\n") - - jr = _make_request(location=f"{md}:1") - cache_verdict(tmp_path, jr, "pass", "Dismissed via ails heal") - - cache = ProjectCache(tmp_path) - file_hash = content_hash(md) - cached = cache.get_cached_judgment("CLAUDE.md", file_hash) - assert cached is not None - assert cached["CORE:C:0019"]["verdict"] == "pass" - assert "Dismissed" in cached["CORE:C:0019"]["reason"] - - -# --------------------------------------------------------------------------- -# Non-interactive mode (JSON output) -# --------------------------------------------------------------------------- - - -class TestNonInteractiveMode: - """Test the JSON formatter for heal command.""" - - def test_json_format_with_no_fixes(self) -> None: - """JSON output is valid when no fixes are needed.""" - from reporails_cli.formatters.json import format_heal_result - - result = format_heal_result([], []) - assert result["auto_fixed"] == [] - assert result["judgment_requests"] == [] - assert result["summary"]["auto_fixed_count"] == 0 - assert result["summary"]["pending_judgments"] == 0 - - def test_json_format_with_auto_fixes(self) -> None: - """JSON output includes auto-fix details.""" - from reporails_cli.formatters.json import format_heal_result - - auto_fixed = [ - { - "rule_id": "CORE:S:0001", - "file_path": "CLAUDE.md", - "description": "Added root instruction file", - } - ] - result = format_heal_result(auto_fixed, []) - assert len(result["auto_fixed"]) == 1 - assert result["auto_fixed"][0]["rule_id"] == "CORE:S:0001" - assert result["summary"]["auto_fixed_count"] == 1 - - def test_json_format_with_judgment_requests(self) -> None: - """JSON output includes judgment requests.""" - from reporails_cli.formatters.json import format_heal_result - - judgment_requests = [ - { - "rule_id": "CORE:C:0019", - "rule_title": "Navigability Aid", - "question": "Does this file include navigation aids?", - "content": "# Project", - "location": "CLAUDE.md:1", - "criteria": {"toc": "Has TOC"}, - "examples": {"good": [], "bad": []}, - "choices": ["pass", "fail"], - "pass_value": "pass", - } - ] - result = format_heal_result([], judgment_requests) - assert len(result["judgment_requests"]) == 1 - assert result["judgment_requests"][0]["rule_id"] == "CORE:C:0019" - assert result["summary"]["pending_judgments"] == 1 - - def test_json_format_with_both(self) -> None: - """JSON output handles both auto-fixes and judgment requests.""" - from reporails_cli.formatters.json import format_heal_result - - auto_fixed = [{"rule_id": "CORE:S:0001", "file_path": "CLAUDE.md", "description": "Added file"}] - judgment_requests = [ - { - "rule_id": "CORE:C:0019", - "rule_title": "Nav", - "question": "Good?", - "content": "# P", - "location": "CLAUDE.md:1", - "criteria": {}, - "examples": {"good": [], "bad": []}, - "choices": ["pass", "fail"], - "pass_value": "pass", - } - ] - result = format_heal_result(auto_fixed, judgment_requests) - assert result["summary"]["auto_fixed_count"] == 1 - assert result["summary"]["pending_judgments"] == 1 - - def test_json_format_with_violations(self) -> None: - """JSON output includes non-fixable violations.""" - from reporails_cli.formatters.json import format_heal_result - - violations = [ - { - "rule_id": "CORE:S:0003", - "rule_title": "Wall of Prose", - "location": "CLAUDE.md:15", - "message": "Paragraph exceeds 5 lines", - "severity": "medium", - } - ] - result = format_heal_result([], [], violations=violations) - assert "violations" in result - assert len(result["violations"]) == 1 - assert result["summary"]["violations_count"] == 1 - - -# --------------------------------------------------------------------------- -# extract_violation_snippet -# --------------------------------------------------------------------------- - - -class TestExtractViolationSnippet: - def test_returns_snippet_with_marker(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("line 1\nline 2\nline 3\nline 4\nline 5\n") - snippet = extract_violation_snippet(f"{md}:3", tmp_path) - assert snippet is not None - assert ">>" in snippet - assert "line 3" in snippet - # Context lines should be present - assert "line 1" in snippet - assert "line 5" in snippet - - def test_returns_none_for_no_line_number(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("some content\n") - snippet = extract_violation_snippet(str(md), tmp_path) - assert snippet is None - - def test_returns_none_for_missing_file(self, tmp_path: Path) -> None: - snippet = extract_violation_snippet(f"{tmp_path}/missing.md:5", tmp_path) - assert snippet is None - - def test_relative_path(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("a\nb\nc\nd\ne\n") - snippet = extract_violation_snippet("CLAUDE.md:3", tmp_path) - assert snippet is not None - assert ">>" in snippet - - def test_target_line_at_boundary(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("first\nsecond\n") - snippet = extract_violation_snippet(f"{md}:1", tmp_path) - assert snippet is not None - assert ">> " in snippet - assert "first" in snippet - - -# --------------------------------------------------------------------------- -# cache_violation_dismissal -# --------------------------------------------------------------------------- - - -class TestCacheViolationDismissal: - def test_caches_as_pass(self, tmp_path: Path) -> None: - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent here.\n") - - v = _make_violation(location=f"{md}:17") - cache_violation_dismissal(tmp_path, v) - - cache = ProjectCache(tmp_path) - file_hash = content_hash(md) - struct_hash = structural_hash(md) - cached = cache.get_cached_judgment("CLAUDE.md", file_hash, structural_hash=struct_hash) - assert cached is not None - assert "CORE:S:0003" in cached - assert cached["CORE:S:0003"]["verdict"] == "pass" - assert "Dismissed" in cached["CORE:S:0003"]["reason"] - - def test_dismissed_violation_filtered_by_engine(self, tmp_path: Path) -> None: - """Dismissed violations are filtered out by _filter_dismissed_violations.""" - from reporails_cli.core.engine_helpers import _filter_dismissed_violations - - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent here.\n") - - v = _make_violation(location="CLAUDE.md:17") - cache_violation_dismissal(tmp_path, v) - - # Now filter — should remove the dismissed violation - result = _filter_dismissed_violations([v], tmp_path, tmp_path, use_cache=True) - assert len(result) == 0 - - def test_refresh_bypasses_dismissal(self, tmp_path: Path) -> None: - """--refresh (use_cache=False) ignores dismissals.""" - from reporails_cli.core.engine_helpers import _filter_dismissed_violations - - md = tmp_path / "CLAUDE.md" - md.write_text("# Title\n\nContent here.\n") - - v = _make_violation(location="CLAUDE.md:17") - cache_violation_dismissal(tmp_path, v) - - # With use_cache=False, dismissal is ignored - result = _filter_dismissed_violations([v], tmp_path, tmp_path, use_cache=False) - assert len(result) == 1 diff --git a/tests/unit/test_mcp_install.py b/tests/unit/test_mcp_install.py index 14abb7ae..492aa03a 100644 --- a/tests/unit/test_mcp_install.py +++ b/tests/unit/test_mcp_install.py @@ -45,12 +45,10 @@ def test_detect_multiple_agents(tmp_path: Path) -> None: def test_skips_unsupported_agents(tmp_path: Path) -> None: - """Aider and generic agents have no MCP config → skipped.""" - (tmp_path / "CONVENTIONS.md").write_text("# Conventions\n") - (tmp_path / ".aider.conf.yml").write_text("model: gpt-4\n") + """Generic agent has no MCP config → skipped.""" + (tmp_path / "AGENTS.md").write_text("# Agents\n") targets = detect_mcp_targets(tmp_path) agent_ids = {t[0] for t in targets} - assert "aider" not in agent_ids assert "generic" not in agent_ids diff --git a/tests/unit/test_mechanical.py b/tests/unit/test_mechanical.py index 8adb482b..fc482774 100644 --- a/tests/unit/test_mechanical.py +++ b/tests/unit/test_mechanical.py @@ -23,60 +23,64 @@ filename_matches_pattern, ) from reporails_cli.core.mechanical.runner import ( - _matches_any_pattern, - bind_instruction_files, resolve_location, run_mechanical_checks, ) -from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity +from reporails_cli.core.models import Category, Check, ClassifiedFile, FileMatch, Rule, RuleType, Severity -def _vars(instruction_files: list[str] | None = None) -> dict[str, str | list[str]]: - return {"instruction_files": instruction_files or ["**/CLAUDE.md"]} +def _cf(root: Path, *rel_paths: str, file_type: str = "main") -> list[ClassifiedFile]: + """Create ClassifiedFile list from relative paths.""" + return [ClassifiedFile(path=root / p, file_type=file_type) for p in rel_paths] + + +def _cf_mixed(root: Path, *specs: tuple[str, str]) -> list[ClassifiedFile]: + """Create ClassifiedFile list from (rel_path, file_type) tuples.""" + return [ClassifiedFile(path=root / p, file_type=ft) for p, ft in specs] class TestFileExists: def test_file_found(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = file_exists(tmp_path, {}, _vars()) + result = file_exists(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_file_not_found(self, tmp_path: Path) -> None: - result = file_exists(tmp_path, {}, _vars()) + result = file_exists(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed class TestDirectoryExists: def test_exists(self, tmp_path: Path) -> None: (tmp_path / ".claude" / "rules").mkdir(parents=True) - result = directory_exists(tmp_path, {"path": ".claude/rules"}, {}) + result = directory_exists(tmp_path, {"path": ".claude/rules"}, []) assert result.passed def test_missing(self, tmp_path: Path) -> None: - result = directory_exists(tmp_path, {"path": ".claude/rules"}, {}) + result = directory_exists(tmp_path, {"path": ".claude/rules"}, []) assert not result.passed class TestGitTracked: def test_git_dir_present(self, tmp_path: Path) -> None: (tmp_path / ".git").mkdir() - result = git_tracked(tmp_path, {}, {}) + result = git_tracked(tmp_path, {}, []) assert result.passed def test_no_git(self, tmp_path: Path) -> None: - result = git_tracked(tmp_path, {}, {}) + result = git_tracked(tmp_path, {}, []) assert not result.passed class TestLineCount: def test_within_bounds(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("line1\nline2\nline3\n") - result = line_count(tmp_path, {"max": 10}, _vars()) + result = line_count(tmp_path, {"max": 10}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_exceeds_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("\n".join(f"line{i}" for i in range(50))) - result = line_count(tmp_path, {"max": 10}, _vars()) + result = line_count(tmp_path, {"max": 10}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert result.location == "CLAUDE.md:0" @@ -84,12 +88,12 @@ def test_exceeds_max(self, tmp_path: Path) -> None: class TestByteSize: def test_within_bounds(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("small") - result = byte_size(tmp_path, {"max": 1000}, _vars()) + result = byte_size(tmp_path, {"max": 1000}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_exceeds_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("x" * 1000) - result = byte_size(tmp_path, {"max": 100}, _vars()) + result = byte_size(tmp_path, {"max": 100}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert result.location == "CLAUDE.md:0" @@ -97,17 +101,17 @@ def test_exceeds_max(self, tmp_path: Path) -> None: class TestContentAbsent: def test_pattern_absent(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, _vars()) + result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_pattern_present(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# FORBIDDEN content here") - result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, _vars()) + result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed def test_invalid_regex_returns_failure(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = content_absent(tmp_path, {"pattern": "[invalid"}, _vars()) + result = content_absent(tmp_path, {"pattern": "[invalid"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert "invalid regex" in result.message @@ -121,10 +125,12 @@ def test_pattern_found_in_one_of_two_files(self, tmp_path: Path) -> None: sub = tmp_path / ".claude" / "rules" sub.mkdir(parents=True) (sub / "bad.md").write_text("# Has FORBIDDEN pattern") - vars = {"instruction_files": ["CLAUDE.md", ".claude/rules/bad.md"]} - - result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, vars) - + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/bad.md", "scoped_rule"), + ) + result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, classified) assert not result.passed assert "bad.md" in result.message @@ -134,10 +140,12 @@ def test_pattern_absent_in_all_files(self, tmp_path: Path) -> None: sub = tmp_path / ".claude" / "rules" sub.mkdir(parents=True) (sub / "also_clean.md").write_text("# Also clean") - vars = {"instruction_files": ["CLAUDE.md", ".claude/rules/also_clean.md"]} - - result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, vars) - + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/also_clean.md", "scoped_rule"), + ) + result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, classified) assert result.passed def test_pattern_found_in_all_files(self, tmp_path: Path) -> None: @@ -146,12 +154,13 @@ def test_pattern_found_in_all_files(self, tmp_path: Path) -> None: sub = tmp_path / ".claude" / "rules" sub.mkdir(parents=True) (sub / "also_bad.md").write_text("# Also FORBIDDEN") - vars = {"instruction_files": ["CLAUDE.md", ".claude/rules/also_bad.md"]} - - result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, vars) - + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/also_bad.md", "scoped_rule"), + ) + result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, classified) assert not result.passed - # Short-circuits on first match — reports the first offending file assert "CLAUDE.md" in result.message def test_regex_pattern_across_files(self, tmp_path: Path) -> None: @@ -160,10 +169,12 @@ def test_regex_pattern_across_files(self, tmp_path: Path) -> None: sub = tmp_path / ".claude" / "rules" sub.mkdir(parents=True) (sub / "risky.md").write_text("# Rules\napi_key = sk-12345") - vars = {"instruction_files": ["CLAUDE.md", ".claude/rules/risky.md"]} - - result = content_absent(tmp_path, {"pattern": r"api_key\s*=\s*\S+"}, vars) - + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/risky.md", "scoped_rule"), + ) + result = content_absent(tmp_path, {"pattern": r"api_key\s*=\s*\S+"}, classified) assert not result.passed assert "risky.md" in result.message @@ -171,10 +182,8 @@ def test_empty_files_pass(self, tmp_path: Path) -> None: """Empty instruction files pass content_absent (no content to match).""" (tmp_path / "CLAUDE.md").write_text("") (tmp_path / "AGENTS.md").write_text("") - vars = {"instruction_files": ["CLAUDE.md", "AGENTS.md"]} - - result = content_absent(tmp_path, {"pattern": "anything"}, vars) - + classified = _cf(tmp_path, "CLAUDE.md", "AGENTS.md") + result = content_absent(tmp_path, {"pattern": "anything"}, classified) assert result.passed @@ -187,12 +196,11 @@ def _rule(self, rule_id: str, check_name: str, args: dict | None = None) -> Rule title=f"Rule {rule_id}", category=Category.STRUCTURE, type=RuleType.MECHANICAL, - level="L1", - targets="{{instruction_files}}", + severity=Severity.CRITICAL, + match=FileMatch(), checks=[ Check( id=f"{rule_id}:check:0001", - severity=Severity.CRITICAL, type="mechanical", check=check_name, args=args, @@ -203,14 +211,14 @@ def _rule(self, rule_id: str, check_name: str, args: dict | None = None) -> Rule def test_passing_check_no_violations(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") rules = {"CORE:S:0001": self._rule("CORE:S:0001", "file_exists")} - vars = _vars() - violations = run_mechanical_checks(rules, tmp_path, vars) + classified = _cf(tmp_path, "CLAUDE.md") + violations = run_mechanical_checks(rules, tmp_path, classified) assert len(violations) == 0 def test_failing_check_produces_violation(self, tmp_path: Path) -> None: rules = {"CORE:S:0001": self._rule("CORE:S:0001", "file_exists")} - vars = _vars() - violations = run_mechanical_checks(rules, tmp_path, vars) + classified = _cf(tmp_path, "CLAUDE.md") + violations = run_mechanical_checks(rules, tmp_path, classified) assert len(violations) == 1 assert violations[0].rule_id == "CORE:S:0001" assert violations[0].severity == Severity.CRITICAL @@ -218,7 +226,7 @@ def test_failing_check_produces_violation(self, tmp_path: Path) -> None: def test_unknown_check_skipped(self, tmp_path: Path) -> None: rules = {"CORE:S:0001": self._rule("CORE:S:0001", "nonexistent_check")} - violations = run_mechanical_checks(rules, tmp_path, {}) + violations = run_mechanical_checks(rules, tmp_path, []) assert len(violations) == 0 def test_multiple_rules(self, tmp_path: Path) -> None: @@ -228,7 +236,8 @@ def test_multiple_rules(self, tmp_path: Path) -> None: "CORE:S:0001": self._rule("CORE:S:0001", "file_exists"), "CORE:S:0004": self._rule("CORE:S:0004", "git_tracked"), } - violations = run_mechanical_checks(rules, tmp_path, _vars()) + classified = _cf(tmp_path, "CLAUDE.md") + violations = run_mechanical_checks(rules, tmp_path, classified) assert len(violations) == 1 assert violations[0].rule_id == "CORE:S:0004" @@ -239,11 +248,12 @@ def test_check_location_overrides_rule_location(self, tmp_path: Path) -> None: sub.mkdir(parents=True) (sub / "big.md").write_text("\n".join(f"line{i}" for i in range(50))) rules = {"CORE:S:0005": self._rule("CORE:S:0005", "line_count", {"max": 10})} - vars = { - "instruction_files": ["CLAUDE.md", ".claude/rules/big.md"], - "main_instruction_file": ["CLAUDE.md"], - } - violations = run_mechanical_checks(rules, tmp_path, vars) + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/big.md", "scoped_rule"), + ) + violations = run_mechanical_checks(rules, tmp_path, classified) assert len(violations) == 1 assert violations[0].location == ".claude/rules/big.md:0" @@ -278,204 +288,131 @@ class TestTypeSafetyInChecks: def test_byte_size_string_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("short") - result = byte_size(tmp_path, {"max": "100"}, _vars()) + result = byte_size(tmp_path, {"max": "100"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_byte_size_invalid_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("short") - result = byte_size(tmp_path, {"max": "invalid"}, _vars()) + result = byte_size(tmp_path, {"max": "invalid"}, _cf(tmp_path, "CLAUDE.md")) # invalid → float("inf"), so any file passes assert result.passed def test_line_count_string_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("line1\nline2\n") - result = line_count(tmp_path, {"max": "100"}, _vars()) + result = line_count(tmp_path, {"max": "100"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_line_count_invalid_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("line1\nline2\n") - result = line_count(tmp_path, {"max": "invalid"}, _vars()) + result = line_count(tmp_path, {"max": "invalid"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed -class TestMatchesAnyPattern: - """Tests for _matches_any_pattern glob helper.""" - - def test_exact_match(self) -> None: - assert _matches_any_pattern("CLAUDE.md", ["CLAUDE.md"]) - - def test_double_star_glob(self) -> None: - assert _matches_any_pattern("CLAUDE.md", ["**/CLAUDE.md"]) - - def test_nested_path_matches_double_star(self) -> None: - assert _matches_any_pattern("docs/CLAUDE.md", ["**/CLAUDE.md"]) - - def test_no_match(self) -> None: - assert not _matches_any_pattern(".claude/skills/SKILL.md", ["**/CLAUDE.md"]) - - def test_multiple_patterns(self) -> None: - assert _matches_any_pattern("foo.md", ["*.txt", "*.md"]) - - def test_empty_patterns(self) -> None: - assert not _matches_any_pattern("CLAUDE.md", []) - - -class TestBindInstructionFiles: - """Tests for bind_instruction_files main_instruction_file binding.""" - - def test_binds_main_instruction_file(self, tmp_path: Path) -> None: - files = [tmp_path / "CLAUDE.md", tmp_path / ".claude" / "rules" / "foo.md"] - vars = { - "instruction_files": ["**/CLAUDE.md", "**/.claude/rules/*.md"], - "main_instruction_file": ["**/CLAUDE.md"], - } - result = bind_instruction_files(vars, tmp_path, files) - assert result["instruction_files"] == ["CLAUDE.md", ".claude/rules/foo.md"] - assert result["main_instruction_file"] == ["CLAUDE.md"] - - def test_main_excludes_skill_files(self, tmp_path: Path) -> None: - files = [ - tmp_path / ".claude" / "skills" / "integrations" / "SKILL.md", - tmp_path / "CLAUDE.md", - ] - vars = { - "instruction_files": ["**/CLAUDE.md", "**/.claude/skills/**/*.md"], - "main_instruction_file": ["**/CLAUDE.md"], - } - result = bind_instruction_files(vars, tmp_path, files) - assert result["main_instruction_file"] == ["CLAUDE.md"] - - def test_no_main_pattern_leaves_unchanged(self, tmp_path: Path) -> None: - files = [tmp_path / "CLAUDE.md"] - vars = {"instruction_files": ["**/CLAUDE.md"]} - result = bind_instruction_files(vars, tmp_path, files) - assert "main_instruction_file" not in result - - def test_no_instruction_files_returns_original(self) -> None: - vars = {"instruction_files": ["**/CLAUDE.md"], "main_instruction_file": ["**/CLAUDE.md"]} - result = bind_instruction_files(vars, Path("/tmp"), None) - assert result is vars - - def test_main_pattern_as_string(self, tmp_path: Path) -> None: - files = [tmp_path / "CLAUDE.md"] - vars = { - "instruction_files": ["**/CLAUDE.md"], - "main_instruction_file": "**/CLAUDE.md", - } - result = bind_instruction_files(vars, tmp_path, files) - assert result["main_instruction_file"] == ["CLAUDE.md"] - - def test_no_main_match_keeps_original_patterns(self, tmp_path: Path) -> None: - files = [tmp_path / ".claude" / "rules" / "foo.md"] - vars = { - "instruction_files": ["**/.claude/rules/*.md"], - "main_instruction_file": ["**/CLAUDE.md"], - } - result = bind_instruction_files(vars, tmp_path, files) - # No files matched the main pattern, so it stays as original - assert result["main_instruction_file"] == ["**/CLAUDE.md"] - - class TestResolveLocationMainFile: - """Tests for resolve_location preferring main_instruction_file.""" + """Tests for resolve_location using classified files.""" - def _rule_with_targets(self, targets: str) -> Rule: + def _rule_with_match(self, match: FileMatch | None) -> Rule: return Rule( id="CORE:C:0001", title="Test rule", - category=Category.CONTENT, + category=Category.COHERENCE, type=RuleType.MECHANICAL, - level="L1", - targets=targets, + match=match, checks=[], ) - def test_prefers_main_instruction_file(self, tmp_path: Path) -> None: - rule = self._rule_with_targets("{{instruction_files}}") - vars = { - "instruction_files": [".claude/skills/integrations/SKILL.md", "CLAUDE.md"], - "main_instruction_file": ["CLAUDE.md"], - } - assert resolve_location(tmp_path, rule, vars) == "CLAUDE.md:0" - - def test_prefers_main_for_main_target(self, tmp_path: Path) -> None: - rule = self._rule_with_targets("{{main_instruction_file}}") - vars = { - "instruction_files": [".claude/skills/SKILL.md", "CLAUDE.md"], - "main_instruction_file": ["CLAUDE.md"], - } - assert resolve_location(tmp_path, rule, vars) == "CLAUDE.md:0" + def test_prefers_main_classified_file(self, tmp_path: Path) -> None: + rule = self._rule_with_match(FileMatch()) # match-all + classified = _cf_mixed( + tmp_path, + (".claude/skills/integrations/SKILL.md", "skill"), + ("CLAUDE.md", "main"), + ) + assert resolve_location(rule, classified) == "CLAUDE.md:0" + + def test_prefers_main_for_main_match(self, tmp_path: Path) -> None: + rule = self._rule_with_match(FileMatch(type="main")) + classified = _cf_mixed( + tmp_path, + (".claude/skills/SKILL.md", "skill"), + ("CLAUDE.md", "main"), + ) + assert resolve_location(rule, classified) == "CLAUDE.md:0" def test_falls_back_without_main(self, tmp_path: Path) -> None: - (tmp_path / ".claude" / "skills").mkdir(parents=True) - (tmp_path / ".claude" / "skills" / "SKILL.md").write_text("") - rule = self._rule_with_targets("{{instruction_files}}") - vars = {"instruction_files": [".claude/skills/SKILL.md", "CLAUDE.md"]} - # Falls through to generic resolution, picks first from list - assert resolve_location(tmp_path, rule, vars) == ".claude/skills/SKILL.md:0" - - def test_no_targets_returns_dot(self, tmp_path: Path) -> None: - rule = self._rule_with_targets("") - vars = {"main_instruction_file": ["CLAUDE.md"]} - assert resolve_location(tmp_path, rule, vars) == ".:0" - - def test_non_instruction_target_unchanged(self, tmp_path: Path) -> None: - (tmp_path / ".reporails").mkdir() - (tmp_path / ".reporails" / "config.yml").write_text("") - rule = self._rule_with_targets(".reporails/config.yml") - vars = {"main_instruction_file": ["CLAUDE.md"]} - assert resolve_location(tmp_path, rule, vars) == ".reporails/config.yml:0" + rule = self._rule_with_match(FileMatch()) # match-all + classified = _cf_mixed( + tmp_path, + (".claude/skills/SKILL.md", "skill"), + (".claude/rules/foo.md", "scoped_rule"), + ) + # No main type — falls back to first classified file + assert resolve_location(rule, classified) == "SKILL.md:0" + + def test_no_match_returns_dot(self) -> None: + rule = self._rule_with_match(None) + classified = _cf(Path("/tmp"), "CLAUDE.md") + assert resolve_location(rule, classified) == ".:0" + + def test_config_type_resolves_to_settings(self, tmp_path: Path) -> None: + rule = self._rule_with_match(FileMatch(type="config")) + classified = _cf_mixed( + tmp_path, + (".claude/settings.json", "config"), + ("CLAUDE.md", "main"), + ) + assert resolve_location(rule, classified) == "settings.json:0" # --------------------------------------------------------------------------- -# New probes: count_at_most, count_at_least, check_import_targets_exist, +# count_at_most, count_at_least, check_import_targets_exist, # filename_matches_pattern # --------------------------------------------------------------------------- class TestCountAtMost: def test_within_threshold(self, tmp_path: Path) -> None: - result = count_at_most(tmp_path, {"threshold": 3, "items": ["a", "b"]}, {}) + result = count_at_most(tmp_path, {"threshold": 3, "items": ["a", "b"]}, []) assert result.passed def test_at_threshold(self, tmp_path: Path) -> None: - result = count_at_most(tmp_path, {"threshold": 2, "items": ["a", "b"]}, {}) + result = count_at_most(tmp_path, {"threshold": 2, "items": ["a", "b"]}, []) assert result.passed def test_exceeds_threshold(self, tmp_path: Path) -> None: - result = count_at_most(tmp_path, {"threshold": 1, "items": ["a", "b", "c"]}, {}) + result = count_at_most(tmp_path, {"threshold": 1, "items": ["a", "b", "c"]}, []) assert not result.passed assert "exceeds" in result.message def test_empty_list_passes(self, tmp_path: Path) -> None: - result = count_at_most(tmp_path, {"threshold": 0}, {}) + result = count_at_most(tmp_path, {"threshold": 0}, []) assert result.passed def test_default_threshold_zero(self, tmp_path: Path) -> None: - result = count_at_most(tmp_path, {"items": ["a"]}, {}) + result = count_at_most(tmp_path, {"items": ["a"]}, []) assert not result.passed class TestCountAtLeast: def test_meets_minimum(self, tmp_path: Path) -> None: - result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b", "c"]}, {}) + result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b", "c"]}, []) assert result.passed def test_at_minimum(self, tmp_path: Path) -> None: - result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b"]}, {}) + result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b"]}, []) assert result.passed def test_below_minimum(self, tmp_path: Path) -> None: - result = count_at_least(tmp_path, {"threshold": 3, "items": ["a"]}, {}) + result = count_at_least(tmp_path, {"threshold": 3, "items": ["a"]}, []) assert not result.passed assert "below" in result.message def test_empty_list_fails_default(self, tmp_path: Path) -> None: - result = count_at_least(tmp_path, {}, {}) + result = count_at_least(tmp_path, {}, []) assert not result.passed def test_default_threshold_one(self, tmp_path: Path) -> None: - result = count_at_least(tmp_path, {"items": ["a"]}, {}) + result = count_at_least(tmp_path, {"items": ["a"]}, []) assert result.passed @@ -483,107 +420,104 @@ class TestCheckImportTargetsExist: def test_all_imports_resolve(self, tmp_path: Path) -> None: (tmp_path / "rules.md").write_text("# Rules") (tmp_path / "config.md").write_text("# Config") - result = check_import_targets_exist(tmp_path, {"import_paths": ["@rules.md", "@config.md"]}, {}) + result = check_import_targets_exist(tmp_path, {"import_paths": ["@rules.md", "@config.md"]}, []) assert result.passed def test_missing_import(self, tmp_path: Path) -> None: (tmp_path / "rules.md").write_text("# Rules") - result = check_import_targets_exist(tmp_path, {"import_paths": ["@rules.md", "@missing.md"]}, {}) + result = check_import_targets_exist(tmp_path, {"import_paths": ["@rules.md", "@missing.md"]}, []) assert not result.passed assert "missing.md" in result.message def test_empty_imports_pass(self, tmp_path: Path) -> None: - result = check_import_targets_exist(tmp_path, {}, {}) + result = check_import_targets_exist(tmp_path, {}, []) assert result.passed def test_no_metadata_key_pass(self, tmp_path: Path) -> None: - result = check_import_targets_exist(tmp_path, {"threshold": 5}, {}) + result = check_import_targets_exist(tmp_path, {"threshold": 5}, []) assert result.passed class TestFilenameMatchesPattern: def test_matches(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = filename_matches_pattern(tmp_path, {"pattern": r"^[A-Z]+\.md$"}, _vars()) + result = filename_matches_pattern(tmp_path, {"pattern": r"^[A-Z]+\.md$"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed def test_no_match(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = filename_matches_pattern(tmp_path, {"pattern": r"^[a-z]+\.md$"}, _vars()) + result = filename_matches_pattern(tmp_path, {"pattern": r"^[a-z]+\.md$"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert "does not match" in result.message def test_no_pattern_fails(self, tmp_path: Path) -> None: - result = filename_matches_pattern(tmp_path, {}, _vars()) + result = filename_matches_pattern(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed def test_invalid_regex_fails(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") - result = filename_matches_pattern(tmp_path, {"pattern": "[invalid"}, _vars()) + result = filename_matches_pattern(tmp_path, {"pattern": "[invalid"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert "invalid regex" in result.message class TestFileAbsent: def test_file_not_present_passes(self, tmp_path: Path) -> None: - result = file_absent(tmp_path, {"pattern": "README.md"}, {}) + result = file_absent(tmp_path, {"pattern": "README.md"}, []) assert result.passed def test_file_present_fails(self, tmp_path: Path) -> None: (tmp_path / "README.md").write_text("# README") - result = file_absent(tmp_path, {"pattern": "README.md"}, {}) + result = file_absent(tmp_path, {"pattern": "README.md"}, []) assert not result.passed assert "Forbidden" in result.message def test_glob_pattern_no_match_passes(self, tmp_path: Path) -> None: - result = file_absent(tmp_path, {"pattern": "**/*.lock"}, {}) + result = file_absent(tmp_path, {"pattern": "**/*.lock"}, []) assert result.passed def test_glob_pattern_match_fails(self, tmp_path: Path) -> None: (tmp_path / "package-lock.json").write_text("{}") - result = file_absent(tmp_path, {"pattern": "**/*.json"}, {}) + result = file_absent(tmp_path, {"pattern": "**/*.json"}, []) assert not result.passed def test_no_pattern_fails(self, tmp_path: Path) -> None: - result = file_absent(tmp_path, {}, {}) + result = file_absent(tmp_path, {}, []) assert not result.passed assert "no pattern" in result.message - def test_var_resolution(self, tmp_path: Path) -> None: - (tmp_path / "FORBIDDEN.md").write_text("bad") - result = file_absent(tmp_path, {"pattern": "{{forbidden_file}}"}, {"forbidden_file": "FORBIDDEN.md"}) - assert not result.passed - -class TestTargetScoping: - """Checks respect rule.targets via injected _targets arg.""" +class TestMatchTypeScoping: + """Checks respect rule.match.type via injected _match_type arg.""" def test_filename_matches_pattern_scoped_to_main_file(self, tmp_path: Path) -> None: """Bug fix: CORE:S:0004 — should only check main_instruction_file, not all files.""" (tmp_path / "CLAUDE.md").write_text("# Main") (tmp_path / ".claude" / "rules").mkdir(parents=True) (tmp_path / ".claude" / "rules" / "core-rules.md").write_text("# Rules") - vars = { - "instruction_files": ["CLAUDE.md", ".claude/rules/core-rules.md"], - "main_instruction_file": ["CLAUDE.md"], - } - # With _targets scoping to main_instruction_file, only CLAUDE.md is checked - args = {"pattern": r"(?i)^(CLAUDE|AGENTS)\.md$", "_targets": "{{main_instruction_file}}"} - result = filename_matches_pattern(tmp_path, args, vars) + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/core-rules.md", "scoped_rule"), + ) + # With _match_type scoping to main, only CLAUDE.md is checked + args = {"pattern": r"(?i)^(CLAUDE|AGENTS)\.md$", "_match_type": "main"} + result = filename_matches_pattern(tmp_path, args, classified) assert result.passed def test_filename_matches_pattern_unscoped_leaks(self, tmp_path: Path) -> None: - """Without _targets, filename_matches_pattern falls back to all instruction_files.""" + """Without _match_type, filename_matches_pattern falls back to all classified files.""" (tmp_path / "CLAUDE.md").write_text("# Main") (tmp_path / ".claude" / "rules").mkdir(parents=True) (tmp_path / ".claude" / "rules" / "core-rules.md").write_text("# Rules") - vars = { - "instruction_files": ["CLAUDE.md", ".claude/rules/core-rules.md"], - "main_instruction_file": ["CLAUDE.md"], - } - # Without _targets, falls back to instruction_files — core-rules.md fails the regex + classified = _cf_mixed( + tmp_path, + ("CLAUDE.md", "main"), + (".claude/rules/core-rules.md", "scoped_rule"), + ) + # Without _match_type, falls back to all classified files — core-rules.md fails regex args = {"pattern": r"(?i)^(CLAUDE|AGENTS)\.md$"} - result = filename_matches_pattern(tmp_path, args, vars) + result = filename_matches_pattern(tmp_path, args, classified) assert not result.passed assert "core-rules.md" in result.message @@ -593,9 +527,9 @@ def test_file_absent_scoped_ignores_root_readme(self, tmp_path: Path) -> None: skills = tmp_path / ".claude" / "skills" / "test-skill" skills.mkdir(parents=True) (skills / "SKILL.md").write_text("# Skill") - args = {"pattern": "README.md", "_targets": "{{skills_dir}}/**/*.md"} - vars = {"skills_dir": ".claude/skills"} - result = file_absent(tmp_path, args, vars) + args = {"pattern": "README.md", "_match_type": "skill"} + classified = [ClassifiedFile(path=skills / "SKILL.md", file_type="skill")] + result = file_absent(tmp_path, args, classified) assert result.passed def test_file_absent_scoped_catches_readme_in_skills(self, tmp_path: Path) -> None: @@ -604,27 +538,27 @@ def test_file_absent_scoped_catches_readme_in_skills(self, tmp_path: Path) -> No skills.mkdir(parents=True) (skills / "SKILL.md").write_text("# Skill") (skills / "README.md").write_text("# Bad") - args = {"pattern": "README.md", "_targets": "{{skills_dir}}/**/*.md"} - vars = {"skills_dir": ".claude/skills"} - result = file_absent(tmp_path, args, vars) + args = {"pattern": "README.md", "_match_type": "skill"} + classified = [ClassifiedFile(path=skills / "SKILL.md", file_type="skill")] + result = file_absent(tmp_path, args, classified) assert not result.passed assert "README.md" in result.message def test_file_absent_unscoped_finds_root_readme(self, tmp_path: Path) -> None: - """Without _targets, file_absent searches from project root (original behavior).""" + """Without _match_type, file_absent searches from project root (original behavior).""" (tmp_path / "README.md").write_text("# Project") - result = file_absent(tmp_path, {"pattern": "README.md"}, {}) + result = file_absent(tmp_path, {"pattern": "README.md"}, []) assert not result.passed def test_explicit_path_overrides_targets(self, tmp_path: Path) -> None: - """Explicit args.path takes priority over _targets.""" + """Explicit args.path takes priority over classified files.""" (tmp_path / "CLAUDE.md").write_text("# Main") (tmp_path / "docs").mkdir() (tmp_path / "docs" / "notes.md").write_text("# Notes") - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - # path points to docs/, _targets points to main_instruction_file — path wins - args = {"pattern": r"^CLAUDE\.md$", "path": "docs/**/*.md", "_targets": "{{main_instruction_file}}"} - result = filename_matches_pattern(tmp_path, args, vars) + classified = _cf(tmp_path, "CLAUDE.md") + # path points to docs/ — path arg wins over classified files + args = {"pattern": r"^CLAUDE\.md$", "path": "docs/**/*.md"} + result = filename_matches_pattern(tmp_path, args, classified) assert not result.passed assert "notes.md" in result.message diff --git a/tests/unit/test_merger.py b/tests/unit/test_merger.py new file mode 100644 index 00000000..0fb91567 --- /dev/null +++ b/tests/unit/test_merger.py @@ -0,0 +1,84 @@ +"""Tests for core/merger.py — result merging.""" + +from __future__ import annotations + +import pytest + +from reporails_cli.core.api_client import ( + Diagnostic, + FileAnalysis, + QualityResult, + RulesetReport, +) +from reporails_cli.core.merger import merge_results +from reporails_cli.core.models import LocalFinding + + +@pytest.fixture +def m_findings() -> list[LocalFinding]: + return [ + LocalFinding("CLAUDE.md", 10, "warning", "CORE:S:0005", "Missing section", source="m_probe"), + LocalFinding("CLAUDE.md", 20, "error", "CORE:S:0001", "No root file", source="m_probe"), + ] + + +@pytest.fixture +def client_findings() -> list[LocalFinding]: + return [ + LocalFinding("CLAUDE.md", 30, "warning", "ordering", "Constraint before directive", source="client_check"), + ] + + +class TestMergeResults: + def test_offline_returns_all_local( + self, m_findings: list[LocalFinding], client_findings: list[LocalFinding] + ) -> None: + result = merge_results(m_findings, client_findings, None) + assert result.offline is True + assert result.quality is None + assert len(result.findings) == 3 + assert result.stats.m_probe_count == 2 + assert result.stats.client_check_count == 1 + assert result.stats.server_diagnostic_count == 0 + + def test_empty_inputs(self) -> None: + result = merge_results([], [], None) + assert result.offline is True + assert len(result.findings) == 0 + assert result.stats.total_findings == 0 + + def test_sorting_by_file_severity_line(self, m_findings: list[LocalFinding]) -> None: + result = merge_results(m_findings, [], None) + # error should come before warning (both in same file) + assert result.findings[0].severity == "error" + assert result.findings[1].severity == "warning" + + def test_stats_counted_correctly(self, m_findings: list[LocalFinding], client_findings: list[LocalFinding]) -> None: + result = merge_results(m_findings, client_findings, None) + assert result.stats.errors == 1 + assert result.stats.warnings == 2 + assert result.stats.infos == 0 + assert result.stats.total_findings == 3 + + def test_server_deduplicates_matching_local(self) -> None: + local = [LocalFinding("CLAUDE.md", 10, "warning", "ordering", "local msg", source="client_check")] + server = RulesetReport( + per_file=( + FileAnalysis( + file="CLAUDE.md", + diagnostics=(Diagnostic("CLAUDE.md", 10, "warning", "ordering", "server msg", "fix"),), + ), + ), + quality=QualityResult(compliance_band="MODERATE"), + ) + result = merge_results([], local, server) + assert result.offline is False + # Server version kept, local deduplicated + assert len(result.findings) == 1 + assert result.findings[0].source == "server" + assert result.findings[0].message == "server msg" + + @pytest.mark.parametrize("server_report", [None, RulesetReport()]) + def test_offline_flag(self, server_report: RulesetReport | None) -> None: + result = merge_results([], [], server_report) + assert result.offline == (server_report is None) diff --git a/tests/unit/test_package_levels.py b/tests/unit/test_package_levels.py index 9a13700b..05402480 100644 --- a/tests/unit/test_package_levels.py +++ b/tests/unit/test_package_levels.py @@ -1,61 +1,53 @@ -"""Unit tests for rule applicability and level-based filtering.""" +"""Unit tests for rule applicability with target existence filtering.""" from __future__ import annotations +from dataclasses import replace + from reporails_cli.core.applicability import get_applicable_rules -from reporails_cli.core.models import Category, Level, Rule, RuleType +from reporails_cli.core.models import Category, FileMatch, Rule, RuleType -def _make_rule(rule_id: str, level: str = "L2") -> Rule: +def _make_rule(rule_id: str, match_type: str = "main") -> Rule: """Helper to create a minimal Rule.""" return Rule( id=rule_id, title=f"Rule {rule_id}", category=Category.STRUCTURE, type=RuleType.DETERMINISTIC, - level=level, + match=FileMatch(type=match_type), checks=[], ) -class TestGetApplicableRulesLevelFiltering: - """Test rule-level-based applicability filtering.""" +class TestGetApplicableRulesTargetFiltering: + """Test target-existence-based applicability filtering.""" - def test_rule_at_or_below_level_included(self) -> None: - """A rule at L2 is included when project is at L3.""" - rules = {"CORE:S:0001": _make_rule("CORE:S:0001", "L2")} + def test_rule_included_when_target_present(self) -> None: + """A rule targeting 'main' is included when 'main' is present.""" + rules = {"CORE:S:0001": _make_rule("CORE:S:0001", "main")} - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main"}) assert "CORE:S:0001" in result - def test_rule_above_level_excluded(self) -> None: - """A rule at L4 is excluded when project is at L2.""" - rules = {"CORE:S:0001": _make_rule("CORE:S:0001", "L4")} + def test_rule_excluded_when_target_absent(self) -> None: + """A rule targeting 'config' is excluded when 'config' is not present.""" + rules = {"CORE:S:0001": _make_rule("CORE:S:0001", "config")} - result = get_applicable_rules(rules, Level.L2) + result = get_applicable_rules(rules, {"main"}) assert "CORE:S:0001" not in result - def test_rule_at_exact_level_included(self) -> None: - """A rule at L2 is included when project is at L2.""" - rules = {"CORE:S:0001": _make_rule("CORE:S:0001", "L2")} - - result = get_applicable_rules(rules, Level.L2) - - assert "CORE:S:0001" in result - def test_supersession_drops_superseded_rule(self) -> None: """If rule A supersedes rule B, and both are applicable, B is dropped.""" - from dataclasses import replace - - rule_a = replace(_make_rule("CORE:S:0010", "L3"), supersedes="CORE:S:0001") + rule_a = replace(_make_rule("CORE:S:0010"), supersedes="CORE:S:0001") rules = { - "CORE:S:0001": _make_rule("CORE:S:0001", "L2"), + "CORE:S:0001": _make_rule("CORE:S:0001"), "CORE:S:0010": rule_a, } - result = get_applicable_rules(rules, Level.L3) + result = get_applicable_rules(rules, {"main"}) assert "CORE:S:0001" not in result assert "CORE:S:0010" in result diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py deleted file mode 100644 index 033f9174..00000000 --- a/tests/unit/test_pipeline.py +++ /dev/null @@ -1,1114 +0,0 @@ -"""Unit tests for pipeline state engine.""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity -from reporails_cli.core.pipeline import PipelineState, TargetMeta, build_initial_state -from reporails_cli.core.pipeline_exec import execute_rule_checks -from reporails_cli.core.sarif import distribute_sarif_by_rule - -# --------------------------------------------------------------------------- -# TargetMeta -# --------------------------------------------------------------------------- - - -class TestTargetMeta: - def test_defaults(self) -> None: - meta = TargetMeta(path=Path("CLAUDE.md")) - assert not meta.excluded - assert meta.excluded_by is None - assert meta.annotations == {} - - def test_exclusion(self) -> None: - meta = TargetMeta(path=Path("CLAUDE.md")) - meta.excluded = True - meta.excluded_by = "file_exists" - assert meta.excluded - assert meta.excluded_by == "file_exists" - - def test_annotations(self) -> None: - meta = TargetMeta(path=Path("CLAUDE.md")) - meta.annotations["discovered_imports"] = ["foo", "bar"] - assert meta.annotations["discovered_imports"] == ["foo", "bar"] - - -# --------------------------------------------------------------------------- -# PipelineState -# --------------------------------------------------------------------------- - - -class TestPipelineState: - def test_active_targets_excludes_excluded(self) -> None: - state = PipelineState() - state.targets["a.md"] = TargetMeta(path=Path("a.md")) - state.targets["b.md"] = TargetMeta(path=Path("b.md"), excluded=True, excluded_by="file_exists") - state.targets["c.md"] = TargetMeta(path=Path("c.md")) - - active = state.active_targets() - assert len(active) == 2 - paths = {t.path for t in active} - assert Path("a.md") in paths - assert Path("c.md") in paths - - def test_active_targets_all_active(self) -> None: - state = PipelineState() - state.targets["a.md"] = TargetMeta(path=Path("a.md")) - state.targets["b.md"] = TargetMeta(path=Path("b.md")) - assert len(state.active_targets()) == 2 - - def test_active_targets_empty(self) -> None: - state = PipelineState() - assert state.active_targets() == [] - - def test_exclude_target(self) -> None: - state = PipelineState() - state.targets["a.md"] = TargetMeta(path=Path("a.md")) - state.exclude_target("a.md", "file_exists") - assert state.targets["a.md"].excluded - assert state.targets["a.md"].excluded_by == "file_exists" - - def test_exclude_target_idempotent(self) -> None: - """Second exclusion does not overwrite the first.""" - state = PipelineState() - state.targets["a.md"] = TargetMeta(path=Path("a.md")) - state.exclude_target("a.md", "file_exists") - state.exclude_target("a.md", "directory_exists") - assert state.targets["a.md"].excluded_by == "file_exists" - - def test_exclude_target_unknown_path_noop(self) -> None: - state = PipelineState() - state.exclude_target("nonexistent.md", "file_exists") # no error - - def test_annotate_target(self) -> None: - state = PipelineState() - state.targets["a.md"] = TargetMeta(path=Path("a.md")) - state.annotate_target("a.md", "imports", ["x", "y"]) - assert state.targets["a.md"].annotations["imports"] == ["x", "y"] - - def test_annotate_target_unknown_path_noop(self) -> None: - state = PipelineState() - state.annotate_target("nonexistent.md", "key", "val") # no error - - def test_get_rule_sarif_found(self) -> None: - state = PipelineState() - state._sarif_by_rule = {"CORE:S:0001": [{"ruleId": "CORE.S.0001"}]} - assert len(state.get_rule_sarif("CORE:S:0001")) == 1 - - def test_get_rule_sarif_missing(self) -> None: - state = PipelineState() - assert state.get_rule_sarif("CORE:S:9999") == [] - - -# --------------------------------------------------------------------------- -# build_initial_state -# --------------------------------------------------------------------------- - - -class TestBuildInitialState: - def test_with_instruction_files(self, tmp_path: Path) -> None: - root = tmp_path - f1 = root / "CLAUDE.md" - f2 = root / ".claude" / "rules" / "foo.md" - f1.touch() - f2.parent.mkdir(parents=True) - f2.touch() - - state = build_initial_state([f1, f2], root) - - assert "CLAUDE.md" in state.targets - assert str(Path(".claude/rules/foo.md")) in state.targets - assert len(state.targets) == 2 - assert state.targets["CLAUDE.md"].path == f1 - - def test_with_none(self) -> None: - state = build_initial_state(None, Path("/tmp")) - assert state.targets == {} - - def test_with_empty_list(self) -> None: - state = build_initial_state([], Path("/tmp")) - assert state.targets == {} - - def test_fallback_for_unrelated_path(self, tmp_path: Path) -> None: - """Files outside scan_root use absolute string as key.""" - external = Path("/some/other/place.md") - state = build_initial_state([external], tmp_path) - assert str(external) in state.targets - - -# --------------------------------------------------------------------------- -# distribute_sarif_by_rule -# --------------------------------------------------------------------------- - - -def _make_rule( - rule_id: str, - rule_type: RuleType = RuleType.DETERMINISTIC, - checks: list[Check] | None = None, -) -> Rule: - return Rule( - id=rule_id, - title=f"Rule {rule_id}", - category=Category.STRUCTURE, - type=rule_type, - level="L2", - checks=checks or [Check(id=f"{rule_id}:check:0001", severity=Severity.MEDIUM)], - ) - - -class TestDistributeSarifByRule: - def test_empty_sarif(self) -> None: - assert distribute_sarif_by_rule({}, {}) == {} - assert distribute_sarif_by_rule({"runs": []}, {}) == {} - - def test_groups_by_rule_id(self) -> None: - sarif = { - "runs": [ - { - "tool": {"driver": {"rules": []}}, - "results": [ - {"ruleId": "CORE.S.0001.check.0001", "message": {"text": "v1"}}, - {"ruleId": "CORE.C.0002.check.0001", "message": {"text": "v2"}}, - {"ruleId": "CORE.S.0001.check.0002", "message": {"text": "v3"}}, - ], - } - ] - } - rules = { - "CORE:S:0001": _make_rule("CORE:S:0001"), - "CORE:C:0002": _make_rule("CORE:C:0002"), - } - result = distribute_sarif_by_rule(sarif, rules) - - assert len(result["CORE:S:0001"]) == 2 - assert len(result["CORE:C:0002"]) == 1 - - def test_skips_unknown_rules(self) -> None: - sarif = { - "runs": [ - { - "tool": {"driver": {"rules": []}}, - "results": [ - {"ruleId": "CORE.S.9999.check.0001", "message": {"text": "v1"}}, - ], - } - ] - } - rules = {"CORE:S:0001": _make_rule("CORE:S:0001")} - result = distribute_sarif_by_rule(sarif, rules) - assert result == {} - - def test_skips_info_level(self) -> None: - sarif = { - "runs": [ - { - "tool": { - "driver": { - "rules": [ - { - "id": "CORE.S.0001.check.0001", - "defaultConfiguration": {"level": "note"}, - } - ] - } - }, - "results": [ - {"ruleId": "CORE.S.0001.check.0001", "message": {"text": "v1"}}, - ], - } - ] - } - rules = {"CORE:S:0001": _make_rule("CORE:S:0001")} - result = distribute_sarif_by_rule(sarif, rules) - assert result == {} - - def test_handles_temp_prefixed_rule_ids(self) -> None: - sarif = { - "runs": [ - { - "tool": {"driver": {"rules": []}}, - "results": [ - {"ruleId": "tmp.tmpXXX.CORE.C.0006.check.0001", "message": {"text": "v1"}}, - ], - } - ] - } - rules = {"CORE:C:0006": _make_rule("CORE:C:0006")} - result = distribute_sarif_by_rule(sarif, rules) - assert len(result["CORE:C:0006"]) == 1 - - def test_multiple_runs(self) -> None: - sarif = { - "runs": [ - { - "tool": {"driver": {"rules": []}}, - "results": [ - {"ruleId": "CORE.S.0001.check.0001", "message": {"text": "v1"}}, - ], - }, - { - "tool": {"driver": {"rules": []}}, - "results": [ - {"ruleId": "CORE.S.0001.check.0002", "message": {"text": "v2"}}, - ], - }, - ] - } - rules = {"CORE:S:0001": _make_rule("CORE:S:0001")} - result = distribute_sarif_by_rule(sarif, rules) - assert len(result["CORE:S:0001"]) == 2 - - -# --------------------------------------------------------------------------- -# execute_rule_checks -# --------------------------------------------------------------------------- - - -class TestExecuteRuleChecks: - def test_mechanical_only_rule(self, tmp_path: Path) -> None: - """Mechanical-only rule dispatches checks and records violations.""" - (tmp_path / ".git").mkdir() - rule = _make_rule( - "CORE:S:0001", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0001:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="file_exists", - args={"path": "nonexistent.md"}, - ) - ], - ) - state = PipelineState() - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - assert len(state.findings) == 1 - assert state.findings[0].rule_id == "CORE:S:0001" - - def test_deterministic_from_sarif(self, tmp_path: Path) -> None: - """Deterministic rule reads from pre-distributed SARIF.""" - rule = _make_rule("CORE:C:0002", RuleType.DETERMINISTIC) - state = PipelineState() - state._sarif_by_rule = { - "CORE:C:0002": [ - { - "ruleId": "CORE.C.0002.check.0001", - "message": {"text": "violation found"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 5}}} - ], - } - ] - } - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - assert len(state.findings) == 1 - assert state.findings[0].location == "CLAUDE.md:5" - - def test_deterministic_no_sarif_means_pass(self, tmp_path: Path) -> None: - """Rule with no SARIF results produces no violations.""" - rule = _make_rule("CORE:C:0002", RuleType.DETERMINISTIC) - state = PipelineState() - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - assert state.findings == [] - - def test_ceiling_enforcement(self, tmp_path: Path) -> None: - """Mechanical rule cannot have deterministic checks.""" - rule = _make_rule( - "CORE:S:0001", - RuleType.MECHANICAL, - checks=[ - Check(id="CORE:S:0001:check:0001", severity=Severity.MEDIUM, type="mechanical", check="git_tracked"), - Check(id="CORE:S:0001:check:0002", severity=Severity.MEDIUM, type="deterministic"), - ], - ) - (tmp_path / ".git").mkdir() - state = PipelineState() - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - # Only the mechanical check ran (git_tracked passes), deterministic skipped - assert state.findings == [] - - def test_negation_no_finding_produces_violation(self, tmp_path: Path) -> None: - """Negated check with no SARIF match produces violation.""" - rule = _make_rule( - "CORE:C:0005", - RuleType.DETERMINISTIC, - checks=[ - Check( - id="CORE:C:0005:check:0001", - severity=Severity.HIGH, - type="deterministic", - negate=True, - ) - ], - ) - state = PipelineState() - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - assert len(state.findings) == 1 - assert state.findings[0].message == "Expected content not found" - - def test_negation_finding_means_pass(self, tmp_path: Path) -> None: - """Negated check with SARIF match means pass (content present).""" - rule = _make_rule( - "CORE:C:0005", - RuleType.DETERMINISTIC, - checks=[ - Check( - id="CORE:C:0005:check:0001", - severity=Severity.HIGH, - type="deterministic", - negate=True, - ) - ], - ) - state = PipelineState() - state._sarif_by_rule = { - "CORE:C:0005": [ - { - "ruleId": "CORE.C.0005.check.0001", - "message": {"text": "found"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "f.md"}, "region": {"startLine": 1}}} - ], - } - ] - } - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - assert state.findings == [] - - def test_semantic_short_circuit(self, tmp_path: Path) -> None: - """Semantic check with no deterministic candidates never fires.""" - rule = Rule( - id="CORE:C:0010", - title="Semantic rule", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - question="Is this good?", - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check(id="CORE:C:0010:check:0001", severity=Severity.MEDIUM, type="deterministic"), - Check(id="CORE:C:0010:check:0002", severity=Severity.MEDIUM, type="semantic"), - ], - ) - state = PipelineState() - # No SARIF results -> short-circuit - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - assert jrs == [] - - def test_negated_deterministic_uses_effective_vars(self, tmp_path: Path) -> None: - """Negated deterministic violation location resolves {{instruction_files}} via effective_vars.""" - (tmp_path / "CLAUDE.md").touch() - rule = _make_rule( - "CORE:C:0005", - RuleType.DETERMINISTIC, - checks=[ - Check( - id="CORE:C:0005:check:0001", - severity=Severity.HIGH, - type="deterministic", - negate=True, - ) - ], - ) - rule = Rule( - id=rule.id, - title=rule.title, - category=rule.category, - type=rule.type, - level=rule.level, - checks=rule.checks, - targets="{{instruction_files}}", - ) - state = PipelineState() - # Pass instruction_files so effective_vars binds concrete paths - jrs = execute_rule_checks(rule, state, tmp_path, {}, [tmp_path / "CLAUDE.md"]) - assert jrs == [] - assert len(state.findings) == 1 - # Location must resolve to actual file, NOT raw placeholder - assert "{{" not in state.findings[0].location - assert "CLAUDE.md" in state.findings[0].location - - def test_mechanical_negate_inverts_result(self, tmp_path: Path) -> None: - """Mechanical check with negate=True inverts pass/fail logic.""" - (tmp_path / ".git").mkdir() - rule = _make_rule( - "CORE:S:0099", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0099:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="git_tracked", - negate=True, - ) - ], - ) - state = PipelineState() - execute_rule_checks(rule, state, tmp_path, {}, None) - # git_tracked normally passes (git exists) → negate inverts → violation - assert len(state.findings) == 1 - - def test_check_cache_populated(self, tmp_path: Path) -> None: - """Pipeline state check_cache is populated after mechanical dispatch.""" - (tmp_path / ".git").mkdir() - rule = _make_rule( - "CORE:S:0001", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0001:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="git_tracked", - ) - ], - ) - state = PipelineState() - execute_rule_checks(rule, state, tmp_path, {}, None) - # check_cache is available on state (even though not used for dedup yet in single-rule case) - assert state.check_cache is not None - - -# --------------------------------------------------------------------------- -# Blocking behavior — cross-rule target exclusion -# --------------------------------------------------------------------------- - - -class TestBlockingBehaviorAcrossRules: - """Blocking check failure must exclude target for subsequent rules. - - When file_exists fails, the target is recorded as excluded in PipelineState. - Subsequent rules executed against the same state see the exclusion. - """ - - @staticmethod - def _blocking_rule(rule_id: str, check_name: str = "file_exists", args: dict | None = None) -> Rule: - """Create a rule with a blocking check and proper targets.""" - return Rule( - id=rule_id, - title=f"Rule {rule_id}", - category=Category.STRUCTURE, - type=RuleType.MECHANICAL, - level="L1", - targets="{{instruction_files}}", - checks=[ - Check( - id=f"{rule_id}:check:0001", - severity=Severity.CRITICAL, - type="mechanical", - check=check_name, - args=args, - ) - ], - ) - - def test_file_exists_failure_excludes_target(self, tmp_path: Path) -> None: - """file_exists failure marks the target as excluded in state.""" - rule = self._blocking_rule("CORE:S:0001") - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - assert len(state.findings) == 1 - assert state.targets["CLAUDE.md"].excluded - assert state.targets["CLAUDE.md"].excluded_by == "file_exists" - - def test_excluded_target_not_in_active_targets(self, tmp_path: Path) -> None: - """After blocking failure, active_targets() filters out the excluded target.""" - rule = self._blocking_rule("CORE:S:0001") - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state.targets["other.md"] = TargetMeta(path=tmp_path / "other.md") - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - active = state.active_targets() - active_paths = {t.path.name for t in active} - assert "CLAUDE.md" not in active_paths - assert "other.md" in active_paths - - def test_blocking_persists_across_two_rules(self, tmp_path: Path) -> None: - """Exclusion from rule A is visible when processing rule B with same state.""" - rule_a = self._blocking_rule("CORE:S:0001") - rule_b = Rule( - id="CORE:S:0002", - title="Rule CORE:S:0002", - category=Category.STRUCTURE, - type=RuleType.MECHANICAL, - level="L1", - targets="{{instruction_files}}", - checks=[ - Check( - id="CORE:S:0002:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="git_tracked", - ) - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - # Rule A: file_exists fails → target excluded - execute_rule_checks(rule_a, state, tmp_path, vars, None) - assert state.targets["CLAUDE.md"].excluded - - # Rule B executes against same state — target still excluded - execute_rule_checks(rule_b, state, tmp_path, vars, None) - assert state.targets["CLAUDE.md"].excluded - assert state.targets["CLAUDE.md"].excluded_by == "file_exists" - - def test_directory_exists_failure_excludes_target(self, tmp_path: Path) -> None: - """directory_exists is also a blocking check and excludes targets.""" - rule = Rule( - id="CORE:S:0003", - title="Rule CORE:S:0003", - category=Category.STRUCTURE, - type=RuleType.MECHANICAL, - level="L1", - targets=".claude/rules", - checks=[ - Check( - id="CORE:S:0003:check:0001", - severity=Severity.HIGH, - type="mechanical", - check="directory_exists", - args={"path": ".claude/rules"}, - ) - ], - ) - state = PipelineState() - # Target key matches the resolved location path - state.targets[".claude/rules"] = TargetMeta(path=tmp_path / ".claude" / "rules") - vars = {"instruction_files": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - assert len(state.findings) == 1 - assert state.targets[".claude/rules"].excluded - assert state.targets[".claude/rules"].excluded_by == "directory_exists" - - -# --------------------------------------------------------------------------- -# Interleaved check types — M→D→S within a single rule -# --------------------------------------------------------------------------- - - -class TestInterleavedCheckTypes: - """Rules with mixed check types must execute M→D→S in declaration order.""" - - def test_semantic_rule_with_mds_sequence(self, tmp_path: Path) -> None: - """Semantic rule with M+D+S checks: mechanical runs, deterministic reads SARIF, semantic fires.""" - (tmp_path / ".git").mkdir() - (tmp_path / "CLAUDE.md").write_text("# Project\n\nContent for semantic evaluation.\n") - rule = Rule( - id="CORE:C:0010", - title="Comprehensive rule", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L2", - question="Is this good?", - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check( - id="CORE:C:0010:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="git_tracked", - ), - Check( - id="CORE:C:0010:check:0002", - severity=Severity.HIGH, - type="deterministic", - ), - Check( - id="CORE:C:0010:check:0003", - severity=Severity.MEDIUM, - type="semantic", - ), - ], - ) - state = PipelineState() - # Provide SARIF results for the deterministic check - state._sarif_by_rule = { - "CORE:C:0010": [ - { - "ruleId": "CORE.C.0010.check.0002", - "message": {"text": "section candidate"}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": {"startLine": 10}, - } - } - ], - } - ] - } - - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - - # Mechanical passed (git_tracked), no violation for it - mechanical_violations = [f for f in state.findings if f.check_id == "CORE:C:0010:check:0001"] - assert len(mechanical_violations) == 0 - - # Deterministic produced a violation from SARIF - det_violations = [f for f in state.findings if "check:0002" in f.check_id] - assert len(det_violations) == 1 - - # Semantic produced a JudgmentRequest (not a violation — needs human judgment) - assert len(jrs) == 1 - - def test_mechanical_failure_still_allows_deterministic(self, tmp_path: Path) -> None: - """Failing mechanical check within a rule does not block subsequent deterministic checks.""" - rule = Rule( - id="CORE:C:0020", - title="Multi-gate rule", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - checks=[ - Check( - id="CORE:C:0020:check:0001", - severity=Severity.LOW, - type="mechanical", - check="git_tracked", - ), - Check( - id="CORE:C:0020:check:0002", - severity=Severity.HIGH, - type="deterministic", - ), - ], - ) - state = PipelineState() - state._sarif_by_rule = { - "CORE:C:0020": [ - { - "ruleId": "CORE.C.0020.check.0002", - "message": {"text": "violation"}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": {"startLine": 5}, - } - } - ], - } - ] - } - - execute_rule_checks(rule, state, tmp_path, {}, None) - - # Both checks produced violations (no .git → mechanical fails, SARIF hit → deterministic fails) - assert len(state.findings) == 2 - check_ids = {f.check_id for f in state.findings} - assert "CORE:C:0020:check:0001" in check_ids - assert "check:0002" in {f.check_id for f in state.findings} - - def test_semantic_short_circuits_without_deterministic_candidates(self, tmp_path: Path) -> None: - """Semantic check never fires when there are no deterministic candidates.""" - (tmp_path / ".git").mkdir() - rule = Rule( - id="CORE:C:0030", - title="Semantic with no candidates", - category=Category.CONTENT, - type=RuleType.SEMANTIC, - level="L3", - question="Is this good?", - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check( - id="CORE:C:0030:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="git_tracked", - ), - Check( - id="CORE:C:0030:check:0002", - severity=Severity.HIGH, - type="deterministic", - ), - Check( - id="CORE:C:0030:check:0003", - severity=Severity.MEDIUM, - type="semantic", - ), - ], - ) - state = PipelineState() - # No SARIF results — deterministic produces 0 candidates - - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - - # Mechanical passed, deterministic had no results, semantic short-circuited - assert state.findings == [] - assert jrs == [] - - def test_ceiling_blocks_semantic_in_deterministic_rule(self, tmp_path: Path) -> None: - """Deterministic rule cannot execute semantic checks — ceiling enforcement.""" - rule = Rule( - id="CORE:C:0040", - title="Det rule with semantic check", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - question="Is this good?", - choices=[{"value": "pass"}, {"value": "fail"}], - pass_value="pass", - checks=[ - Check( - id="CORE:C:0040:check:0001", - severity=Severity.MEDIUM, - type="deterministic", - ), - Check( - id="CORE:C:0040:check:0002", - severity=Severity.MEDIUM, - type="semantic", - ), - ], - ) - state = PipelineState() - - jrs = execute_rule_checks(rule, state, tmp_path, {}, None) - - # Semantic check skipped due to ceiling — no judgment requests - assert jrs == [] - - -# --------------------------------------------------------------------------- -# Negate on blocking checks (file_exists, directory_exists) -# --------------------------------------------------------------------------- - - -class TestNegateBlockingChecks: - """Negate=True on blocking checks inverts pass/fail at the pipeline level.""" - - def test_negate_file_exists_present_means_violation(self, tmp_path: Path) -> None: - """file_exists + negate=True: file present → violation (require file absent).""" - (tmp_path / "CLAUDE.md").write_text("# Hello") - rule = _make_rule( - "CORE:S:0098", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0098:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="file_exists", - negate=True, - ) - ], - ) - state = PipelineState() - vars = {"instruction_files": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - # file_exists returns passed=True, negate inverts → violation - assert len(state.findings) == 1 - - def test_negate_file_exists_absent_means_pass(self, tmp_path: Path) -> None: - """file_exists + negate=True: file absent → pass (desired state).""" - rule = _make_rule( - "CORE:S:0098", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0098:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="file_exists", - negate=True, - ) - ], - ) - state = PipelineState() - vars = {"instruction_files": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - # file_exists returns passed=False, negate inverts → pass - assert state.findings == [] - - def test_negate_directory_exists_present_means_violation(self, tmp_path: Path) -> None: - """directory_exists + negate=True: dir present → violation.""" - (tmp_path / ".claude" / "rules").mkdir(parents=True) - rule = _make_rule( - "CORE:S:0097", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0097:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="directory_exists", - args={"path": ".claude/rules"}, - negate=True, - ) - ], - ) - state = PipelineState() - - execute_rule_checks(rule, state, tmp_path, {}, None) - - assert len(state.findings) == 1 - - def test_negate_directory_exists_absent_means_pass(self, tmp_path: Path) -> None: - """directory_exists + negate=True: dir absent → pass.""" - rule = _make_rule( - "CORE:S:0097", - RuleType.MECHANICAL, - checks=[ - Check( - id="CORE:S:0097:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="directory_exists", - args={"path": ".claude/rules"}, - negate=True, - ) - ], - ) - state = PipelineState() - - execute_rule_checks(rule, state, tmp_path, {}, None) - - assert state.findings == [] - - -# --------------------------------------------------------------------------- -# D→M metadata propagation via metadata_keys -# --------------------------------------------------------------------------- - - -class TestMetadataKeysPropagation: - """D checks with metadata_keys write to annotations; M checks read them.""" - - def test_deterministic_writes_annotations(self, tmp_path: Path) -> None: - """D check with metadata_keys stores match texts in state annotations.""" - rule = Rule( - id="CORE:C:0050", - title="D→annotations test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - checks=[ - Check( - id="CORE:C:0050:check:0001", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["found_items"], - ) - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = { - "CORE:C:0050": [ - { - "ruleId": "CORE.C.0050.check.0001", - "message": {"text": "match alpha"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 1}}} - ], - }, - { - "ruleId": "CORE.C.0050.check.0001", - "message": {"text": "match beta"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 5}}} - ], - }, - ] - } - - execute_rule_checks(rule, state, tmp_path, {}, None) - - # Violations created as normal - assert len(state.findings) == 2 - # Annotations written to target - assert "found_items" in state.targets["CLAUDE.md"].annotations - assert state.targets["CLAUDE.md"].annotations["found_items"] == ["match alpha", "match beta"] - - def test_deterministic_no_results_no_annotations(self, tmp_path: Path) -> None: - """D check with metadata_keys but no SARIF matches writes nothing.""" - rule = Rule( - id="CORE:C:0051", - title="D→annotations empty test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - checks=[ - Check( - id="CORE:C:0051:check:0001", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["items"], - ) - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - - execute_rule_checks(rule, state, tmp_path, {}, None) - - assert state.findings == [] - assert state.targets["CLAUDE.md"].annotations == {} - - def test_mechanical_reads_annotations(self, tmp_path: Path) -> None: - """M check with metadata_keys gets annotations injected into args.""" - (tmp_path / "CLAUDE.md").write_text("# Hello") - rule = Rule( - id="CORE:C:0052", - title="D→M metadata test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - targets="{{instruction_files}}", - checks=[ - Check( - id="CORE:C:0052:check:0001", - severity=Severity.MEDIUM, - type="deterministic", - metadata_keys=["style_rules"], - ), - Check( - id="CORE:C:0052:check:0002", - severity=Severity.HIGH, - type="mechanical", - check="count_at_most", - args={"threshold": 0}, - metadata_keys=["style_rules"], - ), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = { - "CORE:C:0052": [ - { - "ruleId": "CORE.C.0052.check.0001", - "message": {"text": "indent with 4 spaces"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 3}}} - ], - }, - { - "ruleId": "CORE.C.0052.check.0001", - "message": {"text": "use tabs for indent"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 7}}} - ], - }, - ] - } - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - # D check produced 2 violations + annotations - det_findings = [f for f in state.findings if "check:0001" in (f.check_id or "")] - assert len(det_findings) == 2 - - # M check (count_at_most threshold=0) consumed the 2-item list → violation - mech_findings = [f for f in state.findings if f.check_id == "CORE:C:0052:check:0002"] - assert len(mech_findings) == 1 - assert "exceeds" in mech_findings[0].message - - def test_d_to_m_pass_when_within_threshold(self, tmp_path: Path) -> None: - """D→M chain: count_at_most passes when items within threshold.""" - (tmp_path / "CLAUDE.md").write_text("# Hello") - rule = Rule( - id="CORE:C:0053", - title="D→M pass test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - targets="{{instruction_files}}", - checks=[ - Check( - id="CORE:C:0053:check:0001", - severity=Severity.LOW, - type="deterministic", - metadata_keys=["items"], - ), - Check( - id="CORE:C:0053:check:0002", - severity=Severity.MEDIUM, - type="mechanical", - check="count_at_most", - args={"threshold": 5}, - metadata_keys=["items"], - ), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - state._sarif_by_rule = { - "CORE:C:0053": [ - { - "ruleId": "CORE.C.0053.check.0001", - "message": {"text": "item 1"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "CLAUDE.md"}, "region": {"startLine": 1}}} - ], - }, - ] - } - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - # D violation exists, but M check passes (1 item <= 5 threshold) - mech_findings = [f for f in state.findings if f.check_id == "CORE:C:0053:check:0002"] - assert len(mech_findings) == 0 - - def test_metadata_keys_without_annotations_m_sees_empty(self, tmp_path: Path) -> None: - """M check with metadata_keys but no prior D annotations gets empty args — no crash.""" - (tmp_path / "CLAUDE.md").write_text("# Hello") - rule = Rule( - id="CORE:C:0054", - title="M without prior D test", - category=Category.CONTENT, - type=RuleType.DETERMINISTIC, - level="L2", - targets="{{instruction_files}}", - checks=[ - Check( - id="CORE:C:0054:check:0001", - severity=Severity.MEDIUM, - type="mechanical", - check="count_at_most", - args={"threshold": 0}, - metadata_keys=["nonexistent_key"], - ), - ], - ) - state = PipelineState() - state.targets["CLAUDE.md"] = TargetMeta(path=tmp_path / "CLAUDE.md") - vars = {"instruction_files": ["CLAUDE.md"], "main_instruction_file": ["CLAUDE.md"]} - - execute_rule_checks(rule, state, tmp_path, vars, None) - - # count_at_most with no metadata → empty list → passes (0 <= 0) - assert not any(f.check_id == "CORE:C:0054:check:0001" for f in state.findings) diff --git a/tests/unit/test_project_config.py b/tests/unit/test_project_config.py index 17431c6d..c06f808f 100644 --- a/tests/unit/test_project_config.py +++ b/tests/unit/test_project_config.py @@ -9,25 +9,21 @@ class TestGetProjectConfig: - """Test get_project_config loading from .reporails/config.yml.""" + """Test get_project_config loading from .ails/config.yml.""" def test_returns_defaults_when_missing(self, tmp_path: Path) -> None: config = get_project_config(tmp_path) assert config.packages == [] assert config.disabled_rules == [] assert config.framework_version is None - assert config.experimental is False assert config.recommended is True def test_loads_all_fields(self, tmp_path: Path, make_config_file) -> None: - make_config_file( - "framework_version: '0.1.0'\npackages:\n - recommended\ndisabled_rules:\n - S1\nexperimental: true\n" - ) + make_config_file("framework_version: '0.1.0'\npackages:\n - recommended\ndisabled_rules:\n - S1\n") config = get_project_config(tmp_path) assert config.framework_version == "0.1.0" assert config.packages == ["recommended"] assert config.disabled_rules == ["S1"] - assert config.experimental is True def test_recommended_true_by_default(self, tmp_path: Path, make_config_file) -> None: make_config_file("packages:\n - custom\n") @@ -50,7 +46,7 @@ def test_malformed_yaml_inherits_global(self, tmp_path: Path, make_config_file) make_config_file(": : :\n bad yaml [[[") with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(default_agent="claude", recommended=False), ): config = get_project_config(tmp_path) @@ -72,7 +68,7 @@ def test_inherits_global_default_agent(self, tmp_path: Path, make_config_file) - make_config_file("packages:\n - custom\n") with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) @@ -84,7 +80,7 @@ def test_project_overrides_global_default_agent(self, tmp_path: Path, make_confi make_config_file("default_agent: cursor\n") with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) @@ -96,7 +92,7 @@ def test_inherits_global_recommended(self, tmp_path: Path, make_config_file) -> make_config_file("packages:\n - custom\n") with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(recommended=False), ): config = get_project_config(tmp_path) @@ -108,18 +104,18 @@ def test_project_overrides_global_recommended(self, tmp_path: Path, make_config_ make_config_file("recommended: false\n") with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(recommended=True), ): config = get_project_config(tmp_path) assert config.recommended is False def test_no_config_file_inherits_global(self, tmp_path: Path) -> None: - """No .reporails/config.yml — global defaults apply.""" + """No .ails/config.yml — global defaults apply.""" from reporails_cli.core.models import GlobalConfig with patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.config.get_global_config", return_value=GlobalConfig(default_agent="claude", recommended=False), ): config = get_project_config(tmp_path) @@ -131,18 +127,18 @@ class TestGetPackagePaths: """Test get_package_paths resolution.""" def test_returns_existing_dirs(self, tmp_path: Path) -> None: - pkg_dir = tmp_path / ".reporails" / "packages" / "recommended" + pkg_dir = tmp_path / ".ails" / "packages" / "recommended" pkg_dir.mkdir(parents=True) paths = get_package_paths(tmp_path, ["recommended"]) assert paths == [pkg_dir] def test_skips_missing_dirs(self, tmp_path: Path) -> None: - (tmp_path / ".reporails" / "packages").mkdir(parents=True) + (tmp_path / ".ails" / "packages").mkdir(parents=True) paths = get_package_paths(tmp_path, ["nonexistent"]) assert paths == [] def test_mixed_existing_and_missing(self, tmp_path: Path) -> None: - pkg_base = tmp_path / ".reporails" / "packages" + pkg_base = tmp_path / ".ails" / "packages" (pkg_base / "exists").mkdir(parents=True) paths = get_package_paths(tmp_path, ["exists", "missing"]) assert len(paths) == 1 @@ -173,7 +169,7 @@ def test_project_local_overrides_global(self, tmp_path: Path) -> None: global_pkg.mkdir(parents=True) project = tmp_path / "project" - local_pkg = project / ".reporails" / "packages" / "recommended" + local_pkg = project / ".ails" / "packages" / "recommended" local_pkg.mkdir(parents=True) with patch( @@ -189,7 +185,7 @@ def test_mixed_local_and_global(self, tmp_path: Path) -> None: global_pkg.mkdir(parents=True) project = tmp_path / "project" - local_pkg = project / ".reporails" / "packages" / "custom" + local_pkg = project / ".ails" / "packages" / "custom" local_pkg.mkdir(parents=True) with patch( @@ -253,7 +249,6 @@ def test_additional_path_overrides_framework(self, tmp_path: Path) -> None: # Additional paths override primary framework rules rules = load_rules( rules_paths=[rules_dir, pkg_dir], - include_experimental=True, ) assert "CORE:S:0001" in rules assert rules["CORE:S:0001"].title == "Custom S1" @@ -274,13 +269,12 @@ def test_disabled_rules_excluded(self, tmp_path: Path) -> None: # Project config disabling CORE:S:0001 project = tmp_path / "project" - config_dir = project / ".reporails" + config_dir = project / ".ails" config_dir.mkdir(parents=True) (config_dir / "config.yml").write_text('disabled_rules:\n - "CORE:S:0001"\n') rules = load_rules( rules_paths=[rules_dir], - include_experimental=True, project_root=project, ) assert "CORE:S:0001" not in rules @@ -298,13 +292,12 @@ def test_disabled_nonexistent_rule_harmless(self, tmp_path: Path) -> None: (docs_dir / "sources.yml").write_text("general:\n - id: anthropic-docs\n weight: 1.0\n") project = tmp_path / "project" - config_dir = project / ".reporails" + config_dir = project / ".ails" config_dir.mkdir(parents=True) (config_dir / "config.yml").write_text("disabled_rules:\n - NOPE\n") rules = load_rules( rules_paths=[rules_dir], - include_experimental=True, project_root=project, ) assert "CORE:S:0001" in rules @@ -321,5 +314,5 @@ def test_no_project_root_backward_compat(self, tmp_path: Path) -> None: (docs_dir / "sources.yml").write_text("general:\n - id: anthropic-docs\n weight: 1.0\n") # No project_root — backward compatible - rules = load_rules(rules_paths=[rules_dir], include_experimental=True) + rules = load_rules(rules_paths=[rules_dir]) assert "CORE:S:0001" in rules diff --git a/tests/unit/test_regex_engine.py b/tests/unit/test_regex_engine.py index 249ba715..7244e008 100644 --- a/tests/unit/test_regex_engine.py +++ b/tests/unit/test_regex_engine.py @@ -20,14 +20,8 @@ from reporails_cli.core.regex.runner import ( _file_matches_path_filter, _match_check, - run_capability_detection, run_validation, ) -from reporails_cli.core.templates import ( - _glob_to_regex, - has_templates, - resolve_templates, -) # --------------------------------------------------------------------------- # Helpers @@ -85,13 +79,13 @@ def test_yaml_with_no_rules_key(self, tmp_path: Path) -> None: def test_yaml_with_empty_rules_list(self, tmp_path: Path) -> None: """YAML with empty rules list should produce no checks.""" - p = _write_rule(tmp_path, {"rules": []}) + p = _write_rule(tmp_path, {"checks": []}) result = compile_rules([p]) assert result.checks == [] def test_rule_with_no_operator(self, tmp_path: Path) -> None: """Rule with no recognized operator should be skipped.""" - p = _write_rule(tmp_path, {"rules": [{"id": "TEST-001", "message": "bad"}]}) + p = _write_rule(tmp_path, {"checks": [{"id": "TEST-001", "message": "bad"}]}) result = compile_rules([p]) assert result.checks == [] assert "TEST-001" in result.skipped @@ -100,7 +94,7 @@ def test_rule_with_unknown_operator(self, tmp_path: Path) -> None: """Rule with unsupported operator should be skipped gracefully.""" p = _write_rule( tmp_path, - {"rules": [{"id": "TEST-002", "pattern-metavar": "$X", "message": "bad"}]}, + {"checks": [{"id": "TEST-002", "pattern-metavar": "$X", "message": "bad"}]}, ) result = compile_rules([p]) assert "TEST-002" in result.skipped @@ -109,7 +103,7 @@ def test_invalid_regex_pattern(self, tmp_path: Path) -> None: """Invalid regex should be caught and rule skipped.""" p = _write_rule( tmp_path, - {"rules": [{"id": "BAD-REGEX", "pattern-regex": "[invalid(", "message": "bad"}]}, + {"checks": [{"id": "BAD-REGEX", "pattern-regex": "[invalid(", "message": "bad"}]}, ) result = compile_rules([p]) assert result.checks == [] @@ -120,7 +114,7 @@ def test_invalid_regex_in_pattern_either(self, tmp_path: Path) -> None: p = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "BAD-EITHER", "pattern-either": [{"pattern-regex": "[valid"}, {"pattern-regex": "ok"}], @@ -149,7 +143,7 @@ def test_yaml_bomb(self, tmp_path: Path) -> None: yaml.safe_load should handle this safely. """ - content = "a: &a\n b: &b\n c: &c\n d: test\nrules: []\n" + content = "a: &a\n b: &b\n c: &c\n d: test\nchecks: []\n" p = tmp_path / "nested.yml" p.write_text(content) result = compile_rules([p]) @@ -159,7 +153,7 @@ def test_pattern_either_empty_list(self, tmp_path: Path) -> None: """pattern-either with empty list should produce None.""" p = _write_rule( tmp_path, - {"rules": [{"id": "EMPTY-EITHER", "pattern-either": [], "message": "x"}]}, + {"checks": [{"id": "EMPTY-EITHER", "pattern-either": [], "message": "x"}]}, ) result = compile_rules([p]) assert result.checks == [] @@ -174,7 +168,7 @@ def test_patterns_only_negative(self, tmp_path: Path) -> None: p = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "NEG-ONLY", "patterns": [{"pattern-not-regex": "(?i)secret"}], @@ -194,7 +188,7 @@ def test_missing_id_and_message(self, tmp_path: Path) -> None: """Rule without id or message should use defaults.""" p = _write_rule( tmp_path, - {"rules": [{"pattern-regex": "test"}]}, + {"checks": [{"pattern-regex": "test"}]}, ) result = compile_rules([p]) assert len(result.checks) == 1 @@ -207,7 +201,7 @@ def test_severity_normalization(self, tmp_path: Path) -> None: {"id": f"SEV-{i}", "pattern-regex": f"test{i}", "severity": sev, "message": "x"} for i, sev in enumerate(["ERROR", "error", "CRITICAL", "high", "WARNING", "warning", "info", "LOW", ""]) ] - p = _write_rule(tmp_path, {"rules": rules}) + p = _write_rule(tmp_path, {"checks": rules}) result = compile_rules([p]) severities = {c.id: c.severity for c in result.checks} assert severities["SEV-0"] == "error" # ERROR @@ -224,12 +218,12 @@ def test_multiple_yml_files(self, tmp_path: Path) -> None: """Multiple YAML files should merge checks.""" p1 = _write_rule( tmp_path, - {"rules": [{"id": "R1", "pattern-regex": "a", "message": "x"}]}, + {"checks": [{"id": "R1", "pattern-regex": "a", "message": "x"}]}, "r1.yml", ) p2 = _write_rule( tmp_path, - {"rules": [{"id": "R2", "pattern-regex": "b", "message": "y"}]}, + {"checks": [{"id": "R2", "pattern-regex": "b", "message": "y"}]}, "r2.yml", ) result = compile_rules([p1, p2]) @@ -376,7 +370,7 @@ def test_basic_match_sarif_shape(self, tmp_path: Path) -> None: """Verify SARIF output has all required fields.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "T-001", "pattern-regex": "hello", "message": "found hello", "severity": "WARNING"}]}, + {"checks": [{"id": "T-001", "pattern-regex": "hello", "message": "found hello", "severity": "WARNING"}]}, ) _write_target(tmp_path, "# Doc\nhello world\n") @@ -409,7 +403,7 @@ def test_line_number_first_line(self, tmp_path: Path) -> None: """Match on first line should report line 1.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "L1", "pattern-regex": "^#", "message": "x"}]}, + {"checks": [{"id": "L1", "pattern-regex": "^#", "message": "x"}]}, ) _write_target(tmp_path, "# Title\nBody\n") @@ -420,7 +414,7 @@ def test_line_number_last_line(self, tmp_path: Path) -> None: """Match on last line should report correct line number.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "LL", "pattern-regex": "end", "message": "x"}]}, + {"checks": [{"id": "LL", "pattern-regex": "end", "message": "x"}]}, ) content = "line1\nline2\nline3\nthe end" _write_target(tmp_path, content) @@ -432,7 +426,7 @@ def test_no_match_empty_results(self, tmp_path: Path) -> None: """No matches should produce SARIF with empty results.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "MISS", "pattern-regex": "nonexistent", "message": "x"}]}, + {"checks": [{"id": "MISS", "pattern-regex": "nonexistent", "message": "x"}]}, ) _write_target(tmp_path, "# Nothing here\n") @@ -444,7 +438,7 @@ def test_multiple_matches_same_file(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ {"id": "MULTI", "pattern-regex": "TODO", "message": "found TODO"}, ] }, @@ -460,7 +454,7 @@ def test_multiple_rules_match_same_file(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ {"id": "R-A", "pattern-regex": "hello", "message": "found hello"}, {"id": "R-B", "pattern-regex": "world", "message": "found world"}, ] @@ -478,7 +472,7 @@ def test_empty_target_directory(self, tmp_path: Path) -> None: rules_dir.mkdir(parents=True) rule_yml = _write_rule( rules_dir, - {"rules": [{"id": "X", "pattern-regex": "anything", "message": "x"}]}, + {"checks": [{"id": "X", "pattern-regex": "anything", "message": "x"}]}, "rule.yml", ) target = tmp_path / "project" @@ -491,7 +485,7 @@ def test_binary_target_file_skipped(self, tmp_path: Path) -> None: """Binary files should be skipped silently.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "BIN", "pattern-regex": ".", "message": "x"}]}, + {"checks": [{"id": "BIN", "pattern-regex": ".", "message": "x"}]}, ) binary_file = tmp_path / "CLAUDE.md" binary_file.write_bytes(b"# Title\n\x00\x01binary junk\n") @@ -503,7 +497,7 @@ def test_utf8_encoding_error(self, tmp_path: Path) -> None: """File with invalid UTF-8 should be skipped.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "ENC", "pattern-regex": "test", "message": "x"}]}, + {"checks": [{"id": "ENC", "pattern-regex": "test", "message": "x"}]}, ) bad_file = tmp_path / "CLAUDE.md" bad_file.write_bytes(b"# Title\n\xff\xfe invalid utf8\n") @@ -515,7 +509,7 @@ def test_snippet_truncation(self, tmp_path: Path) -> None: """Very long matches should be truncated in snippet.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "LONG", "pattern-regex": "A+", "message": "x"}]}, + {"checks": [{"id": "LONG", "pattern-regex": "A+", "message": "x"}]}, ) _write_target(tmp_path, "A" * 500 + "\n") @@ -562,7 +556,7 @@ def test_path_includes_with_rule(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "PATH-FILTER", "pattern-regex": "test", @@ -596,7 +590,7 @@ def test_positive_with_negative_match_blocks(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "NEG-1", "patterns": [ @@ -616,7 +610,7 @@ def test_positive_without_negative_fires(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "NEG-2", "patterns": [ @@ -638,7 +632,7 @@ def test_multiple_negatives_all_must_not_match(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "NEG-MULTI", "patterns": [ @@ -673,7 +667,7 @@ def test_negative_only_never_fires(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "NEG-ONLY", "patterns": [{"pattern-not-regex": "(?i)good"}], @@ -703,7 +697,7 @@ def test_catastrophic_backtracking_protection(self, tmp_path: Path) -> None: """ rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "REDOS", "pattern-regex": "(a+)+b", "message": "x"}]}, + {"checks": [{"id": "REDOS", "pattern-regex": "(a+)+b", "message": "x"}]}, ) # Adversarial input: many 'a's followed by non-matching char adversarial = "a" * 25 + "!" @@ -714,9 +708,9 @@ def test_catastrophic_backtracking_protection(self, tmp_path: Path) -> None: elapsed = time.monotonic() - start assert _sarif_results(sarif) == [] - # Should complete in under 5 seconds even with backtracking - # (25 'a's is manageable; 30+ would hang without possessive quantifiers) - assert elapsed < 5.0, f"Regex took {elapsed:.1f}s — possible ReDoS" + # Regex engine has a 500ms per-pattern timeout; this should complete + # within the timeout (pattern times out, returns no match) + assert elapsed < 2.0, f"Regex took {elapsed:.1f}s — timeout guard may not be working" def test_greedy_dot_star_large_file(self, tmp_path: Path) -> None: """Greedy .* between two patterns on a large file. @@ -725,7 +719,7 @@ def test_greedy_dot_star_large_file(self, tmp_path: Path) -> None: """ rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "GREEDY", "pattern-regex": "(?i)(deny|block).*(\\.env|\\.pem)", "message": "x"}]}, + {"checks": [{"id": "GREEDY", "pattern-regex": "(?i)(deny|block).*(\\.env|\\.pem)", "message": "x"}]}, ) # Large file with 'deny' at start but no matching suffix content = "deny " + "x" * 50000 + "\n" @@ -741,7 +735,7 @@ def test_many_files_performance(self, tmp_path: Path) -> None: """Scanning 100 files should complete quickly.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "PERF", "pattern-regex": "## Structure", "message": "x"}]}, + {"checks": [{"id": "PERF", "pattern-regex": "## Structure", "message": "x"}]}, ) for i in range(100): _write_target(tmp_path, f"# File {i}\n## Structure\nContent\n", f"doc_{i}.md") @@ -755,131 +749,36 @@ def test_many_files_performance(self, tmp_path: Path) -> None: # =========================================================================== -# 7. TEMPLATE RESOLUTION -# =========================================================================== - - -class TestTemplateResolution: - """Test template {{placeholder}} resolution edge cases.""" - - def test_template_detected(self, tmp_path: Path) -> None: - p = tmp_path / "test.yml" - p.write_text("pattern-regex: '{{files}}'\n") - assert has_templates(p) - - def test_no_template(self, tmp_path: Path) -> None: - p = tmp_path / "test.yml" - p.write_text("pattern-regex: 'hello'\n") - assert not has_templates(p) - - def test_string_substitution(self, tmp_path: Path) -> None: - p = tmp_path / "test.yml" - p.write_text(' pattern-regex: "(?i){{name}}"') - result = resolve_templates(p, {"name": "CLAUDE"}) - assert "CLAUDE" in result - - def test_list_in_array_context(self, tmp_path: Path) -> None: - """List value in array context should expand to multiple items.""" - p = tmp_path / "test.yml" - p.write_text(' - "{{files}}"') - result = resolve_templates(p, {"files": ["a.md", "b.md"]}) - assert '"a.md"' in result - assert '"b.md"' in result - - def test_list_in_regex_context(self, tmp_path: Path) -> None: - """List value in pattern-regex context should produce alternation.""" - p = tmp_path / "test.yml" - p.write_text(' pattern-regex: "{{patterns}}"') - result = resolve_templates(p, {"patterns": ["*.md", "*.txt"]}) - assert "(" in result - assert "|" in result - - def test_missing_placeholder(self, tmp_path: Path) -> None: - """Template with placeholder not in context should remain unreplaced.""" - p = tmp_path / "test.yml" - p.write_text(' pattern-regex: "{{missing}}"') - result = resolve_templates(p, {"other": "value"}) - assert "{{missing}}" in result - - def test_template_injection_attempt(self, tmp_path: Path) -> None: - """Context values with regex metacharacters should be inserted literally. - - This is NOT an attack vector for the template system itself, but the - resulting regex could be broken. Template resolution does simple - string substitution — it's the compiler's re.compile that would fail. - """ - p = tmp_path / "test.yml" - p.write_text(' pattern-regex: "{{value}}"') - result = resolve_templates(p, {"value": "[unclosed"}) - assert "[unclosed" in result - - def test_glob_to_regex_special_chars(self) -> None: - """glob_to_regex should escape regex special chars.""" - assert _glob_to_regex("file.md") == "file\\\\.md" # for YAML - assert _glob_to_regex("file.md", for_yaml=False) == "file\\.md" # raw - - def test_glob_to_regex_double_star(self) -> None: - """**/ prefix should be stripped, ** in middle consumes trailing /.""" - assert _glob_to_regex("**/CLAUDE.md") == "CLAUDE\\\\.md" - # ** consumes the trailing /, so docs/**/file.md → docs/.*file\.md - # This is correct: .* already matches /, so it matches docs/sub/file.md - assert _glob_to_regex("docs/**/file.md", for_yaml=False) == "docs/.*file\\.md" - - def test_glob_to_regex_single_star(self) -> None: - """Single * should not match /""" - result = _glob_to_regex("*.md", for_yaml=False) - assert result == "[^/]*\\.md" - - def test_template_with_empty_list(self, tmp_path: Path) -> None: - """Empty list value should produce empty expansion.""" - p = tmp_path / "test.yml" - p.write_text(' - "{{files}}"') - result = resolve_templates(p, {"files": []}) - # Empty list → no items generated - lines = [line for line in result.split("\n") if line.strip()] - assert lines == [] - - def test_template_with_empty_list_regex_context(self, tmp_path: Path) -> None: - """Empty list in regex context should produce empty alternation.""" - p = tmp_path / "test.yml" - p.write_text(' pattern-regex: "{{patterns}}"') - result = resolve_templates(p, {"patterns": []}) - # Produces "()" — empty group, which is valid but matches empty string - assert "()" in result - - -# =========================================================================== -# 8. INTEGRATION: FULL PIPELINE EDGE CASES +# 7. INTEGRATION: FULL PIPELINE EDGE CASES # =========================================================================== class TestIntegration: """Full pipeline integration tests with edge cases.""" - def test_negate_interaction_with_regex(self, tmp_path: Path) -> None: - """Test that regex results can be consumed by negated deterministic checks. + def test_expect_present_interaction_with_regex(self, tmp_path: Path) -> None: + """Test that regex results can be consumed by expect=present deterministic checks. - In the pipeline, negate=True inverts the result. With the regex engine, - this means: if regex finds no match → treat as violation. - This tests the SARIF output shape is compatible. + In the pipeline, expect=present means: if regex finds no match → violation. + This tests the SARIF output shape is compatible with evidence-based checks. """ rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "NEG-DET", "pattern-regex": "(?i)\\bMCP\\b", "message": "MCP not documented"}]}, + {"checks": [{"id": "NEG-DET", "pattern-regex": "(?i)\\bMCP\\b", "message": "MCP not documented"}]}, ) - # File mentions MCP → match → if negate=True in pipeline, this would be NOT a violation + # File mentions MCP → match → expect=present means this is evidence → pass _write_target(tmp_path, "# MCP Config\nUse MCP servers\n") sarif = run_validation([rule_yml], tmp_path) results = _sarif_results(sarif) - # Should produce a match (pipeline negate happens downstream) + # Should produce a match (expect logic happens downstream in pipeline) assert len(results) == 1 def test_exclude_dirs(self, tmp_path: Path) -> None: """--exclude-dir should prevent scanning those directories.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "EXCL", "pattern-regex": "secret", "message": "x"}]}, + {"checks": [{"id": "EXCL", "pattern-regex": "secret", "message": "x"}]}, ) # Create files in included and excluded directories sub = tmp_path / "vendor" @@ -897,7 +796,7 @@ def test_symlink_extra_targets(self, tmp_path: Path) -> None: """Extra targets (from symlinks) should be scanned.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "SYM", "pattern-regex": "external", "message": "x"}]}, + {"checks": [{"id": "SYM", "pattern-regex": "external", "message": "x"}]}, ) project = tmp_path / "project" project.mkdir() @@ -918,7 +817,7 @@ def test_instruction_files_explicit(self, tmp_path: Path) -> None: """When instruction_files is provided, only those files should be scanned.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "IF", "pattern-regex": "content", "message": "x"}]}, + {"checks": [{"id": "IF", "pattern-regex": "content", "message": "x"}]}, ) a = _write_target(tmp_path, "content in A\n", "a.md") _write_target(tmp_path, "content in B\n", "b.md") @@ -929,18 +828,11 @@ def test_instruction_files_explicit(self, tmp_path: Path) -> None: assert any("a.md" in u for u in uris) assert not any("b.md" in u for u in uris) - def test_capability_detection_runs(self, tmp_path: Path) -> None: - """run_capability_detection should work with bundled patterns.""" - _write_target(tmp_path, "# Project\n\n## Structure\n\nSome content\n") - sarif = run_capability_detection(tmp_path) - # Should produce results (the bundled patterns detect features) - assert "runs" in sarif - def test_zero_byte_file(self, tmp_path: Path) -> None: """Zero-byte file should not crash.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "ZERO", "pattern-regex": "anything", "message": "x"}]}, + {"checks": [{"id": "ZERO", "pattern-regex": "anything", "message": "x"}]}, ) (tmp_path / "empty.md").write_text("") @@ -951,7 +843,7 @@ def test_file_with_only_newlines(self, tmp_path: Path) -> None: """File with only newlines should not crash.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "NL", "pattern-regex": "\\S", "message": "x"}]}, + {"checks": [{"id": "NL", "pattern-regex": "\\S", "message": "x"}]}, ) (tmp_path / "newlines.md").write_text("\n\n\n\n") @@ -962,7 +854,7 @@ def test_very_long_line(self, tmp_path: Path) -> None: """File with a single very long line should not crash.""" rule_yml = _write_rule( tmp_path, - {"rules": [{"id": "VLONG", "pattern-regex": "needle", "message": "x"}]}, + {"checks": [{"id": "VLONG", "pattern-regex": "needle", "message": "x"}]}, ) # 1MB line with needle buried in the middle content = "x" * 500000 + "needle" + "x" * 500000 @@ -981,7 +873,7 @@ def test_pattern_either_all_sub_unsupported(self, tmp_path: Path) -> None: rule_yml = _write_rule( tmp_path, { - "rules": [ + "checks": [ { "id": "BAD-SUB", "pattern-either": [ diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index b988f2b4..22664c4f 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -14,9 +14,8 @@ "title": "Test Rule", "category": "structure", "type": "deterministic", - "level": "L2", "slug": "test-rule", - "targets": "{{instruction_files}}", + "match": {"type": "main"}, } @@ -76,9 +75,9 @@ def test_minimal_rule(self) -> None: assert rule.title == "Test Rule" assert rule.category == Category.STRUCTURE assert rule.type == RuleType.DETERMINISTIC - assert rule.level == "L2" assert rule.slug == "test-rule" - assert rule.targets == "{{instruction_files}}" + assert rule.match is not None + assert rule.match.type == "main" def test_checks_parsed_new_format(self) -> None: fm = { @@ -93,7 +92,8 @@ def test_checks_parsed_new_format(self) -> None: assert rule.checks[0].id == "CORE:S:0001:check:0001" assert rule.checks[0].type == "mechanical" assert rule.checks[0].check == "file_exists" - assert rule.checks[0].severity.value == "critical" + # Severity derived from first check's frontmatter entry → rule level + assert rule.severity.value == "critical" assert rule.checks[1].type == "deterministic" assert rule.checks[1].check is None diff --git a/tests/unit/test_rule_runner.py b/tests/unit/test_rule_runner.py new file mode 100644 index 00000000..eced3b1d --- /dev/null +++ b/tests/unit/test_rule_runner.py @@ -0,0 +1,70 @@ +"""Tests for core/rule_runner.py — M-probe dispatch.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +class TestRunMProbes: + """Verify run_m_probes dispatches mechanical and deterministic checks.""" + + def test_returns_list(self, dev_rules_dir: Path, level2_project: Path) -> None: + """run_m_probes should return a list of LocalFinding.""" + from reporails_cli.core.agents import get_all_instruction_files + from reporails_cli.core.rule_runner import run_m_probes + + files = get_all_instruction_files(level2_project) + if not files: + pytest.skip("No instruction files in fixture") + findings = run_m_probes(level2_project, files) + assert isinstance(findings, list) + + def test_findings_are_local_finding(self, dev_rules_dir: Path, level2_project: Path) -> None: + """Each finding should be a LocalFinding instance.""" + from reporails_cli.core.agents import get_all_instruction_files + from reporails_cli.core.models import LocalFinding + from reporails_cli.core.rule_runner import run_m_probes + + files = get_all_instruction_files(level2_project) + if not files: + pytest.skip("No instruction files in fixture") + findings = run_m_probes(level2_project, files) + for f in findings: + assert isinstance(f, LocalFinding) + assert f.source == "m_probe" + + def test_findings_sorted_by_severity(self, dev_rules_dir: Path, level2_project: Path) -> None: + """Findings should be sorted by severity (error < warning < info).""" + from reporails_cli.core.agents import get_all_instruction_files + from reporails_cli.core.rule_runner import run_m_probes + + files = get_all_instruction_files(level2_project) + if not files: + pytest.skip("No instruction files in fixture") + findings = run_m_probes(level2_project, files) + severity_order = {"error": 0, "warning": 1, "info": 2} + for i in range(len(findings) - 1): + assert severity_order.get(findings[i].severity, 9) <= severity_order.get(findings[i + 1].severity, 9) + + def test_agent_specific_rules_load(self, dev_rules_dir: Path, level2_project: Path) -> None: + """When agent='claude' is passed, CLAUDE-namespaced rules are loaded and checked.""" + from reporails_cli.core.agents import get_all_instruction_files + from reporails_cli.core.rule_runner import run_m_probes + + files = get_all_instruction_files(level2_project) + if not files: + pytest.skip("No instruction files in fixture") + findings = run_m_probes(level2_project, files, agent="claude") + rules_hit = {f.rule for f in findings} + # With agent="claude", we should get both CORE and CLAUDE rules loaded. + # At minimum, CORE rules still fire. + assert any(r.startswith("CORE:") for r in rules_hit) + + def test_no_agent_loads_only_core(self, dev_rules_dir: Path, level2_project: Path) -> None: + """Without agent param, only CORE rules load — no agent-specific rules.""" + from reporails_cli.core.registry import load_rules + + rules = load_rules(project_root=level2_project, scan_root=level2_project) + assert all(not k.startswith("CLAUDE:") for k in rules) diff --git a/tests/unit/test_rule_validation.py b/tests/unit/test_rule_validation.py index 67e506bd..26c9ae2b 100644 --- a/tests/unit/test_rule_validation.py +++ b/tests/unit/test_rule_validation.py @@ -19,7 +19,7 @@ def test_pattern_regex_matches(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-simple message: "Found a TODO comment" severity: WARNING @@ -42,7 +42,7 @@ def test_pattern_either_matches_any(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-either message: "Found test framework" severity: WARNING @@ -68,7 +68,7 @@ def test_patterns_block_with_not_regex(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-patterns-and message: "File has content but no Commands section" severity: WARNING @@ -95,7 +95,7 @@ def test_patterns_block_negation_suppresses(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-neg-suppresses message: "Missing commands" severity: WARNING @@ -122,7 +122,7 @@ def test_no_pattern_operator_skipped(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-no-pattern message: "No pattern" severity: WARNING @@ -145,7 +145,7 @@ def test_path_filter_includes_matching(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-path message: "Found in md" severity: WARNING @@ -172,7 +172,7 @@ def test_no_path_filter_scans_all_md(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-no-filter message: "Found" severity: WARNING @@ -196,7 +196,7 @@ def test_line_number_accuracy(self, tmp_path: Path) -> None: from reporails_cli.core.regex import run_validation rule_yaml = """\ -rules: +checks: - id: test-lines message: "Found TODO" severity: WARNING diff --git a/tests/unit/test_sarif.py b/tests/unit/test_sarif.py deleted file mode 100644 index ce9962b7..00000000 --- a/tests/unit/test_sarif.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Unit tests for SARIF parsing — all functions are pure, no mocking needed.""" - -from __future__ import annotations - -import pytest - -from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity, Violation -from reporails_cli.core.sarif import ( - dedupe_violations, - extract_check_id, - extract_rule_id, - get_location, - get_severity, - parse_sarif, -) - -# --------------------------------------------------------------------------- -# extract_rule_id (coordinate format) -# --------------------------------------------------------------------------- - - -class TestExtractRuleId: - @pytest.mark.parametrize( - "raw, expected", - [ - ("CORE.S.0001.check.0001", "CORE:S:0001"), - ("CLAUDE.S.0002.check.0001", "CLAUDE:S:0002"), - ("RRAILS_CLAUDE.S.0002.check.0001", "RRAILS_CLAUDE:S:0002"), - ("CORE.C.0010", "CORE:C:0010"), # no check suffix - ("some-unexpected-format", "some-unexpected-format"), # no dots - # Temp path prefix handling (template-resolved yml files) - ("tmp.tmpbb5ongfm.CORE.C.0006.check.0001", "CORE:C:0006"), - ("tmp.tmpXXXXXX.CLAUDE.S.0005.check.0001", "CLAUDE:S:0005"), - ("tmp.abc123.RRAILS.C.0001.check.0001", "RRAILS:C:0001"), - ("tmp.xyz.RRAILS_CLAUDE.S.0002.check.0001", "RRAILS_CLAUDE:S:0002"), - ("tmp.foo.CODEX.S.0001.check.0001", "CODEX:S:0001"), - ], - ) - def test_extract_rule_id(self, raw: str, expected: str) -> None: - assert extract_rule_id(raw) == expected - - -# --------------------------------------------------------------------------- -# extract_check_id -# --------------------------------------------------------------------------- - - -class TestExtractCheckId: - def test_standard_check_id(self) -> None: - assert extract_check_id("CORE.S.0001.check.0001") == "check:0001" - - def test_no_check_suffix_returns_none(self) -> None: - assert extract_check_id("CORE.S.0001") is None - - def test_two_parts_returns_none(self) -> None: - assert extract_check_id("CORE.S") is None - - # Temp path prefix handling - - def test_temp_prefix_stripped(self) -> None: - assert extract_check_id("tmp.tmpbb5ongfm.CORE.C.0006.check.0001") == "check:0001" - - def test_temp_prefix_stripped_no_check(self) -> None: - """Coordinate with temp prefix but no check suffix.""" - assert extract_check_id("tmp.xyz.CORE.S.0001") is None - - -# --------------------------------------------------------------------------- -# get_location -# --------------------------------------------------------------------------- - - -class TestGetLocation: - def test_normal_result(self) -> None: - result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": {"startLine": 42}, - } - } - ] - } - assert get_location(result) == "CLAUDE.md:42" - - def test_missing_locations(self) -> None: - assert get_location({}) == "unknown" - assert get_location({"locations": []}) == "unknown" - - def test_missing_region(self) -> None: - result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "file.md"}, - } - } - ] - } - assert get_location(result) == "file.md:0" - - -# --------------------------------------------------------------------------- -# get_severity -# --------------------------------------------------------------------------- - - -def _make_rule(checks: list[Check]) -> Rule: - return Rule( - id="CORE:S:0001", - title="Test", - category=Category.STRUCTURE, - type=RuleType.DETERMINISTIC, - level="L2", - checks=checks, - ) - - -class TestGetSeverity: - @pytest.mark.parametrize( - "checks, check_id, expected", - [ - (None, "check:0001", Severity.MEDIUM), # rule=None fallback - ([Check(id="CORE:S:0001:check:0001", severity=Severity.HIGH)], "check:0001", Severity.HIGH), - ( - [Check(id="CORE:S:0001:check:0001", severity=Severity.LOW)], - "check:9999", - Severity.LOW, - ), # no match → first - ([], "check:0001", Severity.MEDIUM), # empty checks fallback - ], - ids=["rule-none", "matching-check", "no-match-returns-first", "empty-checks"], - ) - def test_severity_resolution(self, checks: list[Check] | None, check_id: str, expected: Severity) -> None: - rule = _make_rule(checks) if checks is not None else None - assert get_severity(rule, check_id) == expected - - -# --------------------------------------------------------------------------- -# parse_sarif -# --------------------------------------------------------------------------- - - -class TestParseSarif: - def _rule(self, rule_id: str, checks: list[Check] | None = None) -> Rule: - return Rule( - id=rule_id, - title=f"Rule {rule_id}", - category=Category.STRUCTURE, - type=RuleType.DETERMINISTIC, - level="L2", - checks=checks or [Check(id=f"{rule_id}:check:0001", severity=Severity.MEDIUM)], - ) - - def test_empty_runs(self) -> None: - assert parse_sarif({"runs": []}, {}) == [] - assert parse_sarif({}, {}) == [] - - def test_info_findings_skipped(self) -> None: - sarif = { - "runs": [ - { - "tool": { - "driver": { - "rules": [{"id": "CORE.S.0001.check.0001", "defaultConfiguration": {"level": "note"}}] - } - }, - "results": [ - { - "ruleId": "CORE.S.0001.check.0001", - "message": {"text": "info finding"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "f.md"}, "region": {"startLine": 1}}} - ], - } - ], - } - ] - } - assert parse_sarif(sarif, {"CORE:S:0001": self._rule("CORE:S:0001")}) == [] - - def test_unknown_rules_skipped(self) -> None: - sarif = { - "runs": [ - { - "tool": {"driver": {"rules": []}}, - "results": [ - { - "ruleId": "CORE.S.9999.check.0001", - "message": {"text": "msg"}, - "locations": [ - {"physicalLocation": {"artifactLocation": {"uri": "f.md"}, "region": {"startLine": 1}}} - ], - } - ], - } - ] - } - assert parse_sarif(sarif, {"CORE:S:0001": self._rule("CORE:S:0001")}) == [] - - def test_full_parse(self) -> None: - sarif = { - "runs": [ - { - "tool": { - "driver": { - "rules": [{"id": "CORE.S.0001.check.0001", "defaultConfiguration": {"level": "warning"}}] - } - }, - "results": [ - { - "ruleId": "CORE.S.0001.check.0001", - "message": {"text": "violation msg"}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": {"startLine": 10}, - } - } - ], - } - ], - } - ] - } - rules = {"CORE:S:0001": self._rule("CORE:S:0001")} - violations = parse_sarif(sarif, rules) - - assert len(violations) == 1 - v = violations[0] - assert v.rule_id == "CORE:S:0001" - assert v.location == "CLAUDE.md:10" - assert v.message == "violation msg" - assert v.check_id == "check:0001" - - def test_temp_prefixed_ruleid_parsed(self) -> None: - """SARIF ruleIds with temp directory prefix are matched to rules.""" - sarif = { - "runs": [ - { - "tool": { - "driver": { - "rules": [ - { - "id": "tmp.tmpXXX.CORE.C.0006.check.0001", - "defaultConfiguration": {"level": "warning"}, - } - ] - } - }, - "results": [ - { - "ruleId": "tmp.tmpXXX.CORE.C.0006.check.0001", - "message": {"text": "vague qualifier found"}, - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": {"startLine": 7}, - } - } - ], - } - ], - } - ] - } - rules = {"CORE:C:0006": self._rule("CORE:C:0006")} - violations = parse_sarif(sarif, rules) - - assert len(violations) == 1 - assert violations[0].rule_id == "CORE:C:0006" - assert violations[0].check_id == "check:0001" - - -# --------------------------------------------------------------------------- -# dedupe_violations -# --------------------------------------------------------------------------- - - -class TestViolationDeduplication: - """Test that duplicate violations are handled correctly.""" - - def test_duplicate_violations_deduped(self) -> None: - """Duplicate violations (same file, rule, check) should be deduplicated.""" - violations = [ - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="test.md:1", - message="Same violation", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="test.md:5", - message="Same violation", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - ] - assert len(dedupe_violations(violations)) == 1 - - def test_different_files_not_deduped(self) -> None: - """Same rule in different files should not be deduped.""" - violations = [ - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="a.md:1", - message="Violation", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="b.md:1", - message="Violation", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - ] - assert len(dedupe_violations(violations)) == 2 - - def test_different_rules_not_deduped(self) -> None: - """Different rules in same file should not be deduped.""" - violations = [ - Violation( - rule_id="CORE:S:0001", - rule_title="Rule 1", - location="test.md:1", - message="Violation 1", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - Violation( - rule_id="CORE:S:0002", - rule_title="Rule 2", - location="test.md:1", - message="Violation 2", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - ] - assert len(dedupe_violations(violations)) == 2 - - def test_different_checks_not_deduped(self) -> None: - """Different checks on same rule/file should not be deduped.""" - violations = [ - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="test.md:1", - message="Check 1", - severity=Severity.MEDIUM, - check_id="check:0001", - ), - Violation( - rule_id="CORE:S:0001", - rule_title="Test", - location="test.md:5", - message="Check 2", - severity=Severity.HIGH, - check_id="check:0002", - ), - ] - assert len(dedupe_violations(violations)) == 2 diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index f5f84bc3..3513f2ed 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -2,6 +2,10 @@ Verifies that `ails check /foo` only discovers and validates files inside /foo, even when /foo is nested inside a larger project with .git or backbone markers. + +External instruction surface files (~/..., absolute paths from config patterns) +are intentionally included by agent discovery — they are part of the instruction +surface even though they live outside the repo. Scope assertions filter these out. """ from __future__ import annotations @@ -17,6 +21,11 @@ from reporails_cli.core.engine_helpers import _find_project_root +def _is_external(f: Path, scope: Path) -> bool: + """True if f is an external instruction surface file (outside any tmp scope).""" + return not str(f).startswith("/tmp/") + + def _make_nested_project(tmp_path: Path) -> Path: """Create a parent project with a child subdirectory, each with instruction files. @@ -53,7 +62,7 @@ def _make_backbone_project(tmp_path: Path) -> Path: Structure: tmp/ ├── .git/ - ├── .reporails/backbone.yml + ├── .ails/backbone.yml ├── CLAUDE.md └── child/ └── CLAUDE.md @@ -61,8 +70,8 @@ def _make_backbone_project(tmp_path: Path) -> Path: Returns the child directory path. """ (tmp_path / ".git").mkdir() - (tmp_path / ".reporails").mkdir() - (tmp_path / ".reporails" / "backbone.yml").write_text("version: 3\n") + (tmp_path / ".ails").mkdir() + (tmp_path / ".ails" / "backbone.yml").write_text("version: 3\n") (tmp_path / "CLAUDE.md").write_text("# Parent instructions") child = tmp_path / "child" @@ -88,11 +97,13 @@ def test_child_does_not_see_parent_files(self, tmp_path: Path) -> None: all_files.extend(a.rule_files) all_files.extend(a.config_files) - for f in all_files: + # External instruction surface files (~/...) are by-design outside the repo + local_files = [f for f in all_files if not _is_external(f, child)] + for f in local_files: assert str(f).startswith(str(child)), f"File outside child scope: {f}" - def test_parent_sees_only_root_files(self, tmp_path: Path) -> None: - """Detection uses shallow globs — child CLAUDE.md is not found.""" + def test_parent_sees_hierarchical_files(self, tmp_path: Path) -> None: + """Config-driven discovery with **/CLAUDE.md finds hierarchical instruction files.""" _make_nested_project(tmp_path) agents = detect_agents(tmp_path) @@ -101,8 +112,8 @@ def test_parent_sees_only_root_files(self, tmp_path: Path) -> None: all_files.extend(a.instruction_files) paths = {str(f) for f in all_files} - assert not any("child/CLAUDE.md" in p for p in paths), "Should not recurse into child" - assert any(p.endswith("/CLAUDE.md") for p in paths), "Should find root CLAUDE.md" + assert any(p.endswith("/CLAUDE.md") and "child" not in p for p in paths), "Should find root CLAUDE.md" + assert any("child/CLAUDE.md" in p for p in paths), "Should find child CLAUDE.md via ** pattern" class TestInstructionFilesScope: @@ -137,7 +148,9 @@ def test_child_scope(self, tmp_path: Path) -> None: child = _make_nested_project(tmp_path) files = get_all_scannable_files(child) - for f in files: + # External instruction surface files (~/...) are by-design outside the repo + local_files = [f for f in files if not _is_external(f, child)] + for f in local_files: assert str(f).startswith(str(child)), f"File outside child scope: {f}" def test_child_does_not_include_parent_rules(self, tmp_path: Path) -> None: @@ -161,9 +174,10 @@ def test_project_root_above_scan_root(self, tmp_path: Path) -> None: project_root = _find_project_root(child) assert project_root == tmp_path, "project_root should walk up to backbone" - # But file discovery must stay within child + # But file discovery must stay within child (external surface files excluded) files = get_all_scannable_files(child) - for f in files: + local_files = [f for f in files if not _is_external(f, child)] + for f in local_files: assert str(f).startswith(str(child)), f"File outside scan scope: {f}" def test_project_root_equals_scan_root_when_no_parent(self, tmp_path: Path) -> None: @@ -211,5 +225,7 @@ def test_engine_uses_scan_root_agents(self, tmp_path: Path) -> None: agents = detect_agents(scan_root) files = get_all_scannable_files(scan_root, agents=agents) - for f in files: + # External instruction surface files (~/...) are by-design outside the repo + local_files = [f for f in files if not _is_external(f, child)] + for f in local_files: assert str(f).startswith(str(child)), f"File outside scan scope: {f}" diff --git a/tests/unit/test_scoring.py b/tests/unit/test_scoring.py deleted file mode 100644 index 5a2219db..00000000 --- a/tests/unit/test_scoring.py +++ /dev/null @@ -1,445 +0,0 @@ -"""Scoring unit tests - scores must be reproducible and match spec. - -The score (0-10) represents how well a project follows reporails rules. -Scoring must be deterministic and predictable. - -These tests are pure unit tests with no external dependencies. -""" - -from __future__ import annotations - -from reporails_cli.core.models import Severity, Violation - - -class TestScoreCalculation: - """Test score calculation from violations.""" - - def test_no_violations_perfect_score(self) -> None: - """No violations should result in perfect 10.0 score.""" - from reporails_cli.core.scorer import calculate_score - - score = calculate_score(rules_checked=10, violations=[]) - - assert score == 10.0, f"No violations should give 10.0, got {score}" - - def test_violations_reduce_score(self) -> None: - """Violations should reduce score from 10.0.""" - from reporails_cli.core.scorer import calculate_score - - violations = [ - Violation( - rule_id="S1", - rule_title="Test Rule", - location="test.md:1", - message="Test violation", - severity=Severity.MEDIUM, - check_id="test-check", - ) - ] - - score = calculate_score(rules_checked=10, violations=violations) - - assert score < 10.0, f"Violations should reduce score below 10.0, got {score}" - assert score >= 0.0, f"Score should not go below 0, got {score}" - - def test_more_violations_lower_score(self) -> None: - """More violations should result in lower score.""" - from reporails_cli.core.scorer import calculate_score - - one_violation = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - ] - - three_violations = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - for i in range(3) - ] - - score_one = calculate_score(rules_checked=10, violations=one_violation) - score_three = calculate_score(rules_checked=10, violations=three_violations) - - assert score_three < score_one, ( - f"More violations should give lower score: 1 violation={score_one}, 3 violations={score_three}" - ) - - def test_higher_severity_more_impact(self) -> None: - """Higher severity violations should impact score more.""" - from reporails_cli.core.scorer import calculate_score - - low_violation = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.LOW, - check_id="test", - ) - ] - - critical_violation = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.CRITICAL, - check_id="test", - ) - ] - - score_low = calculate_score(rules_checked=10, violations=low_violation) - score_critical = calculate_score(rules_checked=10, violations=critical_violation) - - assert score_critical < score_low, ( - f"Critical violation should impact more than low: LOW={score_low}, CRITICAL={score_critical}" - ) - - -class TestScoreDeterminism: - """Test that scoring is deterministic.""" - - def test_same_violations_same_score(self) -> None: - """Same violations should always produce same score.""" - from reporails_cli.core.scorer import calculate_score - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - ] - - scores = [calculate_score(rules_checked=10, violations=violations) for _ in range(5)] - - assert len(set(scores)) == 1, f"Same violations should give same score, got: {scores}" - - def test_violation_order_does_not_affect_score(self) -> None: - """Order of violations should not affect score.""" - from reporails_cli.core.scorer import calculate_score - - v1 = Violation( - rule_id="S1", - rule_title="Rule 1", - location="a.md:1", - message="Test", - severity=Severity.HIGH, - check_id="test", - ) - v2 = Violation( - rule_id="S2", - rule_title="Rule 2", - location="b.md:1", - message="Test", - severity=Severity.LOW, - check_id="test", - ) - - score_12 = calculate_score(rules_checked=10, violations=[v1, v2]) - score_21 = calculate_score(rules_checked=10, violations=[v2, v1]) - - assert score_12 == score_21, f"Violation order should not matter: [v1,v2]={score_12}, [v2,v1]={score_21}" - - -class TestScoreBounds: - """Test that scores stay within valid bounds.""" - - def test_score_minimum_zero(self) -> None: - """Score should never go below 0.""" - from reporails_cli.core.scorer import calculate_score - - # Create many high-severity violations - many_violations = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test{i}.md:1", - message="Test", - severity=Severity.CRITICAL, - check_id="test", - ) - for i in range(100) - ] - - score = calculate_score(rules_checked=5, violations=many_violations) - - assert score >= 0.0, f"Score should not go below 0, got {score}" - - def test_score_maximum_ten(self) -> None: - """Score should never exceed 10.""" - from reporails_cli.core.scorer import calculate_score - - score = calculate_score(rules_checked=100, violations=[]) - - assert score <= 10.0, f"Score should not exceed 10, got {score}" - - def test_score_zero_rules_checked(self) -> None: - """Zero rules checked should return perfect score (no rules = nothing failed).""" - from reporails_cli.core.scorer import calculate_score - - score = calculate_score(rules_checked=0, violations=[]) - - assert score == 10.0 - - -class TestScoreAnchored: - """Anchored score values — catch formula regressions.""" - - def test_single_medium_violation_anchored(self) -> None: - """One MEDIUM violation out of 10 rules: lost 2.5, earned 22.5/25 = 9.0.""" - from reporails_cli.core.scorer import calculate_score - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - ] - - score = calculate_score(rules_checked=10, violations=violations) - assert score == 9.0, f"Expected 9.0, got {score}" - - def test_single_critical_violation_anchored(self) -> None: - """One CRITICAL violation out of 10 rules: capped at 2.5, earned 22.5/25 = 9.0.""" - from reporails_cli.core.scorer import calculate_score - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.CRITICAL, - check_id="test", - ) - ] - - score = calculate_score(rules_checked=10, violations=violations) - assert score == 9.0, f"Expected 9.0 (capped at rule weight), got {score}" - - def test_per_rule_cap_limits_deduction(self) -> None: - """Multiple violations of the same rule should not deduct more than DEFAULT_RULE_WEIGHT.""" - from reporails_cli.core.scorer import calculate_score - - # 3 MEDIUM violations (3 * 2.5 = 7.5) for one rule — capped at 2.5 - same_rule_violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - for i in range(3) - ] - - # 1 violation of a different rule - different_rule_violation = [ - Violation( - rule_id="S2", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - ] - - score_same = calculate_score(rules_checked=10, violations=same_rule_violations) - score_diff = calculate_score(rules_checked=10, violations=different_rule_violation) - - # Same rule 3x should be capped at 2.5 total, same as 1 violation of 1 rule - assert score_same == score_diff, ( - f"Per-rule cap: 3 violations of 1 rule ({score_same}) should equal 1 violation ({score_diff})" - ) - - def test_different_rules_deduct_independently(self) -> None: - """Violations of different rules should deduct independently.""" - from reporails_cli.core.scorer import calculate_score - - # 1 violation of 1 rule - one_rule = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - ] - - # 1 violation of each of 2 different rules - two_rules = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.MEDIUM, - check_id="test", - ) - for i in range(2) - ] - - score_one = calculate_score(rules_checked=10, violations=one_rule) - score_two = calculate_score(rules_checked=10, violations=two_rules) - - assert score_two < score_one, "2 rules violated should score lower than 1 rule violated" - assert score_one == 9.0, f"1 MEDIUM violation out of 10 rules: expected 9.0, got {score_one}" - assert score_two == 8.0, f"2 MEDIUM violations out of 10 rules: expected 8.0, got {score_two}" - - -class TestEstimateFriction: - """Test friction estimation from violations.""" - - def test_no_violations_none(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - result = estimate_friction([]) - assert result.level == "none" - - def test_critical_extreme(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.CRITICAL, - check_id="test", - ) - ] - result = estimate_friction(violations) - assert result.level == "extreme" - - def test_two_high_is_high(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - violations = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.HIGH, - check_id="test", - ) - for i in range(2) - ] - result = estimate_friction(violations) - assert result.level == "high" - - def test_one_high_is_medium(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.HIGH, - check_id="test", - ) - ] - result = estimate_friction(violations) - assert result.level == "medium" - - def test_five_low_is_high(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - violations = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.LOW, - check_id="test", - ) - for i in range(5) - ] - result = estimate_friction(violations) - assert result.level == "high" - - def test_two_low_is_small(self) -> None: - from reporails_cli.core.scorer import estimate_friction - - violations = [ - Violation( - rule_id=f"S{i}", - rule_title="Test", - location=f"test.md:{i}", - message="Test", - severity=Severity.LOW, - check_id="test", - ) - for i in range(2) - ] - result = estimate_friction(violations) - assert result.level == "small" - - -class TestHasCriticalViolations: - """Test has_critical_violations.""" - - def test_empty_list(self) -> None: - from reporails_cli.core.scorer import has_critical_violations - - assert has_critical_violations([]) is False - - def test_no_critical(self) -> None: - from reporails_cli.core.scorer import has_critical_violations - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.HIGH, - check_id="test", - ) - ] - assert has_critical_violations(violations) is False - - def test_has_critical(self) -> None: - from reporails_cli.core.scorer import has_critical_violations - - violations = [ - Violation( - rule_id="S1", - rule_title="Test", - location="test.md:1", - message="Test", - severity=Severity.CRITICAL, - check_id="test", - ) - ] - assert has_critical_violations(violations) is True diff --git a/tests/unit/test_semantic.py b/tests/unit/test_semantic.py deleted file mode 100644 index ce3269c0..00000000 --- a/tests/unit/test_semantic.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Unit tests for semantic rule request building.""" - -from __future__ import annotations - -from pathlib import Path - -from reporails_cli.core.models import ( - Category, - Check, - JudgmentRequest, - Rule, - RuleType, - Severity, -) -from reporails_cli.core.semantic import ( - _parse_choices, - _parse_criteria, - build_request, - build_request_from_sarif_result, - extract_snippet, -) - - -def _make_rule(**overrides) -> Rule: - defaults = { - "id": "C6", - "title": "Test", - "category": Category.CONTENT, - "type": RuleType.SEMANTIC, - "level": "L2", - "checks": [Check(id="c6-check", name="check", severity=Severity.MEDIUM)], - "question": "Is it good?", - "criteria": None, - "choices": None, - "examples": None, - "pass_value": "pass", - "targets": "CLAUDE.md", - "slug": "test", - "see_also": [], - "supersedes": None, - "md_path": None, - "yml_path": None, - } - defaults.update(overrides) - return Rule(**defaults) - - -# --------------------------------------------------------------------------- -# _parse_criteria -# --------------------------------------------------------------------------- - - -class TestParseCriteria: - def test_none_returns_default(self) -> None: - result = _parse_criteria(None) - assert result == {"pass_condition": "Evaluate based on context"} - - def test_string_returns_pass_condition(self) -> None: - result = _parse_criteria("Instructions are clear") - assert result == {"pass_condition": "Instructions are clear"} - - def test_list_of_dicts(self) -> None: - criteria = [ - {"key": "clarity", "check": "Instructions are clear"}, - {"key": "scope", "check": "Scope is defined"}, - ] - result = _parse_criteria(criteria) - assert result == {"clarity": "Instructions are clear", "scope": "Scope is defined"} - - -# --------------------------------------------------------------------------- -# _parse_choices -# --------------------------------------------------------------------------- - - -class TestParseChoices: - def test_none_returns_default(self) -> None: - result = _parse_choices(None) - assert result == ["pass", "fail"] - - def test_list_of_strings(self) -> None: - result = _parse_choices(["yes", "no", "maybe"]) - assert result == ["yes", "no", "maybe"] - - def test_list_of_dicts(self) -> None: - choices = [ - {"value": "pass", "label": "Passes"}, - {"value": "fail", "label": "Fails"}, - ] - result = _parse_choices(choices) - assert result == ["pass", "fail"] - - -# --------------------------------------------------------------------------- -# build_request -# --------------------------------------------------------------------------- - - -class TestBuildRequest: - def test_missing_question_returns_none(self) -> None: - rule = _make_rule(question=None) - result = build_request(rule, "some content", "CLAUDE.md:1") - assert result is None - - def test_valid_rule_returns_judgment_request(self) -> None: - rule = _make_rule( - checks=[Check(id="c6-check", name="check", severity=Severity.HIGH)], - ) - result = build_request(rule, "file content", "CLAUDE.md:10") - - assert isinstance(result, JudgmentRequest) - assert result.rule_id == "C6" - assert result.rule_title == "Test" - assert result.content == "file content" - assert result.location == "CLAUDE.md:10" - assert result.question == "Is it good?" - assert result.severity == Severity.HIGH - assert result.pass_value == "pass" - assert result.choices == ["pass", "fail"] - assert result.points_if_fail == -10 - - -# --------------------------------------------------------------------------- -# extract_snippet -# --------------------------------------------------------------------------- - - -class TestExtractSnippet: - def test_snippet_in_sarif(self) -> None: - result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": { - "startLine": 5, - "snippet": {"text": "matched text"}, - }, - } - } - ] - } - snippet = extract_snippet(result, Path("/tmp/project")) - assert snippet == "matched text" - - def test_missing_file_returns_none(self) -> None: - result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "nonexistent.md"}, - "region": {"startLine": 5}, - } - } - ] - } - snippet = extract_snippet(result, Path("/tmp/does-not-exist")) - assert snippet is None - - -# --------------------------------------------------------------------------- -# build_request_from_sarif_result -# --------------------------------------------------------------------------- - - -class TestBuildRequestFromSarifResult: - def test_valid_data_returns_request(self) -> None: - rule = _make_rule() - sarif_result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "CLAUDE.md"}, - "region": { - "startLine": 3, - "snippet": {"text": "some instruction content"}, - }, - } - } - ] - } - result = build_request_from_sarif_result(rule, sarif_result, Path("/tmp/project")) - - assert isinstance(result, JudgmentRequest) - assert result.rule_id == "C6" - assert result.content == "some instruction content" - assert result.location == "CLAUDE.md:3" - - def test_no_snippet_returns_none(self) -> None: - rule = _make_rule() - sarif_result = { - "locations": [ - { - "physicalLocation": { - "artifactLocation": {"uri": "nonexistent.md"}, - "region": {"startLine": 1}, - } - } - ] - } - result = build_request_from_sarif_result(rule, sarif_result, Path("/tmp/does-not-exist")) - assert result is None - - -# --------------------------------------------------------------------------- -# Edge cases appended to existing classes -# --------------------------------------------------------------------------- - - -class TestParseCriteriaEdges: - def test_empty_list_returns_default(self) -> None: - """An empty list should fall back to the default dict.""" - result = _parse_criteria([]) - assert result == {"pass_condition": "Evaluate based on context"} - - -class TestParseChoicesEdges: - def test_empty_list_returns_empty(self) -> None: - """An empty list hits the falsy guard and returns the default.""" - result = _parse_choices([]) - assert result == ["pass", "fail"] - - -class TestBuildRequestEdges: - def test_empty_checks_returns_none(self) -> None: - """A rule with checks=[] should still return a JudgmentRequest (default severity).""" - rule = _make_rule(checks=[]) - result = build_request(rule, "some content", "CLAUDE.md:1") - - # build_request only returns None when question is missing. - # Empty checks means severity defaults to MEDIUM. - assert isinstance(result, JudgmentRequest) - assert result.severity == Severity.MEDIUM diff --git a/tests/unit/test_stopwords.py b/tests/unit/test_stopwords.py new file mode 100644 index 00000000..aa39e754 --- /dev/null +++ b/tests/unit/test_stopwords.py @@ -0,0 +1,530 @@ +"""Tests for stopwords vocabulary extraction and sync.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from reporails_cli.core.stopwords import ( + PatternParts, + _split_alternation, + _strip_flags, + decompose, + extract_vocab, + is_guard, + recompose, +) +from reporails_cli.core.stopwords_sync import check_staleness, sync_vocab + +# ── decompose / recompose ──────────────────────────────────────────── + + +class TestDecompose: + @pytest.mark.parametrize( + "pattern, expected_flags, expected_prefix, expected_terms, expected_suffix", + [ + # Simple alternation + ("(?:a|b|c)", "", "", ["a", "b", "c"], ""), + # With flags + ("(?i)(?:foo|bar)", "(?i)", "", ["foo", "bar"], ""), + # With word boundaries + (r"\b(?:x|y|z)\b", "", r"\b", ["x", "y", "z"], r"\b"), + # Capturing group + ("(MUST NOT?|NEVER|ALWAYS)", "", "", ["MUST NOT?", "NEVER", "ALWAYS"], ""), + # Flags + boundaries + (r"(?i)\b(?:error|fail)\b", "(?i)", r"\b", ["error", "fail"], r"\b"), + # Regex terms with char classes + ("(?:auto[- ]?generated|do not edit)", "", "", ["auto[- ]?generated", "do not edit"], ""), + # Complex prefix/suffix + (r"(?i)^[\s*>-]*(?:always|never).{10,}", "(?i)", r"^[\s*>-]*", ["always", "never"], ".{10,}"), + ], + ) + def test_decompose_patterns( + self, + pattern: str, + expected_flags: str, + expected_prefix: str, + expected_terms: list[str], + expected_suffix: str, + ) -> None: + parts = decompose(pattern) + assert parts is not None, f"Failed to decompose: {pattern}" + assert parts.flags == expected_flags + assert parts.prefix == expected_prefix + assert parts.terms == expected_terms + assert parts.suffix == expected_suffix + + def test_decompose_returns_none_for_no_alternation(self) -> None: + assert decompose(r"^---\n") is None + + def test_decompose_returns_none_for_single_term_group(self) -> None: + assert decompose("(?:single)") is None + + def test_decompose_nested_groups_in_terms(self) -> None: + """Terms can contain nested groups — they're regex fragments.""" + parts = decompose(r"(?:(?:confirm|ask)\s+before|risky|dangerous)") + assert parts is not None + assert len(parts.terms) == 3 + assert parts.terms[0] == r"(?:confirm|ask)\s+before" + assert parts.terms[1] == "risky" + + def test_decompose_terms_with_char_classes(self) -> None: + parts = decompose("(?:AKIA[A-Z0-9]{12,}|ghp_[A-Za-z0-9]{20,})") + assert parts is not None + assert parts.terms == ["AKIA[A-Z0-9]{12,}", "ghp_[A-Za-z0-9]{20,}"] + + def test_decompose_escaped_pipe_not_split(self) -> None: + parts = decompose(r"(?:a\|b|c)") + assert parts is not None + assert parts.terms == [r"a\|b", "c"] + + +class TestRecompose: + def test_round_trip_simple(self) -> None: + original = "(?i)(?:foo|bar|baz)" + parts = decompose(original) + assert parts is not None + assert recompose(parts) == original + + def test_round_trip_with_boundaries(self) -> None: + original = r"(?i)\b(?:error|fail|crash)\b" + parts = decompose(original) + assert parts is not None + assert recompose(parts) == original + + def test_recompose_with_new_terms(self) -> None: + parts = PatternParts( + flags="(?i)", + prefix=r"\b", + group_open="(?:", + terms=["old"], + suffix=r"\b", + ) + result = recompose(parts, ["new1", "new2"]) + assert result == r"(?i)\b(?:new1|new2)\b" + + @pytest.mark.parametrize( + "pattern", + [ + "(?i)(?:TODO:\\s|FIXME:\\s|HACK:\\s|XXX:\\s|TEMP:\\s)", + r"\b(MUST NOT?|NEVER|ALWAYS|REQUIRED|PROHIBITED|CRITICAL)\b", + "(?i)(?:auto[- ]?generated|do not edit|generated by|template default)", + r"(?i)\b(?:error|exception|fail|crash|retry|fallback)\b", + ], + ) + def test_round_trip_real_patterns(self, pattern: str) -> None: + parts = decompose(pattern) + assert parts is not None + assert recompose(parts) == pattern + + +# ── _strip_flags / _split_alternation / is_guard ───────────────────── + + +class TestHelpers: + def test_strip_flags_single(self) -> None: + flags, rest = _strip_flags("(?i)abc") + assert flags == "(?i)" + assert rest == "abc" + + def test_strip_flags_multiple(self) -> None: + flags, rest = _strip_flags("(?i)(?s)abc") + assert flags == "(?i)(?s)" + assert rest == "abc" + + def test_strip_flags_none(self) -> None: + flags, rest = _strip_flags("abc") + assert flags == "" + assert rest == "abc" + + def test_split_alternation_simple(self) -> None: + assert _split_alternation("a|b|c") == ["a", "b", "c"] + + def test_split_alternation_nested_groups(self) -> None: + result = _split_alternation("(?:a|b)|c") + assert result == ["(?:a|b)", "c"] + + def test_split_alternation_char_class(self) -> None: + result = _split_alternation("[a|b]|c") + assert result == ["[a|b]", "c"] + + def test_is_guard_true(self) -> None: + assert is_guard(r"(?s)\A[\s\S]+") + + def test_is_guard_false(self) -> None: + assert not is_guard("(?i)(?:foo|bar)") + + +# ── extract_vocab ──────────────────────────────────────────────────── + + +class TestExtractVocab: + def _write_checks(self, rule_dir: Path, checks: list[dict]) -> None: + checks_path = rule_dir / "checks.yml" + checks_path.write_text(yaml.dump({"checks": checks}), encoding="utf-8") + + def test_extract_standalone_pattern_regex(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + { + "id": "X.0001.has_terms", + "type": "deterministic", + "pattern-regex": "(?i)(?:foo|bar|baz)", + }, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is not None + assert vocab["has_terms"] == ["foo", "bar", "baz"] + + def test_extract_mechanical_content_absent(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + { + "id": "X.0001.fast_check", + "type": "mechanical", + "check": "content_absent", + "args": {"pattern": "(?:a|b|c)"}, + }, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is not None + assert vocab["fast_check"] == ["a", "b", "c"] + + def test_extract_patterns_array_with_guard(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + { + "id": "X.0001.find_keywords", + "type": "deterministic", + "patterns": [ + {"pattern-regex": r"(?s)\A[\s\S]+"}, + {"pattern-not-regex": r"\b(MUST|NEVER|ALWAYS)\b"}, + ], + }, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is not None + assert vocab["find_keywords"] == ["MUST", "NEVER", "ALWAYS"] + + def test_extract_patterns_array_both_extractable(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + { + "id": "X.0001.dual_check", + "type": "deterministic", + "patterns": [ + {"pattern-regex": r"(?i)\b(?:push|deploy|delete)\b"}, + {"pattern-not-regex": "(?i)(?:risky|dangerous)"}, + ], + }, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is not None + # Multiple patterns → nested dict + assert isinstance(vocab["dual_check"], dict) + assert vocab["dual_check"]["pattern-regex"] == ["push", "deploy", "delete"] + assert vocab["dual_check"]["pattern-not-regex"] == ["risky", "dangerous"] + + def test_extract_skips_semantic(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + {"id": "X.0001.eval_quality", "type": "semantic"}, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is None + + def test_extract_skips_no_alternation(self, tmp_path: Path) -> None: + self._write_checks( + tmp_path, + [ + { + "id": "X.0001.single_pattern", + "type": "deterministic", + "pattern-regex": "^---\\n", + }, + ], + ) + vocab = extract_vocab(tmp_path) + assert vocab is None + + def test_extract_no_checks_yml(self, tmp_path: Path) -> None: + assert extract_vocab(tmp_path) is None + + +# ── sync_vocab ─────────────────────────────────────────────────────── + + +class TestSyncVocab: + def _setup_rule( + self, + rule_dir: Path, + checks: list[dict], + vocab: dict, + ) -> None: + (rule_dir / "checks.yml").write_text(yaml.dump({"checks": checks}), encoding="utf-8") + (rule_dir / "vocab.yml").write_text( + yaml.dump(vocab, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) + + def _read_checks(self, rule_dir: Path) -> list[dict]: + data = yaml.safe_load((rule_dir / "checks.yml").read_text(encoding="utf-8")) + return data.get("checks", []) + + def test_sync_standalone_pattern_regex(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.has_terms", + "type": "deterministic", + "pattern-regex": "(?i)(?:foo|bar)", + } + ], + vocab={"has_terms": ["foo", "bar", "baz"]}, + ) + result = sync_vocab(tmp_path) + assert result.updated == 1 + + checks = self._read_checks(tmp_path) + assert checks[0]["pattern-regex"] == "(?i)(?:foo|bar|baz)" + + def test_sync_mechanical_content_absent(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.fast", + "type": "mechanical", + "check": "content_absent", + "args": {"pattern": "(?:a|b)"}, + } + ], + vocab={"fast": ["a", "b", "c"]}, + ) + result = sync_vocab(tmp_path) + assert result.updated == 1 + + checks = self._read_checks(tmp_path) + assert checks[0]["args"]["pattern"] == "(?:a|b|c)" + + def test_sync_patterns_array_guard_plus_vocab(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.kw", + "type": "deterministic", + "patterns": [ + {"pattern-regex": r"(?s)\A[\s\S]+"}, + {"pattern-not-regex": r"\b(MUST|NEVER)\b"}, + ], + } + ], + vocab={"kw": ["MUST", "NEVER", "ALWAYS"]}, + ) + result = sync_vocab(tmp_path) + assert result.updated == 1 + + checks = self._read_checks(tmp_path) + entry = checks[0]["patterns"][1] + assert entry["pattern-not-regex"] == r"\b(MUST|NEVER|ALWAYS)\b" + + def test_sync_nested_format(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.dual", + "type": "deterministic", + "patterns": [ + {"pattern-regex": r"(?i)\b(?:push|deploy)\b"}, + {"pattern-not-regex": "(?i)(?:risky|dangerous)"}, + ], + } + ], + vocab={ + "dual": { + "pattern-regex": ["push", "deploy", "delete"], + "pattern-not-regex": ["risky", "dangerous", "irreversible"], + }, + }, + ) + result = sync_vocab(tmp_path) + assert result.updated == 1 + + checks = self._read_checks(tmp_path) + assert checks[0]["patterns"][0]["pattern-regex"] == r"(?i)\b(?:push|deploy|delete)\b" + assert checks[0]["patterns"][1]["pattern-not-regex"] == "(?i)(?:risky|dangerous|irreversible)" + + def test_sync_no_change_when_terms_match(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.same", + "type": "deterministic", + "pattern-regex": "(?:a|b)", + } + ], + vocab={"same": ["a", "b"]}, + ) + result = sync_vocab(tmp_path) + assert result.updated == 0 + assert result.skipped == 1 + + def test_sync_dry_run_does_not_write(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.t", + "type": "deterministic", + "pattern-regex": "(?:a|b)", + } + ], + vocab={"t": ["a", "b", "c"]}, + ) + original = (tmp_path / "checks.yml").read_text(encoding="utf-8") + sync_vocab(tmp_path, dry_run=True) + assert (tmp_path / "checks.yml").read_text(encoding="utf-8") == original + + def test_sync_missing_check_suffix(self, tmp_path: Path) -> None: + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.existing", + "type": "deterministic", + "pattern-regex": "(?:a|b)", + } + ], + vocab={"nonexistent": ["x", "y"]}, + ) + result = sync_vocab(tmp_path) + assert result.skipped == 1 + assert any("nonexistent" in m for m in result.messages) + + def test_sync_preserves_wrapper(self, tmp_path: Path) -> None: + """Sync preserves flags, prefix, suffix from the original pattern.""" + self._setup_rule( + tmp_path, + checks=[ + { + "id": "X.0001.bounded", + "type": "deterministic", + "pattern-regex": r"(?i)\b(?:old1|old2)\b", + } + ], + vocab={"bounded": ["new1", "new2", "new3"]}, + ) + sync_vocab(tmp_path) + checks = self._read_checks(tmp_path) + assert checks[0]["pattern-regex"] == r"(?i)\b(?:new1|new2|new3)\b" + + +# ── Round-trip: extract → sync ────────────────────────────────────── + + +class TestRoundTrip: + @pytest.mark.parametrize( + "original_pattern", + [ + "(?i)(?:TODO:\\s|FIXME:\\s|HACK:\\s|XXX:\\s|TEMP:\\s)", + r"(?i)\b(?:error|exception|fail|crash|retry|fallback)\b", + r"\b(MUST NOT?|NEVER|ALWAYS|REQUIRED|PROHIBITED|CRITICAL)\b", + "(?i)(?:auto[- ]?generated|do not edit|generated by|placeholder)", + ], + ) + def test_extract_then_sync_preserves_pattern(self, tmp_path: Path, original_pattern: str) -> None: + """Extract terms, then sync them back — pattern should be identical.""" + checks = [ + { + "id": "X.0001.roundtrip", + "type": "deterministic", + "pattern-regex": original_pattern, + } + ] + (tmp_path / "checks.yml").write_text(yaml.dump({"checks": checks}), encoding="utf-8") + + # Extract + vocab = extract_vocab(tmp_path) + assert vocab is not None + (tmp_path / "vocab.yml").write_text( + yaml.dump(vocab, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) + + # Sync — should not change anything since terms are identical + result = sync_vocab(tmp_path) + assert result.updated == 0 # No change expected + + # Verify pattern is unchanged + data = yaml.safe_load((tmp_path / "checks.yml").read_text(encoding="utf-8")) + assert data["checks"][0]["pattern-regex"] == original_pattern + + +# ── Staleness detection ────────────────────────────────────────────── + + +class TestStaleness: + def test_stale_when_terms_differ(self, tmp_path: Path) -> None: + (tmp_path / "checks.yml").write_text( + yaml.dump( + { + "checks": [ + { + "id": "X.0001.check", + "type": "deterministic", + "pattern-regex": "(?:a|b)", + } + ] + } + ), + encoding="utf-8", + ) + (tmp_path / "vocab.yml").write_text( + yaml.dump({"check": ["a", "b", "c"]}), + encoding="utf-8", + ) + result = check_staleness(tmp_path) + assert result is not None + assert result.stale is True + + def test_not_stale_when_terms_match(self, tmp_path: Path) -> None: + (tmp_path / "checks.yml").write_text( + yaml.dump( + { + "checks": [ + { + "id": "X.0001.check", + "type": "deterministic", + "pattern-regex": "(?:a|b)", + } + ] + } + ), + encoding="utf-8", + ) + (tmp_path / "vocab.yml").write_text( + yaml.dump({"check": ["a", "b"]}), + encoding="utf-8", + ) + result = check_staleness(tmp_path) + assert result is not None + assert result.stale is False + + def test_no_vocab_returns_none(self, tmp_path: Path) -> None: + assert check_staleness(tmp_path) is None diff --git a/tests/unit/test_summary.py b/tests/unit/test_summary.py index e7bbf06e..8e987812 100644 --- a/tests/unit/test_summary.py +++ b/tests/unit/test_summary.py @@ -16,33 +16,59 @@ main = summary.main +# Helper: build a CombinedResult-style dict +def _result( + files: dict | None = None, + stats: dict | None = None, + offline: bool = True, +) -> dict: + return { + "offline": offline, + "files": files or {}, + "stats": stats or {"errors": 0, "warnings": 0}, + } + + +def _file(findings: list[dict]) -> dict: + return {"findings": findings, "count": len(findings)} + + +def _finding(severity: str = "warning", rule: str = "CORE:S:0001", line: int = 1, message: str = "msg") -> dict: + return {"severity": severity, "rule": rule, "line": line, "message": message} + + # --------------------------------------------------------------------------- -# generate_summary — score table +# generate_summary — header table # --------------------------------------------------------------------------- -class TestScoreTable: - """Score/level/status header table.""" +class TestHeaderTable: + """Status/findings/files/mode header table.""" - def test_score_display(self): - md = generate_summary({"score": 7.5, "level": "L3"}) - assert "**7.5/10**" in md + def test_status_pass_no_findings(self): + md = generate_summary(_result()) + assert "Pass" in md - def test_level_and_capability(self): - md = generate_summary({"score": 9, "level": "L5", "capability": "Autonomous"}) - assert "**L5** Autonomous" in md + def test_findings_count(self): + md = generate_summary(_result( + files={"CLAUDE.md": _file([_finding(), _finding()])}, + stats={"errors": 0, "warnings": 2}, + )) + assert "**2**" in md - def test_positive_delta(self): - md = generate_summary({"score": 8, "level": "L4", "score_delta": 1.5}) - assert "(+1.5)" in md + def test_file_count(self): + md = generate_summary(_result( + files={"CLAUDE.md": _file([_finding()]), "rules/foo.md": _file([_finding()])}, + )) + assert "| Files | 2 |" in md - def test_negative_delta(self): - md = generate_summary({"score": 5, "level": "L2", "score_delta": -2.0}) - assert "(-2.0)" in md + def test_offline_mode(self): + md = generate_summary(_result(offline=True)) + assert "offline" in md - def test_zero_delta_omitted(self): - md = generate_summary({"score": 6, "level": "L3", "score_delta": 0}) - assert "(+" not in md and "(-" not in md + def test_online_mode(self): + md = generate_summary(_result(offline=False)) + assert "online" in md # --------------------------------------------------------------------------- @@ -51,154 +77,64 @@ def test_zero_delta_omitted(self): class TestStatus: - """Status line derivation from violations.""" + """Status line derivation from findings.""" - def test_no_violations_pass(self): - md = generate_summary({"score": 10, "level": "L6", "violations": []}) + def test_no_findings_pass(self): + md = generate_summary(_result()) assert "Pass" in md - def test_critical_fail(self): - md = generate_summary( - { - "score": 2, - "level": "L1", - "violations": [{"severity": "critical", "rule_id": "X", "location": "f", "message": "m"}], - } - ) - assert "Fail" in md - - def test_high_fail(self): - md = generate_summary( - { - "score": 3, - "level": "L2", - "violations": [{"severity": "high", "rule_id": "X", "location": "f", "message": "m"}], - } - ) + def test_errors_fail(self): + md = generate_summary(_result( + files={"f.md": _file([_finding(severity="error")])}, + stats={"errors": 1, "warnings": 0}, + )) assert "Fail" in md - def test_medium_warnings(self): - md = generate_summary( - { - "score": 6, - "level": "L3", - "violations": [{"severity": "medium", "rule_id": "X", "location": "f", "message": "m"}], - } - ) + def test_warnings_only(self): + md = generate_summary(_result( + files={"f.md": _file([_finding(severity="warning")])}, + stats={"errors": 0, "warnings": 1}, + )) assert "Warnings" in md - def test_low_warnings(self): - md = generate_summary( - { - "score": 7, - "level": "L3", - "violations": [{"severity": "low", "rule_id": "X", "location": "f", "message": "m"}], - } - ) - assert "Warnings" in md - - -# --------------------------------------------------------------------------- -# generate_summary — category summary -# --------------------------------------------------------------------------- - - -class TestCategorySummary: - """Category summary table rendering.""" - - def test_table_rendered(self): - md = generate_summary( - { - "score": 7, - "level": "L3", - "category_summary": [{"name": "structure", "passed": 3, "failed": 1, "worst_severity": "medium"}], - } - ) - assert "### Categories" in md - assert "Structure" in md - assert "| 3 | 1 |" in md - - def test_empty_skipped(self): - md = generate_summary({"score": 10, "level": "L6", "category_summary": []}) - assert "### Categories" not in md - - def test_severity_icon_shown(self): - md = generate_summary( - { - "score": 5, - "level": "L2", - "category_summary": [{"name": "content", "passed": 0, "failed": 2, "worst_severity": "critical"}], - } - ) - # critical icon - assert "\u274c" in md - - def test_passing_checkmark(self): - md = generate_summary( - { - "score": 9, - "level": "L5", - "category_summary": [{"name": "content", "passed": 5, "failed": 0, "worst_severity": "-"}], - } - ) - assert "\u2705" in md - # --------------------------------------------------------------------------- -# generate_summary — violations table +# generate_summary — findings table # --------------------------------------------------------------------------- -class TestViolationsTable: - """Violations detail table rendering.""" +class TestFindingsTable: + """Findings detail table rendering.""" def test_row_rendered(self): - md = generate_summary( - { - "score": 5, - "level": "L2", - "violations": [ - { - "severity": "high", - "rule_id": "CORE:S:0001", - "location": "CLAUDE.md", - "message": "Missing section", - } - ], - } - ) - assert "### Violations" in md + md = generate_summary(_result( + files={"CLAUDE.md": _file([_finding(rule="CORE:S:0001", message="Missing section")])}, + )) + assert "### Findings" in md assert "`CORE:S:0001`" in md - assert "`CLAUDE.md`" in md + assert "CLAUDE.md" in md assert "Missing section" in md def test_long_message_truncated(self): long_msg = "A" * 100 - md = generate_summary( - { - "score": 3, - "level": "L1", - "violations": [{"severity": "low", "rule_id": "X", "location": "f", "message": long_msg}], - } - ) + md = generate_summary(_result( + files={"f.md": _file([_finding(message=long_msg)])}, + )) assert "AAA..." in md - # Truncated to 77 + "..." assert "A" * 78 not in md - def test_empty_violations_skipped(self): - md = generate_summary({"score": 10, "level": "L6", "violations": []}) - assert "### Violations" not in md + def test_empty_findings_no_table(self): + md = generate_summary(_result()) + assert "### Findings" not in md def test_severity_icons(self): - violations = [ - {"severity": sev, "rule_id": "X", "location": "f", "message": "m"} - for sev in ("critical", "high", "medium", "low") - ] - md = generate_summary({"score": 1, "level": "L1", "violations": violations}) - assert "\u274c" in md # critical - assert "\U0001f7e0" in md # high/orange - assert "\u26a0\ufe0f" in md # medium/warning - assert "\U0001f535" in md # low/blue + findings = [_finding(severity=s) for s in ("error", "warning", "medium", "info")] + md = generate_summary(_result( + files={"f.md": _file(findings)}, + )) + assert "\u274c" in md # error + assert "\u26a0\ufe0f" in md # warning/medium + assert "\U0001f535" in md # info # --------------------------------------------------------------------------- @@ -230,9 +166,12 @@ def test_invalid_json(self, capsys, monkeypatch): def test_valid_json(self, capsys, monkeypatch): import json - payload = json.dumps({"score": 8.0, "level": "L4", "violations": []}) + payload = json.dumps(_result( + files={"CLAUDE.md": _file([_finding()])}, + stats={"errors": 0, "warnings": 1}, + )) monkeypatch.setenv("REPORAILS_RESULT", payload) main() out = capsys.readouterr().out - assert "8.0/10" in out - assert "Pass" in out + assert "Reporails Check" in out + assert "Warnings" in out diff --git a/tests/unit/test_symlink_detection.py b/tests/unit/test_symlink_detection.py index 36c73237..0cac4bc2 100644 --- a/tests/unit/test_symlink_detection.py +++ b/tests/unit/test_symlink_detection.py @@ -162,7 +162,7 @@ def test_extra_targets_scanned(self, tmp_path: Path) -> None: # Create a rule that matches "SHARED_CONTENT" yml_file = tmp_path / "test.yml" yml_file.write_text("""\ -rules: +checks: - id: test.extra message: "Found shared content" severity: WARNING @@ -198,7 +198,7 @@ def test_extra_targets_none_is_noop(self, tmp_path: Path) -> None: """No extra_targets → only main target scanned.""" yml_file = tmp_path / "test.yml" yml_file.write_text("""\ -rules: +checks: - id: test.main message: "Found content" severity: WARNING diff --git a/uv.lock b/uv.lock index 852bfe37..3add990a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -11,6 +20,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -42,6 +70,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "blis" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/d1/429cf0cf693d4c7dc2efed969bd474e315aab636e4a95f66c4ed7264912d/blis-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef", size = 6929663, upload-time = "2025-11-17T12:27:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/363c8df8d98b3cc97be19aad6aabb2c9c53f372490d79316bdee92d476e7/blis-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a", size = 1230939, upload-time = "2025-11-17T12:27:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/fbf65d906d823d839076c5150a6f8eb5ecbc5f9135e0b6510609bda1e6b7/blis-1.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0", size = 2818835, upload-time = "2025-11-17T12:27:48.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/58deaa3ad856dd3cc96493e40ffd2ed043d18d4d304f85a65cde1ccbf644/blis-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb", size = 11366550, upload-time = "2025-11-17T12:27:49.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/82/816a7adfe1f7acc8151f01ec86ef64467a3c833932d8f19f8e06613b8a4e/blis-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7", size = 3023686, upload-time = "2025-11-17T12:27:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/0e93b865f648b5519360846669a35f28ee8f4e1d93d054f6850d8afbabde/blis-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7", size = 14250939, upload-time = "2025-11-17T12:27:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/20/07/fb43edc2ff0a6a367e4a94fc39eb3b85aa1e55e24cc857af2db145ce9f0d/blis-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af", size = 6192759, upload-time = "2025-11-17T12:27:56.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f7/d26e62d9be3d70473a63e0a5d30bae49c2fe138bebac224adddcdef8a7ce/blis-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa", size = 6928322, upload-time = "2025-11-17T12:27:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/750d12da388f714958eb2f2fd177652323bbe7ec528365c37129edd6eb84/blis-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e", size = 1229635, upload-time = "2025-11-17T12:28:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/36/eac4199c5b200a5f3e93cad197da8d26d909f218eb444c4f552647c95240/blis-1.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4", size = 2815650, upload-time = "2025-11-17T12:28:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/bf/51/472e7b36a6bedb5242a9757e7486f702c3619eff76e256735d0c8b1679c6/blis-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127", size = 11359008, upload-time = "2025-11-17T12:28:04.589Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/d0dfb6d6e6321ae44df0321384c32c322bd07b15740d7422727a1a49fc5d/blis-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf", size = 3011959, upload-time = "2025-11-17T12:28:06.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2b0b5e556fa0364ed671051ea078a6d6d7b979b1cfef78d64ad3ca5f0c7f/blis-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458", size = 14232456, upload-time = "2025-11-17T12:28:08.779Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" }, +] + +[[package]] +name = "catalogue" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -108,6 +177,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -120,6 +246,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cloudpathlib" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/18/2ac35d6b3015a0c74e923d94fc69baf8307f7c3233de015d69f99e17afa8/cloudpathlib-0.23.0.tar.gz", hash = "sha256:eb38a34c6b8a048ecfd2b2f60917f7cbad4a105b7c979196450c2f541f4d6b4b", size = 53126, upload-time = "2025-10-07T22:47:56.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8a/c4bb04426d608be4a3171efa2e233d2c59a5c8937850c10d098e126df18e/cloudpathlib-0.23.0-py3-none-any.whl", hash = "sha256:8520b3b01468fee77de37ab5d50b1b524ea6b4a8731c35d1b7407ac0cd716002", size = 62755, upload-time = "2025-10-07T22:47:54.905Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -129,6 +264,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "confection" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "srsly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/d3/57c6631159a1b48d273b40865c315cf51f89df7a9d1101094ef12e3a37c2/confection-0.1.5.tar.gz", hash = "sha256:8e72dd3ca6bd4f48913cd220f10b8275978e740411654b6e8ca6d7008c590f0e", size = 38924, upload-time = "2024-05-31T16:17:01.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/00/3106b1854b45bd0474ced037dfe6b73b90fe68a68968cef47c23de3d43d2/confection-0.1.5-py3-none-any.whl", hash = "sha256:e29d3c3f8eac06b3f77eb9dfb4bf2fc6bcc9622a98ca00a698e3d019c6430b14", size = 35451, upload-time = "2024-05-31T16:16:59.075Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + [[package]] name = "coverage" version = "7.13.1" @@ -259,6 +473,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cymem" +version = "2.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/52/478a2911ab5028cb710b4900d64aceba6f4f882fcb13fd8d40a456a1b6dc/cymem-2.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb", size = 43745, upload-time = "2025-11-14T14:57:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/f9/71/f0f8adee945524774b16af326bd314a14a478ed369a728a22834e6785a18/cymem-2.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8", size = 42927, upload-time = "2025-11-14T14:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/159780fe162ff715d62b809246e5fc20901cef87ca28b67d255a8d741861/cymem-2.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33", size = 258346, upload-time = "2025-11-14T14:57:34.917Z" }, + { url = "https://files.pythonhosted.org/packages/eb/12/678d16f7aa1996f947bf17b8cfb917ea9c9674ef5e2bd3690c04123d5680/cymem-2.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d", size = 260843, upload-time = "2025-11-14T14:57:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/0dd8c167c08cd85e70d274b7235cfe1e31b3cebc99221178eaf4bbb95c6f/cymem-2.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298", size = 254607, upload-time = "2025-11-14T14:57:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/d6514a412a1160aa65db539836b3d47f9b59f6675f294ec34ae32f867c82/cymem-2.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6", size = 262421, upload-time = "2025-11-14T14:57:39.265Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/3ee37d02ca4040f2fb22d34eb415198f955862b5dd47eee01df4c8f5454c/cymem-2.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc", size = 40176, upload-time = "2025-11-14T14:57:40.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/fb/1b681635bfd5f2274d0caa8f934b58435db6c091b97f5593738065ddb786/cymem-2.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67", size = 35959, upload-time = "2025-11-14T14:57:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/95a4d1e3bebfdfa7829252369357cf9a764f67569328cd9221f21e2c952e/cymem-2.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476", size = 43478, upload-time = "2025-11-14T14:57:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/8fc929cc29ae466b7b4efc23ece99cbd3ea34992ccff319089c624d667fd/cymem-2.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e", size = 42695, upload-time = "2025-11-14T14:57:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b3/deeb01354ebaf384438083ffe0310209ef903db3e7ba5a8f584b06d28387/cymem-2.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28", size = 250573, upload-time = "2025-11-14T14:57:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/36/bc980b9a14409f3356309c45a8d88d58797d02002a9d794dd6c84e809d3a/cymem-2.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c", size = 254572, upload-time = "2025-11-14T14:57:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/a12522952624685bd0f8968e26d2ed6d059c967413ce6eb52292f538f1b0/cymem-2.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3", size = 248060, upload-time = "2025-11-14T14:57:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/5dc933ddfeb2dfea747a0b935cb965b9a7580b324d96fc5f5a1b5ff8df29/cymem-2.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c", size = 254601, upload-time = "2025-11-14T14:57:48.861Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/d23b06166864fa94e13a98e5922986ce774832936473578febce64448d75/cymem-2.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba", size = 40103, upload-time = "2025-11-14T14:57:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9e/c7b21271ab88a21760f3afdec84d2bc09ffa9e6c8d774ad9d4f1afab0416/cymem-2.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad", size = 36016, upload-time = "2025-11-14T14:57:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/7f/28/d3b03427edc04ae04910edf1c24b993881c3ba93a9729a42bcbb816a1808/cymem-2.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1", size = 46429, upload-time = "2025-11-14T14:57:52.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/7ed53e481f47ebfb922b0b42e980cec83e98ccb2137dc597ea156642440c/cymem-2.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf", size = 46205, upload-time = "2025-11-14T14:57:53.64Z" }, + { url = "https://files.pythonhosted.org/packages/61/39/a3d6ad073cf7f0fbbb8bbf09698c3c8fac11be3f791d710239a4e8dd3438/cymem-2.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51", size = 296083, upload-time = "2025-11-14T14:57:55.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/20697c8bc19f624a595833e566f37d7bcb9167b0ce69de896eba7cfc9c2d/cymem-2.0.13-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3", size = 286159, upload-time = "2025-11-14T14:57:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/9326e3422d1c2d2b4a8fb859bdcce80138f6ab721ddafa4cba328a505c71/cymem-2.0.13-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583", size = 288186, upload-time = "2025-11-14T14:57:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/68da7dd749b72884dc22e898562f335002d70306069d496376e5ff3b6153/cymem-2.0.13-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522", size = 290353, upload-time = "2025-11-14T14:58:00.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/dbf2ad6ecd19b99b3aab6203b1a06608bbd04a09c522d836b854f2f30f73/cymem-2.0.13-cp313-cp313t-win_amd64.whl", hash = "sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472", size = 44764, upload-time = "2025-11-14T14:58:01.793Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -268,6 +539,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "en-core-web-sm" +version = "3.8.0" +source = { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" } +wheels = [ + { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", hash = "sha256:1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -277,6 +641,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hf-xet" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/01/928fd82663fb0ab455551a178303a2960e65029da66b21974594f3a20a94/hf_xet-1.4.0.tar.gz", hash = "sha256:48e6ba7422b0885c9bbd8ac8fdf5c4e1306c3499b82d489944609cc4eae8ecbd", size = 660350, upload-time = "2026-03-11T18:50:03.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/4b/2351e30dddc6f3b47b3da0a0693ec1e82f8303b1a712faa299cf3552002b/hf_xet-1.4.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:76725fcbc5f59b23ac778f097d3029d6623e3cf6f4057d99d1fce1a7e3cff8fc", size = 3796397, upload-time = "2026-03-11T18:49:47.382Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/3db90ec0afb4e26e3330b1346b89fe0e9a3b7bfc2d6a2b2262787790d25f/hf_xet-1.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:76f1f73bee81a6e6f608b583908aa24c50004965358ac92c1dc01080a21bcd09", size = 3556235, upload-time = "2026-03-11T18:49:45.785Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/2a662af2cbc6c0a64ebe9fcdb8faf05b5205753d45a75a3011bb2209d0b4/hf_xet-1.4.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1818c2e5d6f15354c595d5111c6eb0e5a30a6c5c1a43eeaec20f19607cff0b34", size = 4213145, upload-time = "2026-03-11T18:49:38.009Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4a/47c129affb540767e0e3e101039a95f4a73a292ec689c26e8f0c5b633f9d/hf_xet-1.4.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:70764d295f485db9cc9a6af76634ea00ec4f96311be7485f8f2b6144739b4ccf", size = 3991951, upload-time = "2026-03-11T18:49:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/76/81/ec516cfc6281cfeef027b0919166b2fe11ab61fbe6131a2c43fafbed8b68/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d3bd2a1e289f772c715ca88cdca8ceb3d8b5c9186534d5925410e531d849a3e", size = 4193205, upload-time = "2026-03-11T18:49:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/0945b5e542ed6c6ce758b589b27895a449deab630dfcdee5a6ee0f699d21/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:06da3797f1fdd9a8f8dbc8c1bddfa0b914789b14580c375d29c32ee35c2c66ca", size = 4431022, upload-time = "2026-03-11T18:49:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ad/a4859c55ab4b67a4fde2849be8bde81917f54062050419b821071f199a9c/hf_xet-1.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:30b9d8f384ccec848124d51d883e91f3c88d430589e02a7b6d867730ab8d53ac", size = 3674977, upload-time = "2026-03-11T18:50:06.369Z" }, + { url = "https://files.pythonhosted.org/packages/4b/17/5bf3791e3a53e597913c2a775a48a98aaded9c2ddb5d1afaedabb55e2ed8/hf_xet-1.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:07ffdbf7568fa3245b24d949f0f3790b5276fb7293a5554ac4ec02e5f7e2b38d", size = 3536778, upload-time = "2026-03-11T18:50:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a1/05a7f9d6069bf78405d3fc2464b6c76b167128501e13b4f1d6266e1d1f54/hf_xet-1.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2731044f3a18442f9f7a3dcf03b96af13dee311f03846a1df1f0553a3ea0fc6", size = 3796727, upload-time = "2026-03-11T18:49:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8a/67abc642c2b32efcb7a257cdad8555c2904e23f18a1b4fec3aef1ebfe0fc/hf_xet-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b6f3729335fbc4baef60fe14fe32ef13ac9d377bdc898148c541e20c6056b504", size = 3555869, upload-time = "2026-03-11T18:49:51.313Z" }, + { url = "https://files.pythonhosted.org/packages/19/3d/4765367c64ee70db15fa771d5b94bf12540b85076a1d3210ebbfec42d477/hf_xet-1.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c0c9f052738a024073d332c573275c8e33697a3ef3f5dd2fb4ef98216e1e74a", size = 4212980, upload-time = "2026-03-11T18:49:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/bf/6ad99ee0e7ca2318f912a87318e493d82d8f9aace6be81f774bd14b996df/hf_xet-1.4.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f44b2324be75bfa399735996ac299fd478684c48ce47d12a42b5f24b1a99ccb8", size = 3991136, upload-time = "2026-03-11T18:49:42.512Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/932e25c69699076088f57e3c14f83ccae87bac25e755994f3362acc908d5/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:01de78b1ceddf8b38da001f7cc728b3bc3eb956948b18e8a1997ad6fc80fbe9d", size = 4192676, upload-time = "2026-03-11T18:50:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/5e41339a294fd3450948989a47ecba9824d5bc1950cf767f928ecaf53a55/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cac8616e7a974105c3494735313f5ab0fb79b5accadec1a7a992859a15536a9", size = 4430729, upload-time = "2026-03-11T18:50:01.923Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c1/c3d8ed9b7118e9166b0cf71dfd501da82f1abe306387e34e0f3ee59553ec/hf_xet-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3a5d9cb25095ceb3beab4843ae2d1b3e5746371ddbf2e5849f7be6a7d6f44df4", size = 3674989, upload-time = "2026-03-11T18:50:12.633Z" }, + { url = "https://files.pythonhosted.org/packages/65/bc/ea26cf774063cb09d7aaaa6cba9d341fb72b42ea99b8a94ca254dbafbbb0/hf_xet-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9b777674499dc037317db372c90a2dd91329b5f1ee93c645bb89155bb974f5bf", size = 3536805, upload-time = "2026-03-11T18:50:11.082Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f9/a0b01945726aea81d2f213457cd5f5102a51e6fd1ca9f9769f561fb57501/hf_xet-1.4.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:981d2b5222c3baadf9567c135cf1d1073786f546b7745686978d46b5df179e16", size = 3799223, upload-time = "2026-03-11T18:49:49.884Z" }, + { url = "https://files.pythonhosted.org/packages/5d/30/ee62b0c00412f49a7e6f509f0104ee8808692278d247234336df48029349/hf_xet-1.4.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:cc8bd050349d0d7995ce7b3a3a18732a2a8062ce118a82431602088abb373428", size = 3560682, upload-time = "2026-03-11T18:49:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/93/d0/0fe5c44dbced465a651a03212e1135d0d7f95d19faada692920cb56f8e38/hf_xet-1.4.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d0c38d2a280d814280b8c15eead4a43c9781e7bf6fc37843cffab06dcdc76b9", size = 4218323, upload-time = "2026-03-11T18:49:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/73/df/7b3c99a4e50442039eae498e5c23db634538eb3e02214109880cf1165d4c/hf_xet-1.4.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a883f0250682ea888a1bd0af0631feda377e59ad7aae6fb75860ecee7ae0f93", size = 3997156, upload-time = "2026-03-11T18:49:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/a9/26/47dfedf271c21d95346660ae1698e7ece5ab10791fa6c4f20c59f3713083/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:99e1d9255fe8ecdf57149bb0543d49e7b7bd8d491ddf431eb57e114253274df5", size = 4199052, upload-time = "2026-03-11T18:49:57.097Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c0/346b9aad1474e881e65f998d5c1981695f0af045bc7a99204d9d86759a89/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b25f06ce42bd2d5f2e79d4a2d72f783d3ac91827c80d34a38cf8e5290dd717b0", size = 4434346, upload-time = "2026-03-11T18:49:58.67Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d6/88ce9d6caa397c3b935263d5bcbe3ebf6c443f7c76098b8c523d206116b9/hf_xet-1.4.0-cp37-abi3-win_amd64.whl", hash = "sha256:8d6d7816d01e0fa33f315c8ca21b05eca0ce4cdc314f13b81d953e46cc6db11d", size = 3678921, upload-time = "2026-03-11T18:50:09.496Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/17d99ed253b28a9550ca479867c66a8af4c9bcd8cdc9a26b0c8007c2000a/hf_xet-1.4.0-cp37-abi3-win_arm64.whl", hash = "sha256:cb8d9549122b5b42f34b23b14c6b662a88a586a919d418c774d8dbbc4b3ce2aa", size = 3541054, upload-time = "2026-03-11T18:50:07.963Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -314,6 +710,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "huggingface-hub" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7a/304cec37112382c4fe29a43bcb0d5891f922785d18745883d2aa4eb74e4b/huggingface_hub-1.6.0.tar.gz", hash = "sha256:d931ddad8ba8dfc1e816bf254810eb6f38e5c32f60d4184b5885662a3b167325", size = 717071, upload-time = "2026-03-06T14:19:18.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e3/e3a44f54c8e2f28983fcf07f13d4260b37bd6a0d3a081041bc60b91d230e/huggingface_hub-1.6.0-py3-none-any.whl", hash = "sha256:ef40e2d5cb85e48b2c067020fa5142168342d5108a1b267478ed384ecbf18961", size = 612874, upload-time = "2026-03-06T14:19:16.844Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -341,6 +757,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -368,6 +873,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, +] + [[package]] name = "librt" version = "0.7.8" @@ -432,6 +1009,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -475,6 +1169,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "murmurhash" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/46/be8522d3456fdccf1b8b049c6d82e7a3c1114c4fc2cfe14b04cba4b3e701/murmurhash-1.0.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb", size = 27884, upload-time = "2025-11-14T09:50:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/630449bf4f6178d7daf948ce46ad00b25d279065fc30abd8d706be3d87e0/murmurhash-1.0.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d", size = 27855, upload-time = "2025-11-14T09:50:14.859Z" }, + { url = "https://files.pythonhosted.org/packages/ff/30/ea8f601a9bf44db99468696efd59eb9cff1157cd55cb586d67116697583f/murmurhash-1.0.15-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616", size = 134088, upload-time = "2025-11-14T09:50:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/c9/de/c40ce8c0877d406691e735b8d6e9c815f36a82b499d358313db5dbe219d7/murmurhash-1.0.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400", size = 133978, upload-time = "2025-11-14T09:50:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/bd49963ecd84ebab2fe66595e2d1ed41d5e8b5153af5dc930f0bd827007c/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26", size = 132956, upload-time = "2025-11-14T09:50:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/2530769c545074417c862583f05f4245644599f1e9ff619b3dfe2969aafc/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40", size = 134184, upload-time = "2025-11-14T09:50:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/b249b042f5afe34d14ada2dc4afc777e883c15863296756179652e081c44/murmurhash-1.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca", size = 25647, upload-time = "2025-11-14T09:50:21.049Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/028179259aebc18fd4ba5cae2601d1d47517427a537ab44336446431a215/murmurhash-1.0.15-cp312-cp312-win_arm64.whl", hash = "sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda", size = 23338, upload-time = "2025-11-14T09:50:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/ba300b5f04dae0409202d6285668b8a9d3ade43a846abee3ef611cb388d5/murmurhash-1.0.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba", size = 27861, upload-time = "2025-11-14T09:50:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/29c19d268e6f4ea1ed2a462c901eed1ed35b454e2cbc57da592fad663ac6/murmurhash-1.0.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad", size = 27840, upload-time = "2025-11-14T09:50:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/e2/63/58e2de2b5232cd294c64092688c422196e74f9fa8b3958bdf02d33df24b9/murmurhash-1.0.15-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f", size = 133080, upload-time = "2025-11-14T09:50:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/d13e2e9f8ba1ced06840921a50f7cece0a475453284158a3018b72679761/murmurhash-1.0.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c", size = 132648, upload-time = "2025-11-14T09:50:27.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/47994f1813fa205c84977b0ff51ae6709f8539af052c7491a5f863d82bdc/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af", size = 131502, upload-time = "2025-11-14T09:50:29.339Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/90c1fd00b4aeb704fb5e84cd666b33ffd7f245155048071ffbb51d2bb57d/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823", size = 132736, upload-time = "2025-11-14T09:50:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/da73462dbfa77f6433b128d2120ba7ba300f8c06dc4f4e022c38d240a5f5/murmurhash-1.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c", size = 25682, upload-time = "2025-11-14T09:50:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/bb/83/032729ef14971b938fbef41ee125fc8800020ee229bd35178b6ede8ee934/murmurhash-1.0.15-cp313-cp313-win_arm64.whl", hash = "sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378", size = 23370, upload-time = "2025-11-14T09:50:33.264Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/7547d9205e9bd2f8e5dfd0b682cc9277594f98909f228eb359489baec1df/murmurhash-1.0.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055", size = 29955, upload-time = "2025-11-14T09:50:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c7/3afd5de7a5b3ae07fe2d3a3271b327ee1489c58ba2b2f2159bd31a25edb9/murmurhash-1.0.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070", size = 30108, upload-time = "2025-11-14T09:50:35.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/69/d6637ee67d78ebb2538c00411f28ea5c154886bbe1db16c49435a8a4ab16/murmurhash-1.0.15-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98", size = 164054, upload-time = "2025-11-14T09:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4c/89e590165b4c7da6bf941441212a721a270195332d3aacfdfdf527d466ca/murmurhash-1.0.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec", size = 168153, upload-time = "2025-11-14T09:50:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/95c42df0c21d2e413b9fcd17317a7587351daeb264dc29c6aec1fdbd26f8/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535", size = 164345, upload-time = "2025-11-14T09:50:39.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/9d02c880a88b83bb3ce7d6a38fb727373ab78d82e5f3d8d9fc5612219f90/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5", size = 161990, upload-time = "2025-11-14T09:50:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/750232524e0dc262e8dcede6536dafc766faadd9a52f1d23746b02948ad8/murmurhash-1.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58", size = 28812, upload-time = "2025-11-14T09:50:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/4ad9d215ef6ade89f27a72dc4e86b98ef1a43534cc3e6a6900a362a0bf0a/murmurhash-1.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0", size = 25398, upload-time = "2025-11-14T09:50:43.023Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" }, + { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -517,6 +1268,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, + { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -544,6 +1398,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -575,6 +1498,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/bc/73327d12b176abea7a3c6c7d760e1a953992f7b59d72c0354e39d7a353b5/poethepoet-0.40.0-py3-none-any.whl", hash = "sha256:afd276ae31d5c53573c0c14898118d4848ccee3709b6b0be6a1c6cbe522bbc8a", size = 106672, upload-time = "2026-01-05T19:09:11.536Z" }, ] +[[package]] +name = "preshed" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cymem" }, + { name = "murmurhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/34/eb4f5f0f678e152a96e826da867d2f41c4b18a2d589e40e1dd3347219e91/preshed-3.0.12.tar.gz", hash = "sha256:b73f9a8b54ee1d44529cc6018356896cff93d48f755f29c134734d9371c0d685", size = 15027, upload-time = "2025-11-17T13:00:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/f7/ff3aca937eeaee19c52c45ddf92979546e52ed0686e58be4bc09c47e7d88/preshed-3.0.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2779861f5d69480493519ed123a622a13012d1182126779036b99d9d989bf7e9", size = 129958, upload-time = "2025-11-17T12:59:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/80/24/fd654a9c0f5f3ed1a9b1d8a392f063ae9ca29ad0b462f0732ae0147f7cee/preshed-3.0.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffe1fd7d92f51ed34383e20d8b734780c814ca869cfdb7e07f2d31651f90cdf4", size = 124550, upload-time = "2025-11-17T12:59:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/8271c7f680696f4b0880f44357d2a903d649cb9f6e60a1efc97a203104df/preshed-3.0.12-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:91893404858502cc4e856d338fef3d2a4a552135f79a1041c24eb919817c19db", size = 874987, upload-time = "2025-11-17T12:59:36.062Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a5/ca200187ca1632f1e2c458b72f1bd100fa8b55deecd5d72e1e4ebf09e98c/preshed-3.0.12-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9e06e8f2ba52f183eb9817a616cdebe84a211bb859a2ffbc23f3295d0b189638", size = 866499, upload-time = "2025-11-17T12:59:37.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/a1/943b61f850c44899910c21996cb542d0ef5931744c6d492fdfdd8457e693/preshed-3.0.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbe8b8a2d4f9af14e8a39ecca524b9de6defc91d8abcc95eb28f42da1c23272c", size = 878064, upload-time = "2025-11-17T12:59:39.651Z" }, + { url = "https://files.pythonhosted.org/packages/3e/75/d7fff7f1fa3763619aa85d6ba70493a5d9c6e6ea7958a6e8c9d3e6e88bbe/preshed-3.0.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d0aaac9c5862f5471fddd0c931dc64d3af2efc5fe3eb48b50765adb571243b9", size = 900540, upload-time = "2025-11-17T12:59:41.384Z" }, + { url = "https://files.pythonhosted.org/packages/e4/12/a2285b78bd097a1e53fb90a1743bc8ce0d35e5b65b6853f3b3c47da398ca/preshed-3.0.12-cp312-cp312-win_amd64.whl", hash = "sha256:0eb8d411afcb1e3b12a0602fb6a0e33140342a732a795251a0ce452aba401dc0", size = 118298, upload-time = "2025-11-17T12:59:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/0b/34/4e8443fe99206a2fcfc63659969a8f8c8ab184836533594a519f3899b1ad/preshed-3.0.12-cp312-cp312-win_arm64.whl", hash = "sha256:dcd3d12903c9f720a39a5c5f1339f7f46e3ab71279fb7a39776768fb840b6077", size = 104746, upload-time = "2025-11-17T12:59:43.934Z" }, + { url = "https://files.pythonhosted.org/packages/1e/36/1d3df6f9f37efc34be4ee3013b3bb698b06f1e372f80959851b54d8efdb2/preshed-3.0.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3deb3ab93d50c785eaa7694a8e169eb12d00263a99c91d56511fe943bcbacfb6", size = 128023, upload-time = "2025-11-17T12:59:45.157Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d4/3ca81f42978da1b81aa57b3e9b5193d8093e187787a3b2511d16b30b7c62/preshed-3.0.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604350001238dab63dc14774ee30c257b5d71c7be976dbecd1f1ed37529f60f", size = 122851, upload-time = "2025-11-17T12:59:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/f388398f8d789f69b510272d144a9186d658423f6d3ecc484c0fe392acec/preshed-3.0.12-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04fb860a8aab18d2201f06159337eda5568dc5eed218570d960fad79e783c7d0", size = 835926, upload-time = "2025-11-17T12:59:47.882Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/b7170933451cbc27eaefd57b36f61a5e7e7c8da50ae24f819172e0ca8a4d/preshed-3.0.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d0c8fcd44996031c46a0aa6773c7b7aa5ee58c3ee87bc05236dacd5599d35063", size = 827294, upload-time = "2025-11-17T12:59:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ec/6504730d811c0a375721db2107d31684ec17ee5b7bb3796ecfa41e704d41/preshed-3.0.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b07efc3abd3714ce01cf67db0a2dada6e829ab7def74039d446e49ddb32538c5", size = 838809, upload-time = "2025-11-17T12:59:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1a/09d13240c1fbadcc0603e2fe029623045a36c88b4b50b02e7fdc89e3b88e/preshed-3.0.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f184ef184b76e0e4707bce2395008779e4dfa638456b13b18469c2c1a42903a6", size = 861448, upload-time = "2025-11-17T12:59:52.702Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/9523160153037ee8337672249449be416ee92236f32602e7dd643767814f/preshed-3.0.12-cp313-cp313-win_amd64.whl", hash = "sha256:ebb3da2dc62ab09e5dc5a00ec38e7f5cdf8741c175714ab4a80773d8ee31b495", size = 117413, upload-time = "2025-11-17T12:59:54.4Z" }, + { url = "https://files.pythonhosted.org/packages/79/eb/4263e6e896753b8e2ffa93035458165850a5ea81d27e8888afdbfd8fa9c4/preshed-3.0.12-cp313-cp313-win_arm64.whl", hash = "sha256:b36a2cf57a5ca6e78e69b569c92ef3bdbfb00e3a14859e201eec6ab3bdc27085", size = 104041, upload-time = "2025-11-17T12:59:55.596Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b33910b7ba3db9ce1515c39eb4657232913fb171fe701f792ef50726e60/preshed-3.0.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0d8b458dfbd6cc5007d045fa5638231328e3d6f214fd24ab999cc10f8b9097e5", size = 129211, upload-time = "2025-11-17T12:59:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/97dceebe0b2b4dd94333e4ec283d38614f92996de615859a952da082890d/preshed-3.0.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e9196e2ea704243a69df203e0c9185eb7c5c58c3632ba1c1e2e2e0aa3aae3b4", size = 123311, upload-time = "2025-11-17T12:59:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6f/f3772f6eaad1eae787f82ffb65a81a4a1993277eacf5a78a29da34608323/preshed-3.0.12-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ffa644e1730012ed435fb9d0c3031ea19a06b11136eff5e9b96b2aa25ec7a5f5", size = 831683, upload-time = "2025-11-17T13:00:00.229Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/997d39ca61202486dd06c669b4707a5b8e5d0c2c922db9f7744fd6a12096/preshed-3.0.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:39e83a16ce53e4a3c41c091fe4fe1c3d28604e63928040da09ba0c5d5a7ca41e", size = 830035, upload-time = "2025-11-17T13:00:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f2/51bf44e3fdbef08d40a832181842cd9b21b11c3f930989f4ff17e9201e12/preshed-3.0.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2ec9bc0baee426303a644c7bf531333d4e7fd06fedf07f62ee09969c208d578d", size = 841728, upload-time = "2025-11-17T13:00:03.643Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b1/2d0e3d23d9f885f7647654d770227eb13e4d892deb9b0ed50b993d63fb18/preshed-3.0.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7db058f1b4a3d4d51c4c05b379c6cc9c36fcad00160923cb20ca1c7030581ea4", size = 858860, upload-time = "2025-11-17T13:00:05.185Z" }, + { url = "https://files.pythonhosted.org/packages/e7/57/7c28c7f6f9bfce02796b54f1f6acd2cebb3fa3f14a2dce6fb3c686e3c3a8/preshed-3.0.12-cp314-cp314-win_amd64.whl", hash = "sha256:c87a54a55a2ba98d0c3fd7886295f2825397aff5a7157dcfb89124f6aa2dca41", size = 120325, upload-time = "2025-11-17T13:00:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/df235ca679a08e09103983ec17c668f96abe897eadbe18d635972b43d8a9/preshed-3.0.12-cp314-cp314-win_arm64.whl", hash = "sha256:d9c5f10b4b971d71d163c2416b91b7136eae54ef3183b1742bb5993269af1b18", size = 107393, upload-time = "2025-11-17T13:00:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f1/51a2a72381c8aa3aeb8305d88e720c745048527107e649c01b8d49d6b5bf/preshed-3.0.12-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2739a9c57efcfa16466fa6e0257d67f0075a9979dc729585fbadaed7383ab449", size = 137703, upload-time = "2025-11-17T13:00:09.001Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/f3c3d50647f3af6ce6441c596a4f6fb0216d549432ef51f61c0c1744c9b9/preshed-3.0.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:364249656bfbf98b4008fac707f35835580ec56207f7cbecdafef6ebb6a595a6", size = 134889, upload-time = "2025-11-17T13:00:10.29Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/012dbae28a0b88cd98eae99f87701ffbe3a7d2ea3de345cb8a6a6e1b16cd/preshed-3.0.12-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7f933d509ee762a90f62573aaf189eba94dfee478fca13ea2183b2f8a1bb8f7e", size = 911078, upload-time = "2025-11-17T13:00:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/88/c1/0cd0f8cdb91f63c298320cf946c4b97adfb8e8d3a5d454267410c90fcfaa/preshed-3.0.12-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f73f4e29bf90e58034e6f5fa55e6029f3f2d7c042a7151ed487b49898b0ce887", size = 930506, upload-time = "2025-11-17T13:00:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/20/1a/cab79b3181b2150eeeb0e2541c2bd4e0830e1e068b8836b24ea23610cec3/preshed-3.0.12-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a61ede0c3d18f1ae128113f785a396351a46f4634beccfdf617b0a86008b154d", size = 900009, upload-time = "2025-11-17T13:00:14.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/5ea9d6d95d5c07ba70166330a43bff7f0a074d0134eb7984eca6551e8c70/preshed-3.0.12-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eafc08a86f77be78e722d96aa8a3a0aef0e3c7ac2f2ada22186a138e63d4033c", size = 910826, upload-time = "2025-11-17T13:00:16.861Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/39024f9873ff317eac724b2759e94d013703800d970d51de77ccc6afff7e/preshed-3.0.12-cp314-cp314t-win_amd64.whl", hash = "sha256:fadaad54973b8697d5ef008735e150bd729a127b6497fd2cb068842074a6f3a7", size = 141358, upload-time = "2025-11-17T13:00:18.167Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0d/431bb85252119f5d2260417fa7d164619b31eed8f1725b364dc0ade43a8e/preshed-3.0.12-cp314-cp314t-win_arm64.whl", hash = "sha256:c0c0d3b66b4c1e40aa6042721492f7b07fc9679ab6c361bc121aa54a1c3ef63f", size = 114839, upload-time = "2025-11-17T13:00:19.513Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -725,6 +1707,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -755,6 +1746,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -851,15 +1854,21 @@ wheels = [ [[package]] name = "reporails-cli" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ + { name = "en-core-web-sm" }, { name = "httpx" }, + { name = "markdown-it-py" }, { name = "mcp" }, + { name = "numpy" }, + { name = "onnxruntime" }, { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "rich" }, + { name = "spacy" }, + { name = "tokenizers" }, { name = "typer" }, ] @@ -876,20 +1885,31 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "anthropic" }, + { name = "huggingface-hub" }, + { name = "matplotlib" }, { name = "mypy" }, + { name = "networkx" }, { name = "poethepoet" }, { name = "pylint" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sympy" }, { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ + { name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "numpy", specifier = ">=1.26,<3" }, + { name = "onnxruntime", specifier = ">=1.18,<2" }, { name = "packaging", specifier = ">=23.0" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = ">=0.25.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -899,6 +1919,8 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "spacy", specifier = ">=3.8.11,<4" }, + { name = "tokenizers", specifier = ">=0.19,<1" }, { name = "typer", specifier = ">=0.12.0" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, ] @@ -906,15 +1928,37 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "anthropic", specifier = ">=0.40.0" }, + { name = "huggingface-hub", specifier = ">=0.23" }, + { name = "matplotlib", specifier = ">=3.10.8" }, { name = "mypy", specifier = ">=1.8.0" }, + { name = "networkx", specifier = ">=3.6.1" }, { name = "poethepoet", specifier = ">=0.25.0" }, { name = "pylint", specifier = ">=3.0.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "ruff", specifier = ">=0.3.0" }, + { name = "scikit-learn", specifier = ">=1.8.0" }, + { name = "scipy", specifier = ">=1.17.1" }, + { name = "sympy", specifier = ">=1.14.0" }, { name = "types-pyyaml", specifier = ">=6.0.0" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -1035,6 +2079,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1044,6 +2202,145 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "7.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/be/a66598b305763861a9ab15ff0f2fbc44e47b1ce7a776797337a4eef37c66/smart_open-7.5.1.tar.gz", hash = "sha256:3f08e16827c4733699e6b2cc40328a3568f900cb12ad9a3ad233ba6c872d9fe7", size = 54034, upload-time = "2026-02-23T11:01:28.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/ea/dcdecd68acebb49d3fd560473a43499b1635076f7f1ae8641c060fe7ce74/smart_open-7.5.1-py3-none-any.whl", hash = "sha256:3e07cbbd9c8a908bcb8e25d48becf1a5cbb4886fa975e9f34c672ed171df2318", size = 64108, upload-time = "2026-02-23T11:01:27.429Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "spacy" +version = "3.8.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, + { name = "cymem" }, + { name = "jinja2" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "spacy-legacy" }, + { name = "spacy-loggers" }, + { name = "srsly" }, + { name = "thinc" }, + { name = "tqdm" }, + { name = "typer-slim" }, + { name = "wasabi" }, + { name = "weasel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/9f/424244b0e2656afc9ff82fb7a96931a47397bfce5ba382213827b198312a/spacy-3.8.11.tar.gz", hash = "sha256:54e1e87b74a2f9ea807ffd606166bf29ac45e2bd81ff7f608eadc7b05787d90d", size = 1326804, upload-time = "2025-11-17T20:40:03.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/fb/01eadf4ba70606b3054702dc41fc2ccf7d70fb14514b3cd57f0ff78ebea8/spacy-3.8.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aa1ee8362074c30098feaaf2dd888c829a1a79c4311eec1b117a0a61f16fa6dd", size = 6073726, upload-time = "2025-11-17T20:39:01.679Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f8/07b03a2997fc2621aaeafae00af50f55522304a7da6926b07027bb6d0709/spacy-3.8.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:75a036d04c2cf11d6cb566c0a689860cc5a7a75b439e8fea1b3a6b673dabf25d", size = 5724702, upload-time = "2025-11-17T20:39:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/13/0c/c4fa0f379dbe3258c305d2e2df3760604a9fcd71b34f8f65c23e43f4cf55/spacy-3.8.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cb599d2747d4a59a5f90e8a453c149b13db382a8297925cf126333141dbc4f7", size = 32727774, upload-time = "2025-11-17T20:39:05.894Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8e/6a4ba82bed480211ebdf5341b0f89e7271b454307525ac91b5e447825914/spacy-3.8.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:94632e302ad2fb79dc285bf1e9e4d4a178904d5c67049e0e02b7fb4a77af85c4", size = 33215053, upload-time = "2025-11-17T20:39:08.588Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bc/44d863d248e9d7358c76a0aa8b3f196b8698df520650ed8de162e18fbffb/spacy-3.8.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aeca6cf34009d48cda9fb1bbfb532469e3d643817241a73e367b34ab99a5806f", size = 32074195, upload-time = "2025-11-17T20:39:11.601Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7d/0b115f3f16e1dd2d3f99b0f89497867fc11c41aed94f4b7a4367b4b54136/spacy-3.8.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:368a79b8df925b15d89dccb5e502039446fb2ce93cf3020e092d5b962c3349b9", size = 32996143, upload-time = "2025-11-17T20:39:14.705Z" }, + { url = "https://files.pythonhosted.org/packages/7d/48/7e9581b476df76aaf9ee182888d15322e77c38b0bbbd5e80160ba0bddd4c/spacy-3.8.11-cp312-cp312-win_amd64.whl", hash = "sha256:88d65941a87f58d75afca1785bd64d01183a92f7269dcbcf28bd9d6f6a77d1a7", size = 14217511, upload-time = "2025-11-17T20:39:17.316Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1f/307a16f32f90aa5ee7ad8d29ff8620a57132b80a4c8c536963d46d192e1a/spacy-3.8.11-cp312-cp312-win_arm64.whl", hash = "sha256:97b865d6d3658e2ab103a67d6c8a2d678e193e84a07f40d9938565b669ceee39", size = 13614446, upload-time = "2025-11-17T20:39:19.748Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/3f07cff8bc478fcf48a915ca9fe8637486a1ec676587ed3e6fd775423301/spacy-3.8.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea4adeb399636059925be085c5bb852c1f3a2ebe1c2060332cbad6257d223bbc", size = 6051355, upload-time = "2025-11-17T20:39:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/6d/44/4671e8098b62befec69c7848538a0824086559f74065284bbd57a5747781/spacy-3.8.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd785e6bd85a58fa037da0c18fcd7250e2daecdfc30464d3882912529d1ad588", size = 5700468, upload-time = "2025-11-17T20:39:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/0c/98/5708bdfb39f94af0655568e14d953886117e18bd04c3aa3ab5ff1a60ea89/spacy-3.8.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:598c177054eb6196deed03cac6fb7a3229f4789719ad0c9f7483f9491e375749", size = 32521877, upload-time = "2025-11-17T20:39:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1f/731beb48f2c7415a71e2f655876fea8a0b3a6798be3d4d51b794f939623d/spacy-3.8.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a5a449ed3f2d03399481870b776f3ec61f2b831812d63dc1acedf6da70e5ab03", size = 32848355, upload-time = "2025-11-17T20:39:28.971Z" }, + { url = "https://files.pythonhosted.org/packages/47/6b/f3d131d3f9bb1c7de4f355a12adcd0a5fa77f9f624711ddd0f19c517e88b/spacy-3.8.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a6c35c2cb93bade9b7360d1f9db608a066246a41301bb579309efb50764ba55b", size = 31764944, upload-time = "2025-11-17T20:39:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/72/bf/37ea8134667a4f2787b5f0e0146f2e8df1fb36ab67d598ad06eb5ed2e7db/spacy-3.8.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0156ae575b20290021573faa1fed8a82b11314e9a1c28f034713359a5240a325", size = 32718517, upload-time = "2025-11-17T20:39:35.286Z" }, + { url = "https://files.pythonhosted.org/packages/79/fe/436435dfa93cc355ed511f21cf3cda5302b7aa29716457317eb07f1cf2da/spacy-3.8.11-cp313-cp313-win_amd64.whl", hash = "sha256:6f39cf36f86bd6a8882076f86ca80f246c73aa41d7ebc8679fbbe41b6f8ec045", size = 14211913, upload-time = "2025-11-17T20:39:37.906Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/f89cfa51f54aa5e9c6c7a37f8bf4952d678f0902a5e1d81dfda33a94bfb2/spacy-3.8.11-cp313-cp313-win_arm64.whl", hash = "sha256:9a7151eee0814a5ced36642b42b1ecc8f98ac7225f3e378fb9f862ffbe84b8bf", size = 13605169, upload-time = "2025-11-17T20:39:40.455Z" }, + { url = "https://files.pythonhosted.org/packages/d7/78/ddeb09116b593f3cccc7eb489a713433076b11cf8cdfb98aec641b73a2c2/spacy-3.8.11-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:43c24d19a3f85bde0872935294a31fd9b3a6db3f92bb2b75074177cd3acec03f", size = 6067734, upload-time = "2025-11-17T20:39:42.629Z" }, + { url = "https://files.pythonhosted.org/packages/65/bb/1bb630250dc70e00fa3821879c6e2cb65c19425aba38840d3484061285c1/spacy-3.8.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b6158c21da57b8373d2d1afb2b73977c4bc4235d2563e7788d44367fc384939a", size = 5732963, upload-time = "2025-11-17T20:39:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/c58071b3db23932ab2b934af3462a958e7edf472da9668e4869fe2a2199e/spacy-3.8.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1c0bd1bde1d91f1d7a44774ca4ca3fcf064946b72599a8eb34c25e014362ace1", size = 32447290, upload-time = "2025-11-17T20:39:47.392Z" }, + { url = "https://files.pythonhosted.org/packages/34/eb/d3947efa2b46848372e89ced8371671d77219612a3eebef15db5690aa4d2/spacy-3.8.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99b767c41a772e544cf2d48e0808764f42f17eb2fd6188db4a729922ff7f0c1e", size = 32488011, upload-time = "2025-11-17T20:39:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/04/9e/8c6c01558b62388557247e553e48874f52637a5648b957ed01fbd628391d/spacy-3.8.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3c500f04c164e4366a1163a61bf39fd50f0c63abdb1fc17991281ec52a54ab4", size = 31731340, upload-time = "2025-11-17T20:39:53.221Z" }, + { url = "https://files.pythonhosted.org/packages/23/1f/21812ec34b187ef6ba223389760dfea09bbe27d2b84b553c5205576b4ac2/spacy-3.8.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a2bfe45c0c1530eaabc68f5434c52b1be8df10d5c195c54d4dc2e70cea97dc65", size = 32478557, upload-time = "2025-11-17T20:39:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/a0c9174a232dfe7b48281c05364957e2c6d0f80ef26b67ce8d28a49c2d91/spacy-3.8.11-cp314-cp314-win_amd64.whl", hash = "sha256:45d0bbc8442d18dcea9257be0d1ab26e884067e038b1fa133405bf2f20c74edf", size = 14396041, upload-time = "2025-11-17T20:39:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d0/a6aad5b73d523e4686474b0cfcf46f37f3d7a18765be5c1f56c1dcee4c18/spacy-3.8.11-cp314-cp314-win_arm64.whl", hash = "sha256:90a12961ecc44e0195fd42db9f0ce4aade17e6fe03f8ab98d4549911d9e6f992", size = 13823760, upload-time = "2025-11-17T20:40:00.831Z" }, +] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" }, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, +] + +[[package]] +name = "srsly" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/77/5633c4ba65e3421b72b5b4bd93aa328360b351b3a1e5bf3c90eb224668e5/srsly-2.5.2.tar.gz", hash = "sha256:4092bc843c71b7595c6c90a0302a197858c5b9fe43067f62ae6a45bc3baa1c19", size = 492055, upload-time = "2025-11-17T14:11:02.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/1c/21f658d98d602a559491b7886c7ca30245c2cd8987ff1b7709437c0f74b1/srsly-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f92b4f883e6be4ca77f15980b45d394d310f24903e25e1b2c46df783c7edcce", size = 656161, upload-time = "2025-11-17T14:10:03.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a2/bc6fd484ed703857043ae9abd6c9aea9152f9480a6961186ee6c1e0c49e8/srsly-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac4790a54b00203f1af5495b6b8ac214131139427f30fcf05cf971dde81930eb", size = 653237, upload-time = "2025-11-17T14:10:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ea/e3895da29a15c8d325e050ad68a0d1238eece1d2648305796adf98dcba66/srsly-2.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce5c6b016050857a7dd365c9dcdd00d96e7ac26317cfcb175db387e403de05bf", size = 1174418, upload-time = "2025-11-17T14:10:05.945Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a5/21996231f53ee97191d0746c3a672ba33a4d86a19ffad85a1c0096c91c5f/srsly-2.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:539c6d0016e91277b5e9be31ebed03f03c32580d49c960e4a92c9003baecf69e", size = 1183089, upload-time = "2025-11-17T14:10:07.335Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/eb17aa8e4a828e8df7aa7dc471295529d9126e6b710f1833ebe0d8568a8e/srsly-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f24b2c4f4c29da04083f09158543eb3f8893ba0ac39818693b3b259ee8044f0", size = 1122594, upload-time = "2025-11-17T14:10:08.899Z" }, + { url = "https://files.pythonhosted.org/packages/80/74/1654a80e6c8ec3ee32370ea08a78d3651e0ba1c4d6e6be31c9efdb9a2d10/srsly-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d34675047460a3f6999e43478f40d9b43917ea1e93a75c41d05bf7648f3e872d", size = 1139594, upload-time = "2025-11-17T14:10:10.286Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/8393344ca7f0e81965febba07afc5cad68335ed0426408d480b861ab915b/srsly-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:81fd133ba3c66c07f0e3a889d2b4c852984d71ea833a665238a9d47d8e051ba5", size = 654750, upload-time = "2025-11-17T14:10:11.637Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c5/dc29e65419692444253ea549106be156c5911041f16791f3b62fb90c14f2/srsly-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d976d6ae8e66006797b919e3d58533dce64cd48a5447a8ff7277f9b0505b0185", size = 654723, upload-time = "2025-11-17T14:10:13.305Z" }, + { url = "https://files.pythonhosted.org/packages/80/8c/8111e7e8c766b47b5a5f9864f27f532cf6bb92837a3e277eb297170bd6af/srsly-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:24f52ecd27409ea24ba116ee9f07a2bb1c4b9ba11284b32a0bf2ca364499d1c1", size = 651651, upload-time = "2025-11-17T14:10:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/45/de/3f99d4e44af427ee09004df6586d0746640536b382c948f456be027c599b/srsly-2.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b0667ce1effb32a57522db10705db7c78d144547fcacc8a06df62c4bb7f96e", size = 1158012, upload-time = "2025-11-17T14:10:16.176Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2f/66044ef5a10a487652913c1a7f32396cb0e9e32ecfc3fdc0a0bc0382e703/srsly-2.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60782f6f79c340cdaf1ba7cbaa1d354a0f7c8f86b285f1e14e75edb51452895a", size = 1163258, upload-time = "2025-11-17T14:10:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/698834048672b52937e8cf09b554adb81b106c0492f9bc62e41e3b46a69b/srsly-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec51abb1b58e1e6c689714104aeeba6290c40c0bfad0243b9b594df89f05881", size = 1112214, upload-time = "2025-11-17T14:10:18.679Z" }, + { url = "https://files.pythonhosted.org/packages/85/17/1efc70426be93d32a3c6c5c12d795eb266a9255d8b537fcb924a3de57fcb/srsly-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:76464e45f73afd20c2c34d2ef145bf788afc32e7d45f36f6393ed92a85189ed3", size = 1130687, upload-time = "2025-11-17T14:10:20.346Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/07f8c8a778bc0447ee15e37089b08af81b24fcc1d4a2c09eff4c3a79b241/srsly-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:009424a96d763951e4872b36ba38823f973bef094a1adbc11102e23e8d1ef429", size = 653128, upload-time = "2025-11-17T14:10:21.552Z" }, + { url = "https://files.pythonhosted.org/packages/39/03/3d248f538abc141d9c7ed1aa10e61506c0f95515a61066ee90e888f0cd8f/srsly-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a0911dcf1026f982bd8c5f73e1c43f1bc868416408fcbc1f3d99eb59475420c5", size = 659866, upload-time = "2025-11-17T14:10:22.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/22/0fcff4c977ddfb32a6b10f33d904868b16ce655323756281f973c5a3449e/srsly-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0ff3ac2942aee44235ca3c7712fcbd6e0d1a092e10ee16e07cef459ed6d7f65", size = 655868, upload-time = "2025-11-17T14:10:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c1/e158f26a5597ac31b0f306d2584411ec1f984058e8171d76c678bf439e96/srsly-2.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:78385fb75e1bf7b81ffde97555aee094d270a5e0ea66f8280f6e95f5bb508b3e", size = 1156753, upload-time = "2025-11-17T14:10:25.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bc/2001cd27fd6ecdae79050cf6b655ca646dedc0b69a756e6a87993cc47314/srsly-2.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2e9943b70bd7655b9eefca77aab838c3b7acea00c9dd244fd218a43dc61c518b", size = 1157916, upload-time = "2025-11-17T14:10:26.705Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/56f563c2d0cd76c8fd22fb9f1589f18af50b54d31dd3323ceb05fe7999b8/srsly-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7d235a2bb08f5240e47c6aba4d9688b228d830fbf4c858388d9c151a10039e6d", size = 1114582, upload-time = "2025-11-17T14:10:27.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/e155facc965a119e6f5d32b7e95082cadfb62cc5d97087d53db93f3a5a98/srsly-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ad94ee18b3042a6cdfdc022556e2ed9a7b52b876de86fe334c4d8ec58d59ecbc", size = 1129875, upload-time = "2025-11-17T14:10:29.295Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3a/c12a4d556349c9f491b0a9d27968483f22934d2a02dfb14fb1d3a7d9b837/srsly-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:6658467165d8fa4aec0f5f6e2da8fe977e087eaff13322b0ff20450f0d762cee", size = 658858, upload-time = "2025-11-17T14:10:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/52510cbf478ab3ae8cb6c95aff3a499f2ded69df6d84df8a293630e9f10a/srsly-2.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:517e907792acf574979752ce33e7b15985c95d4ed7d8e38ee47f36063dc985ac", size = 666843, upload-time = "2025-11-17T14:10:32.082Z" }, + { url = "https://files.pythonhosted.org/packages/3d/da/4257b1d4c3eb005ecd135414398c033c13c4d3dffb715f63c3acd63d8d1a/srsly-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5602797e6f87bf030b11ad356828142367c5c81e923303b5ff2a88dfb12d1e4", size = 663981, upload-time = "2025-11-17T14:10:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f8/1ec5edd7299d8599def20fc3440372964f7c750022db8063e321747d1cf8/srsly-2.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3452306118f8604daaaac6d770ee8f910fca449e8f066dcc96a869b43ece5340", size = 1267808, upload-time = "2025-11-17T14:10:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/4ef9782c9a3f331ef80e1ea8fc6fab50fc3d32ae61a494625d2c5f30cc4c/srsly-2.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2d59f1ce00d73397a7f5b9fc33e76d17816ce051abe4eb920cec879d2a9d4f4", size = 1252838, upload-time = "2025-11-17T14:10:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/39/da/d13cfc662d71eec3ccd4072433bf435bd2e11e1c5340150b4cc43fad46f4/srsly-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ebda3736651d33d92b17e26c525ba8d0b94d0ee379c9f92e8d937ba89dca8978", size = 1244558, upload-time = "2025-11-17T14:10:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/26/50/92bf62dfb19532b823ef52251bb7003149e1d4a89f50a63332c8ff5f894b/srsly-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:74a9338fcc044f4bdc7113b2d9db2db8e0a263c69f1cba965acf12c845d8b365", size = 1244935, upload-time = "2025-11-17T14:10:42.324Z" }, + { url = "https://files.pythonhosted.org/packages/95/81/6ea10ef6228ce4438a240c803639f7ccf5eae3469fbc015f33bd84aa8df1/srsly-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:8e2b9058623c44b07441eb0d711dfdf6302f917f0634d0a294cae37578dcf899", size = 676105, upload-time = "2025-11-17T14:10:43.633Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0" @@ -1070,6 +2367,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "thinc" +version = "8.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blis" }, + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "srsly" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/3a/2d0f0be132b9faaa6d56f04565ae122684273e4bf4eab8dee5f48dc00f68/thinc-8.3.10.tar.gz", hash = "sha256:5a75109f4ee1c968fc055ce651a17cb44b23b000d9e95f04a4d047ab3cb3e34e", size = 194196, upload-time = "2025-11-17T17:21:46.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/34/ba3b386d92edf50784b60ee34318d47c7f49c198268746ef7851c5bbe8cf/thinc-8.3.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51bc6ef735bdbcab75ab2916731b8f61f94c66add6f9db213d900d3c6a244f95", size = 794509, upload-time = "2025-11-17T17:21:03.21Z" }, + { url = "https://files.pythonhosted.org/packages/07/f3/9f52d18115cd9d8d7b2590d226cb2752d2a5ffec61576b19462b48410184/thinc-8.3.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4f48b4d346915f98e9722c0c50ef911cc16c6790a2b7afebc6e1a2c96a6ce6c6", size = 741084, upload-time = "2025-11-17T17:21:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9c/129c2b740c4e3d3624b6fb3dec1577ef27cb804bc1647f9bc3e1801ea20c/thinc-8.3.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5003f4db2db22cc8d686db8db83509acc3c50f4c55ebdcb2bbfcc1095096f7d2", size = 3846337, upload-time = "2025-11-17T17:21:06.079Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/738cf188dea8240c2be081c83ea47270fea585eba446171757d2cdb9b675/thinc-8.3.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b12484c3ed0632331fada2c334680dd6bc35972d0717343432dfc701f04a9b4c", size = 3901216, upload-time = "2025-11-17T17:21:07.842Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/32f66eb9b1a29b797bf378a0874615d810d79eefca1d6c736c5ca3f8b918/thinc-8.3.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8677c446d3f9b97a465472c58683b785b25dfcf26c683e3f4e8f8c7c188e4362", size = 4827286, upload-time = "2025-11-17T17:21:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/7ceae1e1f2029efd67ed88e23cd6dc13a5ee647cdc2b35113101b2a62c10/thinc-8.3.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:759c385ac08dcf950238b60b96a28f9c04618861141766928dff4a51b1679b25", size = 5024421, upload-time = "2025-11-17T17:21:11.199Z" }, + { url = "https://files.pythonhosted.org/packages/0b/66/30f9d8d41049b78bc614213d492792fbcfeb1b28642adf661c42110a7ebd/thinc-8.3.10-cp312-cp312-win_amd64.whl", hash = "sha256:bf3f188c3fa1fdcefd547d1f90a1245c29025d6d0e3f71d7fdf21dad210b990c", size = 1718631, upload-time = "2025-11-17T17:21:12.965Z" }, + { url = "https://files.pythonhosted.org/packages/f8/44/32e2a5018a1165a304d25eb9b1c74e5310da19a533a35331e8d824dc6a88/thinc-8.3.10-cp312-cp312-win_arm64.whl", hash = "sha256:234b7e57a6ef4e0260d99f4e8fdc328ed12d0ba9bbd98fdaa567294a17700d1c", size = 1642224, upload-time = "2025-11-17T17:21:14.371Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/17a2818d1f460b8c4f33b8bd3f21b19d263a647bfd23b572768d175e6b64/thinc-8.3.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c7c3a50ddd423d1c49419899acef4ac80d800af3b423593acb9e40578384b543", size = 789771, upload-time = "2025-11-17T17:21:15.784Z" }, + { url = "https://files.pythonhosted.org/packages/8d/24/649f54774b1fbe791a1c2efd7d7f0a95cfd9244902553ca7dcf19daab1dd/thinc-8.3.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a1cb110398f51fc2b9a07a2a4daec6f91e166533a9c9f1c565225330f46569a", size = 737051, upload-time = "2025-11-17T17:21:17.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/5840c6c504c1fa9718e1c74d6e04d77a474f594888867dbba53f9317285f/thinc-8.3.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42318746a67403d04be57d862fe0c0015b58b6fb9bbbf7b6db01f3f103b73a99", size = 3839221, upload-time = "2025-11-17T17:21:20.003Z" }, + { url = "https://files.pythonhosted.org/packages/45/ef/e7fca88074cb0aa1c1a23195470b4549492c2797fe7dc9ff79a85500153a/thinc-8.3.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b0e41e79973f8828adead770f885db8d0f199bfbaa9591d1d896c385842e993", size = 3885024, upload-time = "2025-11-17T17:21:21.735Z" }, + { url = "https://files.pythonhosted.org/packages/9a/eb/805e277aa019896009028d727460f071c6cf83843d70f6a69e58994d2203/thinc-8.3.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9ed982daa1eddbad813bfd079546483b849a68b98c01ad4a7e4efd125ddc5d7b", size = 4815939, upload-time = "2025-11-17T17:21:23.942Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/6425f12a60e3782091c9ec16394b9239f0c18c52c70218f3c8c047ff985c/thinc-8.3.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d22bd381410749dec5f629b3162b7d1f1e2d9b7364fd49a7ea555b61c93772b9", size = 5020260, upload-time = "2025-11-17T17:21:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/85/a2/ae98feffe0b161400e87b7bfc8859e6fa1e6023fa7bcfa0a8cacd83b39a1/thinc-8.3.10-cp313-cp313-win_amd64.whl", hash = "sha256:9c32830446a57da13b6856cacb0225bc2f2104f279d9928d40500081c13aa9ec", size = 1717562, upload-time = "2025-11-17T17:21:27.468Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/faa1d04a6890ea33b9541727d2a3ca88bad794a89f73b9111af6f9aefe10/thinc-8.3.10-cp313-cp313-win_arm64.whl", hash = "sha256:aa43f9af76781d32f5f9fe29299204c8841d71e64cbb56e0e4f3d1e0387c2783", size = 1641536, upload-time = "2025-11-17T17:21:30.129Z" }, + { url = "https://files.pythonhosted.org/packages/b8/32/7a96e1f2cac159d778c6b0ab4ddd8a139bb57c602cef793b7606cd32428d/thinc-8.3.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:44d7038a5d28572105332b44ec9c4c3b6f7953b41d224588ad0473c9b79ccf9e", size = 793037, upload-time = "2025-11-17T17:21:32.538Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/81e8495e8ef412767c09d1f9d0d86dc60cd22e6ed75e61b49fbf1dcfcd65/thinc-8.3.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:639f20952af722cb0ab4c3d8a00e661686b60c04f82ef48d12064ceda3b8cd0c", size = 740768, upload-time = "2025-11-17T17:21:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/716488a301d65c5463e92cb0eddae3672ca84f1d70937808cea9760f759c/thinc-8.3.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9306e62c7e7066c63b0c0ba1d164ae0c23bf38edf5a7df2e09cce69a2c290500", size = 3834983, upload-time = "2025-11-17T17:21:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/d28b21cab9b79e9c803671bebd14489e14c5226136fad6a1c44f96f8e4ef/thinc-8.3.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2982604c21096de1a87b04a781a645863eece71ec6ee9f139ac01b998fb5622d", size = 3845215, upload-time = "2025-11-17T17:21:38.362Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/ff64ead5f1c2298d9e6a9ccc1c676b2347ac06162ad3c5e5d895c32a719e/thinc-8.3.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6b82698e27846004d4eafc38317ace482eced888d4445f7fb9c548fd36777af", size = 4826596, upload-time = "2025-11-17T17:21:40.027Z" }, + { url = "https://files.pythonhosted.org/packages/4a/44/b80c863608d0fd31641a2d50658560c22d4841f1e445529201e22b3e1d0f/thinc-8.3.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2950acab8ae77427a86d11655ed0a161bc83a1edf9d31ba5c43deca6cd27ed4f", size = 4988146, upload-time = "2025-11-17T17:21:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/93/6d/1bdd9344b2e7299faa55129dda624d50c334eed16a3761eb8b1dacd8bfcd/thinc-8.3.10-cp314-cp314-win_amd64.whl", hash = "sha256:c253139a5c873edf75a3b17ec9d8b6caebee072fdb489594bc64e35115df7625", size = 1738054, upload-time = "2025-11-17T17:21:43.328Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/44e3163d48e398efb3748481656963ac6265c14288012871c921dc81d004/thinc-8.3.10-cp314-cp314-win_arm64.whl", hash = "sha256:ad6da67f534995d6ec257f16665377d7ad95bef5c1b1c89618fd4528657a6f24", size = 1665001, upload-time = "2025-11-17T17:21:45.019Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + [[package]] name = "tomlkit" version = "0.14.0" @@ -1079,6 +2469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typer" version = "0.21.1" @@ -1094,6 +2496,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, ] +[[package]] +name = "typer-slim" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1124,6 +2539,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0" @@ -1136,3 +2560,99 @@ sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e66 wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] + +[[package]] +name = "wasabi" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" }, +] + +[[package]] +name = "weasel" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpathlib" }, + { name = "confection" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "smart-open" }, + { name = "srsly" }, + { name = "typer-slim" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/d7/edd9c24e60cf8e5de130aa2e8af3b01521f4d0216c371d01212f580d0d8e/weasel-0.4.3.tar.gz", hash = "sha256:f293d6174398e8f478c78481e00c503ee4b82ea7a3e6d0d6a01e46a6b1396845", size = 38733, upload-time = "2025-11-13T23:52:28.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/74/a148b41572656904a39dfcfed3f84dd1066014eed94e209223ae8e9d088d/weasel-0.4.3-py3-none-any.whl", hash = "sha256:08f65b5d0dbded4879e08a64882de9b9514753d9eaa4c4e2a576e33666ac12cf", size = 50757, upload-time = "2025-11-13T23:52:26.982Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +]