diff --git a/.ails/backbone.yml b/.ails/backbone.yml deleted file mode 100644 index 25c6655..0000000 --- a/.ails/backbone.yml +++ /dev/null @@ -1,53 +0,0 @@ -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: {} - -modules: - core: src/reporails_cli/core/ - interfaces: src/reporails_cli/interfaces/ - formatters: src/reporails_cli/formatters/ - bundled: src/reporails_cli/bundled/ - templates: src/reporails_cli/templates/ - rules: framework/rules/ - schemas: framework/schemas/ - - -bundled: - project_types: src/reporails_cli/bundled/project-types.yml - -artifacts: - changelog: UNRELEASED.md - specs: specs/cli/ - -context: - role: developer - description: "You are developing the reporails CLI, not an end user." - personas: - developer: "Modify source, run tests, read specs" - cli_user: "Install package, run ails check" - mcp_user: "Use reporails via Claude Code MCP integration" - -meta: - version_file: VERSION - -skills: - ails: - path: .claude/skills/ails/ - entry: SKILL.md - qa: - path: .claude/skills/qa/ - entry: SKILL.md - plan-feature: - path: .claude/skills/plan-feature/ - entry: SKILL.md - add-changelog-entry: - path: .claude/skills/add-changelog-entry/ - entry: SKILL.md - bootstrap: - path: .claude/skills/bootstrap/ - entry: SKILL.md diff --git a/.gitignore b/.gitignore index 5ffc82e..2addd60 100644 --- a/.gitignore +++ b/.gitignore @@ -16,15 +16,21 @@ specs/ /.mcp.json /CLAUDE.md +# Local project backbone (developer-private; documents internal modules, +# specs paths, and toolchain that should not ship in a public repo) +/.ails/backbone.yml + # Pre-existing local rule-harness scaffolds with the wrong filename for CORE # rules (should be AGENTS.md — agent-agnostic). 0.5.6 will rename these and # track them properly. Until then they live local-only. framework/rules/**/tests/**/CLAUDE.md framework/rules/**/tests/**/.claude/ -# Bundled ONNX embedding model — fetched by scripts/fetch_bundled_model.py -# (dev-only, not committed; populated on clone and in CI before hatch build) +# Bundled ML assets — fetched by scripts/fetch_bundled_model.py +# (dev-only, not committed; populated on clone and in CI before hatch build). +# ONNX embedder (~80 MB) + spaCy en_core_web_sm pipeline (~15 MB). src/reporails_cli/bundled/models/ +src/reporails_cli/bundled/spacy/ # npm packaging — README.md is copied from the repo root by the prepack # script in packages/npm/package.json before `npm pack` / `npm publish`. diff --git a/.ignore b/.ignore index b1625bb..6b28b91 100644 --- a/.ignore +++ b/.ignore @@ -1,5 +1,5 @@ # Prevent Claude Code from discovering .claude directories inside test fixtures # as real configuration. Ripgrep (used by Claude for file discovery) respects # .ignore files with .gitignore syntax. -framework/rules/**/tests/** +framework/rules/**/**/tests/** tests/fixtures/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdc243..7ffdbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.5.9 + +### Added + +- Tooling: `uv run poe specs_check` validates internal subsystem coverage (declared subsystems exist, each spec is within line-budget, modules colocate under one subpackage); `uv run poe spec_drift` flags potentially stale design docs whose source has been edited more recently +- Tooling: expanded `pytest` marker taxonomy in `pyproject.toml` for granular test selection (lane, cost, subsystem) with new poe tasks `test_fast`, `test_arch`, `test_contracts`, `test_markers` +- Tooling: every `tests/*` test function now carries pytest lane (`unit`/`integration`/`e2e`) + subsystem (`subsys_*`) markers; `check_test_markers.py` enforces tagging on every `qa_fast` run, enabling `pytest -m subsys_caching` and similar slicing +- Tooling: hexagonal platform substrate skeleton bootstrapped at `core/platform/{contract,dto,policy,adapters,runtime,config,observability,utils}` with report-only architecture tests guarding pure-layer purity and adapter boundary (`tests/unit/architecture/`) + +### Changed + +- Build: bundle the `en_core_web_sm` spaCy pipeline (~15 MB) inside the wheel under `bundled/spacy/`, alongside the existing bundled ONNX embedder. `core/mapper/models.py` loads the pipeline by local filesystem path. End users no longer need a separate model download — `pip install reporails-cli` (or `uv pip install`, or `npx @reporails/cli`) delivers the full model bundle. +- Build: tightened `requires-python` to `>=3.12,<3.14`; Python 3.14 ships a `pydantic.v1` introspection regression that breaks `import spacy`. The CLI's verb-lexicon fallback covered the failure silently but with reduced precision. The pin restores spaCy classification under `uv sync`. +- API client: outgoing diagnostic requests now carry a `User-Agent: reporails-cli/` header for accurate attribution in server-side logs; previously the generic `python-httpx/` default was sent. +- Funnel: rate-limit CTA surfaces a "Try again in ~N min." hint when the server returns `reset_in`, between the limit blurb and the upgrade prompt. +- Funnel: CTA and bug-report URLs render as OSC 8 terminal hyperlinks with a short clickable label (`github.com/reporails/cli/issues/new`) instead of dumping the full percent-encoded prefilled URL; falls back to the short label on terminals without hyperlink support. +- Funnel: demoted the "Could not parse N response body" and "Server returned N for tier=" stderr warnings to debug logging so they no longer print above the diagnostic report; reworded the `unknown_error` CTA to `Diagnostics server returned HTTP `. +- Display: file rows annotate duplicates with `(+alias)` labels — symlinked surfaces show the differing path component (e.g. `mintlify (+.claude)`), same-directory content-identical pairs show the alternate filename (e.g. `AGENTS.md (+CLAUDE.md)`). +- Internals: hexagonal platform substrate consolidated under `core/platform/{contract,dto,policy,adapters,runtime,config,observability,utils}`. Every top-level `core/*.py` moved into its appropriate layer (DTOs, adapters, runtime, etc.), with a new `core/install/` subsystem for installer-related modules. Architecture tests at `tests/unit/architecture/` run in fail mode — any forbidden cross-layer import blocks the build. +- Internals: five subsystems consolidated into named subpackages — `core/cache/`, `core/funnel/`, `core/classify/`, `core/heal/`, `core/discovery/`, `core/lint/` — each matching its design boundary. +- Internals: the mapper subsystem went the furthest. `core/mapper/mapper.py` was split into one module per pipeline stage (`imports.py`, `parse.py`, `classify.py`, `annotate.py`, `embed.py`, `cluster.py`, `assemble.py`) plus shared `models.py`, `serialize.py`, `inspect.py`. The orchestration spine retains the name `core/mapper/pipeline.py`. Public import surface (`map_ruleset`, `content_hash`, `map_file`) is unchanged; callers now import via the `core.mapper` package facade. +- Internals: removed the legacy "recommended" rules-overlay machinery from `ails config set/get/list`, `GlobalConfig`/`ProjectConfig`, and `core/install/`. User-installed rule packages remain supported through the generic `packages: [...]` mechanism in `.ails/config.yml` (clone any rule pack into `.ails/packages//` or `~/.reporails/packages//`). + +### Fixed + +- Check: `frontmatter_valid_glob` no longer crashes on comma-separated `paths:` values; each entry is now split and validated individually, and invalid glob syntax surfaces as a structured check failure instead of an unhandled exception +- Discovery: skill and rule files that appear under multiple agent surfaces via symlinks (e.g. `.claude/skills/` → `.agents/skills/`) are now collapsed to one canonical entry, eliminating duplicate findings and inflated scoring + +### Removed + +- CLI: removed `ails map`. + ## 0.5.8 ### Added diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6f22070..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# Reporails CLI - -AI instruction validator — validates instruction files against mechanical, deterministic, and content_query rules using a pure Python regex engine. - -## Session Start - -Read `.ails/backbone.yml` for project structure and agent registry. Read `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 check` — validate instruction files (`-f json` for machine-readable output) -- `uv run ails heal` — interactive auto-fix -- `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 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 `specs/` 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 - -``` -src/reporails_cli/ -├── core/ # Domain logic (regex/, mechanical/, pipeline, agents) -├── bundled/ # CLI-owned config (capability-patterns.yml) -├── interfaces/ # CLI and MCP entry points -└── formatters/ # Output adapters (json, github, text, mcp) -action/ # GitHub Actions composite action -``` - -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` | diff --git a/README.md b/README.md index 0eb9c79..5fbbe40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Reporails CLI (v0.5.8) +# Reporails CLI (v0.5.9) -> **AI Instruction Diagnostics for coding agents. Validates the entire agentic instruction system against 92+ rules across six categories. Supports Claude, Codex, Copilot, Cursor, and Gemini.** +> **AI Instruction Diagnostics for coding agents. Validates the entire agentic instruction system against 120+ rules across six rule packs (core + per-agent). Supports Claude, Codex, Copilot, Cursor, and Gemini.** > > *Beta phase - moving fast, feedback welcome.* diff --git a/framework/rules/core/heading-as-instruction/rule.md b/framework/rules/core/heading-as-instruction/rule.md index 1e3b676..18e60bf 100644 --- a/framework/rules/core/heading-as-instruction/rule.md +++ b/framework/rules/core/heading-as-instruction/rule.md @@ -42,4 +42,4 @@ Use the heading as a section label and put the instruction in the first line of ## 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. +Detects headings classified as directive, imperative, or constraint. Short headings with common verbs may be false positives — "## Process" is a label, not an instruction, but contains a verb. diff --git a/hatch_build.py b/hatch_build.py index d82c191..ace6f28 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -15,10 +15,12 @@ "framework/sources.yml": "reporails_cli/sources.yml", } -# Bundled model (gitignored, but must be in wheel). +# Bundled ML assets (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" +BUNDLED_SPACY = "src/reporails_cli/bundled/spacy" +BUNDLED_SPACY_DEST = "reporails_cli/bundled/spacy" # Directory names to skip when bundling (rule test fixtures are dev-only) SKIP_DIRS = {"tests"} @@ -53,6 +55,15 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: rel = path.relative_to(root / "src") force_include[str(path)] = str(rel) + # Bundle spaCy en_core_web_sm model (gitignored but required at runtime) + spacy_dir = root / BUNDLED_SPACY + if spacy_dir.is_dir(): + for path in spacy_dir.rglob("*"): + if not path.is_file(): + 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. diff --git a/packages/npm/package.json b/packages/npm/package.json index dd0c9f6..a6e5924 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,6 +1,6 @@ { "name": "@reporails/cli", - "version": "0.5.8", + "version": "0.5.9", "description": "AI instruction diagnostics for coding agents", "type": "module", "bin": { diff --git a/pyproject.toml b/pyproject.toml index 5662356..b5b6969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "reporails-cli" -version = "0.5.8" +version = "0.5.9" description = "AI instruction diagnostics for coding agents" readme = "README.md" license = "BUSL-1.1" -requires-python = ">=3.12" +requires-python = ">=3.12,<3.14" authors = [{ name = "Reporails Team" }] keywords = ["ai-instructions", "claude", "codex", "copilot", "cursor", "gemini", "diagnostics", "validation", "mcp"] classifiers = [ @@ -34,9 +34,11 @@ dependencies = [ "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 provides the dependency-parser branch of the classifier. With + # the torch blocker active, spaCy loads in ~1s. The en_core_web_sm + # pipeline files ship inside the wheel under bundled/spacy/ rather + # than as a separate runtime dep — spaCy does not publish its language + # models on PyPI, so a direct-URL pin would block PyPI publication. "spacy>=3.8.11,<4", # sklearn for topic clustering in mapper "scikit-learn>=1.4.0", @@ -91,8 +93,6 @@ Issues = "https://github.com/reporails/cli/issues" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] - [tool.hatch.build.targets.wheel] packages = ["src/reporails_cli"] artifacts = [ @@ -110,10 +110,16 @@ type = "mypy src/" test_unit = "pytest tests/unit/ -v" test_integration = "pytest tests/integration/ -v" test_smoke = "pytest tests/smoke/ -v -m e2e" -qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_unit"] +qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_markers", "test_unit"] qa = ["fmt", "lint_fix", "lint", "pylint_struct", "type", "test_unit", "test_integration", "test_smoke"] mcp_dev = "python -m reporails_cli.interfaces.mcp.server" fetch_bundled_model = "python scripts/fetch_bundled_model.py" +specs_check = "python scripts/specs_check.py" +spec_drift = "python scripts/check_spec_drift.py" +test_markers = "python scripts/check_test_markers.py" +test_fast = "pytest -m 'unit and not slow and not requires_model'" +test_arch = "pytest -m architecture" +test_contracts = "pytest -m contract" [tool.ruff] target-version = "py312" @@ -171,7 +177,29 @@ max-module-lines = 600 testpaths = ["tests"] addopts = "--strict-markers" markers = [ - "unit: Fast, isolated unit tests", - "integration: Cross-component tests", - "e2e: End-to-end tests", + # Lane — mutually exclusive within a test + "unit: Fast, isolated, no I/O outside repo, no ML models", + "integration: Cross-component, may load ML models", + "e2e: End-to-end CLI on fixture project", + "smoke: Alias for e2e (preferred for new tests)", + "architecture: AST/structural assertions across the codebase", + "contract: Pure validator function in isolation", + # Cost — orthogonal, additive + "slow: Takes more than 1 second", + "requires_model: Needs the bundled ML runtime", + "requires_network: Hits real external service", + "real_deps: Needs API key or live backend", + # Subsystem tags — one or more per test + "subsys_caching", + "subsys_map", + "subsys_classify", + "subsys_runtime", + "subsys_heal", + "subsys_funnel", + "subsys_lint", + "subsys_diagnostic", + "subsys_server", + "subsys_api", + "subsys_gates", + "subsys_cli_ux", ] diff --git a/scripts/check_spec_drift.py b/scripts/check_spec_drift.py new file mode 100755 index 0000000..13657d7 --- /dev/null +++ b/scripts/check_spec_drift.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Report subsystems whose source has been modified more recently than their spec. + +Reads a project backbone config (path resolved at runtime) for a `subsystems:` +section, then for each entry compares the newest mtime across its listed +source modules against the mtime of the design spec. When source is newer than +spec by more than `STALE_THRESHOLD_DAYS`, the spec is flagged as potentially +stale — its description of invariants, data shapes, or pipeline stages may no +longer match reality. + +This is a heuristic. mtime-newer does not prove a public-shape change happened; +it only flags candidates for review. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +BACKBONE = ROOT / ".ails" / "backbone.yml" + +# Tolerate up to one calendar day of drift before flagging — source edits +# routinely happen without invariant changes. +STALE_THRESHOLD_DAYS = 1 + + +def _newest_mtime(paths: list[Path]) -> float: + """Return the max mtime across paths, recursing into directories.""" + newest = 0.0 + for p in paths: + if not p.exists(): + continue + if p.is_dir(): + for child in p.rglob("*"): + if child.is_file() and "__pycache__" not in child.parts: + newest = max(newest, child.stat().st_mtime) + else: + newest = max(newest, p.stat().st_mtime) + return newest + + +def main() -> int: + if not BACKBONE.exists(): + print("spec-drift: no backbone config; skipped") + return 0 + try: + backbone = yaml.safe_load(BACKBONE.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f"spec-drift: backbone config failed to parse: {exc}", file=sys.stderr) + return 2 + subs = backbone.get("subsystems") or {} + if not isinstance(subs, dict) or not subs: + print("spec-drift: no subsystems declared; nothing to check") + return 0 + + threshold_seconds = STALE_THRESHOLD_DAYS * 86400 + stale: list[tuple[str, str, float]] = [] + + for name, entry in subs.items(): + if not isinstance(entry, dict): + continue + spec_rel = entry.get("spec") + mods = entry.get("modules") or [] + if not spec_rel or not mods: + continue + spec_path = ROOT / spec_rel + if not spec_path.exists(): + continue + spec_mtime = spec_path.stat().st_mtime + module_paths = [ROOT / m for m in mods if isinstance(m, str)] + source_mtime = _newest_mtime(module_paths) + if source_mtime == 0.0: + continue + drift_seconds = source_mtime - spec_mtime + if drift_seconds > threshold_seconds: + stale.append((name, spec_rel, drift_seconds / 86400)) + + if not stale: + print( + f"spec-drift: {len(subs)} subsystems, no specs older than source " + f"beyond {STALE_THRESHOLD_DAYS}d threshold" + ) + return 0 + + print(f"spec-drift: {len(stale)} subsystem(s) with potentially stale specs:") + for name, spec_rel, drift_days in sorted(stale, key=lambda t: -t[2]): + print(f" - {name}: {spec_rel} is {drift_days:.1f}d older than its newest source module") + print("\nReview each listed spec and update if the change altered public shape.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_test_markers.py b/scripts/check_test_markers.py new file mode 100755 index 0000000..543907e --- /dev/null +++ b/scripts/check_test_markers.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Audit `tests/` for the marker taxonomy declared in `pyproject.toml`. + +Every test function should declare: + +- exactly one **lane** marker (`unit`, `integration`, `e2e`, `smoke`, `architecture`, `contract`) +- at least one **subsystem** marker (`subsys_*`) + +This audit currently runs in **report-only** mode — it prints findings without +exiting non-zero. Once existing tests are bulk-tagged, the script will be +flipped to fail mode (set `FAIL_ON_MISSING = True`) and wired into `qa_fast`. +""" + +from __future__ import annotations + +import ast +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TESTS = ROOT / "tests" +PYPROJECT = ROOT / "pyproject.toml" + +LANE_MARKERS = {"unit", "integration", "e2e", "smoke", "architecture", "contract"} +SUBSYS_PREFIX = "subsys_" +# Lanes whose tests are cross-cutting by definition and therefore exempt from +# the "at least one subsys_* marker" requirement. +SUBSYS_EXEMPT_LANES = {"architecture", "contract"} + +# Audit mode: when False, the script reports without failing. +FAIL_ON_MISSING = True + + +def _load_subsys_markers() -> set[str]: + """Read the registered `subsys_*` marker names from `pyproject.toml`.""" + if not PYPROJECT.exists(): + return set() + with PYPROJECT.open("rb") as fh: + data = tomllib.load(fh) + raw = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers", []) + out: set[str] = set() + for entry in raw: + if not isinstance(entry, str): + continue + name = entry.split(":", 1)[0].strip() + if name.startswith(SUBSYS_PREFIX): + out.add(name) + return out + + +def _markers_on(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + """Return the set of `pytest.mark.` decorators on a function node.""" + out: set[str] = set() + for dec in node.decorator_list: + target = dec.func if isinstance(dec, ast.Call) else dec + if not isinstance(target, ast.Attribute): + continue + value = target.value + is_pytest_mark = ( + isinstance(value, ast.Attribute) + and isinstance(value.value, ast.Name) + and value.value.id == "pytest" + and value.attr == "mark" + ) + is_mark = isinstance(value, ast.Name) and value.id == "mark" + if is_pytest_mark or is_mark: + out.add(target.attr) + return out + + +def _audit_file(path: Path, allowed_subsys: set[str]) -> list[tuple[str, str]]: + """Return list of `(function_label, reason)` for tagging gaps.""" + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError) as exc: + return [(str(path), f"could not parse: {exc}")] + + gaps: list[tuple[str, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not node.name.startswith("test_"): + continue + markers = _markers_on(node) + label = f"{path.relative_to(ROOT)}::{node.name}" + lanes = markers & LANE_MARKERS + subsys = {m for m in markers if m.startswith(SUBSYS_PREFIX)} + if not lanes: + gaps.append((label, "no lane marker")) + elif len(lanes) > 1: + gaps.append((label, f"multiple lane markers: {sorted(lanes)}")) + if not subsys and not (lanes & SUBSYS_EXEMPT_LANES): + gaps.append((label, "no subsys_* marker")) + unknown = subsys - allowed_subsys + if unknown: + gaps.append((label, f"unknown subsystem marker(s): {sorted(unknown)}")) + return gaps + + +def _count_tests(path: Path) -> int: + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + return 0 + return sum( + 1 + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_") + ) + + +def _walk_test_files() -> list[Path]: + return [p for p in sorted(TESTS.rglob("test_*.py")) if "__pycache__" not in p.parts] + + +def _print_summary(gaps: list[tuple[str, str]], total_tests: int, files_audited: int) -> None: + by_reason: dict[str, int] = {} + for _, reason in gaps: + bucket = reason.split(":")[0] + by_reason[bucket] = by_reason.get(bucket, 0) + 1 + print(f"check_test_markers: {len(gaps)} gap(s) across {total_tests} test(s) in {files_audited} file(s)") + print("\nBy reason:") + for reason, count in sorted(by_reason.items(), key=lambda t: -t[1]): + print(f" {count:4d} {reason}") + if "-v" in sys.argv or "--verbose" in sys.argv: + print("\nDetails:") + for label, reason in gaps[:50]: + print(f" - {label}: {reason}") + if len(gaps) > 50: + print(f" ... and {len(gaps) - 50} more") + + +def main() -> int: + if not TESTS.is_dir(): + print(f"check_test_markers: {TESTS} missing") + return 0 + allowed_subsys = _load_subsys_markers() + if not allowed_subsys: + print("check_test_markers: no subsys_* markers registered in pyproject.toml") + return 0 + + test_files = _walk_test_files() + gaps: list[tuple[str, str]] = [] + total_tests = 0 + for path in test_files: + gaps.extend(_audit_file(path, allowed_subsys)) + total_tests += _count_tests(path) + + if not gaps: + print(f"check_test_markers: {total_tests} test(s) across {len(test_files)} file(s); all tagged correctly") + return 0 + + _print_summary(gaps, total_tests, len(test_files)) + + if FAIL_ON_MISSING: + return 1 + print("\n(report-only mode; flip FAIL_ON_MISSING in this file to enforce)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fetch_bundled_model.py b/scripts/fetch_bundled_model.py index de38a04..33895cf 100644 --- a/scripts/fetch_bundled_model.py +++ b/scripts/fetch_bundled_model.py @@ -1,43 +1,63 @@ -"""Fetch the bundled ONNX embedding model from Hugging Face Hub. +"""Fetch the bundled ML assets (ONNX embedder + spaCy en_core_web_sm). This is a **dev-only** helper. End users never run it — they install the -pre-built wheel which already contains the ONNX files. +pre-built wheel which already contains both assets. 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``. +before ``hatch build``. Idempotent: skips work if all expected files +are already present. + +Targets: +- ``src/reporails_cli/bundled/models/minilm-l6-v2/`` — ONNX embedder + (~87 MB) from ``Xenova/all-MiniLM-L6-v2`` on Hugging Face. +- ``src/reporails_cli/bundled/spacy/en_core_web_sm/`` — spaCy English + pipeline (~15 MB) from spaCy's GitHub Releases. The model wheel is + not on PyPI, so it cannot be declared as a normal runtime dep; we + ship the model files inside our wheel instead. + +Both target directories are ``.gitignore``d so the binaries are never +committed. ``hatch_build.py`` picks them up via ``force_include`` at +build time. """ from __future__ import annotations +import io import sys +import urllib.request +import zipfile from pathlib import Path -# Files we need from the Xenova repo -_ALLOW_PATTERNS: list[str] = [ +# ─── ONNX embedder ──────────────────────────────────────────────────── + +_ONNX_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" +_ONNX_BUNDLE_RELPATH = Path("src/reporails_cli/bundled/models/minilm-l6-v2") +_ONNX_HF_REPO_ID = "Xenova/all-MiniLM-L6-v2" + +# ─── spaCy en_core_web_sm ───────────────────────────────────────────── + +_SPACY_BUNDLE_RELPATH = Path("src/reporails_cli/bundled/spacy/en_core_web_sm") +_SPACY_WHEEL_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" +) +# Inside the wheel, the model files sit under this prefix. +_SPACY_WHEEL_MODEL_PREFIX = "en_core_web_sm/en_core_web_sm-3.8.0/" +# A small set of files we expect after extraction (idempotency guard). +_SPACY_REQUIRED_FILES: list[str] = [ + "meta.json", + "config.cfg", + "tokenizer", + "vocab/strings.json", + "tagger/model", + "parser/model", +] def _repo_root() -> Path: @@ -45,27 +65,38 @@ def _repo_root() -> Path: return Path(__file__).resolve().parent.parent -def _target_dir() -> Path: - return _repo_root() / _BUNDLE_RELPATH +def _onnx_target() -> Path: + return _repo_root() / _ONNX_BUNDLE_RELPATH + +def _spacy_target() -> Path: + return _repo_root() / _SPACY_BUNDLE_RELPATH -def _already_present(target: Path) -> bool: - """True when every required file exists and is non-empty.""" - for rel in _REQUIRED: + +def _onnx_already_present(target: Path) -> bool: + for rel in _ONNX_ALLOW_PATTERNS: 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)") +def _spacy_already_present(target: Path) -> bool: + for rel in _SPACY_REQUIRED_FILES: + f = target / rel + if not f.exists() or f.stat().st_size == 0: + return False + return True + + +def _fetch_onnx() -> int: + target = _onnx_target() + if _onnx_already_present(target): + size_mb = sum((target / r).stat().st_size for r in _ONNX_ALLOW_PATTERNS) / 1e6 + print(f"✓ ONNX model already present at {target} ({size_mb:.1f} MB)") return 0 - print(f"downloading {_HF_REPO_ID} to {target} ...") + print(f"downloading {_ONNX_HF_REPO_ID} to {target} ...") try: from huggingface_hub import snapshot_download @@ -79,24 +110,68 @@ def main() -> int: target.mkdir(parents=True, exist_ok=True) snapshot_download( - repo_id=_HF_REPO_ID, - allow_patterns=_ALLOW_PATTERNS, + repo_id=_ONNX_HF_REPO_ID, + allow_patterns=_ONNX_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) + if not _onnx_already_present(target): + missing = [r for r in _ONNX_ALLOW_PATTERNS if not (target / r).exists()] + print(f"ERROR: ONNX 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") + size_mb = sum((target / r).stat().st_size for r in _ONNX_ALLOW_PATTERNS) / 1e6 + print(f"✓ fetched ONNX ({size_mb:.1f} MB) to {target}") return 0 +def _fetch_spacy() -> int: + target = _spacy_target() + if _spacy_already_present(target): + total = sum(p.stat().st_size for p in target.rglob("*") if p.is_file()) + print(f"✓ spaCy model already present at {target} ({total / 1e6:.1f} MB)") + return 0 + + print("downloading en_core_web_sm wheel from spaCy releases ...") + with urllib.request.urlopen(_SPACY_WHEEL_URL) as resp: + wheel_bytes = resp.read() + + target.parent.mkdir(parents=True, exist_ok=True) + target.mkdir(parents=True, exist_ok=True) + + extracted = 0 + with zipfile.ZipFile(io.BytesIO(wheel_bytes)) as zf: + for name in zf.namelist(): + if not name.startswith(_SPACY_WHEEL_MODEL_PREFIX): + continue + rel = name[len(_SPACY_WHEEL_MODEL_PREFIX):] + if not rel: + continue + dest = target / rel + if name.endswith("/"): + dest.mkdir(parents=True, exist_ok=True) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + with zf.open(name) as src, open(dest, "wb") as out: + out.write(src.read()) + extracted += 1 + + if not _spacy_already_present(target): + missing = [r for r in _SPACY_REQUIRED_FILES if not (target / r).exists()] + print(f"ERROR: spaCy extraction missing files: {missing}", file=sys.stderr) + return 1 + + total = sum(p.stat().st_size for p in target.rglob("*") if p.is_file()) + print(f"✓ extracted {extracted} spaCy files ({total / 1e6:.1f} MB) to {target}") + return 0 + + +def main() -> int: + rc = _fetch_onnx() + if rc != 0: + return rc + return _fetch_spacy() + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/pre-release-check.sh b/scripts/pre-release-check.sh index a62f418..98a5073 100755 --- a/scripts/pre-release-check.sh +++ b/scripts/pre-release-check.sh @@ -61,12 +61,24 @@ ok "PyPI-compatible dependencies" # 5. Verify wheel install step "Verify wheel install" +# pyproject `requires-python` constrains the install (currently >=3.12,<3.14 +# to dodge the Python 3.14 pydantic.v1 introspection break in spaCy). Pick +# an in-range Python explicitly so this test doesn't break on dev machines +# whose default `python3` lies outside the pin (e.g. Homebrew Python 3.14). +PYTHON_FOR_TEST="" +for cand in python3.12 python3.13; do + if command -v "$cand" >/dev/null 2>&1; then + PYTHON_FOR_TEST="$cand" + break + fi +done +[ -n "$PYTHON_FOR_TEST" ] || fail "No supported Python (3.12 or 3.13) on PATH (pyproject requires-python = '>=3.12,<3.14')" VENV=$(mktemp -d)/venv -python3 -m venv "$VENV" +"$PYTHON_FOR_TEST" -m venv "$VENV" "$VENV/bin/pip" install dist/*.whl --quiet || fail "Wheel install failed" "$VENV/bin/ails" version || fail "ails command not found" "$VENV/bin/ails" check --help > /dev/null || fail "ails check --help failed" -ok "Wheel installs and runs" +ok "Wheel installs and runs (under $PYTHON_FOR_TEST)" # 5b. Verify all expected entry points exist step "Verify entry points" diff --git a/scripts/specs_check.py b/scripts/specs_check.py new file mode 100755 index 0000000..4dc03a2 --- /dev/null +++ b/scripts/specs_check.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Validate the project's design-spec set against its declared subsystems. + +Reads a project backbone config (path resolved at runtime) for a `subsystems:` +section. Three checks: + +- **coverage**: every declared subsystem points at an existing spec file. +- **shape**: each spec has an H1 title, fits within a line-count budget, and is + not a stub. +- **co-location**: each subsystem's listed source modules cluster under a single + parent directory. Scattering across multiple parents signals that the + subsystem boundary in the doc is sharper than the code structure — consider + consolidating the modules under a named subpackage. + +Exits 0 with a "skipped" message if no backbone config is present. +""" + +from __future__ import annotations + +import sys +from os.path import commonpath +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +BACKBONE = ROOT / ".ails" / "backbone.yml" + +# Shape budget. Tuned to the 0.3.0 anchor sizes — `principles.md` at 66 lines +# is the lower end; `RUNTIME.md` at 462 is the upper end before it should split. +MAX_LINES = 500 +MIN_LINES = 30 + + +def _load_backbone() -> dict | None: + if not BACKBONE.exists(): + return None + try: + data = yaml.safe_load(BACKBONE.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f"specs-check: backbone config failed to parse: {exc}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, dict): + print("specs-check: backbone config did not parse to a mapping", file=sys.stderr) + sys.exit(2) + return data + + +def _subsystems(backbone: dict) -> dict[str, dict]: + subs = backbone.get("subsystems") or {} + if not isinstance(subs, dict): + return {} + return {k: v for k, v in subs.items() if isinstance(v, dict)} + + +def check_coverage(subs: dict[str, dict]) -> list[str]: + fails: list[str] = [] + for name, entry in subs.items(): + spec = entry.get("spec") + if not spec: + fails.append(f"{name}: missing `spec` field") + continue + if not (ROOT / spec).exists(): + fails.append(f"{name}: spec not found at {spec}") + return fails + + +def check_shape(subs: dict[str, dict]) -> list[str]: + fails: list[str] = [] + for name, entry in subs.items(): + spec = entry.get("spec") + if not spec: + continue + p = ROOT / spec + if not p.exists(): + continue + text = p.read_text(encoding="utf-8") + lines = text.count("\n") + 1 + if lines > MAX_LINES: + fails.append(f"{name}: {spec} is {lines} lines (cap {MAX_LINES}) — split into subordinate specs") + elif lines < MIN_LINES: + fails.append(f"{name}: {spec} is {lines} lines (min {MIN_LINES}) — too thin, expand or fold into a parent spec") + if not text.lstrip().startswith("# "): + fails.append(f"{name}: {spec} missing H1 title") + return fails + + +def check_colocation(subs: dict[str, dict]) -> list[str]: + """Modules per subsystem should cluster under one parent directory. + + When a subsystem lists modules in 2+ distinct parent directories, the + subsystem boundary is sharper in the doc than in the code. Flag for + consolidation — typically the fix is to create a named subpackage + (e.g., `core/cache/` containing `cache.py`, `check_cache.py`, `map_cache.py`) + that matches the subsystem. + """ + fails: list[str] = [] + for name, entry in subs.items(): + mods = entry.get("modules") or [] + if not mods or len(mods) < 2: + continue + parents = {str(Path(m).parent) for m in mods if isinstance(m, str)} + if len(parents) <= 1: + continue + parent_list = ", ".join(sorted(parents)) + fails.append( + f"{name}: modules span {len(parents)} parent dirs ({parent_list}) — " + f"consider consolidating under a single subpackage matching the subsystem" + ) + return fails + + +def check_orphans(subs: dict[str, dict]) -> list[str]: + """Spec files that exist on disk but are not declared in `subsystems:`.""" + declared = [entry["spec"] for entry in subs.values() if entry.get("spec")] + if not declared: + return [] + spec_root = ROOT / commonpath(declared) + if not spec_root.is_dir(): + return [] + declared_set = {str((ROOT / s).resolve()) for s in declared} + orphans: list[str] = [] + for p in sorted(spec_root.rglob("*.md")): + if p.name == "CLAUDE.md": + continue + if str(p.resolve()) not in declared_set: + orphans.append(str(p.relative_to(ROOT))) + return orphans + + +def main() -> int: + backbone = _load_backbone() + if backbone is None: + print("specs-check: no backbone config; skipped") + return 0 + subs = _subsystems(backbone) + if not subs: + print("specs-check: no `subsystems:` section in backbone config; nothing to check") + return 0 + + cov = check_coverage(subs) + shape = check_shape(subs) + coloc = check_colocation(subs) + orphans = check_orphans(subs) + + issues = 0 + if cov: + print("COVERAGE FAILURES:") + for f in cov: + print(f" - {f}") + issues += len(cov) + if shape: + print("SHAPE FAILURES:") + for f in shape: + print(f" - {f}") + issues += len(shape) + if coloc: + print("CO-LOCATION WARNINGS:") + for f in coloc: + print(f" - {f}") + issues += len(coloc) + if orphans: + print("ORPHAN SPECS (exist on disk but not declared in `subsystems:`):") + for o in orphans: + print(f" - {o}") + issues += len(orphans) + + if issues == 0: + print(f"specs-check: {len(subs)} subsystems, all covered, within shape contract, co-located.") + return 0 + print(f"specs-check: {issues} issue(s) across {len(subs)} subsystems") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tag_tests.py b/scripts/tag_tests.py new file mode 100755 index 0000000..690f62a --- /dev/null +++ b/scripts/tag_tests.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""One-shot auto-tagger: add lane + subsystem markers to every `test_*` function. + +Walks `tests/`, parses each file with `ast`, and inserts `@pytest.mark.` and +`@pytest.mark.` decorators above each test function that lacks them. +The script is **idempotent** — re-running skips already-tagged functions. + +Lane is derived from the directory (`tests/unit/` → `unit`, etc.) with explicit +overrides for files that belong to a different lane than their location suggests. +Subsystem(s) are looked up per file in `_MAPPING` below. + +Run: `uv run python scripts/tag_tests.py` +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TESTS = ROOT / "tests" + +LANE_BY_DIR = { + "tests/unit": "unit", + "tests/integration": "integration", + "tests/smoke": "e2e", +} + +LANE_MARKERS = {"unit", "integration", "e2e", "smoke", "architecture", "contract"} +SUBSYS_PREFIX = "subsys_" + +# Explicit per-file mapping. First element overrides the directory-derived lane +# (None = keep directory default). Second element is the list of subsys_* tags. +_MAPPING: dict[str, tuple[str | None, list[str]]] = { + # tests/integration/ + "tests/integration/test_behavioral.py": (None, ["subsys_lint", "subsys_diagnostic"]), + "tests/integration/test_capability_detection.py": (None, ["subsys_cli_ux"]), + "tests/integration/test_cli_e2e.py": ("e2e", ["subsys_cli_ux"]), + "tests/integration/test_mcp_e2e.py": ("e2e", ["subsys_cli_ux", "subsys_api"]), + "tests/integration/test_self_update.py": (None, ["subsys_cli_ux"]), + "tests/integration/test_symlink_validation.py": (None, ["subsys_lint"]), + "tests/integration/test_template_resolution.py": (None, ["subsys_lint"]), + # tests/smoke/ + "tests/smoke/test_smoke.py": ("e2e", ["subsys_cli_ux"]), + # tests/unit/ + "tests/unit/test_agent_config.py": (None, ["subsys_lint"]), + "tests/unit/test_agents_dedupe.py": (None, ["subsys_lint"]), + "tests/unit/test_api_client.py": (None, ["subsys_api"]), + "tests/unit/test_applicability.py": (None, ["subsys_lint"]), + "tests/unit/test_backbone_gate.py": (None, ["subsys_map", "subsys_cli_ux"]), + "tests/unit/test_byte_preflight.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_cache.py": (None, ["subsys_caching"]), + "tests/unit/test_cache_structural.py": (None, ["subsys_caching"]), + "tests/unit/test_check_cache.py": (None, ["subsys_caching"]), + "tests/unit/test_classification.py": (None, ["subsys_classify"]), + "tests/unit/test_client_checks.py": (None, ["subsys_lint"]), + "tests/unit/test_config_command.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_discover.py": (None, ["subsys_lint"]), + "tests/unit/test_download_rules_staging.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_engine_helpers.py": (None, ["subsys_lint", "subsys_runtime"]), + "tests/unit/test_exit_codes.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_funnel.py": (None, ["subsys_funnel"]), + "tests/unit/test_gates.py": (None, ["subsys_gates"]), + "tests/unit/test_github_formatter.py": (None, ["subsys_diagnostic"]), + "tests/unit/test_harness.py": (None, ["subsys_lint"]), + "tests/unit/test_json_formatter.py": (None, ["subsys_diagnostic"]), + "tests/unit/test_mcp_install.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_mechanical.py": (None, ["subsys_lint"]), + "tests/unit/test_merger.py": (None, ["subsys_lint"]), + "tests/unit/test_package_levels.py": (None, ["subsys_gates"]), + "tests/unit/test_payload.py": (None, ["subsys_server"]), + "tests/unit/test_project_config.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_recommended.py": (None, ["subsys_lint"]), + "tests/unit/test_regex_engine.py": (None, ["subsys_lint"]), + "tests/unit/test_registry.py": (None, ["subsys_lint"]), + "tests/unit/test_rule_runner.py": (None, ["subsys_lint"]), + "tests/unit/test_rule_validation.py": (None, ["subsys_lint"]), + "tests/unit/test_safe_extract.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_scan_scope.py": (None, ["subsys_lint"]), + "tests/unit/test_scorecard.py": (None, ["subsys_diagnostic"]), + "tests/unit/test_self_update.py": (None, ["subsys_cli_ux"]), + "tests/unit/test_stopwords.py": (None, ["subsys_map"]), + "tests/unit/test_summary.py": (None, ["subsys_diagnostic"]), + "tests/unit/test_symlink_detection.py": (None, ["subsys_lint"]), + "tests/unit/test_update_check.py": (None, ["subsys_cli_ux"]), +} + + +def _determine_lane(rel_path: str) -> str | None: + for dir_prefix, lane in LANE_BY_DIR.items(): + if rel_path.startswith(dir_prefix + "/"): + return lane + return None + + +def _existing_markers(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + out: set[str] = set() + for dec in node.decorator_list: + target = dec.func if isinstance(dec, ast.Call) else dec + if isinstance(target, ast.Attribute): + value = target.value + if ( + isinstance(value, ast.Attribute) + and isinstance(value.value, ast.Name) + and value.value.id == "pytest" + and value.attr == "mark" + ): + out.add(target.attr) + return out + + +def _file_has_pytest_import(tree: ast.Module) -> bool: + for node in tree.body: + if isinstance(node, ast.Import) and any(a.name == "pytest" for a in node.names): + return True + if isinstance(node, ast.ImportFrom) and node.module == "pytest": + return True + return False + + +def _tag_file(path: Path) -> tuple[int, int]: + rel = str(path.relative_to(ROOT)) + lane = _determine_lane(rel) + override_lane, subsystems = _MAPPING.get(rel, (None, [])) + if override_lane: + lane = override_lane + if not lane or not subsystems: + return (0, 0) + + text = path.read_text(encoding="utf-8") + lines = text.split("\n") + try: + tree = ast.parse(text) + except SyntaxError as exc: + print(f"{rel}: skipped — could not parse: {exc}", file=sys.stderr) + return (0, 0) + + tagged = skipped = 0 + insertions: list[tuple[int, list[str]]] = [] + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not node.name.startswith("test_"): + continue + existing = _existing_markers(node) + has_lane = bool(existing & LANE_MARKERS) + has_subsys = any(m.startswith(SUBSYS_PREFIX) for m in existing) + if has_lane and has_subsys: + skipped += 1 + continue + indent = " " * node.col_offset + if node.decorator_list: + insert_at = node.decorator_list[0].lineno - 1 + else: + insert_at = node.lineno - 1 + new_decorators: list[str] = [] + if not has_lane: + new_decorators.append(f"{indent}@pytest.mark.{lane}") + if not has_subsys: + new_decorators.extend(f"{indent}@pytest.mark.{m}" for m in subsystems) + insertions.append((insert_at, new_decorators)) + tagged += 1 + + if not insertions: + return (0, skipped) + + needs_pytest_import = not _file_has_pytest_import(tree) + for insert_at, new_decorators in sorted(insertions, key=lambda t: -t[0]): + lines[insert_at:insert_at] = new_decorators + + if needs_pytest_import: + # Only consider top-level imports (col_offset == 0); local imports inside + # functions or classes must not be treated as the insertion anchor. + last_top_import_line = 0 + for top in tree.body: + if isinstance(top, (ast.Import, ast.ImportFrom)): + last_top_import_line = top.end_lineno or top.lineno + # Inserting at end_lineno places `import pytest` on the line after the + # last top-level import. The other inserts above already happened in + # reverse order, so this index still points correctly. + lines.insert(last_top_import_line, "import pytest") + + path.write_text("\n".join(lines), encoding="utf-8") + return (tagged, skipped) + + +def main() -> int: + total_tagged = total_skipped = 0 + files_processed = 0 + for path in sorted(TESTS.rglob("test_*.py")): + if "__pycache__" in path.parts: + continue + tagged, skipped = _tag_file(path) + files_processed += 1 + if tagged or skipped: + print(f"{path.relative_to(ROOT)}: tagged {tagged}, already-tagged {skipped}") + total_tagged += tagged + total_skipped += skipped + + unmapped = [] + for path in sorted(TESTS.rglob("test_*.py")): + rel = str(path.relative_to(ROOT)) + if "__pycache__" in path.parts: + continue + if rel not in _MAPPING: + unmapped.append(rel) + if unmapped: + print(f"\n{len(unmapped)} file(s) without a subsystem mapping (left untagged):") + for u in unmapped: + print(f" - {u}") + + print(f"\nTotal: {total_tagged} tagged, {total_skipped} already tagged, {files_processed} files processed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/reporails_cli/__init__.py b/src/reporails_cli/__init__.py index 86f7bc9..15070c4 100644 --- a/src/reporails_cli/__init__.py +++ b/src/reporails_cli/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( Category, Level, RuleType, diff --git a/src/reporails_cli/_archived/formatters/box.py b/src/reporails_cli/_archived/formatters/box.py index d246b61..18477aa 100644 --- a/src/reporails_cli/_archived/formatters/box.py +++ b/src/reporails_cli/_archived/formatters/box.py @@ -5,8 +5,8 @@ from typing import Any -from reporails_cli.core.levels import get_level_labels -from reporails_cli.core.models import ScanDelta +from reporails_cli.core.platform.dto.models import ScanDelta +from reporails_cli.core.platform.policy.levels import get_level_labels from reporails_cli.formatters.text.chars import ASCII_MODE, get_chars from reporails_cli.formatters.text.components import ( _category_result_color, diff --git a/src/reporails_cli/_archived/formatters/compact.py b/src/reporails_cli/_archived/formatters/compact.py index cd65625..b7e4f12 100644 --- a/src/reporails_cli/_archived/formatters/compact.py +++ b/src/reporails_cli/_archived/formatters/compact.py @@ -8,8 +8,8 @@ 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.core.platform.dto.models import Level, ScanDelta, ValidationResult +from reporails_cli.core.platform.policy.levels import get_level_labels 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 ( diff --git a/src/reporails_cli/_archived/formatters/full.py b/src/reporails_cli/_archived/formatters/full.py index 9398e51..5f7718f 100644 --- a/src/reporails_cli/_archived/formatters/full.py +++ b/src/reporails_cli/_archived/formatters/full.py @@ -6,8 +6,8 @@ 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.core.platform.dto.models import Level, ScanDelta, ValidationResult +from reporails_cli.core.platform.policy.levels import get_level_labels 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 diff --git a/src/reporails_cli/_archived/tests/test_delta.py b/src/reporails_cli/_archived/tests/test_delta.py index c0b90eb..ce4877a 100644 --- a/src/reporails_cli/_archived/tests/test_delta.py +++ b/src/reporails_cli/_archived/tests/test_delta.py @@ -9,7 +9,7 @@ from __future__ import annotations from reporails_cli.core.cache import AnalyticsEntry -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( FrictionEstimate, Level, ScanDelta, diff --git a/src/reporails_cli/_archived/tests/test_templates.py b/src/reporails_cli/_archived/tests/test_templates.py index 093d3d6..bc8def1 100644 --- a/src/reporails_cli/_archived/tests/test_templates.py +++ b/src/reporails_cli/_archived/tests/test_templates.py @@ -11,7 +11,7 @@ import pytest -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( FrictionEstimate, JudgmentRequest, Level, diff --git a/src/reporails_cli/bundled/__init__.py b/src/reporails_cli/bundled/__init__.py index d6952f2..d28c9e1 100644 --- a/src/reporails_cli/bundled/__init__.py +++ b/src/reporails_cli/bundled/__init__.py @@ -4,6 +4,7 @@ - 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) +- spacy/: Bundled spaCy en_core_web_sm pipeline (same fetch script) """ from __future__ import annotations @@ -29,3 +30,14 @@ def get_models_path() -> Path: See ``core/mapper/onnx_embedder.py`` for the consumer. """ return get_bundled_path() / "models" + + +def get_spacy_model_path() -> Path: + """Get path to the bundled spaCy en_core_web_sm pipeline directory. + + The directory is populated at build time by + ``scripts/fetch_bundled_model.py`` and shipped inside the wheel. + Loading from a local path avoids the PyPI direct-URL constraint + that blocks declaring ``en_core_web_sm`` as a normal runtime dep. + """ + return get_bundled_path() / "spacy" / "en_core_web_sm" diff --git a/src/reporails_cli/core/__init__.py b/src/reporails_cli/core/__init__.py index d2be8fb..e3f0270 100644 --- a/src/reporails_cli/core/__init__.py +++ b/src/reporails_cli/core/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( Category, Check, JudgmentRequest, diff --git a/src/reporails_cli/core/cache.py b/src/reporails_cli/core/cache/__init__.py similarity index 90% rename from src/reporails_cli/core/cache.py rename to src/reporails_cli/core/cache/__init__.py index 1ad0aaf..304e6fc 100644 --- a/src/reporails_cli/core/cache.py +++ b/src/reporails_cli/core/cache/__init__.py @@ -10,17 +10,17 @@ from typing import Any # Re-exports for backward compatibility (explicit re-export via `as` for mypy) -from reporails_cli.core.analytics import AnalyticsEntry as AnalyticsEntry -from reporails_cli.core.analytics import ProjectAnalytics as ProjectAnalytics -from reporails_cli.core.analytics import get_analytics_dir as get_analytics_dir -from reporails_cli.core.analytics import get_git_remote as get_git_remote -from reporails_cli.core.analytics import get_previous_scan as get_previous_scan -from reporails_cli.core.analytics import get_project_analytics_path as get_project_analytics_path -from reporails_cli.core.analytics import get_project_id as get_project_id -from reporails_cli.core.analytics import get_project_name as get_project_name -from reporails_cli.core.analytics import load_project_analytics as load_project_analytics -from reporails_cli.core.analytics import record_scan as record_scan -from reporails_cli.core.analytics import save_project_analytics as save_project_analytics +from reporails_cli.core.platform.observability.analytics import AnalyticsEntry as AnalyticsEntry +from reporails_cli.core.platform.observability.analytics import ProjectAnalytics as ProjectAnalytics +from reporails_cli.core.platform.observability.analytics import get_analytics_dir as get_analytics_dir +from reporails_cli.core.platform.observability.analytics import get_git_remote as get_git_remote +from reporails_cli.core.platform.observability.analytics import get_previous_scan as get_previous_scan +from reporails_cli.core.platform.observability.analytics import get_project_analytics_path as get_project_analytics_path +from reporails_cli.core.platform.observability.analytics import get_project_id as get_project_id +from reporails_cli.core.platform.observability.analytics import get_project_name as get_project_name +from reporails_cli.core.platform.observability.analytics import load_project_analytics as load_project_analytics +from reporails_cli.core.platform.observability.analytics import record_scan as record_scan +from reporails_cli.core.platform.observability.analytics import save_project_analytics as save_project_analytics def content_hash(path: Path) -> str: @@ -105,7 +105,7 @@ def reporails_dir(self) -> Path: @property def cache_dir(self) -> Path: """Get project's cache directory (~/.reporails/cache/projects//).""" - from reporails_cli.core.bootstrap import get_project_cache_dir + from reporails_cli.core.platform.config.bootstrap import get_project_cache_dir return get_project_cache_dir(self.target) @@ -315,7 +315,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_helpers import _find_project_root + from reporails_cli.core.platform.runtime.engine_helpers import _find_project_root project_root = _find_project_root(target) cache = ProjectCache(project_root) diff --git a/src/reporails_cli/core/check_cache.py b/src/reporails_cli/core/cache/check_cache.py similarity index 95% rename from src/reporails_cli/core/check_cache.py rename to src/reporails_cli/core/cache/check_cache.py index 725fc2e..1a0f9bb 100644 --- a/src/reporails_cli/core/check_cache.py +++ b/src/reporails_cli/core/cache/check_cache.py @@ -11,7 +11,7 @@ import json from typing import Any -from reporails_cli.core.mechanical.checks import CheckResult +from reporails_cli.core.lint.mechanical.checks import CheckResult class CheckCache: diff --git a/src/reporails_cli/core/mapper/map_cache.py b/src/reporails_cli/core/cache/map_cache.py similarity index 99% rename from src/reporails_cli/core/mapper/map_cache.py rename to src/reporails_cli/core/cache/map_cache.py index aca2271..67ee80e 100644 --- a/src/reporails_cli/core/mapper/map_cache.py +++ b/src/reporails_cli/core/cache/map_cache.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any -from reporails_cli.core.mapper.mapper import ( +from reporails_cli.core.platform.dto.ruleset import ( EMBEDDING_MODEL, SCHEMA_VERSION, Atom, diff --git a/src/reporails_cli/core/classification.py b/src/reporails_cli/core/classify/__init__.py similarity index 97% rename from src/reporails_cli/core/classification.py rename to src/reporails_cli/core/classify/__init__.py index 3a4b1a4..0053842 100644 --- a/src/reporails_cli/core/classification.py +++ b/src/reporails_cli/core/classify/__init__.py @@ -13,8 +13,8 @@ import yaml -from reporails_cli.core.models import ClassifiedFile, FileMatch, FileTypeDeclaration -from reporails_cli.core.utils import load_yaml_file +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileMatch, FileTypeDeclaration +from reporails_cli.core.platform.utils.utils import load_yaml_file logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ def load_file_types( Returns: List of FileTypeDeclaration, empty if no config found """ - from reporails_cli.core.bootstrap import get_agent_config_path + from reporails_cli.core.platform.config.bootstrap import get_agent_config_path candidates: list[Path] = [] if rules_paths: @@ -79,7 +79,7 @@ def _apply_project_overrides( match user-configured fallback instruction files. """ try: - from reporails_cli.core.config import get_project_config + from reporails_cli.core.platform.config.config import get_project_config except ImportError: return declarations @@ -137,8 +137,8 @@ def _parse_file_types(data: dict[str, object]) -> list[FileTypeDeclaration]: 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 + from reporails_cli.core.discovery.agents import _extract_patterns + from reporails_cli.core.discovery.agents import _extract_properties as _agent_props declarations: list[FileTypeDeclaration] = [] for name, spec in data.items(): @@ -301,7 +301,7 @@ def _compute_ancestor_chain(scan_root: Path) -> set[Path]: (in cwd's ancestor chain) from files loaded on-demand (in descendant subdirectories). Mirrors the agent's actual loading model. """ - from reporails_cli.core.agent_discovery import resolve_project_root + from reporails_cli.core.discovery.agent_discovery import resolve_project_root root = resolve_project_root(scan_root) chain: set[Path] = set() diff --git a/src/reporails_cli/core/stopwords.py b/src/reporails_cli/core/classify/stopwords.py similarity index 100% rename from src/reporails_cli/core/stopwords.py rename to src/reporails_cli/core/classify/stopwords.py diff --git a/src/reporails_cli/core/stopwords_sync.py b/src/reporails_cli/core/classify/stopwords_sync.py similarity index 99% rename from src/reporails_cli/core/stopwords_sync.py rename to src/reporails_cli/core/classify/stopwords_sync.py index de5a30b..9a91b4e 100644 --- a/src/reporails_cli/core/stopwords_sync.py +++ b/src/reporails_cli/core/classify/stopwords_sync.py @@ -8,7 +8,7 @@ import yaml -from reporails_cli.core.stopwords import decompose, is_guard, recompose +from reporails_cli.core.classify.stopwords import decompose, is_guard, recompose def _find_check_by_suffix( diff --git a/src/reporails_cli/core/discovery/__init__.py b/src/reporails_cli/core/discovery/__init__.py new file mode 100644 index 0000000..6bad82c --- /dev/null +++ b/src/reporails_cli/core/discovery/__init__.py @@ -0,0 +1 @@ +"""Discovery subsystem — file + agent discovery and classification.""" diff --git a/src/reporails_cli/core/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py similarity index 98% rename from src/reporails_cli/core/agent_discovery.py rename to src/reporails_cli/core/discovery/agent_discovery.py index e83fc02..dc81d44 100644 --- a/src/reporails_cli/core/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from reporails_cli.core.results import ProjectConfig + from reporails_cli.core.platform.dto.results import ProjectConfig logger = logging.getLogger(__name__) @@ -293,7 +293,7 @@ def load_config_file_types( Returns the file_types dict or None if not found. """ - from reporails_cli.core.bootstrap import get_agent_config_path + from reporails_cli.core.platform.config.bootstrap import get_agent_config_path candidates: list[Path] = [] if rules_paths: @@ -304,7 +304,7 @@ def load_config_file_types( if not path.exists(): continue try: - from reporails_cli.core.utils import load_yaml_file + from reporails_cli.core.platform.utils.utils import load_yaml_file data = load_yaml_file(path) if not data: @@ -401,7 +401,7 @@ def discover_from_config( Returns (instruction_files, rule_files, config_files) or None if no config.yml is available for this agent. """ - from reporails_cli.core.agents import _extract_patterns, _extract_properties + from reporails_cli.core.discovery.agents import _extract_patterns, _extract_properties file_types = load_config_file_types(agent_id, rules_paths) if file_types is None: diff --git a/src/reporails_cli/core/agents.py b/src/reporails_cli/core/discovery/agents.py similarity index 76% rename from src/reporails_cli/core/agents.py rename to src/reporails_cli/core/discovery/agents.py index 700bda7..8416b61 100644 --- a/src/reporails_cli/core/agents.py +++ b/src/reporails_cli/core/discovery/agents.py @@ -9,12 +9,13 @@ import logging import os +from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path from typing import Any -from reporails_cli.core.agent_discovery import categorize_file_type as _categorize_file_type -from reporails_cli.core.agent_discovery import discover_from_config as _discover_from_config +from reporails_cli.core.discovery.agent_discovery import categorize_file_type as _categorize_file_type +from reporails_cli.core.discovery.agent_discovery import discover_from_config as _discover_from_config logger = logging.getLogger(__name__) @@ -134,8 +135,8 @@ def _parse_agent_config(data: dict[str, Any]) -> AgentType | None: 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 + from reporails_cli.core.platform.config.bootstrap import get_rules_path + from reporails_cli.core.platform.utils.utils import load_yaml_file registry: dict[str, AgentType] = {} rules_path = get_rules_path() @@ -182,10 +183,17 @@ class DetectedAgent: # Cache keyed on target path, cleared by clear_agent_cache(). _agent_cache: dict[str, list[DetectedAgent]] = {} +# Alias map from the most recent dedup pass for each target. Co-located with +# `_agent_cache` and cleared alongside it because the alias map is a downstream +# product of agent detection — if the agent list is invalidated, the alias map +# computed from it is invalidated too. +_alias_cache: dict[str, dict[Path, list[Path]]] = {} + def clear_agent_cache() -> None: """Clear the agent detection cache. Called by --refresh.""" _agent_cache.clear() + _alias_cache.clear() # ─── Public API ───────────────────────────────────────────────────────── @@ -235,7 +243,7 @@ def _load_project_exclude_dirs(target: Path) -> frozenset[str]: if not config_path.exists(): return _DEFAULT_EXCLUDE_DIRS try: - from reporails_cli.core.utils import load_yaml_file + from reporails_cli.core.platform.utils.utils import load_yaml_file data = load_yaml_file(config_path) if not data: @@ -308,7 +316,7 @@ def detect_agents( # pylint: disable=too-many-locals # Load project config for per-surface include/exclude + fallback filenames project_config = None try: - from reporails_cli.core.config import get_project_config + from reporails_cli.core.platform.config.config import get_project_config project_config = get_project_config(target) except Exception: @@ -531,24 +539,129 @@ def filter_agents_by_exclude_dirs( return filtered +def _dedupe_symlinks(paths: list[Path]) -> tuple[list[Path], dict[Path, list[Path]]]: + """Group paths by `Path.resolve()` and pick the alphabetically-first per group. + + Identical path strings inside a group (the same file discovered by multiple + agents) collapse to one entry — they would otherwise show up as `+self` + aliases of themselves in the display. + """ + by_canonical: dict[Path, list[Path]] = {} + for p in paths: + try: + key = p.resolve(strict=False) + except (OSError, RuntimeError): + key = p + by_canonical.setdefault(key, []).append(p) + + representatives: list[Path] = [] + aliases: dict[Path, list[Path]] = {} + for group in by_canonical.values(): + unique = sorted(set(group)) + representatives.append(unique[0]) + if len(unique) > 1: + aliases[unique[0]] = unique[1:] + return representatives, aliases + + +def _dedupe_with_aliases(paths: Iterable[Path]) -> tuple[list[Path], dict[Path, list[Path]]]: + """Dedupe input paths by canonical resolved path (symlinks → one inode). + + Multi-agent projects often expose the same physical file via several agent + surfaces — `.claude/skills/` symlinked to `.agents/skills/`. Without dedup + that file is scanned and scored once per surface, inflating findings. + + Symlinks are safe to collapse at discovery time because they resolve to + a single physical file and therefore classify identically under every + agent's file_types. Same-directory content-hash dedup is NOT done here + because manual AGENTS.md/CLAUDE.md pairs classify under different agents' + `main` patterns — dropping one would suppress that agent's rules. Display + handles those via `compute_same_dir_content_aliases` instead. + + Returns (canonical_paths, aliases). `aliases[canonical]` lists the dropped + symlinked paths that resolve to the same inode as `canonical`. + """ + representatives, aliases = _dedupe_symlinks(list(paths)) + return sorted(representatives), aliases + + +def compute_same_dir_content_aliases(paths: Iterable[Path]) -> dict[Path, list[Path]]: + """Group paths by (parent_dir, content hash); return alias map. + + Display-time helper for collapsing manually-maintained AGENTS.md/CLAUDE.md + pairs (and similar) into one visual row without dropping them from + classification. Restricted to same-directory pairs so two unrelated + identical files elsewhere in the tree are never merged. Uses + `cache.content_hash` so the truncated SHA-256 keyspace matches every other + content-keyed cache in the codebase. + """ + from reporails_cli.core.cache import content_hash + + by_parent: dict[Path, list[Path]] = {} + for p in paths: + by_parent.setdefault(p.parent, []).append(p) + aliases: dict[Path, list[Path]] = {} + for siblings in by_parent.values(): + if len(siblings) < 2: + continue + by_hash: dict[str, list[Path]] = {} + for s in siblings: + try: + key = content_hash(s) + except OSError: + key = f"__unhashable__:{s}" + by_hash.setdefault(key, []).append(s) + for hash_group in by_hash.values(): + if len(hash_group) < 2: + continue + ordered = sorted(hash_group) + aliases[ordered[0]] = ordered[1:] + return aliases + + +def _alias_cache_key(target: Path) -> str: + """Project-root cache key — matches `_agent_cache` keying so both invalidate + together on `--refresh`.""" + try: + return str(target.resolve()) + except (OSError, RuntimeError): + return str(target) + + +def _store_alias_cache(target: Path, aliases: dict[Path, list[Path]]) -> None: + """Cache aliases keyed by project root so the display layer can look them up.""" + _alias_cache[_alias_cache_key(target)] = aliases + + +def get_file_aliases(target: Path) -> dict[Path, list[Path]]: + """Return the file-alias map produced by the most recent discovery for `target`. + + Maps a canonical instruction-file path (absolute) to the list of duplicate + paths that share its physical file (symlink) or its content within the + same directory (manual copy). Display layer queries this to render + `name (+alias, +alias)` labels for deduplicated entries. + """ + return _alias_cache.get(_alias_cache_key(target), {}) + + def get_all_instruction_files(target: Path, agents: list[DetectedAgent] | None = None) -> list[Path]: """Get deduplicated instruction + rule files for detected agents.""" - all_files: set[Path] = set() - + raw: list[Path] = [] for detected in agents if agents is not None else detect_agents(target): - all_files.update(detected.instruction_files) - all_files.update(detected.rule_files) - - return sorted(all_files) + raw.extend(detected.instruction_files) + raw.extend(detected.rule_files) + canonical, aliases = _dedupe_with_aliases(raw) + _store_alias_cache(target, aliases) + return canonical def get_all_scannable_files(target: Path, agents: list[DetectedAgent] | None = None) -> list[Path]: """Get all scannable files (instruction + rule + config) for detected agents.""" - all_files: set[Path] = set() - + raw: list[Path] = [] for detected in agents if agents is not None else detect_agents(target): - all_files.update(detected.instruction_files) - all_files.update(detected.rule_files) - all_files.update(detected.config_files) - - return sorted(all_files) + raw.extend(detected.instruction_files) + raw.extend(detected.rule_files) + raw.extend(detected.config_files) + canonical, aliases = _dedupe_with_aliases(raw) + _store_alias_cache(target, aliases) + return canonical diff --git a/src/reporails_cli/core/discover.py b/src/reporails_cli/core/discovery/discover.py similarity index 68% rename from src/reporails_cli/core/discover.py rename to src/reporails_cli/core/discovery/discover.py index 424f76f..b6fd87f 100644 --- a/src/reporails_cli/core/discover.py +++ b/src/reporails_cli/core/discovery/discover.py @@ -1,9 +1,11 @@ -"""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. +"""Project-metadata detection — language, framework, paths, commands, manifests. + +Pure-Python primitives that inspect a directory tree against the +`bundled/project-types.yml` registry. Used as building blocks by callers +that need to classify or summarize a project (e.g., scripts driving +external corpus runs). The CLI no longer surfaces these as a command; +add entries to `project-types.yml` to support new languages, frameworks, +and tools without code changes. """ from __future__ import annotations @@ -16,8 +18,6 @@ import yaml -from reporails_cli.core.agents import DetectedAgent - def _load_project_types() -> dict[str, Any]: """Load and cache the bundled project-types.yml.""" @@ -121,14 +121,14 @@ def _get_manifest_spec(filename: str) -> dict[str, Any] | 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 + from reporails_cli.core.discovery.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 + from reporails_cli.core.discovery.discover_commands import detect_commands return detect_commands(target) @@ -214,73 +214,3 @@ def _strip_nulls(data: Any) -> Any: if isinstance(data, list): return [_strip_nulls(item) for item in data] return data - - -# 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. - - 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": 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." - ), - } - - # Identity - data["identity"] = _detect_classification(target) - - # Topology — agents - agents_data: dict[str, Any] = {} - for agent in agents: - agent_data: dict[str, Any] = {} - root_files = [f for f in agent.instruction_files if f.parent == target] - if root_files: - agent_data["main_instruction_file"] = root_files[0].relative_to(target).as_posix() - agent_data.update(agent.detected_directories) - if agent.config_files: - cf = agent.config_files[0] - if cf.is_relative_to(target): - agent_data["config"] = cf.relative_to(target).as_posix() - agents_data[agent.agent_type.id] = agent_data - - data["agents"] = agents_data or {} - - # Topology — paths - data["paths"] = _detect_paths(target) - - clean_data = _strip_nulls(data) - - header = "# Auto-generated by ails map — backbone v3\n# See specs/ 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 - - -def generate_backbone_placeholder() -> str: - """Generate a minimal backbone.yml placeholder. - - Created automatically during `ails check` if no backbone exists. - Users should run `ails map --save` to populate it. - """ - return "# Run `ails map --save` to populate.\nversion: 3\n" - - -def save_backbone(target: Path, content: str) -> Path: - """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" - backbone_path.write_text(content, encoding="utf-8") - - return backbone_path diff --git a/src/reporails_cli/core/discover_classify.py b/src/reporails_cli/core/discovery/discover_classify.py similarity index 98% rename from src/reporails_cli/core/discover_classify.py rename to src/reporails_cli/core/discovery/discover_classify.py index 98c5434..3a4266c 100644 --- a/src/reporails_cli/core/discover_classify.py +++ b/src/reporails_cli/core/discovery/discover_classify.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -from reporails_cli.core.discover import ( +from reporails_cli.core.discovery.discover import ( _find_manifests, _get_manifest_spec, _get_project_types, diff --git a/src/reporails_cli/core/discover_commands.py b/src/reporails_cli/core/discovery/discover_commands.py similarity index 98% rename from src/reporails_cli/core/discover_commands.py rename to src/reporails_cli/core/discovery/discover_commands.py index 22da067..fbc9c38 100644 --- a/src/reporails_cli/core/discover_commands.py +++ b/src/reporails_cli/core/discovery/discover_commands.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -from reporails_cli.core.discover import ( +from reporails_cli.core.discovery.discover import ( _get_project_types, _read_json, _read_toml, diff --git a/src/reporails_cli/core/applicability.py b/src/reporails_cli/core/discovery/features.py similarity index 71% rename from src/reporails_cli/core/applicability.py rename to src/reporails_cli/core/discovery/features.py index 75276dd..161807a 100644 --- a/src/reporails_cli/core/applicability.py +++ b/src/reporails_cli/core/discovery/features.py @@ -1,8 +1,10 @@ -"""Feature detection (filesystem) and rule applicability. +"""Filesystem feature detection for capability gates and symlink resolution. -Filesystem detection populates DetectedFeatures for capability-gate -level detection and symlink resolution. Rule applicability is determined -by target file type existence, not level comparison. +Populates DetectedFeatures by inspecting the project layout: instruction +files, abstracted-rules directories, backbone manifest, shared files, and +content-level signals on the root instruction file. Also resolves +instruction-file symlinks that point outside the scan directory so the +regex engine can scan them as extra targets. """ from __future__ import annotations @@ -12,12 +14,16 @@ import re from pathlib import Path -from reporails_cli.core.agents import DetectedAgent, get_all_instruction_files -from reporails_cli.core.models import DetectedFeatures, Rule +from reporails_cli.core.discovery.agents import DetectedAgent, get_all_instruction_files +from reporails_cli.core.platform.dto.models import DetectedFeatures logger = logging.getLogger(__name__) +_CONSTRAINT_RE = re.compile(r"\b(MUST|NEVER|ALWAYS)\b") +_SIZE_THRESHOLD = 500 # lines + + 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] = [] @@ -48,10 +54,6 @@ def resolve_symlinked_files(target: Path, agents: list[DetectedAgent] | None = N return resolved -_CONSTRAINT_RE = re.compile(r"\b(MUST|NEVER|ALWAYS)\b") -_SIZE_THRESHOLD = 500 # lines - - 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: @@ -183,7 +185,7 @@ def _dir_has_content(target: Path, dirs: list[str]) -> bool: def _count_components(backbone_path: Path) -> int: """Count components declared in backbone.yml.""" try: - from reporails_cli.core.utils import load_yaml_file + from reporails_cli.core.platform.utils.utils import load_yaml_file data = load_yaml_file(backbone_path) if not data: @@ -192,56 +194,3 @@ def _count_components(backbone_path: Path) -> int: return len(components) if isinstance(components, dict) else 0 except Exception: # YAML parsing; yaml imported in try scope return 0 - - -def get_applicable_rules( - rules: dict[str, Rule], - present_types: set[str], -) -> dict[str, Rule]: - """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) - - If rule A supersedes rule B, and both are applicable, drop B. - - Args: - rules: Dict of all rules - present_types: Set of file type names present in the project - - Returns: - Dict of applicable rules - """ - if not present_types: - return {} - - applicable: dict[str, Rule] = {} - for rule_id, rule in rules.items(): - 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 within applicable set. - # NOTE: load_rules() already handles supersession at load time, but this - # covers cases where rules are constructed without load_rules() (e.g., tests) - # and the edge case where a superseding rule's target type is absent. - superseded_ids: set[str] = set() - for rule_id, rule in list(applicable.items()): - if rule.supersedes and rule.supersedes in applicable: - superseded_ids.add(rule.supersedes) - parent = applicable[rule.supersedes] - # Inherit parent checks that aren't replaced by the agent rule - replaced_ids = {c.replaces for c in rule.checks if c.replaces} - inherited = [c for c in parent.checks if c.id not in replaced_ids] - applicable[rule_id] = rule.model_copy(update={"checks": inherited + list(rule.checks)}) - - if superseded_ids: - applicable = {k: v for k, v in applicable.items() if k not in superseded_ids} - - return applicable diff --git a/src/reporails_cli/core/funnel.py b/src/reporails_cli/core/funnel/__init__.py similarity index 85% rename from src/reporails_cli/core/funnel.py rename to src/reporails_cli/core/funnel/__init__.py index 860ce18..19f0639 100644 --- a/src/reporails_cli/core/funnel.py +++ b/src/reporails_cli/core/funnel/__init__.py @@ -40,6 +40,15 @@ class FunnelError: support_url: str = "" message: str = "" + @property + def reset_phrase(self) -> str: + """Render reset_in as a CTA fragment: 'Try again in ~N min. ' or ''.""" + if self.reset_in <= 0: + return "" + minutes = (self.reset_in + 59) // 60 + label = "<1 min" if minutes <= 1 else f"~{minutes} min" + return f"Try again in {label}. " + @dataclass(frozen=True) class LintResponse: @@ -64,15 +73,16 @@ def parse_error_body(status_code: int, body_text: str) -> FunnelError | None: try: body = json.loads(body_text) except (json.JSONDecodeError, ValueError): - logger.warning("Could not parse %d response body as JSON: %r", status_code, body_text[:200]) + logger.debug("Non-JSON %d response body: %r", status_code, body_text[:200]) return FunnelError( error="unknown_error", - message=f"HTTP {status_code} with unparseable response body", + message=f"Diagnostics server returned HTTP {status_code}", ) if not isinstance(body, dict): + logger.debug("Unexpected %d response shape: %r", status_code, body_text[:200]) return FunnelError( error="unknown_error", - message=f"HTTP {status_code} with unexpected response shape", + message=f"Diagnostics server returned HTTP {status_code}", ) error = body.get("error", "") if error not in _KNOWN_ERRORS: @@ -230,14 +240,31 @@ def format_cta(err: FunnelError) -> str: return _with_url(template.format(err=err), url) +def _short_url_label(url: str) -> str: + """Return `netloc + path` from a URL, dropping query string and fragment. + + Used as the clickable label in OSC 8 hyperlinks so the user sees + `github.com/reporails/cli/issues/new` instead of an 800-character + percent-encoded prefilled form URL. + """ + parts = urlsplit(url) + return f"{parts.netloc}{parts.path}" if parts.netloc else url + + def _with_url(text: str, url: str) -> str: - return f"{text} → [bold]{url}[/bold]" if url else text + if not url: + return text + label = _short_url_label(url) + return f"{text} → [link={url}][bold]{label}[/bold][/link]" _CTA_TEMPLATES: dict[tuple[str, str], str] = { - ("rate_limit_exceeded", "anonymous"): "Anonymous limit hit ({err.limit}/hr). Run `ails auth login` to raise it 40x", + ( + "rate_limit_exceeded", + "anonymous", + ): "Anonymous limit hit ({err.limit}/hr). {err.reset_phrase}Run `ails auth login` to raise it 40x", ("rate_limit_exceeded", "pro"): ( - "Hit your hourly limit ({err.limit}/hr) — file an issue with your use case so we can raise it" + "Hit your hourly limit ({err.limit}/hr). {err.reset_phrase}File an issue with your use case so we can raise it" ), ("payload_too_large", "anonymous"): ( "Project too large for anonymous (2 MB cap). Run `ails auth login` to raise it to 20 MB" diff --git a/src/reporails_cli/core/heal/__init__.py b/src/reporails_cli/core/heal/__init__.py new file mode 100644 index 0000000..ceaee0f --- /dev/null +++ b/src/reporails_cli/core/heal/__init__.py @@ -0,0 +1 @@ +"""Heal subsystem — interactive auto-fix.""" diff --git a/src/reporails_cli/core/fixers.py b/src/reporails_cli/core/heal/fixers.py similarity index 99% rename from src/reporails_cli/core/fixers.py rename to src/reporails_cli/core/heal/fixers.py index 175506b..0d255f8 100644 --- a/src/reporails_cli/core/fixers.py +++ b/src/reporails_cli/core/heal/fixers.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from pathlib import Path -from reporails_cli.core.models import Violation +from reporails_cli.core.platform.dto.models import Violation @dataclass(frozen=True) diff --git a/src/reporails_cli/core/mechanical_fixers.py b/src/reporails_cli/core/heal/mechanical_fixers.py similarity index 99% rename from src/reporails_cli/core/mechanical_fixers.py rename to src/reporails_cli/core/heal/mechanical_fixers.py index fa5531f..b54af18 100644 --- a/src/reporails_cli/core/mechanical_fixers.py +++ b/src/reporails_cli/core/heal/mechanical_fixers.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from pathlib import Path -from reporails_cli.core.mapper.mapper import Atom, RulesetMap +from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap @dataclass(frozen=True) diff --git a/src/reporails_cli/core/init.py b/src/reporails_cli/core/init.py deleted file mode 100644 index 8959c44..0000000 --- a/src/reporails_cli/core/init.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Init command - downloads rules framework. - -Core logic has been split into: -- download.py: Download and install rules, recommended packages -- updater.py: Update rules and recommended to latest/specific versions - -This module keeps run_init() and re-exports all public names for backward -compatibility (existing imports and mock patches targeting -``reporails_cli.core.init.*`` continue to work). -""" - -from __future__ import annotations - -from pathlib import Path - -import httpx as httpx - -from reporails_cli.core.bootstrap import ( - get_installed_version as get_installed_version, -) -from reporails_cli.core.bootstrap import ( - get_recommended_package_path as get_recommended_package_path, -) -from reporails_cli.core.bootstrap import ( - get_reporails_home as get_reporails_home, -) -from reporails_cli.core.download import ( - RECOMMENDED_API_URL as RECOMMENDED_API_URL, -) -from reporails_cli.core.download import ( - RECOMMENDED_REPO as RECOMMENDED_REPO, -) -from reporails_cli.core.download import ( - RECOMMENDED_VERSION as RECOMMENDED_VERSION, -) -from reporails_cli.core.download import ( - RULES_API_URL as RULES_API_URL, -) -from reporails_cli.core.download import ( - RULES_TARBALL_URL as RULES_TARBALL_URL, -) -from reporails_cli.core.download import ( - RULES_VERSION as RULES_VERSION, -) -from reporails_cli.core.download import ( - copy_bundled_yml_files as copy_bundled_yml_files, -) -from reporails_cli.core.download import ( - copy_local_framework as copy_local_framework, -) -from reporails_cli.core.download import ( - download_from_github as download_from_github, -) -from reporails_cli.core.download import ( - download_recommended as download_recommended, -) -from reporails_cli.core.download import ( - download_rules as download_rules, -) -from reporails_cli.core.download import ( - download_rules_tarball as download_rules_tarball, -) -from reporails_cli.core.download import ( - get_bundled_checks_path as get_bundled_checks_path, -) -from reporails_cli.core.download import ( - is_recommended_installed as is_recommended_installed, -) -from reporails_cli.core.download import ( - sync_rules_to_local as sync_rules_to_local, -) -from reporails_cli.core.download import ( - write_version_file as write_version_file, -) -from reporails_cli.core.updater import ( - REQUIRED_SCHEMAS as REQUIRED_SCHEMAS, -) -from reporails_cli.core.updater import ( - IncompatibleSchemaError as IncompatibleSchemaError, -) -from reporails_cli.core.updater import ( - UpdateResult as UpdateResult, -) -from reporails_cli.core.updater import ( - check_manifest_compatibility as check_manifest_compatibility, -) -from reporails_cli.core.updater import ( - download_rules_version as download_rules_version, -) -from reporails_cli.core.updater import ( - get_latest_recommended_version as get_latest_recommended_version, -) -from reporails_cli.core.updater import ( - get_latest_version as get_latest_version, -) -from reporails_cli.core.updater import ( - update_recommended as update_recommended, -) -from reporails_cli.core.updater import ( - update_rules as update_rules, -) - - -def run_init() -> dict[str, str | int | Path]: - """Run global initialization. - - Setup rules at ~/.reporails/rules/ (from local framework or GitHub). - - Returns dict with status info. - """ - results: dict[str, str | int | Path] = {} - - # Setup rules (check local framework_path first, then GitHub) - rules_path, rule_count = download_rules() - results["rules_path"] = rules_path - results["rule_count"] = rule_count - - # Write version file - write_version_file(RULES_VERSION) - results["rules_version"] = RULES_VERSION - - return results diff --git a/src/reporails_cli/core/install/__init__.py b/src/reporails_cli/core/install/__init__.py new file mode 100644 index 0000000..bed248c --- /dev/null +++ b/src/reporails_cli/core/install/__init__.py @@ -0,0 +1 @@ +"""Install subsystem — CLI setup, rule download + update, self-upgrade, MCP config.""" diff --git a/src/reporails_cli/core/download.py b/src/reporails_cli/core/install/download.py similarity index 70% rename from src/reporails_cli/core/download.py rename to src/reporails_cli/core/install/download.py index 3a501ac..f8de596 100644 --- a/src/reporails_cli/core/download.py +++ b/src/reporails_cli/core/install/download.py @@ -1,4 +1,4 @@ -"""Download and install rules and recommended packages.""" +"""Download and install rules from a release tarball or local framework copy.""" from __future__ import annotations @@ -11,19 +11,14 @@ import httpx -from reporails_cli.core.bootstrap import ( +from reporails_cli.core.platform.config.bootstrap import ( get_global_config, - get_recommended_package_path, get_reporails_home, get_version_file, ) logger = logging.getLogger(__name__) -RECOMMENDED_REPO = "reporails/recommended" -RECOMMENDED_VERSION = "0.3.0" -RECOMMENDED_API_URL = "https://api.github.com/repos/reporails/recommended/releases/latest" - RULES_VERSION = "0.5.0" RULES_EXPECTED_DIRS = ("core", "schemas") # Minimum dirs after extraction @@ -172,68 +167,6 @@ def download_rules() -> tuple[Path, int]: return download_from_github() -def is_recommended_installed() -> bool: - """Check if recommended package is installed with content.""" - pkg_path = get_recommended_package_path() - if not pkg_path.exists(): - return False - return any(pkg_path.iterdir()) - - -def download_recommended(version: str | None = None) -> Path: # pylint: disable=too-many-locals - """Download recommended rules package from GitHub archive.""" - if version is None: - from reporails_cli.core.updater import get_latest_recommended_version - - version = get_latest_recommended_version() or RECOMMENDED_VERSION - - archive_url = f"https://github.com/{RECOMMENDED_REPO}/archive/refs/tags/{version}.tar.gz" - pkg_path = get_recommended_package_path() - - if pkg_path.exists(): - shutil.rmtree(pkg_path) - pkg_path.mkdir(parents=True, exist_ok=True) - - try: - with httpx.Client(follow_redirects=True, timeout=120.0) as client: - response = client.get(archive_url) - response.raise_for_status() - except httpx.HTTPError as e: - msg = f"Could not download recommended rules. Check your internet connection. ({e})" - raise RuntimeError(msg) from e - - with TemporaryDirectory() as tmpdir: - tarball_path = Path(tmpdir) / "recommended.tar.gz" - tarball_path.write_bytes(response.content) - - with tarfile.open(tarball_path, "r:gz") as tar: - _safe_extractall(tar, Path(tmpdir)) - - extracted_dirs = sorted( - ( - d - for d in Path(tmpdir).iterdir() - if d.is_dir() and d.name != "__MACOSX" and d.name.startswith("recommended-") - ), - key=lambda d: d.name, - ) - if extracted_dirs: - source_dir = extracted_dirs[0] - for item in source_dir.iterdir(): - dest = pkg_path / item.name - if item.is_dir(): - shutil.copytree(item, dest) - else: - shutil.copy2(item, dest) - else: - with tarfile.open(tarball_path, "r:gz") as tar: - _safe_extractall(tar, pkg_path) - - version_file = pkg_path / ".version" - version_file.write_text(version + "\n", encoding="utf-8") - return pkg_path - - def sync_rules_to_local(local_checks_dir: Path) -> int: """Sync rules from GitHub release tarball to a local checks directory.""" return download_rules_tarball(local_checks_dir) diff --git a/src/reporails_cli/core/install/init.py b/src/reporails_cli/core/install/init.py new file mode 100644 index 0000000..f80b206 --- /dev/null +++ b/src/reporails_cli/core/install/init.py @@ -0,0 +1,32 @@ +"""Init command — set up rules at `~/.reporails/rules/`.""" + +from __future__ import annotations + +from pathlib import Path + +from reporails_cli.core.install.download import ( + RULES_VERSION, + download_rules, + write_version_file, +) + + +def run_init() -> dict[str, str | int | Path]: + """Run global initialization. + + Setup rules at ~/.reporails/rules/ (from local framework or GitHub). + + Returns dict with status info. + """ + results: dict[str, str | int | Path] = {} + + # Setup rules (check local framework_path first, then GitHub) + rules_path, rule_count = download_rules() + results["rules_path"] = rules_path + results["rule_count"] = rule_count + + # Write version file + write_version_file(RULES_VERSION) + results["rules_version"] = RULES_VERSION + + return results diff --git a/src/reporails_cli/core/mcp_install.py b/src/reporails_cli/core/install/mcp_install.py similarity index 97% rename from src/reporails_cli/core/mcp_install.py rename to src/reporails_cli/core/install/mcp_install.py index b367c36..ad54aa0 100644 --- a/src/reporails_cli/core/mcp_install.py +++ b/src/reporails_cli/core/install/mcp_install.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path -from reporails_cli.core.agents import detect_agents +from reporails_cli.core.discovery.agents import detect_agents # Agent ID → project-level MCP config file path MCP_PROJECT_CONFIGS: dict[str, str] = { diff --git a/src/reporails_cli/core/self_update.py b/src/reporails_cli/core/install/self_update.py similarity index 98% rename from src/reporails_cli/core/self_update.py rename to src/reporails_cli/core/install/self_update.py index eee7e0a..6211eeb 100644 --- a/src/reporails_cli/core/self_update.py +++ b/src/reporails_cli/core/install/self_update.py @@ -115,7 +115,7 @@ def _verify_installed_version() -> str | None: def upgrade_cli(target_version: str | None = None) -> CliUpdateResult: # pylint: disable=too-many-return-statements """Upgrade the CLI package to target version (or latest).""" from reporails_cli import __version__ as current_version - from reporails_cli.core.update_check import _fetch_latest_cli_version, _is_newer + from reporails_cli.core.install.update_check import _fetch_latest_cli_version, _is_newer method = detect_install_method() diff --git a/src/reporails_cli/core/update_check.py b/src/reporails_cli/core/install/update_check.py similarity index 70% rename from src/reporails_cli/core/update_check.py rename to src/reporails_cli/core/install/update_check.py index 87779cb..c8da7dc 100644 --- a/src/reporails_cli/core/update_check.py +++ b/src/reporails_cli/core/install/update_check.py @@ -1,4 +1,4 @@ -"""Check for CLI, framework, and recommended updates, with 24-hour cache throttling.""" +"""Check for CLI and framework updates, with 24-hour cache throttling.""" from __future__ import annotations @@ -29,8 +29,6 @@ class UpdateNotification: cli_latest: str | None = None rules_current: str | None = None rules_latest: str | None = None - recommended_current: str | None = None - recommended_latest: str | None = None @property def has_cli_update(self) -> bool: @@ -40,19 +38,13 @@ def has_cli_update(self) -> bool: def has_rules_update(self) -> bool: return bool(self.rules_current and self.rules_latest and self.rules_current != self.rules_latest) - @property - def has_recommended_update(self) -> bool: - return bool( - self.recommended_current and self.recommended_latest and self.recommended_current != self.recommended_latest - ) - @property def has_any_update(self) -> bool: - return self.has_cli_update or self.has_rules_update or self.has_recommended_update + return self.has_cli_update or self.has_rules_update def _get_cache_path() -> Path: - from reporails_cli.core.bootstrap import get_reporails_home + from reporails_cli.core.platform.config.bootstrap import get_reporails_home return get_reporails_home() / "cache" / CACHE_FILE @@ -72,18 +64,13 @@ def _read_cache() -> dict[str, str] | None: return None -def _write_cache( - latest_cli: str | None, - latest_rules: str | None, - latest_recommended: str | None = None, -) -> None: +def _write_cache(latest_cli: str | None, latest_rules: str | None) -> None: cache_path = _get_cache_path() cache_path.parent.mkdir(parents=True, exist_ok=True) data = { "last_checked": datetime.now(UTC).isoformat(), "latest_cli_version": latest_cli, "latest_rules_version": latest_rules, - "latest_recommended_version": latest_recommended, } cache_path.write_text(json.dumps(data), encoding="utf-8") @@ -108,7 +95,7 @@ def _is_newer(current: str, latest: str) -> bool: def check_for_updates() -> UpdateNotification | None: - """Check for CLI, framework, and recommended updates. Returns None if everything is current. + """Check for CLI and framework updates. Returns None if everything is current. Reads from cache if checked within 24 hours. Silently returns None on any error. """ @@ -118,33 +105,24 @@ def check_for_updates() -> UpdateNotification | None: if cached is not None: latest_cli = cached.get("latest_cli_version") latest_rules = cached.get("latest_rules_version") - latest_recommended = cached.get("latest_recommended_version") else: # Fetch fresh versions - from reporails_cli.core.init import get_latest_recommended_version, get_latest_version + from reporails_cli.core.install.updater import get_latest_version latest_cli = _fetch_latest_cli_version() latest_rules = get_latest_version() - latest_recommended = get_latest_recommended_version() - _write_cache(latest_cli, latest_rules, latest_recommended) + _write_cache(latest_cli, latest_rules) # Compare against installed versions from reporails_cli import __version__ as cli_version - from reporails_cli.core.bootstrap import ( - get_installed_recommended_version, - get_installed_version, - ) + from reporails_cli.core.platform.config.bootstrap import get_installed_version installed_rules = get_installed_version() - installed_recommended = get_installed_recommended_version() cli_outdated = latest_cli and _is_newer(cli_version, latest_cli) rules_outdated = installed_rules and latest_rules and _is_newer(installed_rules, latest_rules) - recommended_outdated = ( - installed_recommended and latest_recommended and _is_newer(installed_recommended, latest_recommended) - ) - if not cli_outdated and not rules_outdated and not recommended_outdated: + if not cli_outdated and not rules_outdated: return None return UpdateNotification( @@ -152,8 +130,6 @@ def check_for_updates() -> UpdateNotification | None: cli_latest=latest_cli if cli_outdated else None, rules_current=installed_rules if rules_outdated else None, rules_latest=latest_rules if rules_outdated else None, - recommended_current=installed_recommended if recommended_outdated else None, - recommended_latest=latest_recommended if recommended_outdated else None, ) except (OSError, json.JSONDecodeError, ValueError, KeyError) as exc: logger.debug("Update check failed: %s", exc) @@ -167,21 +143,19 @@ def format_update_message(notification: UpdateNotification) -> str: parts.append(f"CLI {notification.cli_current} → {notification.cli_latest}") if notification.has_rules_update: parts.append(f"framework {notification.rules_current} → {notification.rules_latest}") - if notification.has_recommended_update: - parts.append(f"recommended {notification.recommended_current} → {notification.recommended_latest}") detail = ", ".join(parts) commands = [] if notification.has_cli_update: commands.append("ails update --cli") - if notification.has_rules_update or notification.has_recommended_update: + if notification.has_rules_update: commands.append("ails update") cmd = " && ".join(commands) return f"[dim]Update available: {detail}. Run: {cmd}[/dim]" def _execute_updates(notification: UpdateNotification, console: Any) -> bool: - """Perform the actual update (framework + recommended). + """Perform the framework update. Args: notification: Update notification with version info. @@ -190,7 +164,7 @@ def _execute_updates(notification: UpdateNotification, console: Any) -> bool: Returns: True if anything was updated, False otherwise. """ - from reporails_cli.core.init import update_recommended, update_rules + from reporails_cli.core.install.updater import update_rules updated = False @@ -200,12 +174,6 @@ def _execute_updates(notification: UpdateNotification, console: Any) -> bool: console.print(f"[green]Updated:[/green] framework {result.previous_version} → {result.new_version}") updated = True - if notification.has_recommended_update: - result = update_recommended() - if result.updated: - console.print(f"[green]Updated:[/green] recommended {result.previous_version} → {result.new_version}") - updated = True - if updated: console.print() @@ -218,7 +186,7 @@ def prompt_for_updates(console: Any, no_update_check: bool = False) -> bool: # return False # Respect global config - from reporails_cli.core.bootstrap import get_global_config + from reporails_cli.core.platform.config.bootstrap import get_global_config config = get_global_config() if not config.auto_update_check: @@ -236,16 +204,13 @@ def prompt_for_updates(console: Any, no_update_check: bool = False) -> bool: # parts = [] if notification.has_rules_update: parts.append(f"framework {notification.rules_current} → {notification.rules_latest}") - if notification.has_recommended_update: - parts.append(f"recommended {notification.recommended_current} → {notification.recommended_latest}") if notification.has_cli_update: parts.append(f"CLI {notification.cli_current} → {notification.cli_latest} (run: ails update --cli)") console.print(f"\n[cyan]Updates available:[/cyan] {', '.join(parts)}") - # Only prompt for auto-installable updates (rules + recommended) - has_installable = notification.has_rules_update or notification.has_recommended_update - if not has_installable: + # Only prompt for auto-installable updates (rules) + if not notification.has_rules_update: console.print() return False diff --git a/src/reporails_cli/core/updater.py b/src/reporails_cli/core/install/updater.py similarity index 70% rename from src/reporails_cli/core/updater.py rename to src/reporails_cli/core/install/updater.py index b764cc0..a57532d 100644 --- a/src/reporails_cli/core/updater.py +++ b/src/reporails_cli/core/install/updater.py @@ -1,4 +1,4 @@ -"""Update rules and recommended packages to latest or specific versions.""" +"""Update rules to the latest or a specific version.""" from __future__ import annotations @@ -9,21 +9,18 @@ import httpx -from reporails_cli.core.bootstrap import ( - get_installed_recommended_version, - get_installed_version, - get_reporails_home, -) -from reporails_cli.core.download import ( - RECOMMENDED_API_URL, +from reporails_cli.core.install.download import ( RULES_API_URL, RULES_TARBALL_URL, _safe_extractall, _validate_rules_structure, copy_bundled_yml_files, - download_recommended, write_version_file, ) +from reporails_cli.core.platform.config.bootstrap import ( + get_installed_version, + get_reporails_home, +) # Schema versions this CLI can consume (match on major.minor, ignore patch). # Only schemas the CLI reads directly -- others are ignored. @@ -137,19 +134,6 @@ def get_latest_version() -> str | None: return None -def get_latest_recommended_version() -> str | None: - """Fetch the latest recommended release version from GitHub API.""" - try: - with httpx.Client(timeout=10.0) as client: - response = client.get(RECOMMENDED_API_URL) - response.raise_for_status() - data: dict[str, object] = response.json() - tag_name = data.get("tag_name") - return str(tag_name).removeprefix("v") if tag_name else None - except (httpx.HTTPError, KeyError): - return None - - def update_rules(version: str | None = None, force: bool = False) -> UpdateResult: """Update rules to specified version or latest.""" if version: @@ -205,53 +189,3 @@ def update_rules(version: str | None = None, force: bool = False) -> UpdateResul rule_count=rule_count, message=f"Updated from {current_version or 'none'} to {target_version}.", ) - - -def update_recommended(version: str | None = None, force: bool = False) -> UpdateResult: - """Update recommended package to specified version or latest.""" - if version: - target_version = version.removeprefix("v") - else: - latest = get_latest_recommended_version() - if not latest: - return UpdateResult( - previous_version=get_installed_recommended_version(), - new_version="unknown", - updated=False, - rule_count=0, - message="Failed to fetch latest recommended version from GitHub.", - ) - target_version = latest - - current_version = get_installed_recommended_version() - - if current_version == target_version and not force: - return UpdateResult( - previous_version=current_version, - new_version=target_version, - updated=False, - rule_count=0, - message=f"Recommended already at version {target_version}.", - ) - - try: - pkg_path = download_recommended(version=target_version) - rule_count = sum(1 for _ in pkg_path.rglob("*") if _.is_file()) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - return UpdateResult( - previous_version=current_version, - new_version=target_version, - updated=False, - rule_count=0, - message=f"Recommended version {target_version} not found.", - ) - raise - - return UpdateResult( - previous_version=current_version, - new_version=target_version, - updated=True, - rule_count=rule_count, - message=f"Recommended updated from {current_version or 'none'} to {target_version}.", - ) diff --git a/src/reporails_cli/core/lint/__init__.py b/src/reporails_cli/core/lint/__init__.py new file mode 100644 index 0000000..217b922 --- /dev/null +++ b/src/reporails_cli/core/lint/__init__.py @@ -0,0 +1 @@ +"""Lint subsystem — mechanical + regex checks against instruction files.""" diff --git a/src/reporails_cli/core/client_checks.py b/src/reporails_cli/core/lint/client_checks.py similarity index 97% rename from src/reporails_cli/core/client_checks.py rename to src/reporails_cli/core/lint/client_checks.py index 4055519..3127e3c 100644 --- a/src/reporails_cli/core/client_checks.py +++ b/src/reporails_cli/core/lint/client_checks.py @@ -8,8 +8,8 @@ import re -from reporails_cli.core.mapper.mapper import Atom, RulesetMap -from reporails_cli.core.models import LocalFinding +from reporails_cli.core.platform.dto.models import LocalFinding +from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap _BOLD_TERM_RE = re.compile(r"\*\*([^*]+)\*\*") _BOLD_NEGATION_RE = re.compile( @@ -60,7 +60,7 @@ def run_client_checks(ruleset_map: RulesetMap) -> list[LocalFinding]: 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 + from reporails_cli.core.platform.runtime.merger import normalize_finding_path return normalize_finding_path(file_path) diff --git a/src/reporails_cli/core/content_checker.py b/src/reporails_cli/core/lint/content_checker.py similarity index 90% rename from src/reporails_cli/core/content_checker.py rename to src/reporails_cli/core/lint/content_checker.py index 304648c..1eaaf2d 100644 --- a/src/reporails_cli/core/content_checker.py +++ b/src/reporails_cli/core/lint/content_checker.py @@ -13,9 +13,9 @@ import logging from typing import Any -from reporails_cli.core.content_queries import QUERY_REGISTRY -from reporails_cli.core.mapper.mapper import RulesetMap -from reporails_cli.core.models import ClassifiedFile, FileMatch, LocalFinding, Rule +from reporails_cli.core.lint.content_queries import QUERY_REGISTRY +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileMatch, LocalFinding, Rule +from reporails_cli.core.platform.dto.ruleset import RulesetMap logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def _matching_files( if match is None: return sorted(rm_paths) - from reporails_cli.core.classification import file_matches + from reporails_cli.core.classify 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 — @@ -111,6 +111,6 @@ def run_content_checks( 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 + from reporails_cli.core.platform.runtime.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/lint/content_queries.py similarity index 99% rename from src/reporails_cli/core/content_queries.py rename to src/reporails_cli/core/lint/content_queries.py index fb20a9e..5f85d68 100644 --- a/src/reporails_cli/core/content_queries.py +++ b/src/reporails_cli/core/lint/content_queries.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from typing import Any -from reporails_cli.core.mapper.mapper import Atom, RulesetMap +from reporails_cli.core.platform.dto.ruleset import Atom, RulesetMap @dataclass(frozen=True) diff --git a/src/reporails_cli/core/harness.py b/src/reporails_cli/core/lint/harness.py similarity index 98% rename from src/reporails_cli/core/harness.py rename to src/reporails_cli/core/lint/harness.py index 2055f73..d322c76 100644 --- a/src/reporails_cli/core/harness.py +++ b/src/reporails_cli/core/lint/harness.py @@ -21,11 +21,11 @@ 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 +from reporails_cli.core.classify import classify_files, load_file_types +from reporails_cli.core.lint.mechanical.checks import MECHANICAL_CHECKS, CheckResult +from reporails_cli.core.lint.regex import run_validation as run_regex_validation +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileTypeDeclaration +from reporails_cli.core.platform.utils.utils import parse_frontmatter logger = logging.getLogger(__name__) @@ -1102,7 +1102,7 @@ def lint_rules(rules: list[RuleInfo]) -> list[LintError]: 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 + from reporails_cli.core.platform.dto.models import CATEGORY_CODES errors: list[LintError] = [] seen_ids: dict[str, Path] = {} diff --git a/src/reporails_cli/core/mechanical/__init__.py b/src/reporails_cli/core/lint/mechanical/__init__.py similarity index 84% rename from src/reporails_cli/core/mechanical/__init__.py rename to src/reporails_cli/core/lint/mechanical/__init__.py index d487087..c86031c 100644 --- a/src/reporails_cli/core/mechanical/__init__.py +++ b/src/reporails_cli/core/lint/mechanical/__init__.py @@ -3,7 +3,7 @@ Public API: run_mechanical_checks(), dispatch_single_check() """ -from reporails_cli.core.mechanical.runner import ( +from reporails_cli.core.lint.mechanical.runner import ( dispatch_single_check, resolve_location, run_mechanical_checks, diff --git a/src/reporails_cli/core/mechanical/checks.py b/src/reporails_cli/core/lint/mechanical/checks.py similarity index 98% rename from src/reporails_cli/core/mechanical/checks.py rename to src/reporails_cli/core/lint/mechanical/checks.py index 727f787..cc9c179 100644 --- a/src/reporails_cli/core/mechanical/checks.py +++ b/src/reporails_cli/core/lint/mechanical/checks.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from reporails_cli.core.models import ClassifiedFile +from reporails_cli.core.platform.dto.models import ClassifiedFile def _safe_float(value: Any, default: float = float("inf")) -> float: @@ -246,7 +246,7 @@ def byte_size( # Import advanced checks for re-export and registry registration -from reporails_cli.core.mechanical.checks_advanced import ( # noqa: E402 +from reporails_cli.core.lint.mechanical.checks_advanced import ( # noqa: E402 aggregate_byte_size, check_import_targets_exist, content_absent, diff --git a/src/reporails_cli/core/mechanical/checks_advanced.py b/src/reporails_cli/core/lint/mechanical/checks_advanced.py similarity index 97% rename from src/reporails_cli/core/mechanical/checks_advanced.py rename to src/reporails_cli/core/lint/mechanical/checks_advanced.py index ab526c7..b6ce6c5 100644 --- a/src/reporails_cli/core/mechanical/checks_advanced.py +++ b/src/reporails_cli/core/lint/mechanical/checks_advanced.py @@ -13,14 +13,14 @@ import yaml -from reporails_cli.core.mechanical.checks import ( +from reporails_cli.core.lint.mechanical.checks import ( CheckResult, _get_counted_files, _get_target_files, _resolve_glob_targets, _safe_float, ) -from reporails_cli.core.models import ClassifiedFile +from reporails_cli.core.platform.dto.models import ClassifiedFile def frontmatter_present( @@ -262,14 +262,18 @@ def _validate_file_globs( return None paths = fm.get("globs") or fm.get("paths") or fm.get("applyTo") or [] if isinstance(paths, str): - paths = [paths] + paths = [s.strip() for s in paths.split(",") if s.strip()] for p in paths: if not isinstance(p, str): return CheckResult(passed=False, message=f"{f.name}: non-string path: {p}") if p.count("[") != p.count("]"): return CheckResult(passed=False, message=f"{f.name}: unbalanced brackets: {p}") - if require_matches and not any(True for _ in root.glob(p)): - unresolved.append(f"{f.name}: {p}") + if require_matches: + try: + if not any(True for _ in root.glob(p)): + unresolved.append(f"{f.name}: {p}") + except ValueError as exc: + return CheckResult(passed=False, message=f"{f.name}: invalid glob '{p}': {exc}") except (OSError, yaml.YAMLError): pass return None diff --git a/src/reporails_cli/core/mechanical/runner.py b/src/reporails_cli/core/lint/mechanical/runner.py similarity index 96% rename from src/reporails_cli/core/mechanical/runner.py rename to src/reporails_cli/core/lint/mechanical/runner.py index ebb1e2f..c9c1ae2 100644 --- a/src/reporails_cli/core/mechanical/runner.py +++ b/src/reporails_cli/core/lint/mechanical/runner.py @@ -9,8 +9,8 @@ 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, ClassifiedFile, Rule, Severity, Violation +from reporails_cli.core.lint.mechanical.checks import MECHANICAL_CHECKS, CheckResult +from reporails_cli.core.platform.dto.models import Check, ClassifiedFile, Rule, Severity, Violation logger = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def run_mechanical_checks( Returns: List of Violation objects for failed checks """ - from reporails_cli.core.classification import match_files + from reporails_cli.core.classify import match_files violations: list[Violation] = [] diff --git a/src/reporails_cli/core/memory_checks.py b/src/reporails_cli/core/lint/memory_checks.py similarity index 98% rename from src/reporails_cli/core/memory_checks.py rename to src/reporails_cli/core/lint/memory_checks.py index b39d4c2..51f8ed4 100644 --- a/src/reporails_cli/core/memory_checks.py +++ b/src/reporails_cli/core/lint/memory_checks.py @@ -9,7 +9,7 @@ import re from pathlib import Path -from reporails_cli.core.models import LocalFinding +from reporails_cli.core.platform.dto.models import LocalFinding _MEMORY_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+\.md)\)(?:\s*[—\-]\s*(.+))?") _FRONTMATTER_REQUIRED = {"name", "description", "type"} diff --git a/src/reporails_cli/core/regex/__init__.py b/src/reporails_cli/core/lint/regex/__init__.py similarity index 82% rename from src/reporails_cli/core/regex/__init__.py rename to src/reporails_cli/core/lint/regex/__init__.py index 6898e49..605a339 100644 --- a/src/reporails_cli/core/regex/__init__.py +++ b/src/reporails_cli/core/lint/regex/__init__.py @@ -4,12 +4,12 @@ from pathlib import Path -from reporails_cli.core.models import Rule -from reporails_cli.core.regex.runner import ( +from reporails_cli.core.lint.regex.runner import ( checks_per_file, run_checks, run_validation, ) +from reporails_cli.core.platform.dto.models import Rule def get_checks_paths(rules: dict[str, Rule]) -> list[Path]: diff --git a/src/reporails_cli/core/regex/compiler.py b/src/reporails_cli/core/lint/regex/compiler.py similarity index 99% rename from src/reporails_cli/core/regex/compiler.py rename to src/reporails_cli/core/lint/regex/compiler.py index 1d1d0f1..ef506f8 100644 --- a/src/reporails_cli/core/regex/compiler.py +++ b/src/reporails_cli/core/lint/regex/compiler.py @@ -10,7 +10,7 @@ import regex as re import yaml -from reporails_cli.core.utils import load_yaml_file +from reporails_cli.core.platform.utils.utils import load_yaml_file logger = logging.getLogger(__name__) diff --git a/src/reporails_cli/core/regex/runner.py b/src/reporails_cli/core/lint/regex/runner.py similarity index 99% rename from src/reporails_cli/core/regex/runner.py rename to src/reporails_cli/core/lint/regex/runner.py index 5e40e7f..f19986e 100644 --- a/src/reporails_cli/core/regex/runner.py +++ b/src/reporails_cli/core/lint/regex/runner.py @@ -9,12 +9,12 @@ import regex as re -from reporails_cli.core.models import LocalFinding -from reporails_cli.core.regex.compiler import ( +from reporails_cli.core.lint.regex.compiler import ( CombinedPattern, CompiledCheck, compile_rules, ) +from reporails_cli.core.platform.dto.models import LocalFinding logger = logging.getLogger(__name__) diff --git a/src/reporails_cli/core/rule_runner.py b/src/reporails_cli/core/lint/rule_runner.py similarity index 85% rename from src/reporails_cli/core/rule_runner.py rename to src/reporails_cli/core/lint/rule_runner.py index e53131f..cf81348 100644 --- a/src/reporails_cli/core/rule_runner.py +++ b/src/reporails_cli/core/lint/rule_runner.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any -from reporails_cli.core.models import LocalFinding, Rule, RuleType +from reporails_cli.core.platform.dto.models import LocalFinding, Rule, RuleType logger = logging.getLogger(__name__) @@ -28,8 +28,8 @@ def _collect_mechanical_findings( classified: list[Any], ) -> list[LocalFinding]: """Run mechanical checks and convert Violations to LocalFinding.""" - from reporails_cli.core.mechanical.runner import run_mechanical_checks - from reporails_cli.core.models import Execution + from reporails_cli.core.lint.mechanical.runner import run_mechanical_checks + from reporails_cli.core.platform.dto.models import Execution mechanical_rules = { k: v for k, v in rules.items() if v.type == RuleType.MECHANICAL and v.execution == Execution.LOCAL @@ -72,8 +72,8 @@ def _collect_deterministic_findings( scopes, formats, or other properties get the correct file set. Rules of any type are included if they contain deterministic checks. """ - from reporails_cli.core.classification import match_files - from reporails_cli.core.regex import run_checks + from reporails_cli.core.classify import match_files + from reporails_cli.core.lint.regex import run_checks findings: list[LocalFinding] = [] for rule in rules.values(): @@ -102,8 +102,8 @@ def run_m_probes( 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.registry import load_rules + from reporails_cli.core.classify import classify_files, load_file_types + from reporails_cli.core.platform.adapters.registry import load_rules rules = load_rules(project_root=project_dir, scan_root=project_dir, agent=agent) file_types = load_file_types(agent or "generic") @@ -128,10 +128,10 @@ def run_content_quality_checks( 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 + from reporails_cli.core.classify import classify_files, load_file_types + from reporails_cli.core.lint.content_checker import run_content_checks + from reporails_cli.core.platform.adapters.registry import load_rules + from reporails_cli.core.platform.dto.ruleset import RulesetMap as _RulesetMap if not isinstance(ruleset_map, _RulesetMap): return [] diff --git a/src/reporails_cli/core/mapper/__init__.py b/src/reporails_cli/core/mapper/__init__.py index 06adcee..42073b4 100644 --- a/src/reporails_cli/core/mapper/__init__.py +++ b/src/reporails_cli/core/mapper/__init__.py @@ -4,21 +4,21 @@ and produces a compact RulesetMap wire format. """ -from reporails_cli.core.mapper.mapper import ( +from reporails_cli.core.mapper.models import Models, get_models +from reporails_cli.core.mapper.parse import tokenize +from reporails_cli.core.mapper.pipeline import ( + content_hash, + map_file, + map_ruleset, +) +from reporails_cli.core.mapper.serialize import load_ruleset_map, save_ruleset_map +from reporails_cli.core.platform.dto.ruleset import ( Atom, ClusterRecord, FileRecord, - Models, RulesetMap, RulesetSummary, TopicCluster, - content_hash, - get_models, - load_ruleset_map, - map_file, - map_ruleset, - save_ruleset_map, - tokenize, ) __all__ = [ diff --git a/src/reporails_cli/core/mapper/annotate.py b/src/reporails_cli/core/mapper/annotate.py new file mode 100644 index 0000000..afdbe5d --- /dev/null +++ b/src/reporails_cli/core/mapper/annotate.py @@ -0,0 +1,172 @@ +"""Stage 4 — annotate atoms with specificity, formatting tokens, and code references. + +Five parallel regex passes per atom: backtick-wrapped tokens, italic spans, bold +spans (excluding negation phrases), known code tokens (from a curated list), and +code-shaped patterns. The result drives the equation's specificity term and the +formatting modulator. + +Public entry point: `check_specificity(text)`. + +The regex constants (`_BACKTICK_RE`, `_BOLD_TERM_RE`, etc.) are also consumed by +Stage 3 (`_strip_md_for_classify`) and Stages 1+2 (`_specificity_fields` in +parse.py); both import them from this module. +""" + +from __future__ import annotations + +import re + +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", +} + +# Single-pass regex for all KNOWN_CODE_TOKENS. Sorted longest-first so +# the alternation engine prefers longer matches (e.g. "dataclasses" over +# "dataclass"), though word-boundary assertions make this a safety belt. +_KNOWN_TOKEN_RE = re.compile( + r"(? 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] = [] + + # Pre-lowercase backtick content once for O(1)-ish lookups below. + bt_lower = {bt.lower() for bt in backtick_content} + + # Single regex pass finds all known code tokens in one engine invocation. + seen: set[str] = set() + for m in _KNOWN_TOKEN_RE.finditer(text_no_bt): + tok = m.group(1).lower() + if tok not in seen: + seen.add(tok) + if not any(tok in bt for bt in bt_lower): + unformatted.append(tok) + + for m in CODE_SHAPE_RE.finditer(text_no_bt): + token = m.group(1) + if token.lower().rstrip(".") in _DOTTED_EXCLUSIONS_NORMALIZED: + continue + if token not in unformatted and not any(token in bt for bt in bt_lower): + unformatted.append(token) + + # Named if ANY construct is identified — backtick-wrapped OR unformatted known token. + # The model recognizes `pytest` with or without backticks at the token level. + spec = "named" if (named or unformatted) else "abstract" + return spec, named, unformatted, italic, bold diff --git a/src/reporails_cli/core/mapper/assemble.py b/src/reporails_cli/core/mapper/assemble.py new file mode 100644 index 0000000..ea04a31 --- /dev/null +++ b/src/reporails_cli/core/mapper/assemble.py @@ -0,0 +1,60 @@ +"""Mapper Stage 7: Assemble — wire-format construction from per-stage outputs. + +Mechanical row-building: takes the file metadata, classified atoms, and topic +clusters produced by Stages 0-6 and packs them into the `RulesetMap` wire +format. No I/O, no ML, no decisions; pure dataclass assembly with +`schema_version` / `embedding_model` / `generated_at` stamped at construction +time. The on-disk JSON round-trip lives separately in `serialize.py`. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from reporails_cli.core.platform.dto.ruleset import ( + EMBEDDING_MODEL, + SCHEMA_VERSION, + Atom, + ClusterRecord, + FileRecord, + RulesetMap, + RulesetSummary, + TopicCluster, +) + + +def build_ruleset_map( + file_records: list[FileRecord], + all_atoms: list[Atom], + topics: list[TopicCluster], +) -> RulesetMap: + """Assemble the final RulesetMap from classified and clustered data.""" + cluster_records = [ + 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, + ) + for tc in topics + ] + + n_charged = sum(1 for a in all_atoms if a.charge_value != 0) + summary = RulesetSummary( + n_atoms=len(all_atoms), + n_charged=n_charged, + n_neutral=len(all_atoms) - n_charged, + n_topics=len(topics), + n_topics_charged=sum(1 for tc in topics if tc.charged), + ) + + return 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, + ) diff --git a/src/reporails_cli/core/mapper/classify.py b/src/reporails_cli/core/mapper/classify.py new file mode 100644 index 0000000..da5c2e6 --- /dev/null +++ b/src/reporails_cli/core/mapper/classify.py @@ -0,0 +1,1295 @@ +# ruff: noqa: C901, SIM102 +"""Stage 3 — three-phase charge classification. + +Three deterministic phases (regex Phase 1 + Phase 2, spaCy/lexicon Phase 3 with +sub-phases 3a-3g) classify each atom's charge into CONSTRAINT (-1), DIRECTIVE +(+1), IMPERATIVE (+1), or NEUTRAL (0). The output is consumed by downstream +scoring and conflict detection. + +Public entry point: `classify_charge(md_text, plain_text=..., inline_tokens=...)`. + +Imports `_BACKTICK_RE` from annotate (Stage 4) for backtick filtering during +Phase 3 spaCy disambiguation, and `get_models` from models for the spaCy `nlp` +singleton (with graceful lexicon fallback when spaCy is unavailable). +""" + +from __future__ import annotations + +import re +from typing import Any + +from reporails_cli.core.mapper.annotate import _BACKTICK_RE +from reporails_cli.core.mapper.models import get_models +from reporails_cli.core.platform.dto.ruleset import InlineToken + +# ────────────────────────────────────────────────────────────────── +# RULE-BASED CHARGE CLASSIFIER +# Corpus-calibrated verb lexicon from 434 projects (13,789 atoms). +# Three phases: negation → modal → imperative verb detection. +# No spaCy dependency. +# ────────────────────────────────────────────────────────────────── + +# Phase 1: Negation / Prohibition +_NEGATION_PHRASES_RE = re.compile( + r"^(do not|don't|must not|shall not|should not|will not|cannot|can not|can't)\b", + re.IGNORECASE, +) +_PROHIBITION_START_RE = re.compile( + r"^(never|no|don't|cannot|can't|won't|avoid|refrain|prevent|prohibit|forbid)\b", + re.IGNORECASE, +) +_MID_NEGATION_RE = re.compile( + r"\b(is|are|does|do|did|has|have|was|were)\s+(NOT|not|n't)\b", +) +_LATE_DONOT_RE = re.compile(r"\bdo not\b|\bdon't\b|\bdo NOT\b", re.IGNORECASE) + +# Phase 2: Modals / Adverbs +_MODAL_ABSOLUTE: set[str] = {"must", "shall"} +# Removed: "will" — future tense, not directive. "you will" handled in Phase 2. +_MODAL_HEDGED: set[str] = {"should", "could", "might"} +# Removed: "can" (capability), "may" (possibility) — not instructions. +_ABSOLUTE_ADVERBS: set[str] = {"always", "only", "exclusively"} + +# Phase 3: Corpus-calibrated verb lexicon +# CORE: charged_ratio >= 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", + "answer", + "append", + "assess", + "assign", + "assist", + "audit", + "avoid", + "be", + "begin", + "capture", + "choose", + "clarify", + "classify", + "collaborate", + "collect", + "compare", + "compose", + "confirm", + "consolidate", + "coordinate", + "continue", + "convert", + "cross", + "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", + "leave", + "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", + "stage", + "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", + "pass", + "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 + + +# Conditional marker set excluding "for" — reused across scope detection. +_COND_CHECK = _CONDITIONAL_MARKERS - {"for"} + + +def _detect_scope_conditional(doc: Any, has_cond_prefix: bool) -> bool: + """Detect conditional scope frame from first 8 tokens of a spaCy doc.""" + lowers = [t.text.lower() for t in doc[:8]] + has_cond = any(w in _COND_CHECK for w in lowers) + return has_cond_prefix or has_cond + + +def _is_root_in_backtick( + root_lower: str, + clean: str, + md_text: str, + inline_tokens: list[InlineToken] | None, + root_position: int = 0, +) -> bool: + """Check if the ROOT word falls inside a backtick span. + + When root_position is 0, checks only the first occurrence in inline_tokens + to avoid false positives from the same word appearing both as a position-0 + verb and inside a later backtick span (e.g., "Build the wheel with `uv build`"). + """ + if inline_tokens is not None: + if root_position == 0: + # Position-0 ROOT: only check if the first token matches and is backtick + for itok in inline_tokens: + if itok.text.lower() == root_lower: + return itok.format == "backtick" + # First non-whitespace token reached — if it's not the root word, + # the root is plain text at position 0, not backticked + if itok.text.strip(): + return False + return False + for itok in inline_tokens: + if itok.text.lower() == root_lower: + return itok.format == "backtick" + return False + # Regex fallback for direct calls (no inline_tokens). + # Position-0 verbs are never code identifiers — only check backtick + # when the root is NOT at position 0 in the text. + root_pos = clean.lower().find(root_lower) + if root_pos == -1: + return False + if root_pos == 0: + # Position-0: check if the very first word is inside a backtick span + first_bt = _BACKTICK_RE.search(md_text) + return first_bt is not None and first_bt.start() == 0 and root_lower in first_bt.group().lower() + return any(root_lower in _CLASSIFY_WORD_RE.findall(m.group().lower()) for m in _BACKTICK_RE.finditer(md_text)) + + +def _is_advcl_rescue_candidate(doc: Any) -> bool: + """Check if position-0 token qualifies for advcl verb rescue.""" + return ( + 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 + ) + + +_POST_COLON_NEGATION = frozenset({"never", "no", "not", "don't", "do", "avoid"}) + +# Labels before colon/dash that are meta-descriptions, not instruction headers. +# "fix: correct a bug" is a commit type definition, not an instruction. +_META_LABELS = frozenset( + { + "fix", + "feat", + "chore", + "docs", + "refactor", + "test", + "ci", + "build", + "perf", + "style", + "goal", + "purpose", + "pattern", + "example", + "impact", + "default", + "note", + "result", + "output", + "input", + "return", + "trigger", + "both", + "screenshot", + } +) + + +def _check_colon_label(doc: Any, root: Any) -> tuple[str, int, str, str, bool] | None: + """Detect 'Noun: ...' label pattern in early tokens. + + Scans all tokens in first 5 positions (not limited to pre-root) because + spaCy often assigns ROOT to the label noun itself. + + Returns: + - CONSTRAINT if post-colon text starts with negation + - IMPERATIVE if post-colon text starts with a non-ambiguous verb + - NEUTRAL if post-colon text is descriptive or label is meta + - None if no colon-label pattern found + """ + if len(doc) > 0 and doc[0].text.lower() in _ALL_VERBS: + return None + # Skip if ROOT is a verb before the colon — the verb is the instruction + if root.tag_ in {"VB", "VBP"} and any(t.text == ":" and t.i > root.i for t in doc[:6]): + return None + for tok in doc: + if tok.i > 5: + break + if tok.text == ":" and 0 < tok.i <= 4: + if any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[: tok.i]): + break # conditional clause break, not a label + prev = doc[tok.i - 1] + if prev.tag_ in {"NN", "NNS", "NNP", "NNPS"}: + # Skip meta-labels (commit types, purpose statements) + label_text = doc[: tok.i].text.lower() + if any(ml in label_text for ml in _META_LABELS): + return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) + # Skip function-like labels (camelCase or contains uppercase mid-word) + label_raw = doc[: tok.i].text + if any(c.isupper() for c in label_raw[1:] if c.isalpha()): + # camelCase or PascalCase label → description + if any(c.islower() for c in label_raw): + return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) + # Check post-colon tokens for charge indicators + post = [t for t in doc if t.i > tok.i and not t.is_space] + if post: + first_word = post[0].text.lower() + # Negation after colon → CONSTRAINT + if first_word in _POST_COLON_NEGATION: + return ("CONSTRAINT", -1, "direct", "p3_colon_label_constraint", False) + # Non-ambiguous verb after colon → IMPERATIVE + if first_word in _ALL_VERBS and first_word not in _VERBS_AMBIGUOUS: + return ("IMPERATIVE", 1, "imperative", "p3_colon_label_imperative", False) + return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) + return None + + +def _check_postcolon_verb(doc: Any) -> tuple[str, int, str, str, bool] | None: + """Post-colon verb rescue: conditional markers before colon, verb after. + + Returns IMPERATIVE result if a conditional-colon-verb pattern is found, + None otherwise. Shared by NN and non-verb tag branches. + """ + colon_idx = next((t.i for t in doc if t.text == ":"), -1) + if colon_idx <= 0: + return None + if not any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[:colon_idx]): + return None + for pt in (t for t in doc if t.i > colon_idx): + if pt.i > colon_idx + 3: + break + 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 None + + +def _classify_nn_tag( + doc: Any, + root: Any, + has_subj: bool, + has_cond_prefix: bool, +) -> tuple[str, int, str, str, bool]: + """POS classification for NN/NNS/NNP/NNPS root tags.""" + # Lexicon override: imperative verbs mistagged as nouns at position 0 + if root.i == 0 and root.text.lower() in _ALL_VERBS and root.text.lower() not in _VERBS_AMBIGUOUS: + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0", sc) + # Ambiguous verb at position 0 with no subject: likely imperative. + # "Test behavior" (imperative) vs "Test results showed" (noun + subj). + if root.i == 0 and not has_subj and root.text.lower() in _VERBS_AMBIGUOUS: + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0!amb", sc) + # Position-0 verb rescue: non-ambiguous verb demoted by spaCy + t0_lower = doc[0].text.lower() if len(doc) > 0 else "" + if root.i > 0 and not has_subj and t0_lower in _ALL_VERBS and t0_lower not in _VERBS_AMBIGUOUS: + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0_rescue", sc) + # Position-0 ambiguous verb rescue: demoted by spaCy, no subject + if root.i > 0 and not has_subj and t0_lower in _VERBS_AMBIGUOUS: + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0_rescue!amb", sc) + # Post-colon verb rescue (conditional markers before colon) + pcv = _check_postcolon_verb(doc) + if pcv is not None: + return pcv + # Colon-label rescue: "Label: Use X" / "Label: Never Y" + cl = _check_colon_label(doc, root) + if cl is not None: + return cl + return ("NEUTRAL", 0, "none", "p3_spacy_nn", False) + + +def _classify_vb_vbp_tag( + doc: Any, + root: Any, + tag: str, + has_subj: bool, + has_cond_prefix: bool, + *, + shallow: bool, +) -> tuple[str, int, str, str, bool] | None: + """POS classification for VB/VBP root tags. + + Returns 5-tuple result or None to fall through to lexicon. + """ + subj_trace = f"p3_spacy_{tag.lower()}_subj" + if has_subj and not has_cond_prefix: + return ("NEUTRAL", 0, "none", subj_trace, False) + # In shallow mode, only charge if pre-root words are context words + 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: only charge confirmed verbs + if root.text.lower() not in _ALL_VERBS: + return None # fall through to lexicon + sc = _detect_scope_conditional(doc, has_cond_prefix) + if tag == "VBP": + 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) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb", sc) + + +def _classify_nonverb_tag( + doc: Any, + root: Any, + tag: str, +) -> tuple[str, int, str, str, bool]: + """POS classification for non-verb root tags (JJ, RB, CD, etc.).""" + 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 for conditional markers as ROOT + pcv = _check_postcolon_verb(doc) + if pcv is not None: + # Rename trace for non-verb branch + return ("IMPERATIVE", 1, "imperative", "p3_spacy_postcolon_verb", True) + return ("NEUTRAL", 0, "none", f"p3_spacy_{tag.lower()}", False) + + +_VERB0_RESCUE_DEPS = frozenset({"csubj", "compound", "nmod", "dep", "amod", "advcl", "ccomp"}) + + +def _check_verb0_rescue( + doc: Any, + root: Any, + has_cond_prefix: bool, +) -> tuple[str, int, str, str, bool] | None: + """Check position-0 verb rescue: advcl rescue and general dep-demotion rescue. + + Returns IMPERATIVE result if position 0 has a non-ambiguous verb that + spaCy demoted, or None to continue classification. + """ + if root.i == 0 or len(doc) == 0: + return None + t0_lower = doc[0].text.lower() + if t0_lower not in _ALL_VERBS or t0_lower in _VERBS_AMBIGUOUS: + return None + # advcl/ccomp rescue (verb at pos 0 demoted by clause boundary) + if doc[0].tag_ in {"VB", "VBP"} and doc[0].dep_ in ("advcl", "ccomp", "ROOT"): + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb_advcl_rescue", sc) + # General rescue (csubj, compound, nmod, etc.) + if doc[0].dep_ in _VERB0_RESCUE_DEPS: + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_verb0_rescue", sc) + return None + + +_PAST_TENSE_TAGS = frozenset({"VBZ", "VBD", "VBN", "VBG"}) + +_LATE_CONSTRAINT_RE = re.compile( + r"[.:;\u2014\u2013\-]\s*(?:do not|don't|never|avoid|must not|should not|cannot|no )\b", + re.IGNORECASE, +) + + +def _has_late_constraint(text: str) -> bool: + """True if text has constraint language after a sentence/clause boundary. + + Catches compound instructions like 'Prefer X. Do not introduce Y' and + 'Label — Avoid X' where the positive verb at the start masks a constraint. + """ + return bool(_LATE_CONSTRAINT_RE.search(text)) + + +def _spacy_pre_checks( + doc: Any, + root: Any, + clean: str, + md_text: str, + has_cond_prefix: bool, + inline_tokens: list[InlineToken] | None, +) -> tuple[str, int, str, str, bool] | None: + """Run pre-POS checks: verb0 rescue, backtick filter, colon label.""" + # Verb0 rescue runs BEFORE backtick filter: "Use `createAIClient()`" + # has a backticked ROOT (createAIClient) but the verb at position 0 + # is the instruction. The verb takes precedence over the object. + rescue = _check_verb0_rescue(doc, root, has_cond_prefix) + if rescue is not None: + return rescue + # Backtick filter: ROOT inside backticks → NEUTRAL (code reference). + # Skip when the sentence has imperative structure — the backticked word + # is the object of the instruction, not a code reference. + # Imperative signals: known verb at pos 0, conditional prefix, "Please". + t0_lower = doc[0].text.lower() if len(doc) > 0 else "" + has_imperative_signal = t0_lower in _ALL_VERBS or has_cond_prefix or t0_lower in {"to", "please", "re"} + if not has_imperative_signal and _is_root_in_backtick( + root.text.lower(), clean, md_text, inline_tokens, root_position=root.i + ): + return ("NEUTRAL", 0, "none", "p3_spacy_backtick", False) + return _check_colon_label(doc, root) + + +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 + + pre = _spacy_pre_checks(doc, root, clean, md_text, has_cond_prefix, inline_tokens) + if pre is not None: + result = pre + else: + has_subj = any(child.dep_ in ("nsubj", "nsubjpass") for child in root.children) + tag = root.tag_ + + # Position-0 nsubj rescue: spaCy demoted a known verb to noun-subject. + # "Extract display logic" → spaCy: Extract(nsubj) display(ROOT/VBP) + # "Group related local variables" → Group(nsubj) related(ROOT/VBD) + # In instruction files, position-0 non-ambiguous verbs tagged as + # nsubj are always misparsed imperatives. The ambiguous-verb guard + # prevents false positives; the nsubj dep guard limits to cases + # where spaCy explicitly assigned subject role to position 0. + if has_subj and root.i > 0 and not shallow: + t0 = doc[0] + if ( + t0.dep_ in ("nsubj", "nsubjpass") + and t0.text.lower() in _ALL_VERBS + and t0.text.lower() not in _VERBS_AMBIGUOUS + ): + sc = _detect_scope_conditional(doc, has_cond_prefix) + return ("IMPERATIVE", 1, "imperative", "p3_spacy_nsubj_verb0_rescue", sc) + + # POS classification by tag group + if tag in {"NN", "NNS", "NNP", "NNPS"}: + result = _classify_nn_tag(doc, root, has_subj, has_cond_prefix) + elif tag in _PAST_TENSE_TAGS: + cl = _check_colon_label(doc, root) + result = cl if cl is not None else ("NEUTRAL", 0, "none", f"p3_spacy_{tag.lower()}", False) + elif tag in {"VB", "VBP"}: + vb_result = _classify_vb_vbp_tag(doc, root, tag, has_subj, has_cond_prefix, shallow=shallow) + if vb_result is not None: + result = vb_result + else: + return None # fall through to lexicon + else: + result = _classify_nonverb_tag(doc, root, tag) + + # Late-constraint guard: if classified IMPERATIVE but text contains + # constraint language after a sentence boundary or colon, the atom is + # a compound instruction — mark AMBIGUOUS to avoid charge inversion. + if result is not None and result[1] == 1: # charge_value == 1 (positive) + if _has_late_constraint(clean): + return ("AMBIGUOUS", 0, "none", "p3_compound_ambiguous", False) + + return result + + +def _classify_phase1( + clean: str, + words: list[str], + lowers: list[str], + has_cond_prefix: bool, +) -> tuple[str, int, str, str, bool] | None: + """Phase 1: Negation/prohibition patterns → 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 = 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 + return None + + +_NEGATION_WORDS = frozenset({"not", "never", "n't"}) + + +def _modal_result( + next_negated: bool, + modality: str, + directive_trace: str, + negated_trace: str, +) -> tuple[str, int, str, str, bool]: + """Return CONSTRAINT if negated, DIRECTIVE otherwise.""" + if next_negated: + return "CONSTRAINT", -1, modality, negated_trace, False + return "DIRECTIVE", 1, modality, directive_trace, False + + +def _check_modal_word( + w: str, + i: int, + lowers: list[str], +) -> tuple[str, int, str, str, bool] | None: + """Check a single word for modal/hedged/you-will patterns. Returns result or None.""" + next_negated = i + 1 < len(lowers) and lowers[i + 1] in _NEGATION_WORDS + if w in _MODAL_ABSOLUTE: + return _modal_result(next_negated, "absolute", f"p2_modal_{w}", "p2_modal_negated") + if w in _MODAL_HEDGED: + if next_negated: + return "CONSTRAINT", -1, "hedged", f"p2_hedged_{w}_negated", False + is_positioned = ( + w == "should" + or 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) if is_positioned else None + if w == "will" and i > 0 and lowers[i - 1] == "you": + return _modal_result(next_negated, "absolute", "p2_you_will", "p2_you_will_not") + return None + + +def _classify_phase2( + lowers: list[str], +) -> tuple[str, int, str, str, bool] | None: + """Phase 2: Modal verbs and absolute adverbs → DIRECTIVE.""" + for i, w in enumerate(lowers): + result = _check_modal_word(w, i, lowers) + if result is not None: + return result + 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 + return None + + +# Determiners for verb-noun disambiguation in Phase 3c +_DETERMINERS: frozenset[str] = frozenset( + { + "the", + "a", + "an", + "any", + "all", + "each", + "every", + "this", + "that", + "these", + "those", + "your", + "our", + "my", + "its", + "their", + "his", + "her", + "no", + "some", + "both", + "either", + "neither", + } +) + +# Declarative sentence starters for Phase 3g +_DECLARATIVE_STARTS: frozenset[str] = frozenset( + _PROBABLE_SUBJECTS + | { + "the", + "a", + "an", + "its", + "their", + "our", + "your", + "my", + "his", + "her", + } +) + + +def _classify_phase3e_break( + clean: str, +) -> tuple[str, int, str, str, bool] | None: + """Phase 3e: verb after sentence/clause break.""" + sentences = re.split(r"(?<=[.!?:;])\s+", clean) + 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 + return None + + +def _classify_phase3d_context( + lowers: list[str], + verb_idx: int, + pre: set[str], +) -> tuple[str, int, str, str, bool] | None: + """Phase 3d: verb after context words only.""" + if not (pre <= _CONTEXT_WORDS): + return None + 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 + + +def _classify_phase3_deep( + clean: str, + lowers: list[str], + verb_idx: int, + pre: set[str], +) -> tuple[str, int, str, str, bool]: + """Phase 3 deep detection: sub-phases 3d-3g (mid-sentence verb detection).""" + # 3d: Verb after context words only + p3d = _classify_phase3d_context(lowers, verb_idx, pre) + if p3d is not None: + return p3d + + # 3e: Verb after sentence/clause break + p3e = _classify_phase3e_break(clean) + if p3e is not None: + return p3e + + # 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 + if lowers[0] not in _DECLARATIVE_STARTS and verb_idx <= 7 and 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 _classify_phase3_lexicon( + clean: str, + lowers: list[str], + verb_idx: int, + *, + shallow: bool, +) -> tuple[str, int, str, str, bool]: + """Phase 3 fallback: verb lexicon detection (when spaCy unavailable or returns None). + + Covers sub-phases 3c through 3g. + """ + # 3c: Verb at position 0 + if verb_idx == 0: + verb = lowers[0] + amb = "" + if verb in _VERBS_AMBIGUOUS: + pos1 = lowers[1] if len(lowers) > 1 else "" + if pos1 not in _DETERMINERS: + amb = "!amb" + has_cond = any(w in _COND_CHECK for w in lowers[1:8]) + return "IMPERATIVE", 1, "imperative", f"p3c_verb0_{verb}{amb}", has_cond + + if shallow: + return "NEUTRAL", 0, "none", "p3_shallow_stop", False + + return _classify_phase3_deep(clean, lowers, verb_idx, set(lowers[:verb_idx])) + + +_NEUTRAL_RESULT: tuple[str, int, str, str, bool] = ("NEUTRAL", 0, "none", "fallthrough", False) + + +def _classify_phase3b_bold(md_text: str) -> tuple[str, int, str, str, bool] | None: + """Phase 3b: Bold label + verb after it — shallow recursive call.""" + after = _after_bold_label(md_text) + if after is None: + return None + after_clean = _strip_md_for_classify(after) + if not after_clean: + return None + 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 + return None + + +def _classify_phase3( + clean: str, + md_text: str, + lowers: list[str], + has_cond_prefix: bool, + *, + shallow: bool, + inline_tokens: list[InlineToken] | None, +) -> tuple[str, int, str, str, bool]: + """Phase 3: Imperative verb detection (spaCy + lexicon fallback).""" + # 3b: Bold label recursive + if not shallow: + p3b = _classify_phase3b_bold(md_text) + if p3b is not None: + return p3b + + # 3_spacy: primary Phase 3 + 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 + verb_idx = _find_verb_idx(lowers) + if verb_idx == -1: + return "NEUTRAL", 0, "none", "p3_no_verb", False + return _classify_phase3_lexicon(clean, lowers, verb_idx, shallow=shallow) + + +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. + """ + clean = plain_text.strip().lstrip("-+>#0123456789. ") if plain_text is not None else _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] + has_cond_prefix = lowers[0] in _CONDITIONAL_MARKERS + + p1 = _classify_phase1(clean, words, lowers, has_cond_prefix) + if p1 is not None: + return p1 + + p2 = _classify_phase2(lowers) + if p2 is not None: + return p2 + + return _classify_phase3( + clean, + md_text, + lowers, + has_cond_prefix, + shallow=_shallow, + inline_tokens=inline_tokens, + ) diff --git a/src/reporails_cli/core/mapper/cluster.py b/src/reporails_cli/core/mapper/cluster.py new file mode 100644 index 0000000..f8c9f09 --- /dev/null +++ b/src/reporails_cli/core/mapper/cluster.py @@ -0,0 +1,107 @@ +"""Mapper Stage 6: Cluster atoms into topic groups using stored embeddings. + +Reuses the int8 vectors written by Stage 5 (Embed) — does not re-encode. +Dequantises to float32 in place for AgglomerativeClustering, then computes +L2-normalised centroids per cluster. Falls back to a single cluster when +fewer than two atoms have embeddings. +""" + +from __future__ import annotations + +from typing import Any + +from reporails_cli.core.platform.dto.ruleset import Atom, TopicCluster + +# Topic clustering threshold (L2 distance on L2-normalized embeddings). +TOPIC_CLUSTER_THRESHOLD = 1.2 + + +def _compute_centroid(embeddings_norm: Any, member_indices: list[int]) -> tuple[float, ...]: + """Compute L2-normalized centroid from member vectors.""" + import numpy as np + + member_vecs = embeddings_norm[member_indices] + mean_vec = member_vecs.mean(axis=0) + norm = float(np.linalg.norm(mean_vec)) + if norm > 1e-12: + mean_vec = mean_vec / norm + return tuple(float(x) for x in mean_vec.tolist()) + + +def _build_topic_clusters( + clusters: dict[int, list[Atom]], + indices: dict[int, list[int]], + embeddings_norm: Any, +) -> list[TopicCluster]: + """Build TopicCluster list from cluster assignments and normalized embeddings.""" + 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) + j = len(charged) / n_total if n_total else 0.0 + centroid = _compute_centroid(embeddings_norm, indices[tid]) + result.append(TopicCluster(topic_id=tid, atoms=cluster_atoms, charged=charged, j=j, centroid=centroid)) + return result + + +def _run_agglomerative_clustering( + embedded: list[Atom], +) -> tuple[Any, Any]: + """Run AgglomerativeClustering on embedded atoms. Returns (embeddings_norm, labels).""" + import numpy as np + from sklearn.cluster import AgglomerativeClustering + from sklearn.preprocessing import normalize + + vecs = np.array( + [list(a.embedding_int8) for a in embedded if a.embedding_int8 is not None], + dtype=np.float32, + ) + embeddings_norm = normalize(vecs, norm="l2") + clustering = AgglomerativeClustering( + n_clusters=None, + distance_threshold=TOPIC_CLUSTER_THRESHOLD, + metric="euclidean", + linkage="average", + ) + return embeddings_norm, clustering.fit_predict(embeddings_norm) + + +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. + """ + exc = [a for a in atoms if a.kind != "heading"] + if not exc: + return [] + + embedded = [a for a in exc if a.embedding_int8 is not None] + if len(embedded) < 2: + 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)] + + embeddings_norm, labels = _run_agglomerative_clustering(embedded) + + 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) + + for a in exc: + if a.embedding_int8 is None: + a.cluster_id = -1 + + return _build_topic_clusters(clusters, indices, embeddings_norm) diff --git a/src/reporails_cli/core/mapper/daemon.py b/src/reporails_cli/core/mapper/daemon.py index 74ff70a..ff5e3c9 100644 --- a/src/reporails_cli/core/mapper/daemon.py +++ b/src/reporails_cli/core/mapper/daemon.py @@ -36,7 +36,7 @@ def _daemon_dir() -> Path: - from reporails_cli.core.bootstrap import get_daemon_dir + from reporails_cli.core.platform.config.bootstrap import get_daemon_dir return get_daemon_dir() @@ -197,7 +197,7 @@ def start_daemon() -> int: def _init_daemon_process() -> None: """Initialize daemon process: torch blocker, PID file, ML noise suppression.""" - from reporails_cli.core import _torch_blocker + from reporails_cli.core.platform.runtime import _torch_blocker _torch_blocker.install() _pid_path().write_text(str(os.getpid())) @@ -214,7 +214,7 @@ def _init_daemon_process() -> None: def _start_model_warmup() -> tuple[Any, threading.Event]: """Start model warmup in a background thread. Returns (models, warmup_done).""" - from reporails_cli.core.mapper.mapper import get_models + from reporails_cli.core.mapper.models import get_models models = get_models() warmup_done = threading.Event() @@ -353,8 +353,8 @@ def _dispatch( def _handle_map_ruleset(request: dict[str, Any], models: Any) -> dict[str, Any]: """Handle map_ruleset request — build RulesetMap from paths.""" - from reporails_cli.core.bootstrap import get_global_cache_dir - from reporails_cli.core.mapper.mapper import map_ruleset + from reporails_cli.core.mapper import map_ruleset + from reporails_cli.core.platform.config.bootstrap import get_global_cache_dir paths_str = request.get("paths", []) paths = [Path(p) for p in paths_str] @@ -366,7 +366,7 @@ def _handle_map_ruleset(request: dict[str, Any], models: Any) -> dict[str, Any]: # Serialize to JSON-compatible dict import tempfile - from reporails_cli.core.mapper.mapper import save_ruleset_map + from reporails_cli.core.mapper.serialize import save_ruleset_map with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: save_ruleset_map(ruleset_map, Path(f.name)) diff --git a/src/reporails_cli/core/mapper/daemon_client.py b/src/reporails_cli/core/mapper/daemon_client.py index 0ad36e8..3715172 100644 --- a/src/reporails_cli/core/mapper/daemon_client.py +++ b/src/reporails_cli/core/mapper/daemon_client.py @@ -17,7 +17,7 @@ def _socket_path() -> Path: - from reporails_cli.core.bootstrap import get_daemon_dir + from reporails_cli.core.platform.config.bootstrap import get_daemon_dir return get_daemon_dir() / "mapper.sock" @@ -100,7 +100,7 @@ def map_ruleset_via_daemon( try: import tempfile - from reporails_cli.core.mapper.mapper import load_ruleset_map + from reporails_cli.core.mapper.serialize import load_ruleset_map with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(map_data, f) diff --git a/src/reporails_cli/core/mapper/embed.py b/src/reporails_cli/core/mapper/embed.py new file mode 100644 index 0000000..adb0827 --- /dev/null +++ b/src/reporails_cli/core/mapper/embed.py @@ -0,0 +1,72 @@ +"""Mapper Stage 5: Embed atoms via the ONNX MiniLM-L6-v2 encoder. + +Builds embedding text from `atom.plain_text` only (no heading prepend, to avoid +double-counting heading atoms and artificial clustering by heading structure). +Deduplicates identical text values before the model call so each unique string +hits the encoder exactly once per run. Quantises the float32 output to int8 for +compact wire-format storage; the per-vector scale is not retained because +cosine similarity is preserved under L2 normalisation. +""" + +from __future__ import annotations + +from typing import Any + +from reporails_cli.core.platform.dto.ruleset import Atom, FileRecord + + +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 + + +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 _embed_atoms_deduped(atoms: list[Atom], encoder: Any) -> None: + """Embed atoms with deduplication. Atoms with identical text share embeddings.""" + texts = [_embed_text(a) for a in atoms] + 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 = encoder.encode(unique_texts) + for atom, u_idx in zip(atoms, atom_to_unique, strict=True): + atom.embedding_int8 = _quantize_int8(unique_embeddings[u_idx]) + + +def _embed_file_descriptions(file_records: list[FileRecord], encoder: Any) -> None: + """Embed frontmatter descriptions for on_invocation files.""" + desc_texts = [fr.description for fr in file_records if fr.description] + if not desc_texts: + return + desc_embeddings = encoder.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 diff --git a/src/reporails_cli/core/mapper/imports.py b/src/reporails_cli/core/mapper/imports.py new file mode 100644 index 0000000..159c069 --- /dev/null +++ b/src/reporails_cli/core/mapper/imports.py @@ -0,0 +1,125 @@ +"""Stage 0 — `@path` inline import expansion. + +Claude Code and Gemini CLI splice imported file content at the reference +position before the model sees it. The mapper must see the same expanded +content to produce accurate atom counts and downstream classification. + +Public entry point: `expand_imports(content, source_path)`. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# 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"(? Path | None: + """Resolve an @path reference to a file path. + + Returns the resolved Path if it should be expanded, or None if it should + be left as-is (non-expandable ext, circular, broken, etc.). + """ + target = Path(ref).expanduser() if ref.startswith("~") else source_path.parent / ref + try: + target = target.resolve(strict=False) + except (OSError, RuntimeError): + return None # circular or broken symlink + if target.suffix.lower() in _NON_EXPANDABLE_EXT: + return None + if str(target) in visited: + return None + if not target.is_file(): + return None + return target + + +def expand_imports( + content: str, + source_path: Path, + *, + depth: int = 0, + visited: set[str] | None = None, +) -> 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())} + + code_ranges = [(m.start(), m.end()) for m in _FENCED_BLOCK_RE.finditer(content)] + + 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) + target = _resolve_import_target(match.group(1), source_path, visited) + if target is None: + return match.group(0) + try: + imported = target.read_text(encoding="utf-8", errors="replace") + except OSError: + return match.group(0) + visited.add(str(target)) + return expand_imports(imported, target, depth=depth + 1, visited=visited) + + return _IMPORT_REF_RE.sub(_replace, content) diff --git a/src/reporails_cli/core/mapper/inspect.py b/src/reporails_cli/core/mapper/inspect.py new file mode 100644 index 0000000..bc32536 --- /dev/null +++ b/src/reporails_cli/core/mapper/inspect.py @@ -0,0 +1,168 @@ +"""Per-file inspection — frontmatter parsing and agent-registry matching. + +Surface for the mapper orchestration spine: given a Path on disk, produce the +metadata fields the wire format needs (loading scope, glob set, agent +attribution, frontmatter description). Pure I/O + pattern matching; no atom +processing, no ML, no caching state. + +The registry-pattern match in `_find_best_registry_match` lazy-imports from +`core/discovery/agents` to pull the agent-config pattern/property accessors. +Dependency direction is one-way (`mapper.inspect` → `discovery.agents`); the +discovery subsystem does not import from mapper. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _extract_frontmatter_yaml(path: Path) -> str: + """Read a file and return the raw YAML frontmatter block, or empty string.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if not text.startswith("---"): + return "" + end = text.find("\n---", 3) + return text[3:end] if end != -1 else "" + + +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. + """ + raw = _extract_frontmatter_yaml(path) + if not raw: + return "" + try: + import yaml + + data = yaml.safe_load(raw) + if not isinstance(data, dict): + return "" + name = str(data.get("name", "")) + desc = str(data.get("description", "")) + return f"{name}: {desc}" if name and desc else (name or desc) + except Exception: # yaml.YAMLError; yaml imported in try scope + return "" + + +def _parse_frontmatter_globs(path: Path) -> tuple[str, ...]: + """Extract globs from YAML frontmatter of a rule/skill file.""" + raw = _extract_frontmatter_yaml(path) + if not raw: + return () + try: + import yaml + + data = yaml.safe_load(raw) + if not isinstance(data, dict) or "globs" not in data: + return () + globs = data["globs"] + if isinstance(globs, list): + return tuple(str(g) for g in globs) + if isinstance(globs, str): + return (globs,) + except Exception: # yaml.YAMLError; yaml imported in try scope + pass + return () + + +def _load_registry() -> dict[str, dict[str, Any]]: + """Load all agent registry configs. Returns {agent: config_dict}.""" + try: + from reporails_cli.core.platform.config.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 (yaml.YAMLError, OSError) as exc: + logger.warning("Failed to load agent config %s: %s", config_path, exc) + continue + return configs + + +def _find_best_registry_match( + rel_lower: str, + registry: dict[str, dict[str, Any]], +) -> tuple[str, dict[str, Any]] | None: + """Find the most specific registry pattern match for a file path. + + Returns (agent_id, properties) or None if no match. + """ + import fnmatch + + from reporails_cli.core.discovery.agents import _extract_patterns, _extract_properties + + best: tuple[int, str, dict[str, Any]] | None = None # (specificity, agent, props) + + for agent_id, config in registry.items(): + for ft in (config.get("file_types") or {}).values(): + 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() + candidates = [pat_lower] + if "**/" in pat_lower: + candidates.append(pat_lower.replace("**/", "")) + candidates.append(pat_lower.replace("**/", "*/")) + if any(fnmatch.fnmatch(rel_lower, c) for c in candidates): + specificity = len(pat_lower.split("*")[0]) + if best is None or specificity > best[0]: + best = (specificity, agent_id, props) + + if best is None: + return None + return best[1], best[2] + + +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 = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path) + match = _find_best_registry_match(rel.lower(), registry) + if match is None: + return "session_start", "global", (), "generic" + + agent_id, props = 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 diff --git a/src/reporails_cli/core/mapper/mapper.py b/src/reporails_cli/core/mapper/mapper.py deleted file mode 100644 index 6760acf..0000000 --- a/src/reporails_cli/core/mapper/mapper.py +++ /dev/null @@ -1,3453 +0,0 @@ -# pylint: disable=C0302 -# ruff: noqa: C901, SIM102, SIM108, N806, PERF401, 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 — classification, embedding, and clustering. - -The RulesetMap is the wire format: ~32KB covering an entire instruction -ruleset, suitable for transmission to the diagnostic API. -""" - -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) - 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) # opposite-direction markers - # 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", -} - -# Single-pass regex for all KNOWN_CODE_TOKENS. Sorted longest-first so -# the alternation engine prefers longer matches (e.g. "dataclasses" over -# "dataclass"), though word-boundary assertions make this a safety belt. -_KNOWN_TOKEN_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", - "answer", - "append", - "assess", - "assign", - "assist", - "audit", - "avoid", - "be", - "begin", - "capture", - "choose", - "clarify", - "classify", - "collaborate", - "collect", - "compare", - "compose", - "confirm", - "consolidate", - "coordinate", - "continue", - "convert", - "cross", - "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", - "leave", - "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", - "stage", - "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", - "pass", - "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 - - -# Conditional marker set excluding "for" — reused across scope detection. -_COND_CHECK = _CONDITIONAL_MARKERS - {"for"} - - -def _detect_scope_conditional(doc: Any, has_cond_prefix: bool) -> bool: - """Detect conditional scope frame from first 8 tokens of a spaCy doc.""" - lowers = [t.text.lower() for t in doc[:8]] - has_cond = any(w in _COND_CHECK for w in lowers) - return has_cond_prefix or has_cond - - -def _is_root_in_backtick( - root_lower: str, - clean: str, - md_text: str, - inline_tokens: list[InlineToken] | None, - root_position: int = 0, -) -> bool: - """Check if the ROOT word falls inside a backtick span. - - When root_position is 0, checks only the first occurrence in inline_tokens - to avoid false positives from the same word appearing both as a position-0 - verb and inside a later backtick span (e.g., "Build the wheel with `uv build`"). - """ - if inline_tokens is not None: - if root_position == 0: - # Position-0 ROOT: only check if the first token matches and is backtick - for itok in inline_tokens: - if itok.text.lower() == root_lower: - return itok.format == "backtick" - # First non-whitespace token reached — if it's not the root word, - # the root is plain text at position 0, not backticked - if itok.text.strip(): - return False - return False - for itok in inline_tokens: - if itok.text.lower() == root_lower: - return itok.format == "backtick" - return False - # Regex fallback for direct calls (no inline_tokens). - # Position-0 verbs are never code identifiers — only check backtick - # when the root is NOT at position 0 in the text. - root_pos = clean.lower().find(root_lower) - if root_pos == -1: - return False - if root_pos == 0: - # Position-0: check if the very first word is inside a backtick span - first_bt = _BACKTICK_RE.search(md_text) - return first_bt is not None and first_bt.start() == 0 and root_lower in first_bt.group().lower() - return any(root_lower in _CLASSIFY_WORD_RE.findall(m.group().lower()) for m in _BACKTICK_RE.finditer(md_text)) - - -def _is_advcl_rescue_candidate(doc: Any) -> bool: - """Check if position-0 token qualifies for advcl verb rescue.""" - return ( - 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 - ) - - -_POST_COLON_NEGATION = frozenset({"never", "no", "not", "don't", "do", "avoid"}) - -# Labels before colon/dash that are meta-descriptions, not instruction headers. -# "fix: correct a bug" is a commit type definition, not an instruction. -_META_LABELS = frozenset( - { - "fix", - "feat", - "chore", - "docs", - "refactor", - "test", - "ci", - "build", - "perf", - "style", - "goal", - "purpose", - "pattern", - "example", - "impact", - "default", - "note", - "result", - "output", - "input", - "return", - "trigger", - "both", - "screenshot", - } -) - - -def _check_colon_label(doc: Any, root: Any) -> tuple[str, int, str, str, bool] | None: - """Detect 'Noun: ...' label pattern in early tokens. - - Scans all tokens in first 5 positions (not limited to pre-root) because - spaCy often assigns ROOT to the label noun itself. - - Returns: - - CONSTRAINT if post-colon text starts with negation - - IMPERATIVE if post-colon text starts with a non-ambiguous verb - - NEUTRAL if post-colon text is descriptive or label is meta - - None if no colon-label pattern found - """ - if len(doc) > 0 and doc[0].text.lower() in _ALL_VERBS: - return None - # Skip if ROOT is a verb before the colon — the verb is the instruction - if root.tag_ in {"VB", "VBP"} and any(t.text == ":" and t.i > root.i for t in doc[:6]): - return None - for tok in doc: - if tok.i > 5: - break - if tok.text == ":" and 0 < tok.i <= 4: - if any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[: tok.i]): - break # conditional clause break, not a label - prev = doc[tok.i - 1] - if prev.tag_ in {"NN", "NNS", "NNP", "NNPS"}: - # Skip meta-labels (commit types, purpose statements) - label_text = doc[: tok.i].text.lower() - if any(ml in label_text for ml in _META_LABELS): - return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) - # Skip function-like labels (camelCase or contains uppercase mid-word) - label_raw = doc[: tok.i].text - if any(c.isupper() for c in label_raw[1:] if c.isalpha()): - # camelCase or PascalCase label → description - if any(c.islower() for c in label_raw): - return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) - # Check post-colon tokens for charge indicators - post = [t for t in doc if t.i > tok.i and not t.is_space] - if post: - first_word = post[0].text.lower() - # Negation after colon → CONSTRAINT - if first_word in _POST_COLON_NEGATION: - return ("CONSTRAINT", -1, "direct", "p3_colon_label_constraint", False) - # Non-ambiguous verb after colon → IMPERATIVE - if first_word in _ALL_VERBS and first_word not in _VERBS_AMBIGUOUS: - return ("IMPERATIVE", 1, "imperative", "p3_colon_label_imperative", False) - return ("NEUTRAL", 0, "none", "p3_spacy_colon_label", False) - return None - - -def _check_postcolon_verb(doc: Any) -> tuple[str, int, str, str, bool] | None: - """Post-colon verb rescue: conditional markers before colon, verb after. - - Returns IMPERATIVE result if a conditional-colon-verb pattern is found, - None otherwise. Shared by NN and non-verb tag branches. - """ - colon_idx = next((t.i for t in doc if t.text == ":"), -1) - if colon_idx <= 0: - return None - if not any(t.text.lower() in _CONDITIONAL_MARKERS for t in doc[:colon_idx]): - return None - for pt in (t for t in doc if t.i > colon_idx): - if pt.i > colon_idx + 3: - break - 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 None - - -def _classify_nn_tag( - doc: Any, - root: Any, - has_subj: bool, - has_cond_prefix: bool, -) -> tuple[str, int, str, str, bool]: - """POS classification for NN/NNS/NNP/NNPS root tags.""" - # Lexicon override: imperative verbs mistagged as nouns at position 0 - if root.i == 0 and root.text.lower() in _ALL_VERBS and root.text.lower() not in _VERBS_AMBIGUOUS: - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0", sc) - # Ambiguous verb at position 0 with no subject: likely imperative. - # "Test behavior" (imperative) vs "Test results showed" (noun + subj). - if root.i == 0 and not has_subj and root.text.lower() in _VERBS_AMBIGUOUS: - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0!amb", sc) - # Position-0 verb rescue: non-ambiguous verb demoted by spaCy - t0_lower = doc[0].text.lower() if len(doc) > 0 else "" - if root.i > 0 and not has_subj and t0_lower in _ALL_VERBS and t0_lower not in _VERBS_AMBIGUOUS: - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0_rescue", sc) - # Position-0 ambiguous verb rescue: demoted by spaCy, no subject - if root.i > 0 and not has_subj and t0_lower in _VERBS_AMBIGUOUS: - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_nn_verb0_rescue!amb", sc) - # Post-colon verb rescue (conditional markers before colon) - pcv = _check_postcolon_verb(doc) - if pcv is not None: - return pcv - # Colon-label rescue: "Label: Use X" / "Label: Never Y" - cl = _check_colon_label(doc, root) - if cl is not None: - return cl - return ("NEUTRAL", 0, "none", "p3_spacy_nn", False) - - -def _classify_vb_vbp_tag( - doc: Any, - root: Any, - tag: str, - has_subj: bool, - has_cond_prefix: bool, - *, - shallow: bool, -) -> tuple[str, int, str, str, bool] | None: - """POS classification for VB/VBP root tags. - - Returns 5-tuple result or None to fall through to lexicon. - """ - subj_trace = f"p3_spacy_{tag.lower()}_subj" - if has_subj and not has_cond_prefix: - return ("NEUTRAL", 0, "none", subj_trace, False) - # In shallow mode, only charge if pre-root words are context words - 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: only charge confirmed verbs - if root.text.lower() not in _ALL_VERBS: - return None # fall through to lexicon - sc = _detect_scope_conditional(doc, has_cond_prefix) - if tag == "VBP": - 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) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb", sc) - - -def _classify_nonverb_tag( - doc: Any, - root: Any, - tag: str, -) -> tuple[str, int, str, str, bool]: - """POS classification for non-verb root tags (JJ, RB, CD, etc.).""" - 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 for conditional markers as ROOT - pcv = _check_postcolon_verb(doc) - if pcv is not None: - # Rename trace for non-verb branch - return ("IMPERATIVE", 1, "imperative", "p3_spacy_postcolon_verb", True) - return ("NEUTRAL", 0, "none", f"p3_spacy_{tag.lower()}", False) - - -_VERB0_RESCUE_DEPS = frozenset({"csubj", "compound", "nmod", "dep", "amod", "advcl", "ccomp"}) - - -def _check_verb0_rescue( - doc: Any, - root: Any, - has_cond_prefix: bool, -) -> tuple[str, int, str, str, bool] | None: - """Check position-0 verb rescue: advcl rescue and general dep-demotion rescue. - - Returns IMPERATIVE result if position 0 has a non-ambiguous verb that - spaCy demoted, or None to continue classification. - """ - if root.i == 0 or len(doc) == 0: - return None - t0_lower = doc[0].text.lower() - if t0_lower not in _ALL_VERBS or t0_lower in _VERBS_AMBIGUOUS: - return None - # advcl/ccomp rescue (verb at pos 0 demoted by clause boundary) - if doc[0].tag_ in {"VB", "VBP"} and doc[0].dep_ in ("advcl", "ccomp", "ROOT"): - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_vb_advcl_rescue", sc) - # General rescue (csubj, compound, nmod, etc.) - if doc[0].dep_ in _VERB0_RESCUE_DEPS: - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_verb0_rescue", sc) - return None - - -_PAST_TENSE_TAGS = frozenset({"VBZ", "VBD", "VBN", "VBG"}) - -_LATE_CONSTRAINT_RE = re.compile( - r"[.:;\u2014\u2013\-]\s*(?:do not|don't|never|avoid|must not|should not|cannot|no )\b", - re.IGNORECASE, -) - - -def _has_late_constraint(text: str) -> bool: - """True if text has constraint language after a sentence/clause boundary. - - Catches compound instructions like 'Prefer X. Do not introduce Y' and - 'Label — Avoid X' where the positive verb at the start masks a constraint. - """ - return bool(_LATE_CONSTRAINT_RE.search(text)) - - -def _spacy_pre_checks( - doc: Any, - root: Any, - clean: str, - md_text: str, - has_cond_prefix: bool, - inline_tokens: list[InlineToken] | None, -) -> tuple[str, int, str, str, bool] | None: - """Run pre-POS checks: verb0 rescue, backtick filter, colon label.""" - # Verb0 rescue runs BEFORE backtick filter: "Use `createAIClient()`" - # has a backticked ROOT (createAIClient) but the verb at position 0 - # is the instruction. The verb takes precedence over the object. - rescue = _check_verb0_rescue(doc, root, has_cond_prefix) - if rescue is not None: - return rescue - # Backtick filter: ROOT inside backticks → NEUTRAL (code reference). - # Skip when the sentence has imperative structure — the backticked word - # is the object of the instruction, not a code reference. - # Imperative signals: known verb at pos 0, conditional prefix, "Please". - t0_lower = doc[0].text.lower() if len(doc) > 0 else "" - has_imperative_signal = t0_lower in _ALL_VERBS or has_cond_prefix or t0_lower in {"to", "please", "re"} - if not has_imperative_signal and _is_root_in_backtick( - root.text.lower(), clean, md_text, inline_tokens, root_position=root.i - ): - return ("NEUTRAL", 0, "none", "p3_spacy_backtick", False) - return _check_colon_label(doc, root) - - -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 - - pre = _spacy_pre_checks(doc, root, clean, md_text, has_cond_prefix, inline_tokens) - if pre is not None: - result = pre - else: - has_subj = any(child.dep_ in ("nsubj", "nsubjpass") for child in root.children) - tag = root.tag_ - - # Position-0 nsubj rescue: spaCy demoted a known verb to noun-subject. - # "Extract display logic" → spaCy: Extract(nsubj) display(ROOT/VBP) - # "Group related local variables" → Group(nsubj) related(ROOT/VBD) - # In instruction files, position-0 non-ambiguous verbs tagged as - # nsubj are always misparsed imperatives. The ambiguous-verb guard - # prevents false positives; the nsubj dep guard limits to cases - # where spaCy explicitly assigned subject role to position 0. - if has_subj and root.i > 0 and not shallow: - t0 = doc[0] - if ( - t0.dep_ in ("nsubj", "nsubjpass") - and t0.text.lower() in _ALL_VERBS - and t0.text.lower() not in _VERBS_AMBIGUOUS - ): - sc = _detect_scope_conditional(doc, has_cond_prefix) - return ("IMPERATIVE", 1, "imperative", "p3_spacy_nsubj_verb0_rescue", sc) - - # POS classification by tag group - if tag in {"NN", "NNS", "NNP", "NNPS"}: - result = _classify_nn_tag(doc, root, has_subj, has_cond_prefix) - elif tag in _PAST_TENSE_TAGS: - cl = _check_colon_label(doc, root) - result = cl if cl is not None else ("NEUTRAL", 0, "none", f"p3_spacy_{tag.lower()}", False) - elif tag in {"VB", "VBP"}: - vb_result = _classify_vb_vbp_tag(doc, root, tag, has_subj, has_cond_prefix, shallow=shallow) - if vb_result is not None: - result = vb_result - else: - return None # fall through to lexicon - else: - result = _classify_nonverb_tag(doc, root, tag) - - # Late-constraint guard: if classified IMPERATIVE but text contains - # constraint language after a sentence boundary or colon, the atom is - # a compound instruction — mark AMBIGUOUS to avoid charge inversion. - if result is not None and result[1] == 1: # charge_value == 1 (positive) - if _has_late_constraint(clean): - return ("AMBIGUOUS", 0, "none", "p3_compound_ambiguous", False) - - return result - - -def _classify_phase1( - clean: str, - words: list[str], - lowers: list[str], - has_cond_prefix: bool, -) -> tuple[str, int, str, str, bool] | None: - """Phase 1: Negation/prohibition patterns → 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 = 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 - return None - - -_NEGATION_WORDS = frozenset({"not", "never", "n't"}) - - -def _modal_result( - next_negated: bool, - modality: str, - directive_trace: str, - negated_trace: str, -) -> tuple[str, int, str, str, bool]: - """Return CONSTRAINT if negated, DIRECTIVE otherwise.""" - if next_negated: - return "CONSTRAINT", -1, modality, negated_trace, False - return "DIRECTIVE", 1, modality, directive_trace, False - - -def _check_modal_word( - w: str, - i: int, - lowers: list[str], -) -> tuple[str, int, str, str, bool] | None: - """Check a single word for modal/hedged/you-will patterns. Returns result or None.""" - next_negated = i + 1 < len(lowers) and lowers[i + 1] in _NEGATION_WORDS - if w in _MODAL_ABSOLUTE: - return _modal_result(next_negated, "absolute", f"p2_modal_{w}", "p2_modal_negated") - if w in _MODAL_HEDGED: - if next_negated: - return "CONSTRAINT", -1, "hedged", f"p2_hedged_{w}_negated", False - is_positioned = ( - w == "should" - or 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) if is_positioned else None - if w == "will" and i > 0 and lowers[i - 1] == "you": - return _modal_result(next_negated, "absolute", "p2_you_will", "p2_you_will_not") - return None - - -def _classify_phase2( - lowers: list[str], -) -> tuple[str, int, str, str, bool] | None: - """Phase 2: Modal verbs and absolute adverbs → DIRECTIVE.""" - for i, w in enumerate(lowers): - result = _check_modal_word(w, i, lowers) - if result is not None: - return result - 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 - return None - - -# Determiners for verb-noun disambiguation in Phase 3c -_DETERMINERS: frozenset[str] = frozenset( - { - "the", - "a", - "an", - "any", - "all", - "each", - "every", - "this", - "that", - "these", - "those", - "your", - "our", - "my", - "its", - "their", - "his", - "her", - "no", - "some", - "both", - "either", - "neither", - } -) - -# Declarative sentence starters for Phase 3g -_DECLARATIVE_STARTS: frozenset[str] = frozenset( - _PROBABLE_SUBJECTS - | { - "the", - "a", - "an", - "its", - "their", - "our", - "your", - "my", - "his", - "her", - } -) - - -def _classify_phase3e_break( - clean: str, -) -> tuple[str, int, str, str, bool] | None: - """Phase 3e: verb after sentence/clause break.""" - sentences = re.split(r"(?<=[.!?:;])\s+", clean) - 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 - return None - - -def _classify_phase3d_context( - lowers: list[str], - verb_idx: int, - pre: set[str], -) -> tuple[str, int, str, str, bool] | None: - """Phase 3d: verb after context words only.""" - if not (pre <= _CONTEXT_WORDS): - return None - 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 - - -def _classify_phase3_deep( - clean: str, - lowers: list[str], - verb_idx: int, - pre: set[str], -) -> tuple[str, int, str, str, bool]: - """Phase 3 deep detection: sub-phases 3d-3g (mid-sentence verb detection).""" - # 3d: Verb after context words only - p3d = _classify_phase3d_context(lowers, verb_idx, pre) - if p3d is not None: - return p3d - - # 3e: Verb after sentence/clause break - p3e = _classify_phase3e_break(clean) - if p3e is not None: - return p3e - - # 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 - if lowers[0] not in _DECLARATIVE_STARTS and verb_idx <= 7 and 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 _classify_phase3_lexicon( - clean: str, - lowers: list[str], - verb_idx: int, - *, - shallow: bool, -) -> tuple[str, int, str, str, bool]: - """Phase 3 fallback: verb lexicon detection (when spaCy unavailable or returns None). - - Covers sub-phases 3c through 3g. - """ - # 3c: Verb at position 0 - if verb_idx == 0: - verb = lowers[0] - amb = "" - if verb in _VERBS_AMBIGUOUS: - pos1 = lowers[1] if len(lowers) > 1 else "" - if pos1 not in _DETERMINERS: - amb = "!amb" - has_cond = any(w in _COND_CHECK for w in lowers[1:8]) - return "IMPERATIVE", 1, "imperative", f"p3c_verb0_{verb}{amb}", has_cond - - if shallow: - return "NEUTRAL", 0, "none", "p3_shallow_stop", False - - return _classify_phase3_deep(clean, lowers, verb_idx, set(lowers[:verb_idx])) - - -_NEUTRAL_RESULT: tuple[str, int, str, str, bool] = ("NEUTRAL", 0, "none", "fallthrough", False) - - -def _classify_phase3b_bold(md_text: str) -> tuple[str, int, str, str, bool] | None: - """Phase 3b: Bold label + verb after it — shallow recursive call.""" - after = _after_bold_label(md_text) - if after is None: - return None - after_clean = _strip_md_for_classify(after) - if not after_clean: - return None - 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 - return None - - -def _classify_phase3( - clean: str, - md_text: str, - lowers: list[str], - has_cond_prefix: bool, - *, - shallow: bool, - inline_tokens: list[InlineToken] | None, -) -> tuple[str, int, str, str, bool]: - """Phase 3: Imperative verb detection (spaCy + lexicon fallback).""" - # 3b: Bold label recursive - if not shallow: - p3b = _classify_phase3b_bold(md_text) - if p3b is not None: - return p3b - - # 3_spacy: primary Phase 3 - 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 - verb_idx = _find_verb_idx(lowers) - if verb_idx == -1: - return "NEUTRAL", 0, "none", "p3_no_verb", False - return _classify_phase3_lexicon(clean, lowers, verb_idx, shallow=shallow) - - -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. - """ - clean = plain_text.strip().lstrip("-+>#0123456789. ") if plain_text is not None else _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] - has_cond_prefix = lowers[0] in _CONDITIONAL_MARKERS - - p1 = _classify_phase1(clean, words, lowers, has_cond_prefix) - if p1 is not None: - return p1 - - p2 = _classify_phase2(lowers) - if p2 is not None: - return p2 - - return _classify_phase3( - clean, - md_text, - lowers, - has_cond_prefix, - shallow=_shallow, - inline_tokens=inline_tokens, - ) - - -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] = [] - - # Pre-lowercase backtick content once for O(1)-ish lookups below. - bt_lower = {bt.lower() for bt in backtick_content} - - # Single regex pass finds all known code tokens in one engine invocation. - seen: set[str] = set() - for m in _KNOWN_TOKEN_RE.finditer(text_no_bt): - tok = m.group(1).lower() - if tok not in seen: - seen.add(tok) - if not any(tok in bt for bt in bt_lower): - unformatted.append(tok) - - for m in CODE_SHAPE_RE.finditer(text_no_bt): - token = m.group(1) - if token.lower().rstrip(".") in _DOTTED_EXCLUSIONS_NORMALIZED: - continue - if token not in unformatted and not any(token in bt for bt in bt_lower): - unformatted.append(token) - - # Named if ANY construct is identified — backtick-wrapped OR unformatted known token. - # The model recognizes `pytest` with or without backticks at the token level. - spec = "named" if (named or unformatted) else "abstract" - return spec, named, unformatted, italic, bold - - -# ────────────────────────────────────────────────────────────────── -# TOKENIZER (markdown-it AST) -# ────────────────────────────────────────────────────────────────── - -_md_parser = MarkdownIt().enable("table") - -_QUOTED_START_RE = re.compile(r'^["\u201c\u201e]') -_DEFN_LABEL_RE = re.compile(r"^\*{2}\S+\*{2}\s*[:\u2014\u2013(/-]\s?") - -_BOLD_LABEL_RE = re.compile( - r"^\*{2}[^*]{1,60}\*{2}\s*[:\u2014\u2013/-]\s*\S", -) -_INSTRUCTION_WORDS_RE = re.compile( - r"\b(never|always|must|shall|do not|don't|no |only|NEVER|NO |MUST|ALWAYS|avoid" - r"|ensure|require|use |prefer |forbidden|prohibited" - # Imperative verbs — bold labels starting with these are instructions - r"|read |check |run |add |verify |follow |create |update |write " - r"|find |set |install |identify |build |keep |include" - r"|configure |validate |define |generate |execute |review " - r"|apply |load |locate |deploy |export |import |remove |test )\b", - re.IGNORECASE, -) - -_THIRD_PERSON_RE = re.compile( - r"^(Triggers|Sends|Supports|Handles|Manages|Contains" - r"|Provides|Returns|Creates|Runs|Fetches|Stores" - r"|Processes|Generates|Validates|Implements" - r"|Connects|Accepts|Includes|Maintains" - r"|Represents|Defines|Operates|Applies" - r"|Describes|Specifies|Determines|Requires)\s", -) - -_CMD_REF_RE = re.compile(r"^`[^`]+`\s*[-\u2013\u2014:]\s+") -_VERSION_NOTE_RE = 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 _append_content_tokens( - content: str, - fmt: str, - md_parts: list[str], - plain_parts: list[str], - inline_tokens: list[InlineToken], - md_prefix: str = "", -) -> None: - """Append text content with format tracking to md/plain/inline collectors.""" - md_parts.append(f"{md_prefix}{content}" if md_prefix else content) - plain_parts.append(content) - for word in content.split(): - inline_tokens.append(InlineToken(text=word, format=fmt)) - - -# Maps markdown-it open/close tokens to their markdown marker and stack format. -_FORMAT_OPEN = {"strong_open": ("**", "bold"), "em_open": ("*", "italic")} -_FORMAT_CLOSE = {"strong_close": "**", "em_close": "*"} - - -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 in ("text", "html_inline"): - _append_content_tokens(child.content, format_stack[-1], md_parts, plain_parts, inline_tokens) - 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 in _FORMAT_OPEN: - marker, fmt = _FORMAT_OPEN[child.type] - md_parts.append(marker) - format_stack.append(fmt) - elif child.type in _FORMAT_CLOSE: - md_parts.append(_FORMAT_CLOSE[child.type]) - if len(format_stack) > 1: - format_stack.pop() - # link_open, link_close: skip — text child handles content - - 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 _make_fence_atom(tok: Any) -> Atom: - """Create a code block atom from a fence token.""" - lang = (tok.info or "").strip().lower() - return 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", - ) - - -def _specificity_fields(text: str) -> dict[str, Any]: - """Build specificity-related Atom fields from text.""" - spec, named, unformatted, italic, bold = check_specificity(text) - return { - "specificity": spec, - "named_tokens": named, - "unformatted_code": unformatted, - "italic_tokens": italic, - "bold_tokens": bold, - "token_count": len(_BACKTICK_RE.sub("x", text).split()), - } - - -def _tok_line(tok: Any, line_offset: int) -> int: - """Extract line number from a markdown-it token.""" - return (tok.map[0] if tok.map else 0) + line_offset + 1 - - -def _make_heading_atom( - tok: Any, - tokens: list[Any], - i: int, - line_offset: int, -) -> tuple[Atom, str]: - """Create a heading atom and return (atom, heading_text).""" - heading_text = tokens[i + 1].content if i + 1 < len(tokens) and tokens[i + 1].type == "inline" else "" - charge, cv, mod, rule, sc = classify_charge(heading_text) - sf = _specificity_fields(heading_text) - atom = Atom( - line=_tok_line(tok, line_offset), - text=heading_text, - kind="heading", - charge=charge, - charge_value=cv, - modality=mod, - specificity=sf["specificity"], - format="heading", - depth=int(tok.tag[1]), - named_tokens=sf["named_tokens"], - token_count=sf["token_count"], - rule=rule, - scope_conditional=sc, - ) - return atom, heading_text - - -def _collect_table_cells(tokens: list[Any], start: int) -> tuple[str, int]: - """Collect table cells from tr_open to tr_close. Returns (cell_text, next_index).""" - cells: list[str] = [] - j = start + 1 - while j < len(tokens) and tokens[j].type != "tr_close": - if tokens[j].type == "inline": - cells.append(tokens[j].content.strip()) - j += 1 - return " | ".join(cells), j + 1 - - -def _make_table_row_atom( - tok: Any, - tokens: list[Any], - i: int, - line_offset: int, - pos_idx: int, - current_heading: str, -) -> tuple[Atom | None, int]: - """Create a table row atom. Returns (atom_or_None, next_token_index).""" - cell_text, next_i = _collect_table_cells(tokens, i) - if len(cell_text) < 5: - return None, next_i - sf = _specificity_fields(cell_text) - atom = Atom( - line=_tok_line(tok, line_offset), - text=cell_text, - kind="excitation", - charge="NEUTRAL", - charge_value=0, - modality="none", - specificity=sf["specificity"], - format="table", - named_tokens=sf["named_tokens"], - italic_tokens=sf["italic_tokens"], - bold_tokens=sf["bold_tokens"], - unformatted_code=sf["unformatted_code"], - position_index=pos_idx, - token_count=sf["token_count"], - heading_context=current_heading, - ) - return atom, next_i - - -def _make_inline_atom( - md_text: str, - plain_text: str, - fmt: str, - base_line: int, - seg_idx: int, - pos_idx: int, - current_heading: str, - inline_tokens: list[InlineToken], -) -> Atom: - """Create an inline content atom with charge classification.""" - charge, cv, mod, rule_trace, scope_cond = _classify_content( - md_text, - plain_text, - fmt, - inline_tokens=inline_tokens, - ) - sf = _specificity_fields(md_text) - return Atom( - line=base_line + seg_idx, - text=md_text, - kind="excitation", - charge=charge, - charge_value=cv, - modality=mod, - scope_conditional=scope_cond, - specificity=sf["specificity"], - format=fmt, - named_tokens=sf["named_tokens"], - italic_tokens=sf["italic_tokens"], - bold_tokens=sf["bold_tokens"], - unformatted_code=sf["unformatted_code"], - position_index=pos_idx, - token_count=sf["token_count"], - heading_context=current_heading, - plain_text=plain_text, - rule=rule_trace, - ambiguous=rule_trace.endswith("!amb"), - ) - - -# 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", -} - - -def _process_inline_segments( - tok: Any, - line_offset: int, - block_stack: list[str], - pos_idx: int, - current_heading: str, - atoms: list[Atom], -) -> int: - """Process inline token segments, appending atoms. Returns updated pos_idx.""" - base_line = _tok_line(tok, line_offset) - fmt = _determine_format(block_stack) - if fmt == "table": - return pos_idx - for seg_idx, segment in enumerate(_split_at_softbreaks(tok.children)): - md_text, plain_text, inline_toks = _extract_texts(segment) - if len(md_text) >= 5: - atoms.append( - _make_inline_atom( - md_text, - plain_text, - fmt, - base_line, - seg_idx, - pos_idx, - current_heading, - inline_toks, - ) - ) - pos_idx += 1 - return pos_idx - - -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] = [] - - 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() - - if tok.type == "fence": - atoms.append(_make_fence_atom(tok)) - i += 1 - continue - - if tok.type == "hr": - i += 1 - continue - - if tok.type == "heading_open": - atom, current_heading = _make_heading_atom(tok, tokens, i, line_offset) - atoms.append(atom) - i += 3 - continue - - if tok.type == "tr_open": - row_atom, next_i = _make_table_row_atom( - tok, - tokens, - i, - line_offset, - pos_idx, - current_heading, - ) - if row_atom is not None: - atoms.append(row_atom) - pos_idx += 1 - i = next_i - continue - - if tok.type == "inline" and tok.children: - pos_idx = _process_inline_segments( - tok, - line_offset, - block_stack, - pos_idx, - current_heading, - atoms, - ) - - i += 1 - - atoms = _split_mixed_charge_atoms(atoms) - - for atom in atoms: - atom.charge_confidence = _rule_confidence(atom.rule, atom.charge) - - _scan_neutral_for_embedded_markers(atoms) - _scan_charged_for_compound_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[bool]: - """Build a per-character mask: True where the character is inside quotes or parens. - - Tracks: "..." (straight quotes toggle), \u201c...\u201d (curly), (...). - Backtick spans are not tracked here — they're handled by inline_tokens. - """ - mask = [False] * len(text) - in_straight_quote = False - in_curly_quote = False - paren_depth = 0 - for i, ch in enumerate(text): - if ch == '"': - if in_straight_quote: - # Closing — mark this char as inside, then exit - mask[i] = True - in_straight_quote = False - continue - else: - in_straight_quote = True - elif ch == "\u201c": - in_curly_quote = True - elif ch == "\u201d" and in_curly_quote: - mask[i] = True - in_curly_quote = False - continue - elif ch == "(" and not in_straight_quote and not in_curly_quote: - paren_depth += 1 - elif ch == ")" and paren_depth > 0: - mask[i] = True - paren_depth -= 1 - continue - if in_straight_quote or in_curly_quote or paren_depth > 0: - mask[i] = True - return mask - - -def _split_sentences(text: str) -> list[str] | None: - """Split text at sentence and em-dash boundaries outside quoted scope. - - Boundaries inside "..." or (...) are skipped — those are inline - examples, not real instruction boundaries. - Returns None if fewer than 2 sentences. - """ - candidates = list(_SENTENCE_SPLIT_RE.finditer(text)) - if not candidates: - return None - - scope_mask = _build_scope_mask(text) - # Keep only boundaries that fall outside quotes/parens - splits = [m for m in candidates if not scope_mask[m.start()]] - if not splits: - return None - - boundaries = [0] + [m.end() for m in splits] + [len(text)] - sentences = [text[boundaries[i] : boundaries[i + 1]].strip() for i in range(len(boundaries) - 1)] - sentences = [s for s in sentences if len(s) >= 5] - return sentences if len(sentences) >= 2 else None - - -def _atom_from_sentence( - sent: str, - atom: Atom, - charge: str, - cv: int, - mod: str, - rule: str, - scope: bool, -) -> Atom: - """Create an atom from a sub-sentence of a split atom.""" - spec, named, unformatted, italic, bold = check_specificity(sent) - return 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=len(_BACKTICK_RE.sub("x", sent).split()), - heading_context=atom.heading_context, - plain_text=re.sub(r"[*_`]+", "", sent).strip(), - rule=rule, - ambiguous=rule.endswith("!amb"), - ) - - -def _split_mixed_charge_atoms(atoms: list[Atom]) -> 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 - - sentences = _split_sentences(atom.text) - if sentences is None: - result.append(atom) - continue - - # Classify each sentence independently - classified = [] - for sent in sentences: - plain = re.sub(r"[*_]+", "", _BACKTICK_RE.sub("x", sent)).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 - if len({cv for _, _, cv, _, _, _ in classified}) < 2: - result.append(atom) - continue - - for sent, charge, cv, mod, rule, scope in classified: - result.append(_atom_from_sentence(sent, atom, charge, cv, mod, rule, scope)) - - # 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 from explicit rules: high confidence. - # Fallthrough neutrals: slightly lower (nothing matched). - return 0.85 if rule == "fallthrough" else 0.95 - - if rule in _HIGH_CONFIDENCE_RULES: - return 0.95 - if rule in _MEDIUM_CONFIDENCE_RULES: - return 0.80 - if rule.endswith("!amb"): - return 0.60 - if "cond" in rule or "3f_" in rule or "3g_" in rule: - return 0.70 - 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 _scan_charged_for_compound_markers(atoms: list[Atom]) -> None: - """Detect opposite-direction markers in charged atoms. - - A directive like "mention it — don't delete it" contains both a - directive verb and a constraint negation. The classifier picks one - charge; this scanner records the opposite-direction signal so the - equation can skip false-positive conflict detection between the - compound instruction and an aligned constraint. - - Does NOT change the atom's charge. - """ - for atom in atoms: - if atom.charge_value == 0 or atom.kind == "heading": - continue - text = atom.text - opposite: list[str] = [] - if atom.charge_value == 1: - for m in _EMBEDDED_CONSTRAINT_RE.finditer(text): - opposite.append(f"constraint:{m.group().strip()}") - else: - for m in _EMBEDDED_DIRECTIVE_RE.finditer(text): - opposite.append(f"directive:{m.group().strip()}") - for m in _EMBEDDED_IMPERATIVE_RE.finditer(text): - opposite.append(f"imperative:{m.group(1).strip()}") - if opposite: - atom.embedded_charge_markers = opposite - - -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 _compute_centroid(embeddings_norm: Any, member_indices: list[int]) -> tuple[float, ...]: - """Compute L2-normalized centroid from member vectors.""" - import numpy as np - - member_vecs = embeddings_norm[member_indices] - mean_vec = member_vecs.mean(axis=0) - norm = float(np.linalg.norm(mean_vec)) - if norm > 1e-12: - mean_vec = mean_vec / norm - return tuple(float(x) for x in mean_vec.tolist()) - - -def _build_topic_clusters( - clusters: dict[int, list[Atom]], - indices: dict[int, list[int]], - embeddings_norm: Any, -) -> list[TopicCluster]: - """Build TopicCluster list from cluster assignments and normalized embeddings.""" - 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) - j = len(charged) / n_total if n_total else 0.0 - centroid = _compute_centroid(embeddings_norm, indices[tid]) - result.append(TopicCluster(topic_id=tid, atoms=cluster_atoms, charged=charged, j=j, centroid=centroid)) - return result - - -def _run_agglomerative_clustering( - embedded: list[Atom], -) -> tuple[Any, Any]: - """Run AgglomerativeClustering on embedded atoms. Returns (embeddings_norm, labels).""" - import numpy as np - from sklearn.cluster import AgglomerativeClustering - from sklearn.preprocessing import normalize - - vecs = np.array( - [list(a.embedding_int8) for a in embedded if a.embedding_int8 is not None], - dtype=np.float32, - ) - embeddings_norm = normalize(vecs, norm="l2") - clustering = AgglomerativeClustering( - n_clusters=None, - distance_threshold=TOPIC_CLUSTER_THRESHOLD, - metric="euclidean", - linkage="average", - ) - return embeddings_norm, clustering.fit_predict(embeddings_norm) - - -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. - """ - exc = [a for a in atoms if a.kind != "heading"] - if not exc: - return [] - - embedded = [a for a in exc if a.embedding_int8 is not None] - if len(embedded) < 2: - 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)] - - embeddings_norm, labels = _run_agglomerative_clustering(embedded) - - 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) - - for a in exc: - if a.embedding_int8 is None: - a.cluster_id = -1 - - return _build_topic_clusters(clusters, indices, embeddings_norm) - - -# ────────────────────────────────────────────────────────────────── -# 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. - try: - self._nlp = spacy.load( - "en_core_web_sm", - disable=["ner", "lemmatizer", "attribute_ruler"], - ) - except OSError: - # Model not installed — download it once - import subprocess - import sys - - subprocess.run( - [sys.executable, "-m", "spacy", "download", "en_core_web_sm"], - capture_output=True, - ) - 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"(? Path | None: - """Resolve an @path reference to a file path. - - Returns the resolved Path if it should be expanded, or None if it should - be left as-is (non-expandable ext, circular, broken, etc.). - """ - if ref.startswith("~"): - target = Path(ref).expanduser() - else: - target = source_path.parent / ref - try: - target = target.resolve(strict=False) - except (OSError, RuntimeError): - return None # circular or broken symlink - if target.suffix.lower() in _NON_EXPANDABLE_EXT: - return None - if str(target) in visited: - return None - if not target.is_file(): - return None - return target - - -def expand_imports( - content: str, - source_path: Path, - *, - depth: int = 0, - visited: set[str] | None = None, -) -> 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())} - - code_ranges = [(m.start(), m.end()) for m in _FENCED_BLOCK_RE.finditer(content)] - - 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) - target = _resolve_import_target(match.group(1), source_path, visited) - if target is None: - return match.group(0) - try: - imported = target.read_text(encoding="utf-8", errors="replace") - except OSError: - return match.group(0) - visited.add(str(target)) - return expand_imports(imported, target, depth=depth + 1, visited=visited) - - 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 _extract_frontmatter_yaml(path: Path) -> str: - """Read a file and return the raw YAML frontmatter block, or empty string.""" - try: - text = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return "" - if not text.startswith("---"): - return "" - end = text.find("\n---", 3) - return text[3:end] if end != -1 else "" - - -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. - """ - raw = _extract_frontmatter_yaml(path) - if not raw: - return "" - try: - import yaml - - data = yaml.safe_load(raw) - if not isinstance(data, dict): - return "" - name = str(data.get("name", "")) - desc = str(data.get("description", "")) - return f"{name}: {desc}" if name and desc else (name or desc) - except Exception: # yaml.YAMLError; yaml imported in try scope - return "" - - -def _parse_frontmatter_globs(path: Path) -> tuple[str, ...]: - """Extract globs from YAML frontmatter of a rule/skill file.""" - raw = _extract_frontmatter_yaml(path) - if not raw: - return () - try: - import yaml - - data = yaml.safe_load(raw) - if not isinstance(data, dict) or "globs" not in data: - return () - globs = data["globs"] - if isinstance(globs, list): - return tuple(str(g) for g in globs) - if isinstance(globs, str): - return (globs,) - except Exception: # yaml.YAMLError; yaml imported in try scope - 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 (yaml.YAMLError, OSError) as exc: - logger.warning("Failed to load agent config %s: %s", config_path, exc) - continue - return configs - - -def _find_best_registry_match( - rel_lower: str, - registry: dict[str, dict[str, Any]], -) -> tuple[str, dict[str, Any]] | None: - """Find the most specific registry pattern match for a file path. - - Returns (agent_id, properties) or None if no match. - """ - import fnmatch - - from reporails_cli.core.agents import _extract_patterns, _extract_properties - - best: tuple[int, str, dict[str, Any]] | None = None # (specificity, agent, props) - - for agent_id, config in registry.items(): - for ft in (config.get("file_types") or {}).values(): - 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() - candidates = [pat_lower] - if "**/" in pat_lower: - candidates.append(pat_lower.replace("**/", "")) - candidates.append(pat_lower.replace("**/", "*/")) - if any(fnmatch.fnmatch(rel_lower, c) for c in candidates): - specificity = len(pat_lower.split("*")[0]) - if best is None or specificity > best[0]: - best = (specificity, agent_id, props) - - if best is None: - return None - return best[1], best[2] - - -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 = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path) - match = _find_best_registry_match(rel.lower(), registry) - if match is None: - return "session_start", "global", (), "generic" - - agent_id, props = 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 - - -def _embed_atoms_deduped(atoms: list[Atom], encoder: Any) -> None: - """Embed atoms with deduplication. Atoms with identical text share embeddings.""" - texts = [_embed_text(a) for a in atoms] - 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 = encoder.encode(unique_texts) - for atom, u_idx in zip(atoms, atom_to_unique, strict=True): - atom.embedding_int8 = _quantize_int8(unique_embeddings[u_idx]) - - -def _classify_file( - path: Path, - map_cache: Any, - all_atoms: list[Atom], - atoms_needing_embed: list[Atom], -) -> str: - """Classify a single file: tokenize or use cache. Returns content hash.""" - from reporails_cli.core.mapper.map_cache import ( - CachedFileEntry, - atoms_to_dicts, - dicts_to_atoms, - ) - - raw_content = path.read_text(encoding="utf-8", errors="replace") - content = expand_imports(raw_content, path) - chash = content_hash(content) - - cached = map_cache.get(chash) if map_cache else None - if cached is not None: - atoms = dicts_to_atoms(cached.atoms) - for a in atoms: - a.file_path = str(path) - all_atoms.extend(atoms) - else: - atoms = tokenize(content) - for a in atoms: - a.file_path = str(path) - all_atoms.extend(atoms) - atoms_needing_embed.extend(atoms) - if map_cache is not None: - map_cache.put(chash, CachedFileEntry(chash, atoms_to_dicts(atoms))) - - return chash - - -def _update_cache_after_embedding( - map_cache: Any, - all_atoms: list[Atom], - atoms_needing_embed: list[Atom], - file_records: list[FileRecord], -) -> None: - """Update cache entries with embeddings for newly-embedded atoms.""" - from reporails_cli.core.mapper.map_cache import CachedFileEntry, atoms_to_dicts - - by_file: dict[str, list[Atom]] = {} - for a in all_atoms: - by_file.setdefault(a.file_path, []).append(a) - embed_set = {id(a) for a in atoms_needing_embed} - for frec in file_records: - file_atoms = by_file.get(frec.path, []) - if any(id(a) in embed_set for a in file_atoms): - map_cache.put(frec.content_hash, CachedFileEntry(frec.content_hash, atoms_to_dicts(file_atoms))) - - -def _embed_file_descriptions(file_records: list[FileRecord], encoder: Any) -> None: - """Embed frontmatter descriptions for on_invocation files.""" - desc_texts = [fr.description for fr in file_records if fr.description] - if not desc_texts: - return - desc_embeddings = encoder.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 - - -def _build_ruleset_map( - file_records: list[FileRecord], - all_atoms: list[Atom], - topics: list[TopicCluster], -) -> RulesetMap: - """Assemble the final RulesetMap from classified and clustered data.""" - cluster_records = [ - 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, - ) - for tc in topics - ] - - n_charged = sum(1 for a in all_atoms if a.charge_value != 0) - summary = RulesetSummary( - n_atoms=len(all_atoms), - n_charged=n_charged, - n_neutral=len(all_atoms) - n_charged, - n_topics=len(topics), - n_topics_charged=sum(1 for tc in topics if tc.charged), - ) - - return 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, - ) - - -def _validate_and_log(ruleset: RulesetMap) -> None: - """Validate atoms, log findings, raise on errors.""" - findings = validate_atoms(ruleset.atoms) - for f in findings: - if f.severity == "error": - logger.error("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) - elif f.severity == "warn": - logger.warning("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) - errors = [f for f in findings if f.severity == "error"] - if errors: - raise ValueError( - f"Map validation failed with {len(errors)} error(s). First: [{errors[0].rule}] {errors[0].message}" - ) - - -def _classify_all_files( - paths: list[Path], - root: Path, - map_cache: Any, - registry: dict[str, dict[str, Any]], -) -> tuple[list[FileRecord], list[Atom], list[Atom]]: - """Classify all instruction files. Returns (file_records, all_atoms, atoms_needing_embed).""" - file_records: list[FileRecord] = [] - all_atoms: list[Atom] = [] - atoms_needing_embed: list[Atom] = [] - - for path in paths: - chash = _classify_file(path, map_cache, all_atoms, atoms_needing_embed) - loading, scope, globs, agent = _detect_file_loading(path, root, registry) - 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, - ) - ) - - return file_records, all_atoms, atoms_needing_embed - - -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 MapCache - - if models is None: - models = get_models() - if root is None: - root = paths[0].parent if paths else Path(".") - - map_cache: MapCache | None = None - if cache_dir is not None: - map_cache = MapCache(cache_dir) - map_cache.load() - - file_records, all_atoms, atoms_needing_embed = _classify_all_files( - paths, - root, - map_cache, - _load_registry(), - ) - - # Embed uncached atoms - if atoms_needing_embed: - _embed_atoms_deduped(atoms_needing_embed, models.st) - if map_cache is not None: - _update_cache_after_embedding(map_cache, all_atoms, atoms_needing_embed, file_records) - - # Enforce cache cap (LRU eviction) and save - if map_cache is not None: - map_cache.enforce_cap() - map_cache.save() - - # Ensure ALL atoms have embeddings (cached atoms may lack them) - unembedded = [a for a in all_atoms if a.embedding_int8 is None] - if unembedded: - _embed_atoms_deduped(unembedded, models.st) - - _embed_file_descriptions(file_records, models.st) - - ruleset = _build_ruleset_map(file_records, all_atoms, cluster_topics(all_atoms)) - _validate_and_log(ruleset) - - 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 - if atom.embedded_charge_markers: - d["embedded_charge_markers"] = list(atom.embedded_charge_markers) - 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), - embedded_charge_markers=d.get("embedded_charge_markers", []), - 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]') - - -_VALID_CHARGES = frozenset({"CONSTRAINT", "DIRECTIVE", "IMPERATIVE", "NEUTRAL", "AMBIGUOUS"}) -_VALID_MODS = frozenset({"imperative", "direct", "absolute", "hedged", "none"}) - - -def _validate_atom_schema(a: Atom, findings: list[MapFinding]) -> None: - """Check schema and consistency invariants for a single atom.""" - cv, chg, mod = a.charge_value, a.charge, a.modality - _checks: list[tuple[bool, str, str]] = [ - (chg not in _VALID_CHARGES, "schema", f"Invalid charge: {chg}"), - (mod not in _VALID_MODS, "schema", f"Invalid modality: {mod}"), - (cv not in (-1, 0, 1), "schema", f"Invalid charge_value: {cv}"), - (cv == 0 and chg not in ("NEUTRAL", "AMBIGUOUS"), "consistency", f"charge_value=0 but charge={chg}"), - (cv != 0 and chg == "NEUTRAL", "consistency", "charge_value!=0 but charge=NEUTRAL"), - (cv == 0 and mod != "none", "consistency", f"NEUTRAL with modality={mod}"), - (cv != 0 and mod == "none", "consistency", "Charged with modality=none"), - ] - for condition, rule, message in _checks: - if condition: - findings.append(MapFinding("error", rule, message, a.line, a.text[:80], chg)) - - -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] = [] - exc: list[Atom] = [] - - for a in atoms: - _validate_atom_schema(a, findings) - 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: - msg = f"Negation at start but charge={a.charge}" - findings.append(MapFinding("warn", "must_constraint", msg, a.line, a.text[:80], a.charge)) - is_unquoted_neutral = ( - a.charge_value == 0 and not _QUOTED_ATOM_RE.match(a.text.strip()) and _STRONG_CHARGE_RE.search(a.text) - ) - if is_unquoted_neutral: - 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: - msg = f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually high" - findings.append(MapFinding("warn", "distribution", msg)) - if ratio < 0.05 and n_exc > 10: - msg = f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually low" - findings.append(MapFinding("warn", "distribution", msg)) - - 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/models.py b/src/reporails_cli/core/mapper/models.py new file mode 100644 index 0000000..d51e01a --- /dev/null +++ b/src/reporails_cli/core/mapper/models.py @@ -0,0 +1,112 @@ +"""Lazy-loaded ML model singleton — spaCy `en_core_web_sm` + ONNX MiniLM-L6-v2. + +Loaded once per process, reused across files. Both loads are guarded by per-attribute +locks so the daemon's background warmup thread and a serving thread can't +double-initialise the same model. + +Public entry point: `get_models()` returns the process-wide singleton. + +Stage 3 (classify) consumes `.nlp`; Stage 5 (embed) consumes `.st`. Either can +be `None`/missing without the other — spaCy gracefully degrades to a lexicon +fallback in classify, ONNX is mandatory for embed. +""" + +from __future__ import annotations + +from typing import Any + +_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 + + from reporails_cli.bundled import get_spacy_model_path + + # The dependency-parser branch of the classifier 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. The en_core_web_sm + # pipeline ships inside the wheel under bundled/spacy/ + # so there's no PyPI dep and no runtime download. If the + # bundled path is missing (e.g. running from a stripped + # source tree), fall through to the lexicon fallback in + # classify.py. + self._nlp = spacy.load( + get_spacy_model_path(), + 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 diff --git a/src/reporails_cli/core/mapper/parse.py b/src/reporails_cli/core/mapper/parse.py new file mode 100644 index 0000000..05e511d --- /dev/null +++ b/src/reporails_cli/core/mapper/parse.py @@ -0,0 +1,814 @@ +# ruff: noqa: SIM102, N806, PERF401, RUF034 +"""Stages 1 + 2 — markdown-it AST walk + per-atom text extraction. + +Stage 1 parses the markdown source into an AST and walks it, tracking block +nesting (lists, blockquotes, tables, code fences) to populate each atom's +`format` and `position_index`. Stage 2 extracts two parallel text streams from +each inline segment: `md_text` (formatting markers preserved, fed to annotate) +and `plain_text` (markers stripped, fed to classify and embed). The walker +also runs post-classify hooks — mixed-charge splitting, confidence scoring, +neutral-atom scanning — so the returned atom list is ready for clustering. + +Public entry point: `tokenize(content)`. + +Imports `classify_charge` + `_ALL_VERBS` + `_strip_md_for_classify` from classify +(Stage 3) and `check_specificity` + `_BACKTICK_RE` from annotate (Stage 4) so +tokenize can produce fully-classified atoms in a single pass. +""" + +from __future__ import annotations + +import re +from typing import Any + +from markdown_it import MarkdownIt + +from reporails_cli.core.mapper.annotate import _BACKTICK_RE, check_specificity +from reporails_cli.core.mapper.classify import _ALL_VERBS, classify_charge +from reporails_cli.core.platform.dto.ruleset import Atom, InlineToken + +# ────────────────────────────────────────────────────────────────── +# TOKENIZER (markdown-it AST) +# ────────────────────────────────────────────────────────────────── + +_md_parser = MarkdownIt().enable("table") + +_QUOTED_START_RE = re.compile(r'^["\u201c\u201e]') +_DEFN_LABEL_RE = re.compile(r"^\*{2}\S+\*{2}\s*[:\u2014\u2013(/-]\s?") + +_BOLD_LABEL_RE = re.compile( + r"^\*{2}[^*]{1,60}\*{2}\s*[:\u2014\u2013/-]\s*\S", +) +_INSTRUCTION_WORDS_RE = re.compile( + r"\b(never|always|must|shall|do not|don't|no |only|NEVER|NO |MUST|ALWAYS|avoid" + r"|ensure|require|use |prefer |forbidden|prohibited" + # Imperative verbs — bold labels starting with these are instructions + r"|read |check |run |add |verify |follow |create |update |write " + r"|find |set |install |identify |build |keep |include" + r"|configure |validate |define |generate |execute |review " + r"|apply |load |locate |deploy |export |import |remove |test )\b", + re.IGNORECASE, +) + +_THIRD_PERSON_RE = re.compile( + r"^(Triggers|Sends|Supports|Handles|Manages|Contains" + r"|Provides|Returns|Creates|Runs|Fetches|Stores" + r"|Processes|Generates|Validates|Implements" + r"|Connects|Accepts|Includes|Maintains" + r"|Represents|Defines|Operates|Applies" + r"|Describes|Specifies|Determines|Requires)\s", +) + +_CMD_REF_RE = re.compile(r"^`[^`]+`\s*[-\u2013\u2014:]\s+") +_VERSION_NOTE_RE = 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 _append_content_tokens( + content: str, + fmt: str, + md_parts: list[str], + plain_parts: list[str], + inline_tokens: list[InlineToken], + md_prefix: str = "", +) -> None: + """Append text content with format tracking to md/plain/inline collectors.""" + md_parts.append(f"{md_prefix}{content}" if md_prefix else content) + plain_parts.append(content) + for word in content.split(): + inline_tokens.append(InlineToken(text=word, format=fmt)) + + +# Maps markdown-it open/close tokens to their markdown marker and stack format. +_FORMAT_OPEN = {"strong_open": ("**", "bold"), "em_open": ("*", "italic")} +_FORMAT_CLOSE = {"strong_close": "**", "em_close": "*"} + + +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 in ("text", "html_inline"): + _append_content_tokens(child.content, format_stack[-1], md_parts, plain_parts, inline_tokens) + 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 in _FORMAT_OPEN: + marker, fmt = _FORMAT_OPEN[child.type] + md_parts.append(marker) + format_stack.append(fmt) + elif child.type in _FORMAT_CLOSE: + md_parts.append(_FORMAT_CLOSE[child.type]) + if len(format_stack) > 1: + format_stack.pop() + # link_open, link_close: skip — text child handles content + + 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 _make_fence_atom(tok: Any) -> Atom: + """Create a code block atom from a fence token.""" + lang = (tok.info or "").strip().lower() + return 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", + ) + + +def _specificity_fields(text: str) -> dict[str, Any]: + """Build specificity-related Atom fields from text.""" + spec, named, unformatted, italic, bold = check_specificity(text) + return { + "specificity": spec, + "named_tokens": named, + "unformatted_code": unformatted, + "italic_tokens": italic, + "bold_tokens": bold, + "token_count": len(_BACKTICK_RE.sub("x", text).split()), + } + + +def _tok_line(tok: Any, line_offset: int) -> int: + """Extract line number from a markdown-it token.""" + return (tok.map[0] if tok.map else 0) + line_offset + 1 + + +def _make_heading_atom( + tok: Any, + tokens: list[Any], + i: int, + line_offset: int, +) -> tuple[Atom, str]: + """Create a heading atom and return (atom, heading_text).""" + heading_text = tokens[i + 1].content if i + 1 < len(tokens) and tokens[i + 1].type == "inline" else "" + charge, cv, mod, rule, sc = classify_charge(heading_text) + sf = _specificity_fields(heading_text) + atom = Atom( + line=_tok_line(tok, line_offset), + text=heading_text, + kind="heading", + charge=charge, + charge_value=cv, + modality=mod, + specificity=sf["specificity"], + format="heading", + depth=int(tok.tag[1]), + named_tokens=sf["named_tokens"], + token_count=sf["token_count"], + rule=rule, + scope_conditional=sc, + ) + return atom, heading_text + + +def _collect_table_cells(tokens: list[Any], start: int) -> tuple[str, int]: + """Collect table cells from tr_open to tr_close. Returns (cell_text, next_index).""" + cells: list[str] = [] + j = start + 1 + while j < len(tokens) and tokens[j].type != "tr_close": + if tokens[j].type == "inline": + cells.append(tokens[j].content.strip()) + j += 1 + return " | ".join(cells), j + 1 + + +def _make_table_row_atom( + tok: Any, + tokens: list[Any], + i: int, + line_offset: int, + pos_idx: int, + current_heading: str, +) -> tuple[Atom | None, int]: + """Create a table row atom. Returns (atom_or_None, next_token_index).""" + cell_text, next_i = _collect_table_cells(tokens, i) + if len(cell_text) < 5: + return None, next_i + sf = _specificity_fields(cell_text) + atom = Atom( + line=_tok_line(tok, line_offset), + text=cell_text, + kind="excitation", + charge="NEUTRAL", + charge_value=0, + modality="none", + specificity=sf["specificity"], + format="table", + named_tokens=sf["named_tokens"], + italic_tokens=sf["italic_tokens"], + bold_tokens=sf["bold_tokens"], + unformatted_code=sf["unformatted_code"], + position_index=pos_idx, + token_count=sf["token_count"], + heading_context=current_heading, + ) + return atom, next_i + + +def _make_inline_atom( + md_text: str, + plain_text: str, + fmt: str, + base_line: int, + seg_idx: int, + pos_idx: int, + current_heading: str, + inline_tokens: list[InlineToken], +) -> Atom: + """Create an inline content atom with charge classification.""" + charge, cv, mod, rule_trace, scope_cond = _classify_content( + md_text, + plain_text, + fmt, + inline_tokens=inline_tokens, + ) + sf = _specificity_fields(md_text) + return Atom( + line=base_line + seg_idx, + text=md_text, + kind="excitation", + charge=charge, + charge_value=cv, + modality=mod, + scope_conditional=scope_cond, + specificity=sf["specificity"], + format=fmt, + named_tokens=sf["named_tokens"], + italic_tokens=sf["italic_tokens"], + bold_tokens=sf["bold_tokens"], + unformatted_code=sf["unformatted_code"], + position_index=pos_idx, + token_count=sf["token_count"], + heading_context=current_heading, + plain_text=plain_text, + rule=rule_trace, + ambiguous=rule_trace.endswith("!amb"), + ) + + +# 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", +} + + +def _process_inline_segments( + tok: Any, + line_offset: int, + block_stack: list[str], + pos_idx: int, + current_heading: str, + atoms: list[Atom], +) -> int: + """Process inline token segments, appending atoms. Returns updated pos_idx.""" + base_line = _tok_line(tok, line_offset) + fmt = _determine_format(block_stack) + if fmt == "table": + return pos_idx + for seg_idx, segment in enumerate(_split_at_softbreaks(tok.children)): + md_text, plain_text, inline_toks = _extract_texts(segment) + if len(md_text) >= 5: + atoms.append( + _make_inline_atom( + md_text, + plain_text, + fmt, + base_line, + seg_idx, + pos_idx, + current_heading, + inline_toks, + ) + ) + pos_idx += 1 + return pos_idx + + +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] = [] + + 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() + + if tok.type == "fence": + atoms.append(_make_fence_atom(tok)) + i += 1 + continue + + if tok.type == "hr": + i += 1 + continue + + if tok.type == "heading_open": + atom, current_heading = _make_heading_atom(tok, tokens, i, line_offset) + atoms.append(atom) + i += 3 + continue + + if tok.type == "tr_open": + row_atom, next_i = _make_table_row_atom( + tok, + tokens, + i, + line_offset, + pos_idx, + current_heading, + ) + if row_atom is not None: + atoms.append(row_atom) + pos_idx += 1 + i = next_i + continue + + if tok.type == "inline" and tok.children: + pos_idx = _process_inline_segments( + tok, + line_offset, + block_stack, + pos_idx, + current_heading, + atoms, + ) + + i += 1 + + atoms = _split_mixed_charge_atoms(atoms) + + for atom in atoms: + atom.charge_confidence = _rule_confidence(atom.rule, atom.charge) + + _scan_neutral_for_embedded_markers(atoms) + _scan_charged_for_compound_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[bool]: + """Build a per-character mask: True where the character is inside quotes or parens. + + Tracks: "..." (straight quotes toggle), \u201c...\u201d (curly), (...). + Backtick spans are not tracked here — they're handled by inline_tokens. + """ + mask = [False] * len(text) + in_straight_quote = False + in_curly_quote = False + paren_depth = 0 + for i, ch in enumerate(text): + if ch == '"': + if in_straight_quote: + # Closing — mark this char as inside, then exit + mask[i] = True + in_straight_quote = False + continue + else: + in_straight_quote = True + elif ch == "\u201c": + in_curly_quote = True + elif ch == "\u201d" and in_curly_quote: + mask[i] = True + in_curly_quote = False + continue + elif ch == "(" and not in_straight_quote and not in_curly_quote: + paren_depth += 1 + elif ch == ")" and paren_depth > 0: + mask[i] = True + paren_depth -= 1 + continue + if in_straight_quote or in_curly_quote or paren_depth > 0: + mask[i] = True + return mask + + +def _split_sentences(text: str) -> list[str] | None: + """Split text at sentence and em-dash boundaries outside quoted scope. + + Boundaries inside "..." or (...) are skipped — those are inline + examples, not real instruction boundaries. + Returns None if fewer than 2 sentences. + """ + candidates = list(_SENTENCE_SPLIT_RE.finditer(text)) + if not candidates: + return None + + scope_mask = _build_scope_mask(text) + # Keep only boundaries that fall outside quotes/parens + splits = [m for m in candidates if not scope_mask[m.start()]] + if not splits: + return None + + boundaries = [0] + [m.end() for m in splits] + [len(text)] + sentences = [text[boundaries[i] : boundaries[i + 1]].strip() for i in range(len(boundaries) - 1)] + sentences = [s for s in sentences if len(s) >= 5] + return sentences if len(sentences) >= 2 else None + + +def _atom_from_sentence( + sent: str, + atom: Atom, + charge: str, + cv: int, + mod: str, + rule: str, + scope: bool, +) -> Atom: + """Create an atom from a sub-sentence of a split atom.""" + spec, named, unformatted, italic, bold = check_specificity(sent) + return 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=len(_BACKTICK_RE.sub("x", sent).split()), + heading_context=atom.heading_context, + plain_text=re.sub(r"[*_`]+", "", sent).strip(), + rule=rule, + ambiguous=rule.endswith("!amb"), + ) + + +def _split_mixed_charge_atoms(atoms: list[Atom]) -> 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 + + sentences = _split_sentences(atom.text) + if sentences is None: + result.append(atom) + continue + + # Classify each sentence independently + classified = [] + for sent in sentences: + plain = re.sub(r"[*_]+", "", _BACKTICK_RE.sub("x", sent)).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 + if len({cv for _, _, cv, _, _, _ in classified}) < 2: + result.append(atom) + continue + + for sent, charge, cv, mod, rule, scope in classified: + result.append(_atom_from_sentence(sent, atom, charge, cv, mod, rule, scope)) + + # 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 from explicit rules: high confidence. + # Fallthrough neutrals: slightly lower (nothing matched). + return 0.85 if rule == "fallthrough" else 0.95 + + if rule in _HIGH_CONFIDENCE_RULES: + return 0.95 + if rule in _MEDIUM_CONFIDENCE_RULES: + return 0.80 + if rule.endswith("!amb"): + return 0.60 + if "cond" in rule or "3f_" in rule or "3g_" in rule: + return 0.70 + 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 _scan_charged_for_compound_markers(atoms: list[Atom]) -> None: + """Detect opposite-direction markers in charged atoms. + + A directive like "mention it — don't delete it" contains both a + directive verb and a constraint negation. The classifier picks one + charge; this scanner records the opposite-direction signal so the + equation can skip false-positive conflict detection between the + compound instruction and an aligned constraint. + + Does NOT change the atom's charge. + """ + for atom in atoms: + if atom.charge_value == 0 or atom.kind == "heading": + continue + text = atom.text + opposite: list[str] = [] + if atom.charge_value == 1: + for m in _EMBEDDED_CONSTRAINT_RE.finditer(text): + opposite.append(f"constraint:{m.group().strip()}") + else: + for m in _EMBEDDED_DIRECTIVE_RE.finditer(text): + opposite.append(f"directive:{m.group().strip()}") + for m in _EMBEDDED_IMPERATIVE_RE.finditer(text): + opposite.append(f"imperative:{m.group(1).strip()}") + if opposite: + atom.embedded_charge_markers = opposite diff --git a/src/reporails_cli/core/mapper/pipeline.py b/src/reporails_cli/core/mapper/pipeline.py new file mode 100644 index 0000000..ad07b94 --- /dev/null +++ b/src/reporails_cli/core/mapper/pipeline.py @@ -0,0 +1,212 @@ +"""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 — classification, embedding, and clustering. + +The RulesetMap is the wire format: ~32KB covering an entire instruction +ruleset, suitable for transmission to the diagnostic API. +""" + +from __future__ import annotations + +import hashlib +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from reporails_cli.core.mapper.assemble import build_ruleset_map +from reporails_cli.core.mapper.cluster import cluster_topics +from reporails_cli.core.mapper.embed import _embed_atoms_deduped, _embed_file_descriptions +from reporails_cli.core.mapper.imports import expand_imports +from reporails_cli.core.mapper.inspect import ( + _detect_file_loading, + _load_registry, + _parse_frontmatter_description, +) +from reporails_cli.core.mapper.models import Models, get_models +from reporails_cli.core.mapper.parse import tokenize +from reporails_cli.core.mapper.serialize import validate_atoms +from reporails_cli.core.platform.dto.ruleset import Atom, FileRecord, RulesetMap + +if TYPE_CHECKING: + pass # sentence_transformers types if needed + +logger = logging.getLogger(__name__) + + +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}" + + +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 _classify_file( + path: Path, + map_cache: Any, + all_atoms: list[Atom], + atoms_needing_embed: list[Atom], +) -> str: + """Classify a single file: tokenize or use cache. Returns content hash.""" + from reporails_cli.core.cache.map_cache import ( + CachedFileEntry, + atoms_to_dicts, + dicts_to_atoms, + ) + + raw_content = path.read_text(encoding="utf-8", errors="replace") + content = expand_imports(raw_content, path) + chash = content_hash(content) + + cached = map_cache.get(chash) if map_cache else None + if cached is not None: + atoms = dicts_to_atoms(cached.atoms) + for a in atoms: + a.file_path = str(path) + all_atoms.extend(atoms) + else: + atoms = tokenize(content) + for a in atoms: + a.file_path = str(path) + all_atoms.extend(atoms) + atoms_needing_embed.extend(atoms) + if map_cache is not None: + map_cache.put(chash, CachedFileEntry(chash, atoms_to_dicts(atoms))) + + return chash + + +def _update_cache_after_embedding( + map_cache: Any, + all_atoms: list[Atom], + atoms_needing_embed: list[Atom], + file_records: list[FileRecord], +) -> None: + """Update cache entries with embeddings for newly-embedded atoms.""" + from reporails_cli.core.cache.map_cache import CachedFileEntry, atoms_to_dicts + + by_file: dict[str, list[Atom]] = {} + for a in all_atoms: + by_file.setdefault(a.file_path, []).append(a) + embed_set = {id(a) for a in atoms_needing_embed} + for frec in file_records: + file_atoms = by_file.get(frec.path, []) + if any(id(a) in embed_set for a in file_atoms): + map_cache.put(frec.content_hash, CachedFileEntry(frec.content_hash, atoms_to_dicts(file_atoms))) + + +def _validate_and_log(ruleset: RulesetMap) -> None: + """Validate atoms, log findings, raise on errors.""" + findings = validate_atoms(ruleset.atoms) + for f in findings: + if f.severity == "error": + logger.error("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) + elif f.severity == "warn": + logger.warning("Map validation: [%s] L%d: %s — %s", f.rule, f.line, f.message, f.text) + errors = [f for f in findings if f.severity == "error"] + if errors: + raise ValueError( + f"Map validation failed with {len(errors)} error(s). First: [{errors[0].rule}] {errors[0].message}" + ) + + +def _classify_all_files( + paths: list[Path], + root: Path, + map_cache: Any, + registry: dict[str, dict[str, Any]], +) -> tuple[list[FileRecord], list[Atom], list[Atom]]: + """Classify all instruction files. Returns (file_records, all_atoms, atoms_needing_embed).""" + file_records: list[FileRecord] = [] + all_atoms: list[Atom] = [] + atoms_needing_embed: list[Atom] = [] + + for path in paths: + chash = _classify_file(path, map_cache, all_atoms, atoms_needing_embed) + loading, scope, globs, agent = _detect_file_loading(path, root, registry) + 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, + ) + ) + + return file_records, all_atoms, atoms_needing_embed + + +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.cache.map_cache import MapCache + + if models is None: + models = get_models() + if root is None: + root = paths[0].parent if paths else Path(".") + + map_cache: MapCache | None = None + if cache_dir is not None: + map_cache = MapCache(cache_dir) + map_cache.load() + + file_records, all_atoms, atoms_needing_embed = _classify_all_files( + paths, + root, + map_cache, + _load_registry(), + ) + + # Embed uncached atoms + if atoms_needing_embed: + _embed_atoms_deduped(atoms_needing_embed, models.st) + if map_cache is not None: + _update_cache_after_embedding(map_cache, all_atoms, atoms_needing_embed, file_records) + + # Enforce cache cap (LRU eviction) and save + if map_cache is not None: + map_cache.enforce_cap() + map_cache.save() + + # Ensure ALL atoms have embeddings (cached atoms may lack them) + unembedded = [a for a in all_atoms if a.embedding_int8 is None] + if unembedded: + _embed_atoms_deduped(unembedded, models.st) + + _embed_file_descriptions(file_records, models.st) + + ruleset = build_ruleset_map(file_records, all_atoms, cluster_topics(all_atoms)) + _validate_and_log(ruleset) + + return ruleset diff --git a/src/reporails_cli/core/mapper/serialize.py b/src/reporails_cli/core/mapper/serialize.py new file mode 100644 index 0000000..50c7953 --- /dev/null +++ b/src/reporails_cli/core/mapper/serialize.py @@ -0,0 +1,377 @@ +# ruff: noqa: PERF401 +"""RulesetMap serialization + deterministic validation. + +Two responsibilities: + +1. JSON round-trip — `save_ruleset_map` / `load_ruleset_map` and their per-atom + helpers serialize the full client-side wire format (including int8 + embeddings as base64 byte strings and cluster centroids as float32 byte + strings). This is the on-disk format the mapper daemon hands back to the + client. + +2. Post-hoc validation — `validate_atoms` / `validate_map` apply three layers + of deterministic invariants over a finished atom set: schema (legal + charge/modality values), charge consistency (negation→constraint), and + distribution sanity (charge ratios outside calibrated bands). Findings + are returned as a list; the orchestration spine in `pipeline.py` decides + which severities to log or raise on. +""" + +from __future__ import annotations + +import base64 +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from reporails_cli.core.mapper.classify import _strip_md_for_classify +from reporails_cli.core.platform.dto.ruleset import ( + Atom, + ClusterRecord, + FileRecord, + RulesetMap, + RulesetSummary, +) + +# ────────────────────────────────────────────────────────────────── +# SERIALIZATION +# ────────────────────────────────────────────────────────────────── + + +def _pack_optional_atom_fields(atom: Atom, d: dict[str, Any]) -> None: + """Add embedding and topographer-only fields to the per-atom dict when populated.""" + 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") + 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 + if atom.embedded_charge_markers: + d["embedded_charge_markers"] = list(atom.embedded_charge_markers) + + +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 + _pack_optional_atom_fields(atom, d) + 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), + embedded_charge_markers=d.get("embedded_charge_markers", []), + 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'^["“„]') + + +_VALID_CHARGES = frozenset({"CONSTRAINT", "DIRECTIVE", "IMPERATIVE", "NEUTRAL", "AMBIGUOUS"}) +_VALID_MODS = frozenset({"imperative", "direct", "absolute", "hedged", "none"}) + + +def _validate_atom_schema(a: Atom, findings: list[MapFinding]) -> None: + """Check schema and consistency invariants for a single atom.""" + cv, chg, mod = a.charge_value, a.charge, a.modality + _checks: list[tuple[bool, str, str]] = [ + (chg not in _VALID_CHARGES, "schema", f"Invalid charge: {chg}"), + (mod not in _VALID_MODS, "schema", f"Invalid modality: {mod}"), + (cv not in (-1, 0, 1), "schema", f"Invalid charge_value: {cv}"), + (cv == 0 and chg not in ("NEUTRAL", "AMBIGUOUS"), "consistency", f"charge_value=0 but charge={chg}"), + (cv != 0 and chg == "NEUTRAL", "consistency", "charge_value!=0 but charge=NEUTRAL"), + (cv == 0 and mod != "none", "consistency", f"NEUTRAL with modality={mod}"), + (cv != 0 and mod == "none", "consistency", "Charged with modality=none"), + ] + for condition, rule, message in _checks: + if condition: + findings.append(MapFinding("error", rule, message, a.line, a.text[:80], chg)) + + +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] = [] + exc: list[Atom] = [] + + for a in atoms: + _validate_atom_schema(a, findings) + 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: + msg = f"Negation at start but charge={a.charge}" + findings.append(MapFinding("warn", "must_constraint", msg, a.line, a.text[:80], a.charge)) + is_unquoted_neutral = ( + a.charge_value == 0 and not _QUOTED_ATOM_RE.match(a.text.strip()) and _STRONG_CHARGE_RE.search(a.text) + ) + if is_unquoted_neutral: + 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: + msg = f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually high" + findings.append(MapFinding("warn", "distribution", msg)) + if ratio < 0.05 and n_exc > 10: + msg = f"Charge ratio {ratio:.0%} ({n_charged}/{n_exc}) — unusually low" + findings.append(MapFinding("warn", "distribution", msg)) + + 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/platform/__init__.py b/src/reporails_cli/core/platform/__init__.py new file mode 100644 index 0000000..3b4142d --- /dev/null +++ b/src/reporails_cli/core/platform/__init__.py @@ -0,0 +1 @@ +"""Platform substrate — hexagonal layers.""" diff --git a/src/reporails_cli/core/platform/adapters/__init__.py b/src/reporails_cli/core/platform/adapters/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/adapters/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/api_client.py b/src/reporails_cli/core/platform/adapters/api_client.py similarity index 95% rename from src/reporails_cli/core/api_client.py rename to src/reporails_cli/core/platform/adapters/api_client.py index 1028328..e97ebae 100644 --- a/src/reporails_cli/core/api_client.py +++ b/src/reporails_cli/core/platform/adapters/api_client.py @@ -20,11 +20,26 @@ parse_error_body, preflight_oversized, ) -from reporails_cli.core.mapper.mapper import RulesetMap +from reporails_cli.core.platform.dto.ruleset import RulesetMap logger = logging.getLogger(__name__) +def _user_agent() -> str: + """Return `reporails-cli/` for outgoing diagnostic requests. + + Sending a distinct UA lets the server (and any CDN/WAF in front of it) + identify legitimate CLI traffic. The default `python-httpx/*` UA is a + common bot-fight trigger. + """ + try: + from importlib.metadata import version + + return f"reporails-cli/{version('reporails-cli')}" + except Exception: # importlib.metadata.PackageNotFoundError + defensive + return "reporails-cli/unknown" + + # ────────────────────────────────────────────────────────────────── # RESPONSE DATACLASSES # ────────────────────────────────────────────────────────────────── @@ -161,7 +176,7 @@ class LintResult: def _tier_from_config() -> str: """Read tier from global config (~/.reporails/config.yml).""" try: - from reporails_cli.core.config import get_global_config + from reporails_cli.core.platform.config.config import get_global_config cfg = get_global_config() return cfg.tier @@ -229,7 +244,7 @@ def _lint_remote(self, ruleset_map: RulesetMap) -> LintResponse: logger.debug("httpx not installed — cannot use remote diagnostics") return LintResponse() - from reporails_cli.core.payload import encode_msgpack, project_payload + from reporails_cli.core.platform.adapters.payload import encode_msgpack, project_payload payload = project_payload(ruleset_map) if not payload.get("files"): @@ -251,12 +266,17 @@ def _lint_remote(self, ruleset_map: RulesetMap) -> LintResponse: def _post_payload(self, httpx: Any, body: bytes) -> LintResponse: """Execute the HTTP round-trip; isolated so _lint_remote stays within return-count budget.""" dev_mode = os.environ.get("AILS_DEV_MODE", "").lower() in ("true", "1") + ua = _user_agent() if dev_mode: url = f"{self.base_url.rstrip('/')}/diagnose" - headers: dict[str, str] = {"X-Tier": self.tier, "Content-Type": "application/msgpack"} + headers: dict[str, str] = { + "X-Tier": self.tier, + "Content-Type": "application/msgpack", + "User-Agent": ua, + } else: url = f"{self.base_url.rstrip('/')}/v1/diagnose" - headers = {"Content-Type": "application/msgpack"} + headers = {"Content-Type": "application/msgpack", "User-Agent": ua} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" @@ -270,7 +290,7 @@ def _post_payload(self, httpx: Any, body: bytes) -> LintResponse: except httpx.HTTPStatusError as exc: funnel_err = parse_error_body(exc.response.status_code, exc.response.text) if funnel_err is not None: - logger.warning( + logger.debug( "Server returned %d %s for tier=%s", exc.response.status_code, funnel_err.error, funnel_err.tier ) return LintResponse(funnel_error=funnel_err) diff --git a/src/reporails_cli/core/payload.py b/src/reporails_cli/core/platform/adapters/payload.py similarity index 97% rename from src/reporails_cli/core/payload.py rename to src/reporails_cli/core/platform/adapters/payload.py index a446b9e..8ab823f 100644 --- a/src/reporails_cli/core/payload.py +++ b/src/reporails_cli/core/platform/adapters/payload.py @@ -10,7 +10,7 @@ import msgpack -from reporails_cli.core.api_client import ( +from reporails_cli.core.platform.adapters.api_client import ( _CHARGE_ENC, _FORMAT_ENC, _KIND_ENC, @@ -19,7 +19,7 @@ ) if TYPE_CHECKING: - from reporails_cli.core.mapper.mapper import RulesetMap + from reporails_cli.core.platform.dto.ruleset import RulesetMap WIRE_SCHEMA_VERSION_V3 = 3 diff --git a/src/reporails_cli/core/registry.py b/src/reporails_cli/core/platform/adapters/registry.py similarity index 95% rename from src/reporails_cli/core/registry.py rename to src/reporails_cli/core/platform/adapters/registry.py index 1c6a51f..d72c084 100644 --- a/src/reporails_cli/core/registry.py +++ b/src/reporails_cli/core/platform/adapters/registry.py @@ -7,36 +7,36 @@ from pathlib import Path from typing import Any -from reporails_cli.core.bootstrap import ( - get_agent_config, - get_project_config, - get_rules_path, -) -from reporails_cli.core.models import ( - AgentConfig, - ProjectConfig, - Rule, - Severity, -) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( CORE_WEIGHT_THRESHOLD as CORE_WEIGHT_THRESHOLD, ) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( build_rule as build_rule, ) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( derive_tier as derive_tier, ) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( get_checks_paths as get_checks_paths, ) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( get_rules_by_category as get_rules_by_category, ) -from reporails_cli.core.rule_builder import ( +from reporails_cli.core.platform.adapters.rule_builder import ( get_rules_by_type as get_rules_by_type, ) -from reporails_cli.core.utils import clear_yaml_cache, load_yaml_file, parse_frontmatter +from reporails_cli.core.platform.config.bootstrap import ( + get_agent_config, + get_project_config, + get_rules_path, +) +from reporails_cli.core.platform.dto.models import ( + AgentConfig, + ProjectConfig, + Rule, + Severity, +) +from reporails_cli.core.platform.utils.utils import clear_yaml_cache, load_yaml_file, parse_frontmatter logger = logging.getLogger(__name__) diff --git a/src/reporails_cli/core/rule_builder.py b/src/reporails_cli/core/platform/adapters/rule_builder.py similarity index 97% rename from src/reporails_cli/core/rule_builder.py rename to src/reporails_cli/core/platform/adapters/rule_builder.py index 0a7fed9..508614e 100644 --- a/src/reporails_cli/core/rule_builder.py +++ b/src/reporails_cli/core/platform/adapters/rule_builder.py @@ -7,8 +7,8 @@ import yaml -from reporails_cli.core.bootstrap import get_framework_root -from reporails_cli.core.models import ( +from reporails_cli.core.platform.config.bootstrap import get_framework_root +from reporails_cli.core.platform.dto.models import ( Category, Check, Execution, diff --git a/src/reporails_cli/core/platform/config/__init__.py b/src/reporails_cli/core/platform/config/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/config/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py similarity index 81% rename from src/reporails_cli/core/bootstrap.py rename to src/reporails_cli/core/platform/config/bootstrap.py index a7d4750..1c71e6e 100644 --- a/src/reporails_cli/core/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -6,12 +6,12 @@ from pathlib import Path from typing import TYPE_CHECKING -from reporails_cli.core.bundled import get_bundled_package_root, get_bundled_rules_path +from reporails_cli.core.platform.config.bundled import get_bundled_package_root, get_bundled_rules_path logger = logging.getLogger(__name__) if TYPE_CHECKING: - from reporails_cli.core.models import AgentConfig, FileTypeDeclaration, GlobalConfig, ProjectConfig + from reporails_cli.core.platform.dto.models import AgentConfig, FileTypeDeclaration, GlobalConfig, ProjectConfig # Constants REPORAILS_HOME = Path.home() / ".reporails" @@ -38,7 +38,7 @@ def get_project_cache_dir(project_root: Path) -> Path: Layout: ~/.reporails/cache/projects/<12-char-hash>/ Uses git remote URL hash for consistency across clones. """ - from reporails_cli.core.analytics import get_project_id + from reporails_cli.core.platform.observability.analytics import get_project_id return REPORAILS_HOME / "cache" / "projects" / get_project_id(project_root) @@ -103,7 +103,7 @@ def get_agent_config(agent: str) -> AgentConfig: Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.config import get_agent_config as _get_agent_config + from reporails_cli.core.platform.config.config import get_agent_config as _get_agent_config return _get_agent_config(agent) @@ -121,7 +121,7 @@ def get_agent_file_types( Returns: List of FileTypeDeclaration from the agent's config.yml file_types section """ - from reporails_cli.core.classification import load_file_types + from reporails_cli.core.classify import load_file_types return load_file_types(agent, rules_paths) @@ -136,18 +136,6 @@ def get_global_packages_path() -> Path: return get_reporails_home() / "packages" -def get_recommended_package_path() -> Path: - """Get path to recommended package. - - Returns local override from global config if set, otherwise - ~/.reporails/packages/recommended/. - """ - config = get_global_config() - if config.recommended_path and config.recommended_path.is_dir(): - return config.recommended_path - return get_global_packages_path() / "recommended" - - def get_version_file() -> Path: """Get path to version file (~/.reporails/version).""" return get_reporails_home() / "version" @@ -163,7 +151,7 @@ def get_global_config() -> GlobalConfig: Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.config import get_global_config as _get_global_config + from reporails_cli.core.platform.config.config import get_global_config as _get_global_config return _get_global_config() @@ -173,7 +161,7 @@ def get_project_config(project_root: Path) -> ProjectConfig: Delegated to core.config. Re-exported here for backward compatibility. """ - from reporails_cli.core.config import get_project_config as _get_project_config + from reporails_cli.core.platform.config.config import get_project_config as _get_project_config return _get_project_config(project_root) @@ -218,17 +206,6 @@ def get_installed_version() -> str | None: return None -def get_installed_recommended_version() -> str | None: - """Read installed recommended package version from ~/.reporails/packages/recommended/.version.""" - version_file = get_recommended_package_path() / ".version" - if not version_file.exists(): - return None - try: - return version_file.read_text(encoding="utf-8").strip() - except OSError: - return None - - def is_initialized() -> bool: """Check if rules are available (installed, config override, or bundled).""" rules_path = get_rules_path() diff --git a/src/reporails_cli/core/bundled.py b/src/reporails_cli/core/platform/config/bundled.py similarity index 100% rename from src/reporails_cli/core/bundled.py rename to src/reporails_cli/core/platform/config/bundled.py diff --git a/src/reporails_cli/core/config.py b/src/reporails_cli/core/platform/config/config.py similarity index 84% rename from src/reporails_cli/core/config.py rename to src/reporails_cli/core/platform/config/config.py index 04bb877..bcaa46c 100644 --- a/src/reporails_cli/core/config.py +++ b/src/reporails_cli/core/platform/config/config.py @@ -12,10 +12,10 @@ import yaml -from reporails_cli.core.utils import load_yaml_file +from reporails_cli.core.platform.utils.utils import load_yaml_file if TYPE_CHECKING: - from reporails_cli.core.models import AgentConfig, GlobalConfig, ProjectConfig + from reporails_cli.core.platform.dto.models import AgentConfig, GlobalConfig, ProjectConfig logger = logging.getLogger(__name__) @@ -29,8 +29,8 @@ def get_agent_config(agent: str) -> AgentConfig: 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 + from reporails_cli.core.platform.config.bootstrap import get_agent_config_path + from reporails_cli.core.platform.dto.models import AgentConfig config_path = get_agent_config_path(agent) if not config_path.exists(): @@ -59,8 +59,8 @@ def get_global_config() -> GlobalConfig: 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 + from reporails_cli.core.platform.config.bootstrap import get_global_config_path + from reporails_cli.core.platform.dto.models import GlobalConfig config_path = get_global_config_path() if not config_path.exists(): @@ -69,13 +69,10 @@ def get_global_config() -> 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: @@ -137,7 +134,7 @@ def get_project_config(project_root: Path) -> ProjectConfig: Returns: ProjectConfig with loaded or default values """ - from reporails_cli.core.models import ProjectConfig + from reporails_cli.core.platform.dto.models import ProjectConfig base = _load_yaml_dict(project_root / ".ails" / "config.yml") or {} local = _load_yaml_dict(project_root / ".ails" / "config.local.yml") or {} @@ -145,12 +142,7 @@ def get_project_config(project_root: Path) -> ProjectConfig: if not data: global_cfg = get_global_config() - return ProjectConfig( - default_agent=global_cfg.default_agent, - recommended=global_cfg.recommended, - ) - - has_recommended = "recommended" in data + return ProjectConfig(default_agent=global_cfg.default_agent) def _str_list(key: str) -> list[str]: val = data.get(key) @@ -162,8 +154,6 @@ def _str_dict(key: str) -> dict[str, dict[str, object]]: fw = data.get("framework_version") fw_str = fw if isinstance(fw, str) else None - rec = data.get("recommended", True) - rec_bool = bool(rec) if not isinstance(rec, bool) else rec da = data.get("default_agent", "") da_str = da if isinstance(da, str) else "" ovr = data.get("overrides", {}) @@ -174,7 +164,6 @@ def _str_dict(key: str) -> dict[str, dict[str, object]]: packages=_str_list("packages"), disabled_rules=_str_list("disabled_rules"), overrides=ovr_dict, - recommended=rec_bool, exclude_dirs=_str_list("exclude_dirs"), default_agent=da_str, agents=_str_dict("agents"), @@ -184,6 +173,4 @@ def _str_dict(key: str) -> dict[str, dict[str, object]]: 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 diff --git a/src/reporails_cli/core/platform/contract/__init__.py b/src/reporails_cli/core/platform/contract/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/contract/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/platform/dto/__init__.py b/src/reporails_cli/core/platform/dto/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/dto/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/models.py b/src/reporails_cli/core/platform/dto/models.py similarity index 99% rename from src/reporails_cli/core/models.py rename to src/reporails_cli/core/platform/dto/models.py index ca26066..7eec149 100644 --- a/src/reporails_cli/core/models.py +++ b/src/reporails_cli/core/platform/dto/models.py @@ -262,7 +262,7 @@ class JudgmentResponse: # Re-exports for backward compatibility -from reporails_cli.core.results import ( # noqa: E402 +from reporails_cli.core.platform.dto.results import ( # noqa: E402 AgentConfig, CategoryStats, DetectedFeatures, diff --git a/src/reporails_cli/core/results.py b/src/reporails_cli/core/platform/dto/results.py similarity index 96% rename from src/reporails_cli/core/results.py rename to src/reporails_cli/core/platform/dto/results.py index 0f0cfa8..1a20962 100644 --- a/src/reporails_cli/core/results.py +++ b/src/reporails_cli/core/platform/dto/results.py @@ -6,12 +6,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from reporails_cli.core.models import Level, Violation +from reporails_cli.core.platform.dto.models import Level, Violation if TYPE_CHECKING: from reporails_cli.core.cache import AnalyticsEntry -from reporails_cli.core.models import JudgmentRequest +from reporails_cli.core.platform.dto.models import JudgmentRequest # ============================================================================= # Feature Detection Models @@ -107,10 +107,8 @@ class GlobalConfig: """Global user configuration (~/.reporails/config.yml).""" framework_path: Path | None = None # Local override (dev) - recommended_path: Path | None = None # Local override (dev) auto_update_check: bool = True default_agent: str = "" - recommended: bool = True tier: str = "" # "free" | "pro" — overridden by AILS_TIER env var @@ -122,7 +120,6 @@ class ProjectConfig: # pylint: disable=too-many-instance-attributes 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) - 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") # Per-agent overrides keyed by agent id. Currently supports `fallback_filenames` diff --git a/src/reporails_cli/core/platform/dto/ruleset.py b/src/reporails_cli/core/platform/dto/ruleset.py new file mode 100644 index 0000000..605aa7f --- /dev/null +++ b/src/reporails_cli/core/platform/dto/ruleset.py @@ -0,0 +1,123 @@ +"""Pure data shapes for the mapper's wire format — `RulesetMap` and friends. + +These dataclasses describe the structure of a mapped instruction ruleset: +atoms with classified charge, file records, topic clusters, and aggregate +statistics. Pure DTOs — no behavior, no I/O. The mapper produces them, the +adapters serialize them, and the lint subsystem inspects them. + +Previously lived at `core/mapper/mapper.py`; relocated to `core/platform/dto/` +as part of the hexagonal substrate migration so that adapters and other +consumers do not have to import from the `mapper/` subsystem. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +SCHEMA_VERSION = "1.0.0" +EMBEDDING_MODEL = "all-MiniLM-L6-v2" + + +@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) + 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) # opposite-direction markers + 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)) diff --git a/src/reporails_cli/core/platform/observability/__init__.py b/src/reporails_cli/core/platform/observability/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/observability/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/analytics.py b/src/reporails_cli/core/platform/observability/analytics.py similarity index 98% rename from src/reporails_cli/core/analytics.py rename to src/reporails_cli/core/platform/observability/analytics.py index fb455bd..3292705 100644 --- a/src/reporails_cli/core/analytics.py +++ b/src/reporails_cli/core/platform/observability/analytics.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime from pathlib import Path -from reporails_cli.core.bootstrap import get_reporails_home +from reporails_cli.core.platform.config.bootstrap import get_reporails_home def get_analytics_dir() -> Path: diff --git a/src/reporails_cli/core/platform/policy/__init__.py b/src/reporails_cli/core/platform/policy/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/policy/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/platform/policy/applicability.py b/src/reporails_cli/core/platform/policy/applicability.py new file mode 100644 index 0000000..fafe942 --- /dev/null +++ b/src/reporails_cli/core/platform/policy/applicability.py @@ -0,0 +1,63 @@ +"""Rule applicability — pure decision: which rules apply to which file types. + +A rule fires when its target file type is present (or it is a wildcard). +Supersession within the applicable set lets agent overlays replace core +rules without each layer re-declaring the same checks. +""" + +from __future__ import annotations + +from reporails_cli.core.platform.dto.models import Rule + + +def get_applicable_rules( + rules: dict[str, Rule], + present_types: set[str], +) -> dict[str, Rule]: + """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) + + If rule A supersedes rule B, and both are applicable, drop B. + + Args: + rules: Dict of all rules + present_types: Set of file type names present in the project + + Returns: + Dict of applicable rules + """ + if not present_types: + return {} + + applicable: dict[str, Rule] = {} + for rule_id, rule in rules.items(): + 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 within applicable set. + # NOTE: load_rules() already handles supersession at load time, but this + # covers cases where rules are constructed without load_rules() (e.g., tests) + # and the edge case where a superseding rule's target type is absent. + superseded_ids: set[str] = set() + for rule_id, rule in list(applicable.items()): + if rule.supersedes and rule.supersedes in applicable: + superseded_ids.add(rule.supersedes) + parent = applicable[rule.supersedes] + # Inherit parent checks that aren't replaced by the agent rule + replaced_ids = {c.replaces for c in rule.checks if c.replaces} + inherited = [c for c in parent.checks if c.id not in replaced_ids] + applicable[rule_id] = rule.model_copy(update={"checks": inherited + list(rule.checks)}) + + if superseded_ids: + applicable = {k: v for k, v in applicable.items() if k not in superseded_ids} + + return applicable diff --git a/src/reporails_cli/core/capability.py b/src/reporails_cli/core/platform/policy/capability.py similarity index 93% rename from src/reporails_cli/core/capability.py rename to src/reporails_cli/core/platform/policy/capability.py index 195c201..84bbce6 100644 --- a/src/reporails_cli/core/capability.py +++ b/src/reporails_cli/core/platform/policy/capability.py @@ -2,7 +2,7 @@ from __future__ import annotations -from reporails_cli.core.results import DetectedFeatures +from reporails_cli.core.platform.dto.results import DetectedFeatures def get_feature_summary(features: DetectedFeatures) -> str: diff --git a/src/reporails_cli/core/levels.py b/src/reporails_cli/core/platform/policy/levels.py similarity index 97% rename from src/reporails_cli/core/levels.py rename to src/reporails_cli/core/platform/policy/levels.py index 52e3875..1c1a1df 100644 --- a/src/reporails_cli/core/levels.py +++ b/src/reporails_cli/core/platform/policy/levels.py @@ -18,11 +18,11 @@ from pathlib import Path from typing import TYPE_CHECKING -from reporails_cli.core.models import Level +from reporails_cli.core.platform.dto.models import Level if TYPE_CHECKING: - from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration - from reporails_cli.core.results import DetectedFeatures + from reporails_cli.core.platform.dto.models import ClassifiedFile, FileTypeDeclaration + from reporails_cli.core.platform.dto.results import DetectedFeatures # Level labels — canonical mapping (must match framework registry/levels.yml) LEVEL_LABELS: dict[Level, str] = { diff --git a/src/reporails_cli/core/platform/runtime/__init__.py b/src/reporails_cli/core/platform/runtime/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/runtime/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/_torch_blocker.py b/src/reporails_cli/core/platform/runtime/_torch_blocker.py similarity index 100% rename from src/reporails_cli/core/_torch_blocker.py rename to src/reporails_cli/core/platform/runtime/_torch_blocker.py diff --git a/src/reporails_cli/core/engine_helpers.py b/src/reporails_cli/core/platform/runtime/engine_helpers.py similarity index 98% rename from src/reporails_cli/core/engine_helpers.py rename to src/reporails_cli/core/platform/runtime/engine_helpers.py index 26dffcf..db47ef7 100644 --- a/src/reporails_cli/core/engine_helpers.py +++ b/src/reporails_cli/core/platform/runtime/engine_helpers.py @@ -5,7 +5,7 @@ from pathlib import Path from reporails_cli.core.cache import ProjectCache, content_hash, structural_hash -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( Category, CategoryStats, ClassifiedFile, @@ -148,7 +148,7 @@ def _group_rules_by_target_files( 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 + from reporails_cli.core.classify import match_files groups: dict[frozenset[Path], dict[str, Rule]] = {} for rule_id, rule in rules.items(): diff --git a/src/reporails_cli/core/merger.py b/src/reporails_cli/core/platform/runtime/merger.py similarity index 98% rename from src/reporails_cli/core/merger.py rename to src/reporails_cli/core/platform/runtime/merger.py index bed2453..6e6199b 100644 --- a/src/reporails_cli/core/merger.py +++ b/src/reporails_cli/core/platform/runtime/merger.py @@ -11,13 +11,13 @@ from pathlib import Path from typing import Any -from reporails_cli.core.api_client import ( +from reporails_cli.core.platform.adapters.api_client import ( CrossFileFinding, FileAnalysis, QualityResult, RulesetReport, ) -from reporails_cli.core.models import LocalFinding +from reporails_cli.core.platform.dto.models import LocalFinding _SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} diff --git a/src/reporails_cli/core/platform/utils/__init__.py b/src/reporails_cli/core/platform/utils/__init__.py new file mode 100644 index 0000000..49a307e --- /dev/null +++ b/src/reporails_cli/core/platform/utils/__init__.py @@ -0,0 +1 @@ +"""Platform substrate layer.""" diff --git a/src/reporails_cli/core/utils.py b/src/reporails_cli/core/platform/utils/utils.py similarity index 100% rename from src/reporails_cli/core/utils.py rename to src/reporails_cli/core/platform/utils/utils.py diff --git a/src/reporails_cli/formatters/github.py b/src/reporails_cli/formatters/github.py index f9ed192..43238ef 100644 --- a/src/reporails_cli/formatters/github.py +++ b/src/reporails_cli/formatters/github.py @@ -12,7 +12,7 @@ import json from typing import Any -from reporails_cli.core.models import ScanDelta, Severity, ValidationResult +from reporails_cli.core.platform.dto.models import ScanDelta, Severity, ValidationResult from reporails_cli.formatters import json as json_formatter @@ -100,7 +100,7 @@ def format_result( def format_combined_annotations(result: Any) -> str: """Emit GitHub workflow commands from CombinedResult findings.""" - from reporails_cli.core.merger import CombinedResult + from reporails_cli.core.platform.runtime.merger import CombinedResult if not isinstance(result, CombinedResult): return "" diff --git a/src/reporails_cli/formatters/json.py b/src/reporails_cli/formatters/json.py index 3a26879..9344158 100644 --- a/src/reporails_cli/formatters/json.py +++ b/src/reporails_cli/formatters/json.py @@ -8,14 +8,14 @@ from typing import Any -from reporails_cli.core.levels import LEVEL_LABELS -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( CategoryStats, PendingSemantic, RuleResult, ScanDelta, ValidationResult, ) +from reporails_cli.core.platform.policy.levels import LEVEL_LABELS def _format_pending_semantic(pending: PendingSemantic | None) -> dict[str, Any] | None: @@ -215,7 +215,7 @@ def format_combined_result(result: Any, ruleset_map: Any = None) -> dict[str, An """ from dataclasses import asdict - from reporails_cli.core.merger import CombinedResult + from reporails_cli.core.platform.runtime.merger import CombinedResult if not isinstance(result, CombinedResult): return {"error": "Invalid result type"} diff --git a/src/reporails_cli/formatters/mcp.py b/src/reporails_cli/formatters/mcp.py index b1460e9..0fc87c3 100644 --- a/src/reporails_cli/formatters/mcp.py +++ b/src/reporails_cli/formatters/mcp.py @@ -9,11 +9,11 @@ from typing import TYPE_CHECKING, Any -from reporails_cli.core.models import ScanDelta, ValidationResult +from reporails_cli.core.platform.dto.models import ScanDelta, ValidationResult if TYPE_CHECKING: - from reporails_cli.core.fixers import FixResult - from reporails_cli.core.models import JudgmentRequest, Violation + from reporails_cli.core.heal.fixers import FixResult + from reporails_cli.core.platform.dto.models import JudgmentRequest, Violation # --------------------------------------------------------------------------- diff --git a/src/reporails_cli/formatters/text/box.py b/src/reporails_cli/formatters/text/box.py index d246b61..18477aa 100644 --- a/src/reporails_cli/formatters/text/box.py +++ b/src/reporails_cli/formatters/text/box.py @@ -5,8 +5,8 @@ from typing import Any -from reporails_cli.core.levels import get_level_labels -from reporails_cli.core.models import ScanDelta +from reporails_cli.core.platform.dto.models import ScanDelta +from reporails_cli.core.platform.policy.levels import get_level_labels from reporails_cli.formatters.text.chars import ASCII_MODE, get_chars from reporails_cli.formatters.text.components import ( _category_result_color, diff --git a/src/reporails_cli/formatters/text/compact.py b/src/reporails_cli/formatters/text/compact.py index cd65625..b7e4f12 100644 --- a/src/reporails_cli/formatters/text/compact.py +++ b/src/reporails_cli/formatters/text/compact.py @@ -8,8 +8,8 @@ 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.core.platform.dto.models import Level, ScanDelta, ValidationResult +from reporails_cli.core.platform.policy.levels import get_level_labels 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 ( diff --git a/src/reporails_cli/formatters/text/components.py b/src/reporails_cli/formatters/text/components.py index 204a429..2d81228 100644 --- a/src/reporails_cli/formatters/text/components.py +++ b/src/reporails_cli/formatters/text/components.py @@ -10,10 +10,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from reporails_cli.core.models import ScanDelta +from reporails_cli.core.platform.dto.models import ScanDelta if TYPE_CHECKING: - from reporails_cli.core.agents import DetectedAgent + from reporails_cli.core.discovery.agents import DetectedAgent from reporails_cli.formatters.text.chars import get_chars from reporails_cli.templates import render diff --git a/src/reporails_cli/formatters/text/display.py b/src/reporails_cli/formatters/text/display.py index 7c8582f..49f896a 100644 --- a/src/reporails_cli/formatters/text/display.py +++ b/src/reporails_cli/formatters/text/display.py @@ -129,6 +129,31 @@ def _render_quality_compact( console.print(f" [dim]{border} {truncate(agg_line, tw - 8)}[/dim]") +def _format_alias_suffix(canonical: str, aliases: list[str]) -> str: + """Build the ` (+alias1, +alias2)` label for a file with duplicates. + + Picks the shortest distinguishing fragment per alias — the differing leading + path component when the alias lives under a different parent (e.g. + `.claude/skills/foo` vs canonical `.agents/skills/foo` → render `+.claude`), + or the filename when only the leaf differs (e.g. `AGENTS.md` vs `CLAUDE.md` + in the same dir → render `+CLAUDE.md`). + """ + if not aliases: + return "" + canonical_parts = Path(canonical).parts + labels: list[str] = [] + for alias in aliases: + alias_p = Path(alias) + alias_parts = alias_p.parts + label = alias_p.name + for i, (c, a) in enumerate(zip(canonical_parts, alias_parts, strict=False)): + if c != a: + label = a if i < len(alias_parts) - 1 else alias_p.name + break + labels.append(label) + return f" (+{', +'.join(labels)})" + + def _print_file_card( filepath: str, findings: list[Any], @@ -136,6 +161,7 @@ def _print_file_card( verbose: bool, ruleset_map: Any = None, file_hints: list[Any] | None = None, + aliases_by_file: dict[str, list[str]] | None = None, ) -> None: """Print one file's card: name, stats, structural findings, then quality aggregate.""" quality_counts: Counter[str] = Counter() @@ -147,6 +173,8 @@ def _print_file_card( structural.append(f) name = friendly_name(filepath, classify_file(filepath)) + alias_list = (aliases_by_file or {}).get(filepath, []) + name = f"{name}{_format_alias_suffix(filepath, alias_list)}" stats = per_file_stats(filepath, ruleset_map) b = "\u2502" msg_width = get_term_width() - 35 @@ -199,6 +227,7 @@ def _render_one_group( verbose: bool, ruleset_map: Any, hints_by_file: dict[str, list[Any]], + aliases_by_file: dict[str, list[str]] | None = None, ) -> None: """Render a single file group: header, file cards, footer.""" _render_group_header(gkey, group_files, ruleset_map) @@ -217,6 +246,7 @@ def _render_one_group( verbose, ruleset_map=ruleset_map, file_hints=hints_by_file.get(filepath), + aliases_by_file=aliases_by_file, ) console.print(f" [dim]\u2514\u2500 {sum(len(fs) for _, fs in group_files)} findings[/dim]\n") @@ -228,12 +258,13 @@ def _render_file_groups( verbose: bool, ruleset_map: Any, hints_by_file: dict[str, list[Any]], + aliases_by_file: dict[str, list[str]] | None = None, ) -> None: """Render all file groups with cards.""" for gkey in _GROUP_ORDER: group_files = groups.get(gkey, []) if group_files: - _render_one_group(gkey, group_files, sev_icons, verbose, ruleset_map, hints_by_file) + _render_one_group(gkey, group_files, sev_icons, verbose, ruleset_map, hints_by_file, aliases_by_file) def _render_cross_file_coordinates(result: Any, sev_icons: dict[str, str]) -> None: @@ -263,7 +294,7 @@ def _collect_files_and_scope( Returns (all_files, scope_info). """ - from reporails_cli.core.merger import normalize_finding_path + from reporails_cli.core.platform.runtime.merger import normalize_finding_path all_files: set[str] = set() if result.findings: @@ -271,7 +302,7 @@ def _collect_files_and_scope( scope = ScopeInfo() try: - from reporails_cli.core.mapper.mapper import RulesetMap + from reporails_cli.core.platform.dto.ruleset import RulesetMap if isinstance(ruleset_map, RulesetMap): all_files.update(normalize_finding_path(fr.path, project_root) for fr in ruleset_map.files) @@ -299,7 +330,7 @@ def _count_atoms(atoms: Any) -> ScopeInfo: def _detect_agent_name(ruleset_map: Any) -> str: """Detect primary agent name from ruleset_map file records.""" try: - from reporails_cli.core.mapper.mapper import RulesetMap + from reporails_cli.core.platform.dto.ruleset import RulesetMap if isinstance(ruleset_map, RulesetMap): agent_counts = Counter(fr.agent for fr in ruleset_map.files if fr.agent != "generic") @@ -354,7 +385,7 @@ def _build_hints_by_file(hints: Any, project_root: Path) -> dict[str, list[Any]] """Build a file-keyed index of hints for inline display.""" result: dict[str, list[Any]] = {} if hints: - from reporails_cli.core.merger import normalize_finding_path + from reporails_cli.core.platform.runtime.merger import normalize_finding_path for h in hints: norm = normalize_finding_path(h.file, project_root) @@ -362,6 +393,39 @@ def _build_hints_by_file(hints: Any, project_root: Path) -> dict[str, list[Any]] return result +def _build_aliases_by_file(project_root: Path, result: Any) -> dict[str, list[str]]: + """Combine discovery-time symlink aliases with display-time same-dir content aliases. + + `get_file_aliases` returns paths the discovery layer already collapsed + (symlinks to one inode). `compute_same_dir_content_aliases` runs against + the union of files referenced by findings — catches manual AGENTS.md / + CLAUDE.md pairs that classify under different agents but should render as + one row. Both alias sources are returned as project-relative posix strings + so `_print_file_card` can do a plain dict lookup. + """ + from reporails_cli.core.discovery.agents import compute_same_dir_content_aliases, get_file_aliases + from reporails_cli.core.platform.runtime.merger import normalize_finding_path + + out: dict[str, list[str]] = {} + for canonical, alias_paths in get_file_aliases(project_root).items(): + key = normalize_finding_path(str(canonical), project_root) + values = [normalize_finding_path(str(a), project_root) for a in alias_paths] + if values: + out[key] = values + + finding_paths: set[Path] = set() + if result.findings: + for f in result.findings: + p = Path(f.file) + finding_paths.add(p if p.is_absolute() else (project_root / p)) + for canonical, alias_paths in compute_same_dir_content_aliases(finding_paths).items(): + key = normalize_finding_path(str(canonical), project_root) + values = [normalize_finding_path(str(a), project_root) for a in alias_paths] + if values: + out.setdefault(key, []).extend(values) + return out + + # ── Master display function ─────────────────────────────────────────── @@ -385,7 +449,7 @@ def print_text_result( local preflight rejected the payload — surfaces the upgrade CTA below the scorecard so users see why server diagnostics are missing. """ - from reporails_cli.core.merger import CombinedResult + from reporails_cli.core.platform.runtime.merger import CombinedResult if not isinstance(result, CombinedResult): return @@ -421,7 +485,8 @@ def _render_findings_and_scorecard( sev_icons = get_sev_icons(ascii_mode) hints_idx = _build_hints_by_file(result.hints, Path.cwd()) - _render_file_groups(_build_file_groups(result), sev_icons, verbose, ruleset_map, hints_idx) + aliases_idx = _build_aliases_by_file(Path.cwd(), result) + _render_file_groups(_build_file_groups(result), sev_icons, verbose, ruleset_map, hints_idx, aliases_idx) _render_cross_file_coordinates(result, sev_icons) print_scorecard( @@ -438,7 +503,7 @@ def _render_findings_and_scorecard( def _render_funnel_cta(funnel_error: object) -> None: """Render the conversion CTA + bug-report link when a FunnelError is present.""" - from reporails_cli.core.funnel import FunnelError, format_bug_report_url, format_cta + from reporails_cli.core.funnel import FunnelError, _short_url_label, format_bug_report_url, format_cta if not isinstance(funnel_error, FunnelError): return @@ -446,8 +511,9 @@ def _render_funnel_cta(funnel_error: object) -> None: if not cta: return bug_url = format_bug_report_url(funnel_error) + bug_label = _short_url_label(bug_url) console.print() console.print(" [yellow]⚠[/yellow] Server diagnostics unavailable.") console.print(f" {cta}") - console.print(f" [dim]Did you see an error? Let us know: [bold]{bug_url}[/bold][/dim]") + console.print(f" [dim]Did you see an error? Let us know: [link={bug_url}][bold]{bug_label}[/bold][/link][/dim]") console.print() diff --git a/src/reporails_cli/formatters/text/display_constants.py b/src/reporails_cli/formatters/text/display_constants.py index 10e45c8..24e91c2 100644 --- a/src/reporails_cli/formatters/text/display_constants.py +++ b/src/reporails_cli/formatters/text/display_constants.py @@ -268,7 +268,7 @@ def per_file_stats(filepath: str, ruleset_map: Any) -> str: if ruleset_map is None or len(filepath) < 3: return "" try: - from reporails_cli.core.merger import normalize_finding_path + from reporails_cli.core.platform.runtime.merger import normalize_finding_path project_root = Path.cwd() norm_target = normalize_finding_path(filepath, project_root) @@ -308,7 +308,7 @@ def get_group_atoms( if ruleset_map is None: return [] try: - from reporails_cli.core.merger import normalize_finding_path + from reporails_cli.core.platform.runtime.merger import normalize_finding_path project_root = Path.cwd() norm_fps = {normalize_finding_path(fp, project_root) for fp, _ in group_files} diff --git a/src/reporails_cli/formatters/text/full.py b/src/reporails_cli/formatters/text/full.py index 9398e51..d092ae8 100644 --- a/src/reporails_cli/formatters/text/full.py +++ b/src/reporails_cli/formatters/text/full.py @@ -6,8 +6,8 @@ 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.core.platform.dto.models import Level, ScanDelta, ValidationResult +from reporails_cli.core.platform.policy.levels import get_level_labels 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 @@ -24,7 +24,7 @@ def _format_semantic_cta( def _format_install_cta() -> str: """CTA for ephemeral (npx/uvx) users to install permanently.""" - from reporails_cli.core.self_update import is_ephemeral_install + from reporails_cli.core.install.self_update import is_ephemeral_install if not is_ephemeral_install(): return "" diff --git a/src/reporails_cli/formatters/text/scorecard.py b/src/reporails_cli/formatters/text/scorecard.py index 1db653a..b0b1577 100644 --- a/src/reporails_cli/formatters/text/scorecard.py +++ b/src/reporails_cli/formatters/text/scorecard.py @@ -113,7 +113,7 @@ def compute_surface_scores( """ from pathlib import Path - from reporails_cli.core.merger import normalize_finding_path + from reporails_cli.core.platform.runtime.merger import normalize_finding_path root = Path(project_root) if project_root is not None else Path.cwd() diff --git a/src/reporails_cli/interfaces/cli/commands.py b/src/reporails_cli/interfaces/cli/commands.py index e363a71..72163b7 100644 --- a/src/reporails_cli/interfaces/cli/commands.py +++ b/src/reporails_cli/interfaces/cli/commands.py @@ -1,70 +1,17 @@ -"""CLI commands — map, version.""" +"""CLI commands — version, update.""" from __future__ import annotations -import json -import time -from pathlib import Path - import typer -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 app, console -@app.command(hidden=True) -def map( - path: str = typer.Argument(".", help="Project root to analyze"), - output: str = typer.Option( - "text", - "--output", - "-o", - help="Output format: text, yaml, json", - ), - save: bool = typer.Option( - False, - "--save", - "-s", - help="Save backbone.yml to .ails/ directory", - ), -) -> None: - """Detect agents and project layout.""" - target = Path(path).resolve() - - if not target.exists(): - console.print(f"[red]Error:[/red] Path not found: {target}") - raise typer.Exit(1) - - start_time = time.perf_counter() - agents = detect_agents(target) - elapsed_ms = (time.perf_counter() - start_time) * 1000 - - backbone_yaml = generate_backbone_yaml(target, agents) - - if output == "yaml": - print(backbone_yaml, end="") - elif output == "json": - import yaml as yaml_lib - - data = yaml_lib.safe_load(backbone_yaml) - print(json.dumps(data, indent=2)) - else: - from reporails_cli.interfaces.cli.helpers import _print_map_text - - _print_map_text(target, agents, elapsed_ms) - - if save: - backbone_path = save_backbone(target, backbone_yaml) - console.print() - console.print(f"[green]Saved:[/green] {backbone_path}") - - @app.command("version", rich_help_panel="Configuration") def show_version() -> None: """Show CLI version and install method.""" from reporails_cli import __version__ as cli_version - from reporails_cli.core.self_update import detect_install_method + from reporails_cli.core.install.self_update import detect_install_method console.print(f"CLI: {cli_version}") console.print(f"Install: {detect_install_method().value}") diff --git a/src/reporails_cli/interfaces/cli/config_command.py b/src/reporails_cli/interfaces/cli/config_command.py index fdf25ae..45ac03d 100644 --- a/src/reporails_cli/interfaces/cli/config_command.py +++ b/src/reporails_cli/interfaces/cli/config_command.py @@ -24,13 +24,12 @@ "default_agent": str, "exclude_dirs": list, "disabled_rules": list, - "recommended": bool, "framework_version": str, "tier": str, } # Subset of keys allowed in global config (~/.reporails/config.yml) -GLOBAL_KEYS = {"default_agent", "recommended", "tier"} +GLOBAL_KEYS = {"default_agent", "tier"} def _project_config_path(path: Path) -> Path: @@ -38,7 +37,7 @@ def _project_config_path(path: Path) -> Path: def _global_config_path() -> Path: - from reporails_cli.core.bootstrap import get_global_config_path + from reporails_cli.core.platform.config.bootstrap import get_global_config_path return get_global_config_path() diff --git a/src/reporails_cli/interfaces/cli/heal.py b/src/reporails_cli/interfaces/cli/heal.py index 3cfa50c..f67db3f 100644 --- a/src/reporails_cli/interfaces/cli/heal.py +++ b/src/reporails_cli/interfaces/cli/heal.py @@ -63,7 +63,7 @@ def _apply_mechanical_fixes( return [] if show_progress: console.print("[bold]Applying mechanical fixes...[/bold]") - from reporails_cli.core.mechanical_fixers import apply_mechanical_fixes + from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes mech_fixes = apply_mechanical_fixes(ruleset_map, target, dry_run=dry_run) return [ @@ -81,9 +81,9 @@ def _apply_additive_fixes( console: Any, ) -> list[dict[str, Any]]: """Run M probes and apply additive fixes (missing sections).""" - 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.core.heal.fixers import apply_auto_fixes as _apply_auto_fixes + from reporails_cli.core.lint.rule_runner import run_m_probes + from reporails_cli.core.platform.dto.models import Severity, Violation if show_progress: console.print("[bold]Running structural checks...[/bold]") @@ -115,8 +115,8 @@ def _discover_heal_targets( console: Any, ) -> tuple[str, list[Path]] | None: """Discover instruction files for healing. Returns (agent, files) or None.""" - from reporails_cli.core import agents as _agents - from reporails_cli.core.config import get_project_config + from reporails_cli.core.discovery import agents as _agents + from reporails_cli.core.platform.config.config import get_project_config from reporails_cli.interfaces.cli import helpers as _helpers config = get_project_config(target) @@ -183,8 +183,8 @@ def heal( """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. + instruction reordering) and structural fixes (missing sections). Use + --dry-run to preview changes without writing. """ target, console = _heal_validate_path(path) fmt = _resolve_heal_format(format) diff --git a/src/reporails_cli/interfaces/cli/helpers.py b/src/reporails_cli/interfaces/cli/helpers.py index dd9cbca..f433fb0 100644 --- a/src/reporails_cli/interfaces/cli/helpers.py +++ b/src/reporails_cli/interfaces/cli/helpers.py @@ -6,7 +6,6 @@ import logging import os import sys -from collections.abc import Mapping from pathlib import Path from typing import Any @@ -38,73 +37,12 @@ def _default_format() -> str: return "text" -def _resolve_recommended_rules( - rules_paths: list[Path] | None, - project_config: Any, - format: str | None, - con: Console, -) -> list[Path] | None: - """Download recommended rules if needed and append the package path.""" - from reporails_cli.core.init import download_recommended, is_recommended_installed - - use_recommended = project_config.recommended - has_recommended = ( - rules_paths - and len(rules_paths) > 1 - and any( - (p / "docs" / "sources.yml").exists() and p != (rules_paths[0] if rules_paths else None) - for p in rules_paths[1:] - ) - ) - - if use_recommended and not has_recommended and not is_recommended_installed(): - show = sys.stdout.isatty() and format not in ("json", "brief", "compact", "github", "agent") - try: - if show: - with con.status("[bold]Downloading recommended rules...[/bold]"): - download_recommended() - else: - download_recommended() - except (FileNotFoundError, KeyError, OSError) as exc: - logger.debug("Failed to download recommended rules: %s", exc) - con.print("[yellow]Warning:[/yellow] Could not download recommended rules.") - - if use_recommended and not has_recommended: - from reporails_cli.core.bootstrap import get_recommended_package_path - - rec_path = get_recommended_package_path() - if rec_path.is_dir(): - if rules_paths is not None: - if rec_path not in rules_paths: - rules_paths.append(rec_path) - else: - from reporails_cli.core.registry import get_rules_dir - - rules_paths = [get_rules_dir(), rec_path] - - return rules_paths - - -def _handle_update_check(con: Console) -> None: - """Print installed vs latest versions for framework and recommended.""" - from reporails_cli.core.bootstrap import get_installed_recommended_version, get_installed_version - from reporails_cli.core.init import get_latest_recommended_version, get_latest_version - - current, current_rec = get_installed_version(), get_installed_recommended_version() - with con.status("[bold]Checking for updates...[/bold]"): - latest, latest_rec = get_latest_version(), get_latest_recommended_version() - con.print(f"[bold]Framework:[/bold] {current or 'not installed'} → {latest or 'unknown'}") - con.print(f"[bold]Recommended:[/bold] {current_rec or 'not installed'} → {latest_rec or 'unknown'}") - up_to_date = (latest and current == latest) and (latest_rec and current_rec == latest_rec) - 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", "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 get_known_agents as _get_known + from reporails_cli.core.discovery.agents import get_known_agents as _get_known agent = agent.lower().strip() known = _get_known() @@ -170,7 +108,7 @@ def _resolve_agent_filters( 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 ( + from reporails_cli.core.discovery.agents import ( detect_single_agent, filter_agents_by_exclude_dirs, filter_agents_by_id, @@ -195,7 +133,7 @@ def _handle_no_instruction_files(effective_agent: str, output_format: str, con: if output_format in ("json", "github"): print(json.dumps({"violations": [], "score": 0, "level": "L0"})) else: - from reporails_cli.core.agents import get_known_agents + from reporails_cli.core.discovery.agents import get_known_agents at = get_known_agents().get(effective_agent) hint = at.instruction_patterns[0] if at else "AGENTS.md" @@ -212,51 +150,3 @@ def _resolve_rules_paths(rules: list[str] | None, con: Console) -> list[Path] | con.print(f"[red]Error:[/red] Rules directory not found: {rp}") raise typer.Exit(2) return resolved - - -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, - ) - - 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 = root_files[0].relative_to(target).as_posix() 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: - cf = agent.config_files[0] - if cf.is_relative_to(target): - console.print(f" config: {cf.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/install.py b/src/reporails_cli/interfaces/cli/install.py index b79d854..9500aba 100644 --- a/src/reporails_cli/interfaces/cli/install.py +++ b/src/reporails_cli/interfaces/cli/install.py @@ -54,7 +54,7 @@ def install( path: str = typer.Argument(".", help="Project root"), ) -> None: """Install the reporails MCP server and ails command.""" - from reporails_cli.core.mcp_install import detect_mcp_targets, write_mcp_config + from reporails_cli.core.install.mcp_install import detect_mcp_targets, write_mcp_config target = Path(path).resolve() diff --git a/src/reporails_cli/interfaces/cli/main.py b/src/reporails_cli/interfaces/cli/main.py index c68ecd1..b069bed 100644 --- a/src/reporails_cli/interfaces/cli/main.py +++ b/src/reporails_cli/interfaces/cli/main.py @@ -9,7 +9,7 @@ # 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 +from reporails_cli.core.platform.runtime import _torch_blocker _torch_blocker.install() # ───────────────────────────────────────────────────────────────────── @@ -26,8 +26,8 @@ logger = logging.getLogger(__name__) -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.core.platform.adapters.registry import infer_agent_from_rule_id, load_rules # noqa: E402 +from reporails_cli.core.platform.dto.models import FileMatch, LocalFinding # noqa: E402 from reporails_cli.formatters import text as text_formatter # noqa: E402 from reporails_cli.formatters.text.display import print_text_result # noqa: E402 from reporails_cli.interfaces.cli.helpers import ( # noqa: E402 @@ -57,14 +57,10 @@ def _serialize_match(match: FileMatch | None) -> dict[str, object]: def _explain_rules_paths(rules: list[str] | None) -> list[Path] | None: - """Resolve rules paths for explain command, auto-including recommended.""" + """Resolve rules paths for explain command.""" if rules: return [Path(r).resolve() for r in rules] - from reporails_cli.core.bootstrap import get_recommended_package_path - from reporails_cli.core.registry import get_rules_dir - - rec_path = get_recommended_package_path() - return [get_rules_dir(), rec_path] if rec_path.is_dir() else None + return None @app.command(rich_help_panel="Commands") @@ -80,12 +76,12 @@ def check( # noqa: C901 # pylint: disable=too-many-locals """Validate AI instruction files against reporails rules.""" 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.core.discovery.agents import detect_agents, get_all_instruction_files + from reporails_cli.core.lint.client_checks import run_client_checks + from reporails_cli.core.lint.rule_runner import run_content_quality_checks, run_m_probes + from reporails_cli.core.platform.adapters.api_client import AilsClient + from reporails_cli.core.platform.config.config import get_project_config + from reporails_cli.core.platform.runtime.merger import merge_results from reporails_cli.formatters import json as json_formatter target = Path(path).resolve() @@ -186,7 +182,7 @@ def check( # noqa: C901 # pylint: disable=too-many-locals # 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 + from reporails_cli.core.lint.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) @@ -245,7 +241,7 @@ def _map_in_process(instruction_files: list[Path]) -> Any: """ import io as _io - from reporails_cli.core.bootstrap import get_global_cache_dir + from reporails_cli.core.platform.config.bootstrap import get_global_cache_dir saved_stderr = sys.stderr sys.stderr = _io.StringIO() diff --git a/src/reporails_cli/interfaces/cli/stopwords_command.py b/src/reporails_cli/interfaces/cli/stopwords_command.py index dadb7be..a8a655f 100644 --- a/src/reporails_cli/interfaces/cli/stopwords_command.py +++ b/src/reporails_cli/interfaces/cli/stopwords_command.py @@ -24,7 +24,7 @@ def stopwords_extract( ), ) -> None: """Extract alternation terms from checks.yml into vocab.yml files.""" - from reporails_cli.core.stopwords import extract_all, write_vocab + from reporails_cli.core.classify.stopwords import extract_all, write_vocab root = Path(rules_root).resolve() if not root.exists(): @@ -62,7 +62,7 @@ def stopwords_sync( ), ) -> None: """Compile vocab.yml terms into checks.yml patterns.""" - from reporails_cli.core.stopwords_sync import sync_all + from reporails_cli.core.classify.stopwords_sync import sync_all root = Path(rules_root).resolve() if not root.exists(): diff --git a/src/reporails_cli/interfaces/cli/test_command.py b/src/reporails_cli/interfaces/cli/test_command.py index 0a4489a..aa3e6e3 100644 --- a/src/reporails_cli/interfaces/cli/test_command.py +++ b/src/reporails_cli/interfaces/cli/test_command.py @@ -100,7 +100,7 @@ def test_rules( # pylint: disable=too-many-arguments,too-many-locals _run_score_mode(root, path, rule, package_roots, agent, format) return - from reporails_cli.core.harness import HarnessStatus, run_harness + from reporails_cli.core.lint.harness import HarnessStatus, run_harness results = run_harness( root, @@ -136,7 +136,7 @@ def _run_lint( agent: str, ) -> None: """Run structural integrity checks on rule files.""" - from reporails_cli.core.harness import discover_rules, lint_rules, load_agent_config + from reporails_cli.core.lint.harness import discover_rules, lint_rules, load_agent_config _, excludes = load_agent_config(root, agent) rules = discover_rules( @@ -173,7 +173,7 @@ def _run_export_baseline( output_path: str, ) -> None: """Export expected-rules baseline to JSON.""" - from reporails_cli.core.harness import export_baseline + from reporails_cli.core.lint.harness import export_baseline entries = export_baseline(root, package_roots=package_roots, agent=agent) data = [{"rule_id": e.rule_id, "slug": e.slug, "has_fixtures": e.has_fixtures} for e in entries] @@ -189,7 +189,7 @@ def _run_coverage_check( baseline_path: str, ) -> None: """Check rules against expected-rules baseline.""" - from reporails_cli.core.harness import check_coverage + from reporails_cli.core.lint.harness import check_coverage bp = Path(baseline_path) if not bp.exists(): @@ -219,7 +219,7 @@ def _run_score_mode( format: str, ) -> None: """Run effectiveness scoring mode.""" - from reporails_cli.core.harness import score_rules + from reporails_cli.core.lint.harness import score_rules deltas = score_rules( root, @@ -275,7 +275,7 @@ def _print_score_json(deltas: list[Any]) -> None: def _print_text(results: list[Any], verbose: bool) -> None: """Print human-readable test results.""" - from reporails_cli.core.harness import HarnessStatus + from reporails_cli.core.lint.harness import HarnessStatus passed = [r for r in results if r.status == HarnessStatus.PASSED] failed = [r for r in results if r.status == HarnessStatus.FAILED] @@ -342,7 +342,7 @@ def _print_text(results: list[Any], verbose: bool) -> None: def _print_json(results: list[Any]) -> None: """Print JSON test results.""" - from reporails_cli.core.harness import HarnessStatus + from reporails_cli.core.lint.harness import HarnessStatus data = { "rules": [ diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index 8fbd9c4..511cd29 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -5,7 +5,7 @@ # 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 +from reporails_cli.core.platform.runtime import _torch_blocker _torch_blocker.install() # ───────────────────────────────────────────────────────────────────── @@ -20,8 +20,8 @@ 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.core.discovery.agents import get_all_instruction_files # noqa: E402 +from reporails_cli.core.platform.config.bootstrap import is_initialized # noqa: E402 from reporails_cli.interfaces.mcp.tools import ( # noqa: E402 explain_tool, score_tool, @@ -118,7 +118,7 @@ async def list_tools() -> list[Tool]: name="heal", description=( "Auto-fix instruction file issues. Applies formatting, bold→italic," - " constraint wrapping, and charge ordering fixes." + " constraint wrapping, and instruction reordering fixes." " Use --dry-run to preview." ), inputSchema={ diff --git a/src/reporails_cli/interfaces/mcp/tools.py b/src/reporails_cli/interfaces/mcp/tools.py index 06f5ea5..b1fc457 100644 --- a/src/reporails_cli/interfaces/mcp/tools.py +++ b/src/reporails_cli/interfaces/mcp/tools.py @@ -4,9 +4,9 @@ from pathlib import Path from typing import Any -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.core.platform.adapters.registry import infer_agent_from_rule_id, load_rules +from reporails_cli.core.platform.config.bootstrap import is_initialized +from reporails_cli.core.platform.dto.models import FileMatch from reporails_cli.formatters import mcp as mcp_formatter logger = logging.getLogger(__name__) @@ -28,8 +28,8 @@ def _serialize_match(match: FileMatch | None) -> dict[str, object]: def _discover_files(target: Path) -> tuple[list[Any], str, list[Any]] | None: """Detect agents and discover instruction files. Returns (detected, agent, files) or None.""" - from reporails_cli.core.agents import detect_agents, get_all_instruction_files, resolve_agent - from reporails_cli.core.config import get_project_config + from reporails_cli.core.discovery.agents import detect_agents, get_all_instruction_files, resolve_agent + from reporails_cli.core.platform.config.config import get_project_config config = get_project_config(target) detected = detect_agents(target) @@ -43,8 +43,8 @@ def _discover_files(target: Path) -> tuple[list[Any], str, list[Any]] | None: def _build_map(target: Path, instruction_files: list[Any]) -> Any: # noqa: ARG001 """Build ruleset map, returning None on failure.""" try: - from reporails_cli.core.bootstrap import get_global_cache_dir from reporails_cli.core.mapper import map_ruleset + from reporails_cli.core.platform.config.bootstrap import get_global_cache_dir return map_ruleset(list(instruction_files), cache_dir=get_global_cache_dir()) except (ImportError, RuntimeError) as exc: @@ -59,8 +59,8 @@ def _merge_with_server( target: Path, ) -> Any: """Merge local findings with server diagnostics, returning CombinedResult.""" - from reporails_cli.core.api_client import AilsClient - from reporails_cli.core.merger import merge_results + from reporails_cli.core.platform.adapters.api_client import AilsClient + from reporails_cli.core.platform.runtime.merger import merge_results response = AilsClient().lint(ruleset_map) if ruleset_map else None lint_result = response.result if response else None @@ -76,8 +76,8 @@ def _merge_with_server( def _run_pipeline(target: Path) -> dict[str, Any]: """Run the full check pipeline and return CombinedResult as dict.""" - from reporails_cli.core.client_checks import run_client_checks - from reporails_cli.core.rule_runner import run_content_quality_checks, run_m_probes + from reporails_cli.core.lint.client_checks import run_client_checks + from reporails_cli.core.lint.rule_runner import run_content_quality_checks, run_m_probes from reporails_cli.formatters import json as json_formatter discovery = _discover_files(target) @@ -155,7 +155,7 @@ def heal_tool(path: str = ".", dry_run: bool = False) -> dict[str, Any]: fixes: list[dict[str, str]] = [] if ruleset_map is not None: - from reporails_cli.core.mechanical_fixers import apply_mechanical_fixes + from reporails_cli.core.heal.mechanical_fixers import apply_mechanical_fixes 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) @@ -167,14 +167,6 @@ def heal_tool(path: str = ".", dry_run: bool = False) -> dict[str, Any]: 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 - - 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) rules = load_rules(rules_paths, agent=agent) diff --git a/tests/conftest.py b/tests/conftest.py index 681ad5d..b4fa48f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def agent_file_types() -> list: Skips when framework is not installed (CI without ~/.reporails/rules/). """ - from reporails_cli.core.bootstrap import get_agent_file_types + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types result = get_agent_file_types("claude") if not result: diff --git a/tests/integration/test_behavioral.py b/tests/integration/test_behavioral.py index 99629ab..7b7734e 100644 --- a/tests/integration/test_behavioral.py +++ b/tests/integration/test_behavioral.py @@ -14,14 +14,14 @@ import pytest from typer.testing import CliRunner -from reporails_cli.core.agents import clear_agent_cache, get_known_agents +from reporails_cli.core.discovery.agents import clear_agent_cache, get_known_agents from reporails_cli.interfaces.cli.main import app runner = CliRunner() def _rules_installed() -> bool: - from reporails_cli.core.bootstrap import get_rules_path + from reporails_cli.core.platform.config.bootstrap import get_rules_path return (get_rules_path() / "core").exists() @@ -137,6 +137,9 @@ def multi_file_project(tmp_path: Path) -> Path: class TestCheckJsonSchema: """JSON output must have a stable, documented schema.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_required_keys_present(self, minimal_project: Path) -> None: result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) @@ -146,6 +149,9 @@ def test_required_keys_present(self, minimal_project: Path) -> None: required = {"offline", "files", "stats"} assert required.issubset(data.keys()), f"Missing keys: {required - data.keys()}" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_files_is_dict_with_findings(self, minimal_project: Path) -> None: result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) @@ -154,6 +160,9 @@ def test_files_is_dict_with_findings(self, minimal_project: Path) -> None: for file_data in data["files"].values(): assert isinstance(file_data["findings"], list) + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_finding_has_required_fields(self, minimal_project: Path) -> None: result = runner.invoke(app, ["check", str(minimal_project), "-f", "json"]) @@ -165,6 +174,9 @@ def test_finding_has_required_fields(self, minimal_project: Path) -> None: assert "rule" in f assert "message" in f + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic def test_no_files_json_output(self, empty_project: Path) -> None: result = runner.invoke(app, ["check", str(empty_project), "-f", "json"]) assert result.exit_code == 0 @@ -181,6 +193,9 @@ def test_no_files_json_output(self, empty_project: Path) -> None: class TestCheckScanScope: """Files outside the target directory must never appear in results.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_nested_child_only_scans_child(self, nested_project: Path) -> None: result = runner.invoke(app, ["check", str(nested_project), "-f", "json"]) @@ -191,6 +206,9 @@ def test_nested_child_only_scans_child(self, nested_project: Path) -> None: for f in file_data["findings"]: assert "Parent" not in f.get("message", ""), "Parent content leaked into child scan" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @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.""" @@ -210,18 +228,27 @@ def test_nested_child_violation_count_reasonable(self, nested_project: Path) -> class TestCheckTextOutput: """Text output must contain key information.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_score_displayed(self, minimal_project: Path) -> None: 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 + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @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"]) assert result.exit_code == 0 assert "CLAUDE.md" in result.output + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic 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 @@ -236,6 +263,9 @@ def test_no_files_shows_l0_message(self, empty_project: Path) -> None: class TestCheckMultiFile: """Projects with multiple instruction files should report all of them.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_multiple_agents_detected(self, multi_file_project: Path) -> None: result = runner.invoke(app, ["check", str(multi_file_project), "-f", "json"]) @@ -253,6 +283,9 @@ def test_multiple_agents_detected(self, multi_file_project: Path) -> None: class TestCheckScoreConsistency: """Score must be deterministic — same project, same score.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_deterministic_stats(self, structured_project: Path) -> None: stats_list = [] @@ -275,11 +308,17 @@ class TestCheckAgentFlag: # Hint message tests (no agent, claude, codex, copilot) covered by # smoke TestHintMessages. Keep agent-specific behavior tests below. + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic 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"]) assert "Create a .github/copilot-instructions.md to get started" in result.output + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic 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"]) @@ -287,6 +326,9 @@ def test_unknown_agent_errors(self, empty_project: Path) -> None: assert "Unknown agent" in result.output assert "claude" in result.output + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic def test_wrong_agent_no_false_positive(self, tmp_path: Path) -> None: """--agent claude on a project with only AGENTS.md must NOT scan it.""" p = tmp_path / "proj" @@ -295,6 +337,9 @@ def test_wrong_agent_no_false_positive(self, tmp_path: Path) -> None: result = runner.invoke(app, ["check", str(p), "--agent", "claude", "-f", "text"]) assert "No instruction files found" in result.output + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_codex_agent_scans_agents_md(self, tmp_path: Path) -> None: """--agent codex should find and validate AGENTS.md.""" @@ -307,6 +352,9 @@ def test_codex_agent_scans_agents_md(self, tmp_path: Path) -> None: assert "files" in data assert "AGENTS.md" in data["files"], "AGENTS.md should be detected" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_no_agent_core_rules_fire(self, tmp_path: Path) -> None: """No --agent must still apply core rules (not just file presence).""" @@ -327,6 +375,9 @@ def test_no_agent_core_rules_fire(self, tmp_path: Path) -> None: class TestAgentCrossValidation: """Agent registry must be built from framework config.yml files.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_registry_populated_from_configs(self) -> None: """Registry should contain at least the big 5 agents.""" @@ -351,6 +402,9 @@ class TestAgentMatrix: Parametrized from get_known_agents() so new config.yml agents get coverage automatically. """ + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules @pytest.mark.parametrize("agent_id", sorted(get_known_agents())) def test_agent_check_finds_files(self, agent_id: str, tmp_path: Path) -> None: @@ -372,6 +426,9 @@ def test_agent_check_finds_files(self, agent_id: str, tmp_path: Path) -> None: data = json.loads(result.output) assert data["files"], f"--agent {agent_id} should detect {filename}, got empty files" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules @pytest.mark.parametrize("agent_id", sorted(get_known_agents())) def test_agent_check_no_crash(self, agent_id: str, tmp_path: Path) -> None: @@ -390,6 +447,9 @@ def test_agent_check_no_crash(self, agent_id: str, tmp_path: Path) -> None: class TestCheckFileTarget: """ails check FILE should validate just that file.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_single_file_target(self, minimal_project: Path) -> None: """Pointing at a specific file should work.""" @@ -409,6 +469,9 @@ def test_single_file_target(self, minimal_project: Path) -> None: class TestContentChecks: """Content checks must produce findings when mapper is available.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules @requires_model def test_content_checks_produce_findings(self, minimal_project: Path) -> None: @@ -429,6 +492,9 @@ def test_content_checks_produce_findings(self, minimal_project: Path) -> None: class TestCheckConfig: """Project config must affect validation behavior.""" + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_rules def test_disabled_rules_excluded(self, tmp_path: Path) -> None: """Rules listed in .ails/config.yml disabled_rules should not fire.""" @@ -470,6 +536,9 @@ class TestHealCommand: # test_heal_missing_path covered by smoke tests + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_model @requires_rules def test_heal_auto_fixes_applied(self, tmp_path: Path) -> None: @@ -487,6 +556,9 @@ def test_heal_auto_fixes_applied(self, tmp_path: Path) -> None: if content != original: assert len(content) > len(original) + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_model @requires_rules def test_heal_nothing_to_heal(self, tmp_path: Path) -> None: @@ -502,6 +574,9 @@ 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 + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_model @requires_rules def test_heal_json_output(self, tmp_path: Path) -> None: @@ -518,6 +593,9 @@ def test_heal_json_output(self, tmp_path: Path) -> None: assert "summary" in data assert "auto_fixed_count" in data["summary"] + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_model def test_heal_works_without_tty(self, tmp_path: Path) -> None: """CliRunner is non-TTY — heal should still work (no TTY requirement).""" @@ -528,6 +606,9 @@ 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) + @pytest.mark.integration + @pytest.mark.subsys_lint + @pytest.mark.subsys_diagnostic @requires_model @requires_rules def test_heal_shows_remaining_violations(self, tmp_path: Path) -> None: diff --git a/tests/integration/test_capability_detection.py b/tests/integration/test_capability_detection.py index 1c8fb89..d4e54e5 100644 --- a/tests/integration/test_capability_detection.py +++ b/tests/integration/test_capability_detection.py @@ -10,8 +10,8 @@ import pytest -from reporails_cli.core.levels import determine_project_level -from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration, Level +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileTypeDeclaration, Level +from reporails_cli.core.platform.policy.levels import determine_project_level def _cf( @@ -33,18 +33,24 @@ def _ft( class TestProjectLevelDetermination: """Test project level determination from file type properties.""" + @pytest.mark.integration + @pytest.mark.subsys_cli_ux 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() + @pytest.mark.integration + @pytest.mark.subsys_cli_ux 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.integration + @pytest.mark.subsys_cli_ux @pytest.mark.parametrize( "depth, expected_level", [ @@ -73,6 +79,8 @@ def test_progressive_level(self, tmp_path: Path, depth: int, expected_level: Lev level, _ = determine_project_level(tmp_path, [], classified) assert level == expected_level + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_max_depth_wins(self, tmp_path: Path) -> None: """Level is driven by the type with most divergences.""" classified = [ @@ -87,6 +95,8 @@ def test_max_depth_wins(self, tmp_path: Path) -> None: class TestProjectLevelDeterminism: """Test that project level determination is deterministic.""" + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_same_input_same_output(self, tmp_path: Path) -> None: """Same files always give same level.""" classified = [ diff --git a/tests/integration/test_cli_e2e.py b/tests/integration/test_cli_e2e.py index 8e79713..649f2c0 100644 --- a/tests/integration/test_cli_e2e.py +++ b/tests/integration/test_cli_e2e.py @@ -34,7 +34,7 @@ def _rules_installed() -> bool: """Check if rules framework is installed.""" - from reporails_cli.core.bootstrap import get_rules_path + from reporails_cli.core.platform.config.bootstrap import get_rules_path return (get_rules_path() / "core").exists() @@ -52,6 +52,8 @@ def _rules_installed() -> bool: class TestCheckCommand: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_json_output_parseable(self, level2_project: Path) -> None: """JSON output should be valid JSON with expected keys.""" result = runner.invoke( @@ -70,6 +72,8 @@ def test_json_output_parseable(self, level2_project: Path) -> None: # text_output_has_score, compact_output, missing_path, no_instruction_files # covered by smoke and behavioral tests + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_strict_mode_exits_1_on_violations(self, level2_project: Path) -> None: """--strict should exit 1 when violations exist.""" @@ -92,6 +96,8 @@ def test_strict_mode_exits_1_on_violations(self, level2_project: Path) -> None: else: assert result.exit_code == 0 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_model def test_pre_run_prompt_skipped_in_json_format(self, level2_project: Path) -> None: """JSON format should produce clean JSON output with no prompt text.""" diff --git a/tests/integration/test_mcp_e2e.py b/tests/integration/test_mcp_e2e.py index 81cdb21..277a11a 100644 --- a/tests/integration/test_mcp_e2e.py +++ b/tests/integration/test_mcp_e2e.py @@ -42,7 +42,7 @@ def _rules_installed() -> bool: - from reporails_cli.core.bootstrap import get_rules_path + from reporails_cli.core.platform.config.bootstrap import get_rules_path return (get_rules_path() / "core").exists() @@ -73,6 +73,9 @@ def _call_tool(name: str, arguments: dict[str, Any]) -> str: class TestListTools: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_all_tools_present(self) -> None: """list_tools should return all four tools.""" from reporails_cli.interfaces.mcp.server import list_tools @@ -81,6 +84,9 @@ def test_all_tools_present(self) -> None: names = {t.name for t in tools} assert names == {"validate", "score", "explain", "heal"} + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_validate_tool_path_optional(self) -> None: """validate tool should not require path (has default).""" from reporails_cli.interfaces.mcp.server import list_tools @@ -110,6 +116,9 @@ def test_validate_tool_path_optional(self) -> None: class TestValidateTool: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_returns_valid_json(self, level2_project: Path) -> None: """validate must return parseable JSON.""" @@ -117,6 +126,9 @@ def test_returns_valid_json(self, level2_project: Path) -> None: data = json.loads(text) # Must not raise assert isinstance(data, dict) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_json_has_files_and_stats(self, level2_project: Path) -> None: """validate JSON must contain files and stats keys.""" @@ -125,6 +137,9 @@ def test_json_has_files_and_stats(self, level2_project: Path) -> None: assert "files" in data assert "stats" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_json_has_violations_grouped_by_file(self, level2_project: Path) -> None: """validate JSON must contain violations as a dict grouped by file.""" @@ -139,6 +154,9 @@ def test_json_has_violations_grouped_by_file(self, level2_project: Path) -> None assert isinstance(entry, list) assert len(entry) == 4 # [rule_id, line_ref, severity, message] + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_json_has_offline_flag(self, level2_project: Path) -> None: """validate JSON must contain offline flag.""" @@ -146,6 +164,9 @@ def test_json_has_offline_flag(self, level2_project: Path) -> None: data = json.loads(text) assert "offline" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_no_shell_out_guidance(self, level2_project: Path) -> None: """REGRESSION: validate must never tell the LLM to shell out.""" @@ -153,12 +174,18 @@ 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" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api 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"}) data = json.loads(text) assert "error" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_uninitialized_returns_error_json(self) -> None: """When framework is not initialized, should return JSON error.""" with patch("reporails_cli.interfaces.mcp.server.is_initialized", return_value=False): @@ -167,6 +194,9 @@ def test_uninitialized_returns_error_json(self) -> None: assert "error" in data assert data["error"] == "not_initialized" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_runtime_error_returns_error_json(self, level2_project: Path) -> None: """RuntimeError from _run_pipeline must return JSON error, not crash.""" with ( @@ -188,6 +218,9 @@ def test_runtime_error_returns_error_json(self, level2_project: Path) -> None: class TestScoreTool: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_returns_json_with_stats(self, level2_project: Path) -> None: """score should return JSON with findings summary.""" @@ -195,6 +228,9 @@ def test_returns_json_with_stats(self, level2_project: Path) -> None: data = json.loads(text) assert "total_findings" in data or "errors" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_offline_flag_present(self, level2_project: Path) -> None: """Score result should indicate offline status.""" @@ -209,6 +245,9 @@ def test_offline_flag_present(self, level2_project: Path) -> None: class TestExplainTool: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_known_rule_returns_details(self, dev_rules_dir: Path) -> None: """Explaining a known rule should return readable text with rule ID and title.""" from reporails_cli.interfaces.mcp.tools import explain_tool @@ -218,6 +257,9 @@ def test_known_rule_returns_details(self, dev_rules_dir: Path) -> None: assert "CORE:S:0002" in result assert "Section Headers Present" in result + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_unknown_rule_returns_error(self) -> None: """Explaining an unknown rule should return an error.""" text = _call_tool("explain", {"rule_id": "ZZZZZ999"}) @@ -231,6 +273,9 @@ def test_unknown_rule_returns_error(self) -> None: class TestUnknownTool: + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_returns_error(self) -> None: """Unknown tool name should return error JSON.""" text = _call_tool("nonexistent_tool", {}) @@ -262,6 +307,9 @@ def setup_method(self) -> None: def teardown_method(self) -> None: self._reset_states() + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_first_call_succeeds(self, level2_project: Path) -> None: """First validate call should return normal JSON results.""" @@ -270,6 +318,9 @@ def test_first_call_succeeds(self, level2_project: Path) -> None: assert "error" not in data assert "files" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_second_call_succeeds(self, level2_project: Path) -> None: """Second validate call (unchanged files) should still succeed.""" @@ -279,6 +330,9 @@ def test_second_call_succeeds(self, level2_project: Path) -> None: assert "error" not in data assert "files" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_third_unchanged_triggers_breaker(self, level2_project: Path) -> None: """Third call without file changes must trigger circuit breaker.""" @@ -288,6 +342,9 @@ def test_third_unchanged_triggers_breaker(self, level2_project: Path) -> None: data = json.loads(text) assert data.get("error") == "circuit_breaker" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_edit_between_calls_resets_breaker(self, level2_project: Path) -> None: """Editing a file between validate calls should reset the breaker.""" @@ -302,6 +359,9 @@ def test_edit_between_calls_resets_breaker(self, level2_project: Path) -> None: assert "error" not in data assert "files" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_breaker_message_says_do_not_call_again(self, level2_project: Path) -> None: """Breaker message must instruct the LLM to stop calling validate.""" @@ -311,6 +371,9 @@ def test_breaker_message_says_do_not_call_again(self, level2_project: Path) -> N data = json.loads(text) assert "DO NOT call validate again" in data.get("message", "") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_different_paths_independent(self, level2_project: Path, tmp_path: Path) -> None: """Circuit breaker states are per-path, not global.""" @@ -328,6 +391,9 @@ def test_different_paths_independent(self, level2_project: Path, tmp_path: Path) data = json.loads(text) assert "error" not in data or data.get("error") != "circuit_breaker" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_fourth_unchanged_still_blocked(self, level2_project: Path) -> None: """Calls beyond the threshold must all be blocked.""" @@ -337,6 +403,9 @@ def test_fourth_unchanged_still_blocked(self, level2_project: Path) -> None: data = json.loads(text) assert data.get("error") == "circuit_breaker" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_absolute_ceiling(self, level2_project: Path) -> None: """After MAX_CALLS total calls, breaker triggers regardless of file changes.""" @@ -363,6 +432,9 @@ def test_absolute_ceiling(self, level2_project: Path) -> None: class TestScoreToolHelper: """Test the score_tool helper function directly.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api @requires_rules def test_returns_score_dict(self, level2_project: Path) -> None: from reporails_cli.interfaces.mcp.tools import score_tool @@ -371,6 +443,9 @@ def test_returns_score_dict(self, level2_project: Path) -> None: assert "total_findings" in result or "offline" in result assert "error" not in result + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_missing_path_returns_error(self) -> None: from reporails_cli.interfaces.mcp.tools import score_tool @@ -391,15 +466,27 @@ def _parse(self, s: str) -> tuple[str, str, str, str]: return _parse_verdict_string(s) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_short_rule_id(self) -> None: assert self._parse("S1:CLAUDE.md:pass:OK") == ("S1", "CLAUDE.md", "pass", "OK") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_short_rule_id_fail(self) -> None: assert self._parse("C2:CLAUDE.md:fail:Missing") == ("C2", "CLAUDE.md", "fail", "Missing") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_coordinate_rule_id(self) -> None: assert self._parse("CORE:S:0001:CLAUDE.md:pass:Good") == ("CORE:S:0001", "CLAUDE.md", "pass", "Good") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_coordinate_rule_id_fail(self) -> None: assert self._parse("AILS:C:0002:.claude/rules/foo.md:fail:Bad") == ( "AILS:C:0002", @@ -408,10 +495,16 @@ def test_coordinate_rule_id_fail(self) -> None: "Bad", ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_short_with_line_number(self) -> None: """Line number in location must not be confused with verdict.""" assert self._parse("S1:CLAUDE.md:42:pass:Has line") == ("S1", "CLAUDE.md:42", "pass", "Has line") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_coordinate_with_line_number(self) -> None: """Coordinate ID + line number in location must parse correctly.""" assert self._parse("CORE:S:0001:CLAUDE.md:42:pass:Has line") == ( @@ -421,23 +514,41 @@ def test_coordinate_with_line_number(self) -> None: "Has line", ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_colons_in_reason(self) -> None: """Colons in the reason field should be preserved.""" assert self._parse("S1:CLAUDE.md:pass:reason:with:colons") == ("S1", "CLAUDE.md", "pass", "reason:with:colons") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_empty_string(self) -> None: assert self._parse("") == ("", "", "", "") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_garbage(self) -> None: assert self._parse("garbage") == ("", "", "", "") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_just_colons(self) -> None: assert self._parse(":::") == ("", "", "", "") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_invalid_verdict_value(self) -> None: """Verdict must be 'pass' or 'fail'.""" assert self._parse("S1:CLAUDE.md:maybe:unsure") == ("", "", "", "") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_no_location(self) -> None: """Missing location should return empty.""" _rule_id, location, _verdict, _reason = self._parse("S1::pass:no loc") @@ -454,7 +565,7 @@ class TestScanDeltaResilience: """ScanDelta.compute must not crash on corrupted analytics cache.""" def _compute(self, prev_level: str) -> Any: - from reporails_cli.core.models import ScanDelta + from reporails_cli.core.platform.dto.models import ScanDelta class FakePrev: score = 5.0 @@ -463,19 +574,31 @@ class FakePrev: FakePrev.level = prev_level # type: ignore[attr-defined] return ScanDelta.compute(5.0, "L3", 2, FakePrev()) # type: ignore[arg-type] + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_normal_level(self) -> None: d = self._compute("L2") assert d.level_improved is True + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_truncated_level(self) -> None: """'L' with no digit must not crash.""" d = self._compute("L") assert d is not None # No IndexError + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_empty_level(self) -> None: d = self._compute("") assert d is not None + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + @pytest.mark.subsys_api def test_garbage_level(self) -> None: d = self._compute("garbage") assert d is not None diff --git a/tests/integration/test_self_update.py b/tests/integration/test_self_update.py index bd22ba7..ef634d6 100644 --- a/tests/integration/test_self_update.py +++ b/tests/integration/test_self_update.py @@ -52,6 +52,8 @@ def _create_venv(base: Path, name: str = "venv") -> Path: class TestSelfUpdateIntegration: """End-to-end: build wheel, install in venv, verify detect + command construction.""" + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_detect_method_in_pip_venv(self, tmp_path: Path, wheel: Path) -> None: """Install via pip in a venv, verify detect_install_method returns PIP.""" python = _create_venv(tmp_path) @@ -66,7 +68,7 @@ def test_detect_method_in_pip_venv(self, tmp_path: Path, wheel: Path) -> None: [ str(python), "-c", - "from reporails_cli.core.self_update import detect_install_method;" + "from reporails_cli.core.install.self_update import detect_install_method;" " print(detect_install_method().value)", ], capture_output=True, @@ -76,6 +78,8 @@ def test_detect_method_in_pip_venv(self, tmp_path: Path, wheel: Path) -> None: method = result.stdout.strip() assert method == "pip", f"Expected 'pip', got '{method}'" + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_build_command_in_venv(self, tmp_path: Path, wheel: Path) -> None: """Install in venv and verify _build_upgrade_command produces runnable commands.""" python = _create_venv(tmp_path) @@ -91,7 +95,7 @@ def test_build_command_in_venv(self, tmp_path: Path, wheel: Path) -> None: str(python), "-c", ( - "from reporails_cli.core.self_update import _build_upgrade_command, InstallMethod; " + "from reporails_cli.core.install.self_update import _build_upgrade_command, InstallMethod; " "cmd = _build_upgrade_command(InstallMethod.PIP, '99.0.0'); " "assert 'reporails-cli==99.0.0' in cmd, cmd; " "print('OK')" @@ -103,6 +107,8 @@ def test_build_command_in_venv(self, tmp_path: Path, wheel: Path) -> None: assert result.returncode == 0, f"stderr: {result.stderr}" assert "OK" in result.stdout + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_upgrade_cli_dev_install_refused(self, tmp_path: Path) -> None: """Editable install should refuse upgrade without running subprocess.""" python = _create_venv(tmp_path) @@ -134,7 +140,7 @@ def test_upgrade_cli_dev_install_refused(self, tmp_path: Path) -> None: str(python), "-c", ( - "from reporails_cli.core.self_update import upgrade_cli; " + "from reporails_cli.core.install.self_update import upgrade_cli; " "r = upgrade_cli('99.0.0'); " "assert not r.updated; " "assert r.method.value == 'dev'; " @@ -147,6 +153,8 @@ def test_upgrade_cli_dev_install_refused(self, tmp_path: Path) -> None: assert result.returncode == 0, f"stderr: {result.stderr}" assert "OK" in result.stdout + @pytest.mark.integration + @pytest.mark.subsys_cli_ux def test_version_command_shows_install_method(self, tmp_path: Path, wheel: Path) -> None: """Verify `ails version` output includes install method.""" python = _create_venv(tmp_path) diff --git a/tests/integration/test_symlink_validation.py b/tests/integration/test_symlink_validation.py index 6d0675d..2b4b5d8 100644 --- a/tests/integration/test_symlink_validation.py +++ b/tests/integration/test_symlink_validation.py @@ -52,16 +52,20 @@ def symlink_project(tmp_path: Path) -> tuple[Path, Path]: class TestSymlinkIntegration: """Integration tests for symlinked instruction file handling.""" + @pytest.mark.integration + @pytest.mark.subsys_lint 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.core.applicability import detect_features_filesystem + from reporails_cli.core.discovery.features import detect_features_filesystem features = detect_features_filesystem(project) assert features.has_claude_md is True assert len(features.resolved_symlinks) == 1 + @pytest.mark.integration + @pytest.mark.subsys_lint 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 @@ -69,7 +73,7 @@ def test_rule_validation_with_external_symlink(self, symlink_project: tuple[Path # Rewrite external file to be too long (trigger S1 if available) external.write_text("# Project\n\n" + "Line of content.\n" * 300) - from reporails_cli.core.applicability import detect_features_filesystem + from reporails_cli.core.discovery.features import detect_features_filesystem features = detect_features_filesystem(project) assert features.has_claude_md is True diff --git a/tests/integration/test_template_resolution.py b/tests/integration/test_template_resolution.py index af27e1a..a307455 100644 --- a/tests/integration/test_template_resolution.py +++ b/tests/integration/test_template_resolution.py @@ -12,9 +12,11 @@ class TestFileClassificationLoading: """Test that file_types load correctly from agent configs.""" + @pytest.mark.integration + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types file_types = get_agent_file_types("claude") if not file_types: @@ -22,16 +24,20 @@ def test_load_claude_file_types(self) -> None: type_names = {ft.name for ft in file_types} assert "main" in type_names, "Claude config must declare 'main' file type" + @pytest.mark.integration + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types result = get_agent_file_types("nonexistent_agent_xyz") assert result == [] + @pytest.mark.integration + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types file_types = get_agent_file_types("claude") if not file_types: @@ -39,9 +45,11 @@ def test_file_types_have_patterns(self) -> None: for ft in file_types: assert ft.patterns, f"File type '{ft.name}' has no patterns" + @pytest.mark.integration + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types file_types = get_agent_file_types("claude") if not file_types: @@ -50,9 +58,11 @@ def test_main_type_is_required(self) -> None: assert main_types, "No 'main' file type found" assert main_types[0].required, "'main' file type should be required" + @pytest.mark.integration + @pytest.mark.subsys_lint def test_empty_string_agent_returns_empty(self) -> None: """Empty string agent should return empty list.""" - from reporails_cli.core.bootstrap import get_agent_file_types + from reporails_cli.core.platform.config.bootstrap import get_agent_file_types result = get_agent_file_types("") assert result == [] diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index f0ba904..0559695 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -20,7 +20,7 @@ import pytest from typer.testing import CliRunner -from reporails_cli.core.agents import clear_agent_cache +from reporails_cli.core.discovery.agents import clear_agent_cache from reporails_cli.interfaces.cli.main import app runner = CliRunner() @@ -29,7 +29,7 @@ def _rules_installed() -> bool: - from reporails_cli.core.bootstrap import get_rules_path + from reporails_cli.core.platform.config.bootstrap import get_rules_path return (get_rules_path() / "core").exists() @@ -159,6 +159,8 @@ def _finding_files(data: dict) -> set[str]: class TestDefaultAgentCoreOnly: """No --agent flag must apply core rules and produce real violations.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_core_rules_produce_violations(self, generic_only: Path) -> None: """Without --agent, core rules must produce violations on fixture content. @@ -173,6 +175,8 @@ def test_core_rules_produce_violations(self, generic_only: Path) -> None: f"Zero findings on generic_only fixture — core rules not matching files. stats={data.get('stats', {})}" ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_findings_exist(self, generic_only: Path) -> None: """Fixture content has known gaps; must produce findings. @@ -183,6 +187,8 @@ def test_findings_exist(self, generic_only: Path) -> None: total = data.get("stats", {}).get("total_findings", 0) assert total > 0, "Zero findings on generic_only fixture — rules are loaded but not matching content" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_no_agent_core_rules_present(self, generic_only: Path) -> None: """Without --agent, CORE rules must be present in findings.""" @@ -190,6 +196,8 @@ def test_no_agent_core_rules_present(self, generic_only: Path) -> None: namespaces = _finding_namespaces(data) assert "CORE" in namespaces, f"CORE namespace missing without --agent: {namespaces}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_no_agent_and_explicit_agent_both_produce_findings(self, claude_only: Path) -> None: """Both auto-detect and explicit --agent must produce findings. @@ -202,6 +210,8 @@ def test_no_agent_and_explicit_agent_both_produce_findings(self, claude_only: Pa assert len(_all_findings(data_no_agent)) > 0, "Auto-detect produced zero findings" assert len(_all_findings(data_with_agent)) > 0, "--agent claude produced zero findings" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_files_detected(self, generic_only: Path) -> None: """Without --agent, auto-detect must find AGENTS.md and produce findings.""" @@ -218,26 +228,36 @@ def test_files_detected(self, generic_only: Path) -> None: class TestAgentFileTargeting: """--agent flag must scope file discovery to the correct instruction file.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_claude_finds_claude_md(self, claude_only: Path) -> None: data = _check_json(claude_only, agent="claude") assert len(data.get("files", {})) > 0, "--agent claude should detect CLAUDE.md" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_codex_finds_agents_md(self, codex_only: Path) -> None: data = _check_json(codex_only, agent="codex") assert len(data.get("files", {})) > 0, "--agent codex should detect AGENTS.md" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_copilot_finds_its_file(self, copilot_only: Path) -> None: data = _check_json(copilot_only, agent="copilot") assert len(data.get("files", {})) > 0, "--agent copilot should detect copilot-instructions.md" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_wrong_agent_claude_on_codex(self, codex_only: Path) -> None: """--agent claude on a codex-only project must find no files.""" output = _check_text(codex_only, agent="claude") assert "No instruction files found" in output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_wrong_agent_codex_on_claude(self, claude_only: Path) -> None: """--agent codex on a claude-only project must find no files.""" output = _check_text(claude_only, agent="codex") @@ -257,6 +277,8 @@ def test_wrong_agent_codex_on_claude(self, claude_only: Path) -> None: class TestCrossAgentContamination: """Agent-scoped validation must never leak rules from other agents.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_codex_no_claude_rules(self, codex_only: Path) -> None: """--agent codex must produce findings, none from CLAUDE namespace.""" @@ -266,6 +288,8 @@ def test_codex_no_claude_rules(self, codex_only: Path) -> None: claude_rules = {f["rule"] for f in findings if f["rule"].startswith("CLAUDE:")} assert not claude_rules, f"CLAUDE rules fired under --agent codex: {claude_rules}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_claude_no_other_agent_rules(self, claude_only: Path) -> None: """--agent claude must produce findings, none from other agent namespaces.""" @@ -275,6 +299,8 @@ def test_claude_no_other_agent_rules(self, claude_only: Path) -> None: foreign = {f["rule"] for f in findings if f["rule"].split(":")[0] in ("CODEX", "COPILOT", "CURSOR", "GEMINI")} assert not foreign, f"Foreign agent rules fired under --agent claude: {foreign}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_no_agent_multi_project_core_present(self, multi_agent: Path) -> None: """No --agent on a multi-agent project must produce findings with CORE rules.""" @@ -294,19 +320,27 @@ def test_no_agent_multi_project_core_present(self, multi_agent: Path) -> None: class TestHintMessages: """Empty-project hints must name the correct instruction file per agent.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_no_agent_hints_agents_md(self, empty_dir: Path) -> None: output = _check_text(empty_dir) assert "AGENTS.md" in output, "Default hint should reference AGENTS.md" assert "CLAUDE.md" not in output, "Default hint must not reference CLAUDE.md" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_claude_hints_claude_md(self, empty_dir: Path) -> None: output = _check_text(empty_dir, agent="claude") assert "CLAUDE.md" in output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_codex_hints_agents_md(self, empty_dir: Path) -> None: output = _check_text(empty_dir, agent="codex") assert "AGENTS.md" in output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_copilot_hints_its_file(self, empty_dir: Path) -> None: output = _check_text(empty_dir, agent="copilot") assert "copilot-instructions.md" in output @@ -321,6 +355,8 @@ def test_copilot_hints_its_file(self, empty_dir: Path) -> None: class TestMultiAgentProject: """Multi-agent projects must scope correctly per --agent flag.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules 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.""" @@ -332,6 +368,8 @@ def test_no_agent_scans_all_on_mixed_signals(self, multi_agent: Path) -> None: # 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}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @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.""" @@ -343,6 +381,8 @@ def test_agent_claude_scopes_to_claude_md(self, multi_agent: Path) -> None: foreign = {ns for ns in namespaces if ":" in ns} - {"CORE", "RRAILS", "CLAUDE"} assert not foreign, f"Non-Claude namespaced rules fired with --agent claude: {foreign}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_agent_codex_scopes_to_agents_md(self, multi_agent: Path) -> None: """--agent codex on multi-agent project should scope to AGENTS.md.""" @@ -366,6 +406,8 @@ def test_agent_codex_scopes_to_agents_md(self, multi_agent: Path) -> None: class TestViolationLocationAccuracy: """Violation locations must reference the correct file, not a wrong one.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_claude_findings_reference_claude_md(self, claude_only: Path) -> None: """--agent claude findings must include CLAUDE.md (may also include infrastructure files).""" @@ -375,6 +417,8 @@ def test_claude_findings_reference_claude_md(self, claude_only: Path) -> None: files = _finding_files(data) assert "CLAUDE.md" in files, f"Expected CLAUDE.md in findings, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_codex_findings_reference_agents_md(self, codex_only: Path) -> None: """--agent codex findings must include AGENTS.md (may also include infrastructure files).""" @@ -384,6 +428,8 @@ def test_codex_findings_reference_agents_md(self, codex_only: Path) -> None: files = _finding_files(data) assert "AGENTS.md" in files, f"Expected AGENTS.md in findings, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_multi_agent_claude_only_claude_md(self, multi_agent: Path) -> None: """--agent claude on multi-agent project: findings include CLAUDE.md, not AGENTS.md.""" @@ -394,6 +440,8 @@ def test_multi_agent_claude_only_claude_md(self, multi_agent: Path) -> None: assert "CLAUDE.md" in files, f"Expected CLAUDE.md in findings, got: {files}" assert "AGENTS.md" not in files, f"AGENTS.md should not appear with --agent claude, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_multi_agent_codex_only_agents_md(self, multi_agent: Path) -> None: """--agent codex on multi-agent project: findings include AGENTS.md, not CLAUDE.md.""" @@ -404,6 +452,8 @@ def test_multi_agent_codex_only_agents_md(self, multi_agent: Path) -> None: assert "AGENTS.md" in files, f"Expected AGENTS.md in findings, got: {files}" assert "CLAUDE.md" not in files, f"CLAUDE.md should not appear with --agent codex, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_generic_findings_reference_agents_md(self, generic_only: Path) -> None: """No --agent on generic project: findings must exist and reference AGENTS.md.""" @@ -423,6 +473,8 @@ def test_generic_findings_reference_agents_md(self, generic_only: Path) -> None: class TestEmptyAgentString: """--agent '' must behave identically to no --agent flag.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_empty_string_detects_files(self, generic_only: Path) -> None: """--agent '' should auto-detect like no flag.""" @@ -431,6 +483,8 @@ def test_empty_string_detects_files(self, generic_only: Path) -> None: assert set(data_no_flag.get("files", {}).keys()) == set(data_empty.get("files", {}).keys()) assert data_no_flag.get("stats", {}).get("total_findings") == data_empty.get("stats", {}).get("total_findings") + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_empty_string_core_rules_present(self, generic_only: Path) -> None: """--agent '' must fire CORE rules.""" @@ -438,6 +492,8 @@ def test_empty_string_core_rules_present(self, generic_only: Path) -> None: namespaces = _finding_namespaces(data) assert "CORE" in namespaces, f"CORE rules missing with --agent '': {namespaces}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_empty_string_hint_agents_md(self, empty_dir: Path) -> None: """--agent '' on empty project should hint AGENTS.md, not CLAUDE.md.""" output = _check_text(empty_dir, agent="") @@ -454,6 +510,8 @@ def test_empty_string_hint_agents_md(self, empty_dir: Path) -> None: class TestNestedFileDiscovery: """Instruction files in subdirectories must be discovered and scanned.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_nested_claude_md_detected(self, nested_claude: Path) -> None: """CLAUDE.md in a nested subdirectory must be found by --agent claude.""" @@ -463,6 +521,8 @@ def test_nested_claude_md_detected(self, nested_claude: Path) -> None: files = _finding_files(data) assert "CLAUDE.md" in files, f"Root CLAUDE.md not in finding files: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_nested_both_files_scanned(self, nested_claude: Path) -> None: """Root CLAUDE.md must appear in findings; nested may be folded into root location.""" @@ -472,6 +532,8 @@ def test_nested_both_files_scanned(self, nested_claude: Path) -> None: files = _finding_files(data) assert "CLAUDE.md" in files, f"Root CLAUDE.md missing from findings: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_nested_has_findings(self, nested_claude: Path) -> None: """Project with nested CLAUDE.md files must produce findings.""" @@ -488,16 +550,22 @@ def test_nested_has_findings(self, nested_claude: Path) -> None: class TestConfigOnlyProject: """Config files (.claude/settings.json) without instruction files must not false-detect.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_config_only_no_files_detected(self, config_only: Path) -> None: """Project with only .claude/settings.json should report no instruction files.""" output = _check_text(config_only) assert "No instruction files found" in output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_config_only_no_findings(self, config_only: Path) -> None: """Config-only project should produce no findings.""" data = _check_json(config_only) assert len(data.get("files", {})) == 0, "Config-only project should have no files with findings" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_config_only_claude_agent_no_files(self, config_only: Path) -> None: """--agent claude on config-only project should find no instruction files.""" output = _check_text(config_only, agent="claude") @@ -518,6 +586,8 @@ def test_config_only_claude_agent_no_files(self, config_only: Path) -> None: class TestViolationDeduplication: """JSON output must not contain duplicate violations.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_no_duplicate_findings(self, claude_only: Path) -> None: """Each (rule, file, line) tuple must appear at most once.""" @@ -528,6 +598,8 @@ def test_no_duplicate_findings(self, claude_only: Path) -> None: assert key not in seen, f"Duplicate finding: {key}" seen.add(key) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_finding_count_consistent(self, claude_only: Path) -> None: """Total findings from per-file counts must match stats.total_findings.""" @@ -551,6 +623,8 @@ def test_finding_count_consistent(self, claude_only: Path) -> None: class TestGenericAgentTemplateResolution: """--agent generic must resolve template vars from detected files.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_generic_agent_produces_findings(self, codex_only: Path) -> None: """--agent generic on a project with AGENTS.md must produce findings.""" @@ -561,6 +635,8 @@ def test_generic_agent_produces_findings(self, codex_only: Path) -> None: f"Template context likely empty — rules can't match files. stats={data.get('stats', {})}" ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_generic_agent_has_findings(self, codex_only: Path) -> None: """--agent generic must produce findings on imperfect content.""" @@ -580,6 +656,8 @@ def test_generic_agent_has_findings(self, codex_only: Path) -> None: class TestUnknownAgentValidation: """Unknown --agent values must produce an error, not silent failure.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_unknown_agent_exits_2(self, claude_only: Path) -> None: """--agent doesnotexist must exit with code 2.""" result = runner.invoke( @@ -594,6 +672,8 @@ def test_unknown_agent_exits_2(self, claude_only: Path) -> None: assert result.exit_code == 2, f"Expected exit 2, got {result.exit_code}" assert "Unknown agent" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_unknown_agent_shows_known_list(self, claude_only: Path) -> None: """Error message must list valid agents.""" result = runner.invoke( @@ -608,6 +688,8 @@ def test_unknown_agent_shows_known_list(self, claude_only: Path) -> None: assert "claude" in result.output assert "codex" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_uppercase_agent_no_match(self, claude_only: Path) -> None: """--agent CLAUDE (uppercase) finds no files — agents are case-sensitive.""" result = runner.invoke( @@ -635,6 +717,8 @@ def test_uppercase_agent_no_match(self, claude_only: Path) -> None: class TestFormatValidation: """Invalid -f values must produce an error, not silent fallback.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_unknown_format_falls_through(self, claude_only: Path) -> None: """-f sarif falls through to text output (no format validation gate).""" result = runner.invoke( @@ -661,6 +745,8 @@ def test_unknown_format_falls_through(self, claude_only: Path) -> None: class TestDefaultAgentConfig: """default_agent in .ails/config.yml must control agent scoping.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_config_default_agent_scopes_files(self, multi_agent_with_config: Path) -> None: """default_agent: claude in config must scope to CLAUDE.md without --agent flag.""" @@ -669,6 +755,8 @@ def test_config_default_agent_scopes_files(self, multi_agent_with_config: Path) assert "AGENTS.md" not in files, f"default_agent: claude should not scan AGENTS.md, got: {files}" assert "CLAUDE.md" in files, f"default_agent: claude should scan CLAUDE.md, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_cli_flag_overrides_config(self, multi_agent_with_config: Path) -> None: """--agent codex must override default_agent: claude from config.""" @@ -677,6 +765,8 @@ def test_cli_flag_overrides_config(self, multi_agent_with_config: Path) -> None: assert "AGENTS.md" in files, f"--agent codex should scan AGENTS.md, got: {files}" assert "CLAUDE.md" not in files, f"--agent codex should not scan CLAUDE.md, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules 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.""" @@ -699,6 +789,8 @@ def test_no_config_mixed_signals_scans_all(self, multi_agent: Path) -> None: class TestConfigSetGet: """ails config set/get round-trips values correctly.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_set_then_get(self, tmp_path: Path) -> None: """set + get round-trip for a string key.""" project = tmp_path / "project" @@ -710,16 +802,8 @@ def test_set_then_get(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "claude" in result.output - def test_set_bool(self, tmp_path: Path) -> None: - """set + get round-trip for a boolean key.""" - project = tmp_path / "project" - project.mkdir() - runner.invoke(app, ["config", "set", "recommended", "false", "--path", str(project)]) - - result = runner.invoke(app, ["config", "get", "recommended", "--path", str(project)]) - assert result.exit_code == 0 - assert "False" in result.output - + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_set_list(self, tmp_path: Path) -> None: """set + get round-trip for a list key.""" project = tmp_path / "project" @@ -730,6 +814,8 @@ def test_set_list(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "vendor" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_get_unset_key(self, tmp_path: Path) -> None: """get on an unset known key shows (not set).""" project = tmp_path / "project" @@ -738,6 +824,8 @@ def test_get_unset_key(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "not set" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_set_unknown_key(self, tmp_path: Path) -> None: """set with an unknown key exits with code 2.""" project = tmp_path / "project" @@ -746,6 +834,8 @@ def test_set_unknown_key(self, tmp_path: Path) -> None: assert result.exit_code == 2 assert "Unknown config key" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_get_unknown_key(self, tmp_path: Path) -> None: """get with an unknown key exits with code 2.""" project = tmp_path / "project" @@ -759,6 +849,8 @@ def test_get_unknown_key(self, tmp_path: Path) -> None: class TestConfigList: """ails config list shows all values.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_list_no_project_config(self, tmp_path: Path) -> None: """list with no project config exits cleanly.""" project = tmp_path / "project" @@ -766,17 +858,20 @@ def test_list_no_project_config(self, tmp_path: Path) -> None: result = runner.invoke(app, ["config", "list", "--path", str(project)]) assert result.exit_code == 0 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_list_shows_values(self, tmp_path: Path) -> None: """list after setting values shows them.""" project = tmp_path / "project" project.mkdir() runner.invoke(app, ["config", "set", "default_agent", "cursor", "--path", str(project)]) - runner.invoke(app, ["config", "set", "recommended", "false", "--path", str(project)]) + runner.invoke(app, ["config", "set", "exclude_dirs", "vendor,dist", "--path", str(project)]) result = runner.invoke(app, ["config", "list", "--path", str(project)]) assert result.exit_code == 0 assert "default_agent: cursor" in result.output - assert "recommended: False" in result.output + assert "exclude_dirs:" in result.output + assert "vendor" in result.output @pytest.mark.e2e @@ -793,6 +888,8 @@ def _patch_global(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None lambda: self.global_home / "config.yml", ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_global_set_then_get(self) -> None: """--global set + get round-trip.""" result = runner.invoke(app, ["config", "set", "--global", "default_agent", "claude"]) @@ -803,21 +900,27 @@ def test_global_set_then_get(self) -> None: assert result.exit_code == 0 assert "claude" in result.output - def test_global_set_recommended(self) -> None: - """--global set works for boolean key.""" - result = runner.invoke(app, ["config", "set", "--global", "recommended", "false"]) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux + def test_global_set_tier(self) -> None: + """--global set works for string key.""" + result = runner.invoke(app, ["config", "set", "--global", "tier", "pro"]) assert result.exit_code == 0 - result = runner.invoke(app, ["config", "get", "--global", "recommended"]) + result = runner.invoke(app, ["config", "get", "--global", "tier"]) assert result.exit_code == 0 - assert "False" in result.output + assert "pro" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_global_rejects_non_global_key(self) -> None: """--global set with a project-only key errors.""" result = runner.invoke(app, ["config", "set", "--global", "exclude_dirs", "vendor"]) assert result.exit_code == 2 assert "not supported in global config" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_global_list(self) -> None: """--global list shows only global config.""" runner.invoke(app, ["config", "set", "--global", "default_agent", "claude"]) @@ -826,12 +929,16 @@ def test_global_list(self) -> None: assert result.exit_code == 0 assert "default_agent: claude" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_global_list_empty(self) -> None: """--global list with no config shows empty message.""" result = runner.invoke(app, ["config", "list", "--global"]) assert result.exit_code == 0 assert "No global configuration set" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_list_annotates_global_fallback(self, tmp_path: Path) -> None: """project list shows global values annotated with (global).""" project = tmp_path / "project" @@ -848,6 +955,8 @@ def test_list_annotates_global_fallback(self, tmp_path: Path) -> None: if line.startswith("exclude_dirs:"): assert "(global)" not in line, f"exclude_dirs should not be global: {line}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_project_overrides_global_in_list(self, tmp_path: Path) -> None: """project value wins over global — no (global) annotation.""" project = tmp_path / "project" @@ -871,10 +980,12 @@ def _patch_global(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None self.global_home = tmp_path / "fake_home" / ".reporails" self.global_home.mkdir(parents=True) monkeypatch.setattr( - "reporails_cli.core.bootstrap.get_global_config_path", + "reporails_cli.core.platform.config.bootstrap.get_global_config_path", lambda: self.global_home / "config.yml", ) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_global_default_agent_scopes_check(self, tmp_path: Path) -> None: """Global default_agent: claude scopes ails check to CLAUDE.md.""" @@ -892,6 +1003,8 @@ def test_global_default_agent_scopes_check(self, tmp_path: Path) -> None: assert "CLAUDE.md" in files, f"global default_agent: claude should scan CLAUDE.md, got: {files}" assert "AGENTS.md" not in files, f"global default_agent: claude should not scan AGENTS.md, got: {files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_project_default_agent_overrides_global(self, tmp_path: Path) -> None: """Project default_agent overrides global — check uses project value.""" @@ -916,8 +1029,8 @@ def test_project_default_agent_overrides_global(self, tmp_path: Path) -> None: # =========================================================================== # Version Command # -# `ails version` shows CLI, framework, and recommended versions plus -# install method. Must always succeed regardless of installed state. +# `ails version` shows CLI version plus install method. Must always +# succeed regardless of installed state. # =========================================================================== @@ -925,18 +1038,26 @@ def test_project_default_agent_overrides_global(self, tmp_path: Path) -> None: class TestVersionCommand: """ails version must show version info and exit cleanly.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_exits_zero(self) -> None: result = runner.invoke(app, ["version"]) assert result.exit_code == 0, f"version failed:\n{result.output}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_shows_cli_version(self) -> None: result = runner.invoke(app, ["version"]) assert "CLI:" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_shows_install_line(self) -> None: result = runner.invoke(app, ["version"]) assert "Install:" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_shows_install_method(self) -> None: result = runner.invoke(app, ["version"]) assert "Install:" in result.output @@ -954,16 +1075,22 @@ def test_shows_install_method(self) -> None: class TestExplainCommand: """ails explain shows rule details or errors on unknown rule.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_known_rule_exits_zero(self) -> None: result = runner.invoke(app, ["explain", "CORE:S:0002"]) assert result.exit_code == 0, f"explain failed:\n{result.output}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_known_rule_shows_id(self) -> None: result = runner.invoke(app, ["explain", "CORE:S:0002"]) assert "CORE:S:0002" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_known_rule_shows_category(self) -> None: result = runner.invoke(app, ["explain", "CORE:S:0002"]) @@ -971,10 +1098,14 @@ def test_known_rule_shows_category(self) -> None: output_lower = result.output.lower() assert "category" in output_lower or "structure" in output_lower + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_unknown_rule_exits_2(self) -> None: result = runner.invoke(app, ["explain", "FAKE:Z:9999"]) assert result.exit_code == 2, f"Expected exit 2, got {result.exit_code}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_unknown_rule_shows_error(self) -> None: result = runner.invoke(app, ["explain", "FAKE:Z:9999"]) assert "unknown" in result.output.lower() or "not found" in result.output.lower() @@ -992,10 +1123,14 @@ def test_unknown_rule_shows_error(self) -> None: class TestHealCommand: """ails heal applies auto-fixes and reports results.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_missing_path_errors(self) -> None: result = runner.invoke(app, ["heal", "/tmp/no-such-path-xyz-abc-987"]) assert result.exit_code != 0 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_heal_runs_on_project(self, tmp_path: Path) -> None: """heal on a minimal project exits cleanly.""" @@ -1006,6 +1141,8 @@ def test_heal_runs_on_project(self, tmp_path: Path) -> None: result = runner.invoke(app, ["heal", str(project)]) assert result.exit_code in (0, None), f"heal failed:\n{result.output}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_heal_modifies_files(self, tmp_path: Path) -> None: """heal must actually modify files when fixes are available.""" @@ -1019,6 +1156,8 @@ def test_heal_modifies_files(self, tmp_path: Path) -> None: # Heal should add missing sections (e.g., ## Commands, ## Testing) assert len(content) >= len(original), "heal should not shrink files" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_heal_json_output(self, tmp_path: Path) -> None: """heal -f json must produce valid JSON with expected keys.""" @@ -1032,6 +1171,8 @@ def test_heal_json_output(self, tmp_path: Path) -> None: assert "auto_fixed" in data assert "summary" in data + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_heal_with_agent(self, tmp_path: Path) -> None: """heal --agent claude scopes to CLAUDE.md.""" @@ -1042,6 +1183,8 @@ def test_heal_with_agent(self, tmp_path: Path) -> None: result = runner.invoke(app, ["heal", str(project), "--agent", "claude"]) assert result.exit_code in (0, None), f"heal --agent failed:\n{result.output}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_heal_empty_project(self, tmp_path: Path) -> None: """heal on empty project (no instruction files) exits cleanly.""" project = tmp_path / "empty" @@ -1052,74 +1195,8 @@ def test_heal_empty_project(self, tmp_path: Path) -> None: assert result.exit_code in (0, 1, None) -# =========================================================================== -# Map Command -# -# `ails map` detects agents and project layout. Supports text, yaml, -# json output formats and --save to write backbone.yml. -# =========================================================================== - - -@pytest.mark.e2e -class TestMapCommand: - """ails map detects agents and project structure.""" - - def test_text_output(self, claude_only: Path) -> None: - result = runner.invoke(app, ["map", str(claude_only)]) - assert result.exit_code == 0, f"map failed:\n{result.output}" - output_lower = result.output.lower() - assert "claude" in output_lower - - 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 - - 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 / ".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"]) - assert result.exit_code == 0 - data = json.loads(result.output) - agents = data.get("agents", {}) - assert len(agents) >= 2, f"Expected multiple agents, got: {list(agents.keys())}" - - def test_missing_path_errors(self) -> None: - result = runner.invoke(app, ["map", "/tmp/no-such-path-xyz-abc-987"]) - assert result.exit_code == 1 - - def test_empty_dir_no_crash(self, tmp_path: Path) -> None: - project = tmp_path / "empty" - project.mkdir() - result = runner.invoke(app, ["map", str(project)]) - assert result.exit_code == 0 - - # Dismiss and Judge commands removed in 0.5.2 — tests removed. +# `ails map` command removed in 0.5.9 — tests removed. # =========================================================================== @@ -1134,6 +1211,8 @@ def test_empty_dir_no_crash(self, tmp_path: Path) -> None: class TestInstallCommand: """ails install detects agents and writes MCP config.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_install_detects_claude(self, tmp_path: Path) -> None: """Install on project with CLAUDE.md detects claude agent.""" project = tmp_path / "project" @@ -1147,6 +1226,8 @@ def test_install_detects_claude(self, tmp_path: Path) -> None: assert "claude" in result.output.lower() assert "Restart" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_install_no_agents(self, tmp_path: Path) -> None: """Install on empty project succeeds with guidance message.""" project = tmp_path / "empty" @@ -1156,10 +1237,14 @@ def test_install_no_agents(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "No supported agents" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_install_missing_path(self) -> None: result = runner.invoke(app, ["install", "/tmp/no-such-path-xyz-abc-987"]) assert result.exit_code == 1 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux def test_install_writes_config_file(self, tmp_path: Path) -> None: """Install must write an MCP config file.""" project = tmp_path / "project" @@ -1189,12 +1274,16 @@ def test_install_writes_config_file(self, tmp_path: Path) -> None: class TestCheckFlags: """Additional flag coverage for ails check.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_strict_exits_1_on_violations(self, generic_only: Path) -> None: """--strict must exit 1 when violations exist.""" result = runner.invoke(app, ["check", str(generic_only), "--strict"]) assert result.exit_code == 1 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_json_output_valid(self, claude_only: Path) -> None: data = _check_json(claude_only, agent="claude") @@ -1202,6 +1291,8 @@ def test_json_output_valid(self, claude_only: Path) -> None: assert "stats" in data assert isinstance(data["stats"]["total_findings"], int) + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_verbose_output(self, claude_only: Path) -> None: result = runner.invoke(app, ["check", str(claude_only), "-f", "text", "-v", "--agent", "claude"]) @@ -1209,6 +1300,8 @@ def test_verbose_output(self, claude_only: Path) -> None: # Verbose mode shows more detail — at minimum, rule IDs assert "CORE:" in result.output + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_exclude_dir(self, tmp_path: Path) -> None: """--exclude-dir prevents scanning files in that directory.""" @@ -1229,6 +1322,8 @@ def test_exclude_dir(self, tmp_path: Path) -> None: excluded_files = _finding_files(excluded_data) assert not any("vendor" in f for f in excluded_files), f"vendor dir not excluded: {excluded_files}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_ascii_mode(self, claude_only: Path) -> None: """--ascii must not produce Unicode box-drawing characters.""" @@ -1253,6 +1348,8 @@ def test_ascii_mode(self, claude_only: Path) -> None: class TestMechanicalChecksE2E: """Mechanical checks must produce violations in ails check output.""" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_missing_git_produces_findings(self, tmp_path: Path) -> None: """Project without .git directory — mechanical pipeline runs and produces findings.""" @@ -1269,6 +1366,8 @@ def test_missing_git_produces_findings(self, tmp_path: Path) -> None: total = data.get("stats", {}).get("total_findings", 0) assert total > 0, "Minimal project without .git should produce findings" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_findings_include_rule_metadata(self, tmp_path: Path) -> None: """Each finding has required fields: rule, line, message, severity.""" @@ -1286,6 +1385,8 @@ def test_findings_include_rule_metadata(self, tmp_path: Path) -> None: assert "message" in f, f"Missing message in finding: {f}" assert "severity" in f, f"Missing severity in finding: {f}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_findings_have_line_numbers(self, tmp_path: Path) -> None: """Findings must have integer line numbers.""" @@ -1298,6 +1399,8 @@ def test_findings_have_line_numbers(self, tmp_path: Path) -> None: for f in _all_findings(data): assert isinstance(f["line"], int), f"Finding {f['rule']} has non-integer line: {f['line']}" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_mechanical_violations_in_text_output(self, tmp_path: Path) -> None: """Mechanical violations appear in text output (not just JSON).""" @@ -1310,6 +1413,8 @@ def test_mechanical_violations_in_text_output(self, tmp_path: Path) -> None: # Text output should contain violation indicators and reference the file assert "CLAUDE.md" in output, "Text output should reference the instruction file" + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_stats_reflect_mechanical_findings(self, claude_only: Path) -> None: """Stats include both mechanical and deterministic finding counts.""" @@ -1320,6 +1425,8 @@ def test_stats_reflect_mechanical_findings(self, claude_only: Path) -> None: assert isinstance(stats["total_findings"], int) assert stats["total_findings"] >= 0 + @pytest.mark.e2e + @pytest.mark.subsys_cli_ux @requires_rules def test_oversized_file_does_not_crash(self, tmp_path: Path) -> None: """Large instruction file is handled gracefully (no crash).""" diff --git a/tests/unit/architecture/__init__.py b/tests/unit/architecture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/architecture/test_adapter_boundary.py b/tests/unit/architecture/test_adapter_boundary.py new file mode 100644 index 0000000..5f257f8 --- /dev/null +++ b/tests/unit/architecture/test_adapter_boundary.py @@ -0,0 +1,82 @@ +"""Adapter boundary — `core/platform/adapters/` consumes core, not subsystems. + +`adapters/` translates between the pure core layer and wired state. It may +import from `core/platform/{contract,dto,policy}` and from +`core/platform/{config,observability,utils}` (infrastructure). It must NOT +import from any `core//` (cache, classify, mapper, ...) or from +`interfaces/` or `formatters/`. Subsystems import adapters, not the reverse. + +Runs in **report-only** mode today. Flip `_FAIL_ON_VIOLATION = True` once +Phase 5 of the platform migration completes. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent.parent.parent +ADAPTERS = ROOT / "src" / "reporails_cli" / "core" / "platform" / "adapters" + +_FORBIDDEN_PREFIXES = ( + "reporails_cli.interfaces", + "reporails_cli.formatters", +) + +_FORBIDDEN_SUBSYSTEM_PREFIXES = ( + "reporails_cli.core.cache.", + "reporails_cli.core.classify.", + "reporails_cli.core.discovery.", + "reporails_cli.core.funnel.", + "reporails_cli.core.heal.", + "reporails_cli.core.install.", + "reporails_cli.core.lint.", + "reporails_cli.core.mapper.", +) + +_FAIL_ON_VIOLATION = True + +# Known temporary exceptions. Each entry: (importer_path_relative_to_root, imported_module). +# Removed as the corresponding migration phase completes. +_KNOWN_EXCEPTIONS: set[tuple[str, str]] = set() # all entries resolved by Phase 7 + + +def _iter_imports(file_path: Path) -> list[str]: + try: + tree = ast.parse(file_path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + return [] + out: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + out.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + out.append(node.module) + return out + + +@pytest.mark.architecture +def test_adapters_do_not_import_subsystems_or_outer_layers() -> None: + """Adapters must not depend on subsystems, interfaces, or formatters.""" + if not ADAPTERS.is_dir(): + pytest.skip(f"{ADAPTERS} does not exist yet") + forbidden = _FORBIDDEN_PREFIXES + _FORBIDDEN_SUBSYSTEM_PREFIXES + violations: list[tuple[Path, str]] = [ + (py, imp) + for py in sorted(ADAPTERS.rglob("*.py")) + if "__pycache__" not in py.parts + for imp in _iter_imports(py) + if any(imp.startswith(prefix) for prefix in forbidden) + and (str(py.relative_to(ROOT)), imp) not in _KNOWN_EXCEPTIONS + ] + if not violations: + return + lines = [f"adapters layer has {len(violations)} forbidden import(s):"] + lines.extend(f" {v[0].relative_to(ROOT)} imports {v[1]}" for v in violations) + msg = "\n".join(lines) + if _FAIL_ON_VIOLATION: + pytest.fail(msg) + else: + print(f"\n[report-only]\n{msg}\n") diff --git a/tests/unit/architecture/test_core_purity.py b/tests/unit/architecture/test_core_purity.py new file mode 100644 index 0000000..fb97cff --- /dev/null +++ b/tests/unit/architecture/test_core_purity.py @@ -0,0 +1,109 @@ +"""Hexagonal core purity — `core/platform/{contract,dto,policy}` must be pure. + +Pure means: no imports from `core/platform/{adapters,runtime,config,observability,utils}`, +no imports from any `core//` package, no imports from `interfaces/` or +`formatters/`. Validators, data shapes, and decision functions in the pure core +layer should be testable in isolation without dragging wiring or framework code. + +Runs in **report-only** mode today — prints violations without failing — because +the platform substrate is freshly bootstrapped and code has not yet migrated +into `contract/`, `dto/`, `policy/`. The check is wired up so it catches the +first regression once migration begins. + +Flip `_FAIL_ON_VIOLATION = True` once Phase 5 of the platform migration completes. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent.parent.parent +PLATFORM = ROOT / "src" / "reporails_cli" / "core" / "platform" +PURE_LAYERS = ("contract", "dto", "policy") + +# Imports that pure layers must not pull in. +_FORBIDDEN_PREFIXES = ( + "reporails_cli.core.platform.adapters", + "reporails_cli.core.platform.runtime", + "reporails_cli.core.platform.config", + "reporails_cli.core.platform.observability", + "reporails_cli.core.platform.utils", + "reporails_cli.interfaces", + "reporails_cli.formatters", +) + +# Subsystem subpackages (siblings of `platform/` inside `core/`). Populated as +# subsystems migrate. Pure layers must not depend on them. +_FORBIDDEN_SUBSYSTEM_PREFIXES = ( + "reporails_cli.core.cache.", + "reporails_cli.core.classify.", + "reporails_cli.core.discovery.", + "reporails_cli.core.funnel.", + "reporails_cli.core.heal.", + "reporails_cli.core.install.", + "reporails_cli.core.lint.", + "reporails_cli.core.mapper.", +) + +_FAIL_ON_VIOLATION = True + +# Known temporary exceptions. Each entry: (importer_path_relative_to_root, imported_module). +# Removed as the corresponding migration phase completes. +_KNOWN_EXCEPTIONS: set[tuple[str, str]] = set() + + +def _iter_imports(file_path: Path) -> list[str]: + try: + tree = ast.parse(file_path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + return [] + out: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + out.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + out.append(node.module) + return out + + +def _forbidden_imports_in(layer_dir: Path) -> list[tuple[Path, str]]: + if not layer_dir.is_dir(): + return [] + forbidden = _FORBIDDEN_PREFIXES + _FORBIDDEN_SUBSYSTEM_PREFIXES + return [ + (py, imp) + for py in sorted(layer_dir.rglob("*.py")) + if "__pycache__" not in py.parts + for imp in _iter_imports(py) + if any(imp.startswith(prefix) for prefix in forbidden) + and (str(py.relative_to(ROOT)), imp) not in _KNOWN_EXCEPTIONS + ] + + +@pytest.mark.architecture +def test_platform_skeleton_exists() -> None: + """Sanity check: the eight platform layer directories exist.""" + expected = {"contract", "dto", "policy", "adapters", "runtime", "config", "observability", "utils"} + actual = {p.name for p in PLATFORM.iterdir() if p.is_dir() and not p.name.startswith("_")} + missing = expected - actual + assert not missing, f"platform layer directories missing: {sorted(missing)}" + + +@pytest.mark.architecture +def test_pure_layers_have_no_forbidden_imports() -> None: + """`core/platform/{contract,dto,policy}` must not import wiring or subsystem code.""" + all_violations: list[tuple[Path, str]] = [] + for layer in PURE_LAYERS: + all_violations.extend(_forbidden_imports_in(PLATFORM / layer)) + if not all_violations: + return + lines = [f"pure core has {len(all_violations)} forbidden import(s):"] + lines.extend(f" {v[0].relative_to(ROOT)} imports {v[1]}" for v in all_violations) + msg = "\n".join(lines) + if _FAIL_ON_VIOLATION: + pytest.fail(msg) + else: + print(f"\n[report-only]\n{msg}\n") diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index 904b274..df11b52 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -9,15 +9,16 @@ import pytest import yaml -from reporails_cli.core.agents import ( +from reporails_cli.core.discovery.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 ( +from reporails_cli.core.platform.adapters.registry import _apply_agent_overrides, _is_other_agent_rule, load_rules +from reporails_cli.core.platform.config.bootstrap import get_agent_config +from reporails_cli.core.platform.dto.models import ( AgentConfig, Category, Check, @@ -25,7 +26,6 @@ RuleType, Severity, ) -from reporails_cli.core.registry import _apply_agent_overrides, _is_other_agent_rule, load_rules # ============================================================================= # get_agent_config tests @@ -35,6 +35,8 @@ class TestGetAgentConfig: """Test loading agent config from framework.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_loads_excludes_and_overrides(self, tmp_path: Path, make_config_file) -> None: config_data = { "agent": "claude", @@ -46,7 +48,7 @@ def test_loads_excludes_and_overrides(self, tmp_path: Path, make_config_file) -> } config_path = make_config_file(yaml.dump(config_data), subdir="agents/claude") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result.agent == "claude" @@ -54,9 +56,11 @@ def test_loads_excludes_and_overrides(self, tmp_path: Path, make_config_file) -> assert result.overrides["E2-no-ritual-section"] == {"severity": "medium"} assert result.overrides["E5-no-grep-guidance"] == {"severity": "low", "disabled": True} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_missing_config_returns_defaults(self) -> None: with patch( - "reporails_cli.core.bootstrap.get_agent_config_path", + "reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=Path("/nonexistent/config.yml"), ): result = get_agent_config("claude") @@ -65,33 +69,41 @@ def test_missing_config_returns_defaults(self) -> None: assert result.excludes == [] assert result.overrides == {} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_malformed_yaml_returns_defaults(self, tmp_path: Path, make_config_file) -> None: config_path = make_config_file(": : : invalid yaml [[[", subdir=".", name="config.yml") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result == AgentConfig() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_file_returns_defaults(self, tmp_path: Path, make_config_file) -> None: config_path = make_config_file("", subdir=".", name="config.yml") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result == AgentConfig() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_config_without_excludes_or_overrides(self, tmp_path: Path, make_config_file) -> None: config_data = {"agent": "claude", "vars": {"instruction_files": "CLAUDE.md"}} config_path = make_config_file(yaml.dump(config_data), subdir=".", name="config.yml") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result.agent == "claude" assert result.excludes == [] assert result.overrides == {} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_loads_prefix_name_core(self, tmp_path: Path, make_config_file) -> None: config_data = { "agent": "claude", @@ -102,7 +114,7 @@ def test_loads_prefix_name_core(self, tmp_path: Path, make_config_file) -> None: } config_path = make_config_file(yaml.dump(config_data), subdir="agents/claude") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result.prefix == "CLAUDE" @@ -110,24 +122,28 @@ def test_loads_prefix_name_core(self, tmp_path: Path, make_config_file) -> None: assert result.core is False assert result.excludes == ["CODEX:*"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_missing_new_fields_default(self, tmp_path: Path, make_config_file) -> None: """Config without prefix/name/core gets empty defaults.""" config_data = {"agent": "claude"} config_path = make_config_file(yaml.dump(config_data), subdir="agents/claude") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("claude") assert result.prefix == "" assert result.name == "" assert result.core is False + @pytest.mark.unit + @pytest.mark.subsys_lint def test_core_agent_config(self, tmp_path: Path, make_config_file) -> None: """Core agent (generic) sets core=True.""" config_data = {"agent": "generic", "prefix": "CORE", "core": True} config_path = make_config_file(yaml.dump(config_data), subdir="agents/generic") - with patch("reporails_cli.core.bootstrap.get_agent_config_path", return_value=config_path): + with patch("reporails_cli.core.platform.config.bootstrap.get_agent_config_path", return_value=config_path): result = get_agent_config("generic") assert result.core is True @@ -154,6 +170,8 @@ def _make_rule(rule_id: str, checks: list[Check], severity: Severity = Severity. class TestApplyAgentOverrides: """Test agent check-level overrides.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_severity_changed(self) -> None: checks = [Check(id="E2-check")] rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} @@ -164,6 +182,8 @@ def test_severity_changed(self) -> None: # Severity override now lifted to rule level assert result["E2"].severity == Severity.LOW + @pytest.mark.unit + @pytest.mark.subsys_lint def test_check_disabled(self) -> None: checks = [ Check(id="E2-check-a"), @@ -177,6 +197,8 @@ def test_check_disabled(self) -> None: assert len(result["E2"].checks) == 1 assert result["E2"].checks[0].id == "E2-check-b" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_nonexistent_check_is_noop(self) -> None: checks = [Check(id="E2-check")] rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} @@ -186,6 +208,8 @@ def test_nonexistent_check_is_noop(self) -> None: assert result["E2"].severity == Severity.HIGH # unchanged + @pytest.mark.unit + @pytest.mark.subsys_lint def test_all_checks_disabled_leaves_empty_list(self) -> None: checks = [Check(id="E2-check")] rules = {"E2": _make_rule("E2", checks)} @@ -195,6 +219,8 @@ def test_all_checks_disabled_leaves_empty_list(self) -> None: assert result["E2"].checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_severity_skipped(self) -> None: checks = [Check(id="E2-check")] rules = {"E2": _make_rule("E2", checks, severity=Severity.HIGH)} @@ -204,6 +230,8 @@ def test_invalid_severity_skipped(self) -> None: # Invalid severity is skipped — original rule severity unchanged assert result["E2"].severity == Severity.HIGH + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_rules_overridden(self) -> None: rules = { "E2": _make_rule("E2", [Check(id="E2-c1")], severity=Severity.HIGH), @@ -228,6 +256,8 @@ def test_multiple_rules_overridden(self) -> None: class TestLoadRulesExcludes: """Test that agent excludes remove rules from the loaded set.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_excludes_removes_rules(self, tmp_path: Path) -> None: """Excluded rule IDs are filtered out.""" # Create a minimal rules dir with two rules (new format: rule.md in slug dirs) @@ -241,12 +271,14 @@ 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): + with patch("reporails_cli.core.platform.adapters.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 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_excludes_nonexistent_rule_is_noop(self, tmp_path: Path) -> None: """Excluding a rule ID that doesn't exist is harmless.""" rule_dir = tmp_path / "core" / "structure" / "rule-a" @@ -258,11 +290,13 @@ 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): + with patch("reporails_cli.core.platform.adapters.registry.get_agent_config", return_value=agent_config): rules = load_rules([tmp_path], agent="test") assert "CORE:S:0001" in rules + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_agent_skips_processing(self, tmp_path: Path) -> None: """Empty agent string skips agent config loading entirely.""" rule_dir = tmp_path / "core" / "structure" / "rule-a" @@ -277,6 +311,8 @@ def test_no_agent_skips_processing(self, tmp_path: Path) -> None: assert "CORE:S:0001" in rules + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_excludes_match_namespaced_rules(self, tmp_path: Path) -> None: """Glob pattern CLAUDE:* excludes all CLAUDE-namespaced rules.""" rule_fm = ( @@ -299,7 +335,7 @@ 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): + with patch("reporails_cli.core.platform.adapters.registry.get_agent_config", return_value=agent_config): rules = load_rules([tmp_path], agent="copilot") assert "CORE:S:0001" in rules @@ -307,6 +343,8 @@ def test_glob_excludes_match_namespaced_rules(self, tmp_path: Path) -> None: assert "CLAUDE:C:0002" not in rules assert "CODEX:S:0001" not in rules + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exact_and_glob_excludes_coexist(self, tmp_path: Path) -> None: """Exact IDs and glob patterns work together in excludes.""" rule_fm = ( @@ -327,7 +365,7 @@ 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): + with patch("reporails_cli.core.platform.adapters.registry.get_agent_config", return_value=agent_config): rules = load_rules([tmp_path], agent="test") assert "CORE:S:0001" not in rules # exact exclude @@ -343,6 +381,8 @@ def test_exact_and_glob_excludes_coexist(self, tmp_path: Path) -> None: class TestPrefixNamespaceFiltering: """Test that agent_config.prefix is used for namespace filtering.""" + @pytest.mark.unit + @pytest.mark.subsys_lint @pytest.mark.parametrize( ("rule_id", "agent_prefix", "expected"), [ @@ -357,6 +397,8 @@ class TestPrefixNamespaceFiltering: def test_is_other_agent_rule(self, rule_id: str, agent_prefix: str, expected: bool) -> None: assert _is_other_agent_rule(rule_id, agent_prefix) == expected + @pytest.mark.unit + @pytest.mark.subsys_lint def test_prefix_from_config_used(self, tmp_path: Path) -> None: """When agent_config.prefix is set, it's used instead of agent.upper().""" rule_fm = ( @@ -375,13 +417,15 @@ 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): + with patch("reporails_cli.core.platform.adapters.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 assert "OTHER:S:0001" not in rules + @pytest.mark.unit + @pytest.mark.subsys_lint def test_fallback_to_agent_upper_without_prefix(self, tmp_path: Path) -> None: """Without prefix in config, falls back to agent.upper().""" rule_fm = ( @@ -400,7 +444,7 @@ 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): + with patch("reporails_cli.core.platform.adapters.registry.get_agent_config", return_value=agent_config): rules = load_rules([tmp_path], agent="claude") assert "CORE:S:0001" in rules @@ -416,6 +460,8 @@ def test_fallback_to_agent_upper_without_prefix(self, tmp_path: Path) -> None: class TestGlobExcludePatterns: """Verify fnmatch patterns match rule IDs correctly.""" + @pytest.mark.unit + @pytest.mark.subsys_lint @pytest.mark.parametrize( ("rule_id", "pattern", "should_match"), [ @@ -448,6 +494,8 @@ def _detected(agent_id: str, files: list[str] | None = None) -> DetectedAgent: class TestAutoDetectAgent: """Test auto_detect_agent picks agent only when unambiguous.""" + @pytest.mark.unit + @pytest.mark.subsys_lint @pytest.mark.parametrize( ("agents", "expected"), [ @@ -501,6 +549,8 @@ def _make_detected(agent_id: str, instruction_files: list[str], config_files: li class TestCodexGenericDisambiguation: """Three-tier codex/generic disambiguation on AGENTS.md projects.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_tier1_override_file_picks_codex(self, tmp_path: Path) -> None: """AGENTS.override.md present → codex (definitive).""" detected = [ @@ -512,6 +562,8 @@ def test_tier1_override_file_picks_codex(self, tmp_path: Path) -> None: assert "codex" in ids assert "generic" not in ids + @pytest.mark.unit + @pytest.mark.subsys_lint def test_tier2_config_toml_picks_codex(self, tmp_path: Path) -> None: """.codex/config.toml present → codex (definitive).""" detected = [ @@ -523,6 +575,8 @@ def test_tier2_config_toml_picks_codex(self, tmp_path: Path) -> None: assert "codex" in ids assert "generic" not in ids + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -530,7 +584,7 @@ def test_tier3_global_config_plus_gitignore(self, tmp_path: Path) -> None: _make_detected("codex", ["AGENTS.md"]), _make_detected("generic", ["AGENTS.md"]), ] - with patch("reporails_cli.core.agents.Path.home", return_value=tmp_path / "fakehome"): + with patch("reporails_cli.core.discovery.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"} @@ -541,6 +595,8 @@ def test_tier3_global_config_plus_gitignore(self, tmp_path: Path) -> None: result = _disambiguate_codex_generic(detected, tmp_path) assert {a.agent_type.id for a in result} == {"codex"} + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -548,12 +604,14 @@ def test_tier3_override_in_gitignore(self, tmp_path: Path) -> None: _make_detected("codex", ["AGENTS.md"]), _make_detected("generic", ["AGENTS.md"]), ] - with patch("reporails_cli.core.agents.Path.home", return_value=tmp_path / "fakehome"): + with patch("reporails_cli.core.discovery.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"} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_signals_picks_generic(self, tmp_path: Path) -> None: """No codex markers → generic wins, codex dropped.""" detected = [ @@ -565,6 +623,8 @@ def test_no_signals_picks_generic(self, tmp_path: Path) -> None: assert "generic" in ids assert "codex" not in ids + @pytest.mark.unit + @pytest.mark.subsys_lint 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"])] @@ -572,6 +632,8 @@ def test_no_generic_returns_unchanged(self, tmp_path: Path) -> None: assert len(result) == 1 assert result[0].agent_type.id == "codex" + @pytest.mark.unit + @pytest.mark.subsys_lint 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"])] @@ -579,6 +641,8 @@ def test_no_codex_returns_unchanged(self, tmp_path: Path) -> None: assert len(result) == 1 assert result[0].agent_type.id == "generic" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_other_agents_preserved(self, tmp_path: Path) -> None: """Claude and copilot are untouched by disambiguation.""" detected = [ @@ -594,9 +658,11 @@ def test_other_agents_preserved(self, tmp_path: Path) -> None: assert "generic" in ids assert "codex" not in ids + @pytest.mark.unit + @pytest.mark.subsys_lint 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"): + with patch("reporails_cli.core.discovery.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_agents_dedupe.py b/tests/unit/test_agents_dedupe.py new file mode 100644 index 0000000..5c845dc --- /dev/null +++ b/tests/unit/test_agents_dedupe.py @@ -0,0 +1,142 @@ +"""Unit tests for symlink + content-hash deduplication in `core/agents.py`. + +Multi-agent projects often expose the same physical file via several agent +surfaces — `.claude/skills/` symlinked to `.agents/skills/`, or AGENTS.md +manually copied to CLAUDE.md so codex and Claude both load it. The dedup +pipeline collapses symlinked discoveries (correctness fix — they would +otherwise inflate the score) and reports same-directory content-hash +duplicates as display-only aliases (so AGENTS.md + CLAUDE.md still classify +under their respective agents but render as one row). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from reporails_cli.core.discovery.agents import ( + _dedupe_with_aliases, + compute_same_dir_content_aliases, + get_all_instruction_files, + get_file_aliases, +) + + +class TestDedupeWithAliases: + """Verify `_dedupe_with_aliases` collapses symlinks but leaves + same-dir content-identical files in place.""" + + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks require admin on Windows") + def test_symlinked_paths_collapse_to_one_canonical(self, tmp_path: Path) -> None: + """A real file plus a symlink to it → one canonical + one alias.""" + real = tmp_path / ".agents" / "skills" / "x" + real.mkdir(parents=True) + skill = real / "SKILL.md" + skill.write_text("# x\n") + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + os.symlink(str(real.parent), str(claude_dir / "skills")) + + canonical, aliases = _dedupe_with_aliases([skill, claude_dir / "skills" / "x" / "SKILL.md"]) + + assert canonical == [skill] + assert aliases == {skill: [claude_dir / "skills" / "x" / "SKILL.md"]} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_duplicate_identical_paths_collapse_without_self_alias(self, tmp_path: Path) -> None: + """Same path discovered by multiple agents must not appear as its own alias.""" + f = tmp_path / "AGENTS.md" + f.write_text("# x\n") + + canonical, aliases = _dedupe_with_aliases([f, f, f]) + + assert canonical == [f] + assert aliases == {} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_same_dir_identical_content_not_deduped_at_discovery(self, tmp_path: Path) -> None: + """AGENTS.md and CLAUDE.md with matching content stay separate at discovery — + each must remain classifiable under its own agent's `main` file_type. + Display layer collapses them via `compute_same_dir_content_aliases`.""" + body = "# Project\nLine.\n" + (tmp_path / "AGENTS.md").write_text(body) + (tmp_path / "CLAUDE.md").write_text(body) + + canonical, aliases = _dedupe_with_aliases([tmp_path / "AGENTS.md", tmp_path / "CLAUDE.md"]) + + assert canonical == sorted([tmp_path / "AGENTS.md", tmp_path / "CLAUDE.md"]) + assert aliases == {} + + +class TestComputeSameDirContentAliases: + """Verify display-time content-hash grouping is restricted to same parent dir.""" + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_identical_content_same_dir_grouped(self, tmp_path: Path) -> None: + body = "# X\nHello.\n" + (tmp_path / "AGENTS.md").write_text(body) + (tmp_path / "CLAUDE.md").write_text(body) + + result = compute_same_dir_content_aliases([tmp_path / "AGENTS.md", tmp_path / "CLAUDE.md"]) + + assert result == {tmp_path / "AGENTS.md": [tmp_path / "CLAUDE.md"]} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_identical_content_different_dirs_not_grouped(self, tmp_path: Path) -> None: + """Same content in two different directories must not collapse — + the files are independent surfaces that can diverge later.""" + body = "# X\nHello.\n" + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + (tmp_path / "a" / "f.md").write_text(body) + (tmp_path / "b" / "f.md").write_text(body) + + result = compute_same_dir_content_aliases([tmp_path / "a" / "f.md", tmp_path / "b" / "f.md"]) + + assert result == {} + + @pytest.mark.unit + @pytest.mark.subsys_lint + def test_differing_content_same_dir_not_grouped(self, tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("# A\n") + (tmp_path / "CLAUDE.md").write_text("# B (different)\n") + + result = compute_same_dir_content_aliases([tmp_path / "AGENTS.md", tmp_path / "CLAUDE.md"]) + + assert result == {} + + +class TestGetAllInstructionFilesIntegration: + """End-to-end: discovery + dedup + alias cache for `get_all_instruction_files`.""" + + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.skipif(sys.platform == "win32", reason="symlinks require admin on Windows") + def test_symlinked_skill_discovered_once_aliases_populated(self, tmp_path: Path) -> None: + """A skill symlinked across two agent surfaces collapses, alias cache populated.""" + (tmp_path / "AGENTS.md").write_text("# Main\n") + (tmp_path / ".agents" / "skills" / "demo").mkdir(parents=True) + (tmp_path / ".agents" / "skills" / "demo" / "SKILL.md").write_text("# demo\n") + (tmp_path / ".claude").mkdir() + os.symlink(str(tmp_path / ".agents" / "skills"), str(tmp_path / ".claude" / "skills")) + (tmp_path / "CLAUDE.md").write_text("# Claude\n") + + files = get_all_instruction_files(tmp_path) + aliases = get_file_aliases(tmp_path) + + skill_paths = [f for f in files if f.name == "SKILL.md"] + assert len(skill_paths) == 1, f"Expected 1 SKILL.md after dedup, got {skill_paths}" + + canonical = skill_paths[0] + assert canonical in aliases + assert any("claude" in str(a) for a in aliases[canonical]) diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py index 35606d4..cc63c57 100644 --- a/tests/unit/test_api_client.py +++ b/tests/unit/test_api_client.py @@ -2,14 +2,8 @@ from __future__ import annotations -from reporails_cli.core.api_client import ( - AilsClient, - LintResult, - _deserialize_cross_file_coordinates, - _deserialize_hints, - _deserialize_lint_result, - _strip_and_serialize, -) +import pytest + from reporails_cli.core.funnel import ( UNIVERSAL_ATOM_CAP, WIRE_MAX_CLUSTERS, @@ -17,7 +11,15 @@ LintResponse, preflight_oversized, ) -from reporails_cli.core.mapper.mapper import Atom, FileRecord, RulesetMap, RulesetSummary +from reporails_cli.core.platform.adapters.api_client import ( + AilsClient, + LintResult, + _deserialize_cross_file_coordinates, + _deserialize_hints, + _deserialize_lint_result, + _strip_and_serialize, +) +from reporails_cli.core.platform.dto.ruleset import Atom, FileRecord, RulesetMap, RulesetSummary def _make_map() -> RulesetMap: @@ -32,6 +34,8 @@ def _make_map() -> RulesetMap: class TestAilsClient: + @pytest.mark.unit + @pytest.mark.subsys_api def test_lint_empty_response_without_server(self) -> None: """No local fallback — lint requires the API.""" client = AilsClient(base_url="") @@ -40,12 +44,16 @@ def test_lint_empty_response_without_server(self) -> None: assert response.result is None assert response.funnel_error is None + @pytest.mark.unit + @pytest.mark.subsys_api def test_lint_empty_response_on_unreachable_server(self) -> None: client = AilsClient(base_url="https://localhost:1") response = client.lint(_make_map()) assert isinstance(response, LintResponse) assert response.result is None + @pytest.mark.unit + @pytest.mark.subsys_api def test_custom_base_url(self) -> None: client = AilsClient(base_url="https://custom.example.com") assert client.base_url == "https://custom.example.com" @@ -80,10 +88,14 @@ def _make_rm(files: tuple[FileRecord, ...], atoms: tuple[Atom, ...]) -> RulesetM summary=RulesetSummary(n_atoms=len(atoms), n_charged=0, n_neutral=len(atoms)), ) + @pytest.mark.unit + @pytest.mark.subsys_api def test_schema_version_bumped(self) -> None: rm = self._make_rm((), ()) assert _strip_and_serialize(rm)["schema_version"] == "2" + @pytest.mark.unit + @pytest.mark.subsys_api def test_semantic_keys_absent(self) -> None: """No semantic field names in serialized atoms.""" fr = FileRecord(path="test.md", content_hash="a") @@ -109,6 +121,8 @@ def test_semantic_keys_absent(self) -> None: } assert not semantic & set(a.keys()), f"Semantic keys leaked: {semantic & set(a.keys())}" + @pytest.mark.unit + @pytest.mark.subsys_api def test_short_keys_present(self) -> None: """All required short keys emitted.""" fr = FileRecord(path="test.md", content_hash="a") @@ -117,6 +131,8 @@ def test_short_keys_present(self) -> None: 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())}" + @pytest.mark.unit + @pytest.mark.subsys_api def test_enum_fields_are_integers(self) -> None: """Enum fields serialized as integers, not strings.""" fr = FileRecord(path="test.md", content_hash="a") @@ -125,6 +141,8 @@ def test_enum_fields_are_integers(self) -> None: for key in ("t", "c", "m", "s", "f"): assert isinstance(a[key], int), f"{key} should be int, got {type(a[key])}" + @pytest.mark.unit + @pytest.mark.subsys_api 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") @@ -134,6 +152,8 @@ def test_file_index_reference(self) -> None: assert payload["atoms"][0]["fi"] == 0 assert payload["atoms"][1]["fi"] == 1 + @pytest.mark.unit + @pytest.mark.subsys_api def test_inline_style_integer_codes(self) -> None: atom = self._make_atom( named_tokens=["ruff"], @@ -150,6 +170,8 @@ def test_inline_style_integer_codes(self) -> None: terms = [span["term"] for span in a["il"]] assert terms == ["ruff", "always", "NEVER"] + @pytest.mark.unit + @pytest.mark.subsys_api def test_text_fields_stripped(self) -> None: atom = self._make_atom( text="sensitive content", @@ -163,6 +185,8 @@ def test_text_fields_stripped(self) -> None: for key in ("text", "plain_text", "rule", "role", "topics"): assert key not in a, f"Sensitive field '{key}' leaked into wire format" + @pytest.mark.unit + @pytest.mark.subsys_api def test_optional_fields_omitted_when_empty(self) -> None: atom = self._make_atom() fr = FileRecord(path="test.md", content_hash="a") @@ -175,9 +199,13 @@ def test_optional_fields_omitted_when_empty(self) -> None: class TestPayloadCaps: """Preflight rejects oversized payloads before the HTTP round-trip.""" + @pytest.mark.unit + @pytest.mark.subsys_api def test_within_caps_returns_none(self) -> None: assert preflight_oversized({"files": [], "atoms": [], "clusters": []}, has_api_key=True) is None + @pytest.mark.unit + @pytest.mark.subsys_api def test_files_over_cap(self) -> None: payload = {"files": [{}] * (WIRE_MAX_FILES + 1), "atoms": [], "clusters": []} err = preflight_oversized(payload, has_api_key=True) @@ -185,6 +213,8 @@ def test_files_over_cap(self) -> None: assert err.error == "payload_too_large" assert err.limit == WIRE_MAX_FILES + @pytest.mark.unit + @pytest.mark.subsys_api def test_atoms_over_cap(self) -> None: payload = {"files": [], "atoms": [{}] * (UNIVERSAL_ATOM_CAP + 1), "clusters": []} err = preflight_oversized(payload, has_api_key=True) @@ -192,6 +222,8 @@ def test_atoms_over_cap(self) -> None: assert err.error == "atom_cap_exceeded" assert err.limit == UNIVERSAL_ATOM_CAP + @pytest.mark.unit + @pytest.mark.subsys_api def test_clusters_over_cap(self) -> None: payload = {"files": [], "atoms": [], "clusters": [{}] * (WIRE_MAX_CLUSTERS + 1)} err = preflight_oversized(payload, has_api_key=True) @@ -199,6 +231,8 @@ def test_clusters_over_cap(self) -> None: assert err.error == "payload_too_large" assert err.limit == WIRE_MAX_CLUSTERS + @pytest.mark.unit + @pytest.mark.subsys_api def test_at_cap_boundary_passes(self) -> None: payload = { "files": [{}] * WIRE_MAX_FILES, @@ -207,11 +241,13 @@ def test_at_cap_boundary_passes(self) -> None: } assert preflight_oversized(payload, has_api_key=True) is None + @pytest.mark.unit + @pytest.mark.subsys_api def test_lint_skips_http_when_over_cap(self) -> None: """Oversized payload short-circuits before any network call.""" from unittest.mock import patch - from reporails_cli.core.mapper.mapper import RulesetMap, RulesetSummary + from reporails_cli.core.platform.dto.ruleset import RulesetMap, RulesetSummary rm = RulesetMap( schema_version="1.0.0", @@ -231,7 +267,7 @@ def test_lint_skips_http_when_over_cap(self) -> None: "summary": {"n_atoms": 0, "n_charged": 0, "n_neutral": 0, "n_topics": 0, "n_topics_charged": 0}, } with ( - patch("reporails_cli.core.payload.project_payload", return_value=oversized), + patch("reporails_cli.core.platform.adapters.payload.project_payload", return_value=oversized), patch("httpx.post") as mock_post, ): client = AilsClient(base_url="https://example.test", tier="pro") @@ -241,11 +277,13 @@ def test_lint_skips_http_when_over_cap(self) -> None: assert response.funnel_error.error == "payload_too_large" mock_post.assert_not_called() + @pytest.mark.unit + @pytest.mark.subsys_api def test_lint_skips_http_when_no_files(self) -> None: """Empty-files payload short-circuits — Worker would 400 missing_content_hash.""" from unittest.mock import patch - from reporails_cli.core.mapper.mapper import RulesetMap, RulesetSummary + from reporails_cli.core.platform.dto.ruleset import RulesetMap, RulesetSummary rm = RulesetMap( schema_version="1.0.0", @@ -265,7 +303,7 @@ def test_lint_skips_http_when_no_files(self) -> None: "summary": {"n_atoms": 0, "n_charged": 0, "n_neutral": 0, "n_topics": 0, "n_topics_charged": 0}, } with ( - patch("reporails_cli.core.api_client._strip_and_serialize", return_value=empty_payload), + patch("reporails_cli.core.platform.adapters.api_client._strip_and_serialize", return_value=empty_payload), patch("httpx.post") as mock_post, ): client = AilsClient(base_url="https://example.test", tier="pro") @@ -275,7 +313,113 @@ def test_lint_skips_http_when_no_files(self) -> None: mock_post.assert_not_called() +class TestOutgoingHeaders: + """Asserts on the outgoing HTTP request shape. + + The default `httpx` User-Agent (`python-httpx/*`) trips Cloudflare's + bot-fight rules on anonymous-tier requests, producing a 403 with a + "Just a moment..." HTML challenge. Cost-free unit assertion that + every outgoing diagnostic request carries a custom UA prevents the + next regression of the same shape. + """ + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_anonymous_post_carries_custom_user_agent(self) -> None: + from unittest.mock import patch + + from reporails_cli.core.platform.dto.ruleset import RulesetMap, RulesetSummary + + rm = RulesetMap( + schema_version="1.0.0", + embedding_model="test", + generated_at="2026-01-01T00:00:00Z", + files=(FileRecord(path="CLAUDE.md", content_hash="sha256:abc", agent="claude"),), + atoms=(), + summary=RulesetSummary(n_atoms=0, n_charged=0, n_neutral=0), + ) + + captured: dict[str, dict[str, str]] = {} + + class _FakeResponse: + status_code = 200 + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "schema_version": "1.0.0", + "score": 0.0, + "level": "L1", + "violations": [], + "stats": {}, + "tier": "anonymous", + } + + def _fake_post(url: str, **kwargs: object) -> _FakeResponse: + captured["headers"] = dict(kwargs.get("headers") or {}) + return _FakeResponse() + + with patch("httpx.post", side_effect=_fake_post): + client = AilsClient(base_url="https://example.test", tier="anonymous", api_key=None) + client.lint(rm) + + ua = captured["headers"].get("User-Agent", "") + assert ua.startswith("reporails-cli/"), ( + f"outgoing request UA must start with 'reporails-cli/' to avoid Cloudflare bot-fight 403; got {ua!r}" + ) + + @pytest.mark.unit + @pytest.mark.subsys_api + def test_authenticated_post_carries_custom_user_agent_and_bearer(self) -> None: + from unittest.mock import patch + + from reporails_cli.core.platform.dto.ruleset import RulesetMap, RulesetSummary + + rm = RulesetMap( + schema_version="1.0.0", + embedding_model="test", + generated_at="2026-01-01T00:00:00Z", + files=(FileRecord(path="CLAUDE.md", content_hash="sha256:abc", agent="claude"),), + atoms=(), + summary=RulesetSummary(n_atoms=0, n_charged=0, n_neutral=0), + ) + + captured: dict[str, dict[str, str]] = {} + + class _FakeResponse: + status_code = 200 + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "schema_version": "1.0.0", + "score": 0.0, + "level": "L1", + "violations": [], + "stats": {}, + "tier": "pro", + } + + def _fake_post(url: str, **kwargs: object) -> _FakeResponse: + captured["headers"] = dict(kwargs.get("headers") or {}) + return _FakeResponse() + + with patch("httpx.post", side_effect=_fake_post): + client = AilsClient(base_url="https://example.test", tier="pro", api_key="test-key") + client.lint(rm) + + headers = captured["headers"] + assert headers.get("User-Agent", "").startswith("reporails-cli/") + assert headers.get("Authorization") == "Bearer test-key" + + class TestDeserializeHints: + @pytest.mark.unit + @pytest.mark.subsys_api def test_valid_hints(self) -> None: data = { "hints": [ @@ -296,16 +440,22 @@ def test_valid_hints(self) -> None: assert hints[0].error_count == 2 assert hints[0].severity == "error" + @pytest.mark.unit + @pytest.mark.subsys_api def test_missing_fields_skipped(self) -> None: hints = _deserialize_hints({"hints": [{"file": "x.md"}]}) assert len(hints) == 0 + @pytest.mark.unit + @pytest.mark.subsys_api def test_empty(self) -> None: assert _deserialize_hints({}) == () assert _deserialize_hints({"hints": []}) == () class TestDeserializeCrossFileCoordinates: + @pytest.mark.unit + @pytest.mark.subsys_api def test_valid_coordinates(self) -> None: data = { "cross_file_coordinates": [ @@ -318,15 +468,21 @@ def test_valid_coordinates(self) -> None: assert coords[0].finding_type == "conflict" assert coords[0].count == 2 + @pytest.mark.unit + @pytest.mark.subsys_api def test_missing_fields_skipped(self) -> None: coords = _deserialize_cross_file_coordinates({"cross_file_coordinates": [{"file_1": "a.md"}]}) assert len(coords) == 0 + @pytest.mark.unit + @pytest.mark.subsys_api def test_empty(self) -> None: assert _deserialize_cross_file_coordinates({}) == () class TestDeserializeLintResult: + @pytest.mark.unit + @pytest.mark.subsys_api def test_full_response_with_coordinates(self) -> None: data = { "report": {"per_file": [], "cross_file": [], "quality": {"compliance_band": "HIGH"}, "stats": {}}, @@ -342,6 +498,8 @@ def test_full_response_with_coordinates(self) -> None: assert len(result.hints) == 1 assert len(result.cross_file_coordinates) == 1 + @pytest.mark.unit + @pytest.mark.subsys_api def test_pro_tier_no_hints_or_coordinates(self) -> None: data = {"report": {"per_file": [], "cross_file": [], "quality": {}, "stats": {}}, "tier": "pro"} result = _deserialize_lint_result(data) diff --git a/tests/unit/test_applicability.py b/tests/unit/test_applicability.py index 6194ec1..636f440 100644 --- a/tests/unit/test_applicability.py +++ b/tests/unit/test_applicability.py @@ -8,7 +8,9 @@ from pathlib import Path -from reporails_cli.core.models import Category, FileMatch, Rule, RuleType +import pytest + +from reporails_cli.core.platform.dto.models import Category, FileMatch, Rule, RuleType def _make_rule( @@ -37,9 +39,11 @@ def _make_rule( class TestDetectFeaturesFilesystem: """Test detect_features_filesystem with real temp directories.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_directory_no_features(self, tmp_path: Path) -> None: """Empty directory should detect no features.""" - from reporails_cli.core.applicability import detect_features_filesystem + from reporails_cli.core.discovery.features import detect_features_filesystem features = detect_features_filesystem(tmp_path) @@ -50,9 +54,11 @@ def test_empty_directory_no_features(self, tmp_path: Path) -> None: assert features.has_backbone is False assert features.has_multiple_instruction_files is False + @pytest.mark.unit + @pytest.mark.subsys_lint def test_claude_md_only(self, tmp_path: Path) -> None: """CLAUDE.md at root should be detected.""" - from reporails_cli.core.applicability import detect_features_filesystem + from reporails_cli.core.discovery.features import detect_features_filesystem (tmp_path / "CLAUDE.md").write_text("# My Project\n", encoding="utf-8") @@ -62,9 +68,11 @@ def test_claude_md_only(self, tmp_path: Path) -> None: assert features.has_instruction_file is True assert features.instruction_file_count == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_claude_rules_dir_is_abstracted(self, tmp_path: Path) -> None: """Presence of .claude/rules/ with content should set is_abstracted.""" - from reporails_cli.core.applicability import detect_features_filesystem + from reporails_cli.core.discovery.features import detect_features_filesystem rules_dir = tmp_path / ".claude" / "rules" rules_dir.mkdir(parents=True) @@ -80,9 +88,11 @@ def test_claude_rules_dir_is_abstracted(self, tmp_path: Path) -> None: class TestGetApplicableRules: """Test target-existence rule filtering and supersession.""" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type="main"))} @@ -90,9 +100,11 @@ def test_rule_included_when_target_present(self) -> None: assert "S1" in result + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type="main"))} @@ -100,9 +112,11 @@ def test_rule_excluded_when_target_absent(self) -> None: assert "S1" not in result + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = {"S1": _make_rule(rule_id="S1", match=None)} @@ -110,9 +124,11 @@ def test_wildcard_match_none_fires_when_any_present(self) -> None: assert "S1" in result + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = {"S1": _make_rule(rule_id="S1", match=FileMatch(type=None))} @@ -120,9 +136,11 @@ def test_wildcard_type_none_fires_when_any_present(self) -> None: assert "S1" in result + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_present_types_returns_empty(self) -> None: """No present types → no rules fire.""" - from reporails_cli.core.applicability import get_applicable_rules + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = {"S1": _make_rule(rule_id="S1")} @@ -130,9 +148,11 @@ def test_no_present_types_returns_empty(self) -> None: assert result == {} + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = { "S1": _make_rule(rule_id="S1"), @@ -144,9 +164,11 @@ def test_supersession_drops_superseded_rule(self) -> None: assert "S2" in result assert "S1" not in result, "Superseded rule S1 should be dropped" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = { "S1": _make_rule(rule_id="S1", match=FileMatch(type="main")), @@ -159,9 +181,11 @@ def test_supersession_keeps_both_when_superseder_target_absent(self) -> None: assert "S1" in result assert "S2" not in result + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = { "R1": _make_rule(rule_id="R1", match=FileMatch(type="main")), @@ -175,17 +199,21 @@ def test_multiple_types_filter_correctly(self) -> None: assert "R2" in result assert "R3" not in result + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_rules_returns_empty(self) -> None: """Empty rules dict returns empty result.""" - from reporails_cli.core.applicability import get_applicable_rules + from reporails_cli.core.platform.policy.applicability import get_applicable_rules result = get_applicable_rules({}, {"main"}) assert result == {} + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = { "S1": _make_rule(rule_id="S1", supersedes="DOES_NOT_EXIST"), @@ -195,9 +223,11 @@ def test_supersedes_nonexistent_rule_ignored(self) -> None: assert "S1" in result + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.policy.applicability import get_applicable_rules rules = { "R1": _make_rule(rule_id="R1", match=FileMatch(type="main")), diff --git a/tests/unit/test_backbone_gate.py b/tests/unit/test_backbone_gate.py index c9d1da6..3fe32ba 100644 --- a/tests/unit/test_backbone_gate.py +++ b/tests/unit/test_backbone_gate.py @@ -8,12 +8,17 @@ from pathlib import Path -from reporails_cli.core.applicability import detect_features_filesystem +import pytest + +from reporails_cli.core.discovery.features import detect_features_filesystem class TestFeatureDetection: """Verify filesystem feature detection for display purposes.""" + @pytest.mark.unit + @pytest.mark.subsys_map + @pytest.mark.subsys_cli_ux def test_no_backbone_yields_false(self, tmp_path: Path) -> None: """Project without .ails/backbone.yml → has_backbone is False.""" project = tmp_path / "project" @@ -24,6 +29,9 @@ def test_no_backbone_yields_false(self, tmp_path: Path) -> None: assert features.has_backbone is False + @pytest.mark.unit + @pytest.mark.subsys_map + @pytest.mark.subsys_cli_ux def test_real_backbone_detected(self, tmp_path: Path) -> None: """Project with a real backbone.yml → has_backbone is True.""" project = tmp_path / "project" @@ -38,6 +46,9 @@ def test_real_backbone_detected(self, tmp_path: Path) -> None: assert features.has_backbone is True + @pytest.mark.unit + @pytest.mark.subsys_map + @pytest.mark.subsys_cli_ux def test_abstracted_structure_detected(self, tmp_path: Path) -> None: """Project with .claude/rules/ → is_abstracted is True.""" project = tmp_path / "project" @@ -51,6 +62,9 @@ def test_abstracted_structure_detected(self, tmp_path: Path) -> None: assert features.is_abstracted is True + @pytest.mark.unit + @pytest.mark.subsys_map + @pytest.mark.subsys_cli_ux def test_shared_files_detected(self, tmp_path: Path) -> None: """Project with shared/ directory → has_shared_files is True.""" project = tmp_path / "project" @@ -62,6 +76,9 @@ def test_shared_files_detected(self, tmp_path: Path) -> None: assert features.has_shared_files is True + @pytest.mark.unit + @pytest.mark.subsys_map + @pytest.mark.subsys_cli_ux def test_placeholder_backbone_not_detected_before_creation(self, tmp_path: Path) -> None: """Feature detection runs before backbone auto-creation.""" project = tmp_path / "project" diff --git a/tests/unit/test_byte_preflight.py b/tests/unit/test_byte_preflight.py index 07945ea..6364228 100644 --- a/tests/unit/test_byte_preflight.py +++ b/tests/unit/test_byte_preflight.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from reporails_cli.core.funnel import ( WIRE_MAX_BYTES_BY_TIER, FunnelError, @@ -9,15 +11,21 @@ ) +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_under_cap_returns_none() -> None: assert preflight_byte_size(1024, has_api_key=False) is None +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_anonymous_at_cap_passes() -> None: cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] assert preflight_byte_size(cap, has_api_key=False) is None +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_anonymous_over_cap_returns_funnel_error() -> None: cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] err = preflight_byte_size(cap + 1, has_api_key=False) @@ -28,6 +36,8 @@ def test_anonymous_over_cap_returns_funnel_error() -> None: assert err.size == cap + 1 +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_keyed_cap_higher_than_anonymous() -> None: keyed_cap = WIRE_MAX_BYTES_BY_TIER["pro"] anon_cap = WIRE_MAX_BYTES_BY_TIER["anonymous"] @@ -35,6 +45,8 @@ def test_keyed_cap_higher_than_anonymous() -> None: assert preflight_byte_size(anon_cap + 1, has_api_key=True) is None +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_keyed_over_cap_returns_funnel_error() -> None: keyed_cap = WIRE_MAX_BYTES_BY_TIER["pro"] err = preflight_byte_size(keyed_cap + 1, has_api_key=True) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 65fd65d..7d9d175 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -9,6 +9,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from reporails_cli.core.cache import ( ProjectAnalytics, ProjectCache, @@ -26,6 +28,8 @@ class TestContentHash: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_deterministic_hash(self, tmp_path: Path) -> None: f = tmp_path / "test.md" f.write_text("hello world") @@ -42,27 +46,44 @@ def test_deterministic_hash(self, tmp_path: Path) -> None: class TestGetProjectId: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_with_git_remote(self, tmp_path: Path) -> None: - with patch("reporails_cli.core.analytics.get_git_remote", return_value="git@github.com:org/repo.git"): + with patch( + "reporails_cli.core.platform.observability.analytics.get_git_remote", + return_value="git@github.com:org/repo.git", + ): pid = get_project_id(tmp_path) assert len(pid) == 12 assert pid.isalnum() + @pytest.mark.unit + @pytest.mark.subsys_caching def test_without_git_remote(self, tmp_path: Path) -> None: - with patch("reporails_cli.core.analytics.get_git_remote", return_value=None): + with patch("reporails_cli.core.platform.observability.analytics.get_git_remote", return_value=None): pid = get_project_id(tmp_path) assert len(pid) == 12 assert pid.isalnum() + @pytest.mark.unit + @pytest.mark.subsys_caching def test_different_remotes_different_ids(self, tmp_path: Path) -> None: - with patch("reporails_cli.core.analytics.get_git_remote", return_value="git@github.com:org/a.git"): + with patch( + "reporails_cli.core.platform.observability.analytics.get_git_remote", + return_value="git@github.com:org/a.git", + ): id_a = get_project_id(tmp_path) - with patch("reporails_cli.core.analytics.get_git_remote", return_value="git@github.com:org/b.git"): + with patch( + "reporails_cli.core.platform.observability.analytics.get_git_remote", + return_value="git@github.com:org/b.git", + ): id_b = get_project_id(tmp_path) assert id_a != id_b class TestGetProjectName: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_returns_directory_name(self, tmp_path: Path) -> None: name = get_project_name(tmp_path) assert name == tmp_path.resolve().name @@ -74,6 +95,8 @@ def test_returns_directory_name(self, tmp_path: Path) -> None: class TestProjectCacheFileMap: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_round_trip(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) # Create real files so get_cached_files validation passes @@ -89,10 +112,14 @@ def test_round_trip(self, tmp_path: Path) -> None: assert loaded["count"] == 2 assert set(loaded["files"]) == {"a.md", "b.md"} + @pytest.mark.unit + @pytest.mark.subsys_caching def test_returns_none_on_missing(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) assert cache.load_file_map() is None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_returns_none_on_corrupt_json(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) cache.ensure_dir() @@ -106,6 +133,8 @@ def test_returns_none_on_corrupt_json(self, tmp_path: Path) -> None: class TestProjectCacheJudgment: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_set_then_get_hit(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) results = {"C6": {"verdict": "pass", "reason": "ok"}} @@ -114,6 +143,8 @@ def test_set_then_get_hit(self, tmp_path: Path) -> None: cached = cache.get_cached_judgment("CLAUDE.md", "sha256:abc123") assert cached == results + @pytest.mark.unit + @pytest.mark.subsys_caching def test_get_with_wrong_hash_miss(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) results = {"C6": {"verdict": "pass", "reason": "ok"}} @@ -122,6 +153,8 @@ def test_get_with_wrong_hash_miss(self, tmp_path: Path) -> None: cached = cache.get_cached_judgment("CLAUDE.md", "sha256:DIFFERENT") assert cached is None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_get_missing_file_returns_none(self, tmp_path: Path) -> None: cache = ProjectCache(tmp_path) assert cache.get_cached_judgment("nope.md", "sha256:x") is None @@ -133,6 +166,8 @@ def test_get_missing_file_returns_none(self, tmp_path: Path) -> None: class TestProjectAnalytics: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_save_load_round_trip(self, tmp_path: Path) -> None: analytics = ProjectAnalytics( project_id="abc123def456", @@ -144,7 +179,7 @@ def test_save_load_round_trip(self, tmp_path: Path) -> None: history=[], ) - with patch("reporails_cli.core.analytics.get_analytics_dir", return_value=tmp_path): + with patch("reporails_cli.core.platform.observability.analytics.get_analytics_dir", return_value=tmp_path): save_project_analytics(analytics) loaded = load_project_analytics("abc123def456") @@ -153,13 +188,17 @@ def test_save_load_round_trip(self, tmp_path: Path) -> None: assert loaded.project_name == "test-project" assert loaded.scan_count == 1 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_load_returns_none_on_missing(self, tmp_path: Path) -> None: - with patch("reporails_cli.core.analytics.get_analytics_dir", return_value=tmp_path): + with patch("reporails_cli.core.platform.observability.analytics.get_analytics_dir", return_value=tmp_path): assert load_project_analytics("nonexistent") is None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_load_returns_none_on_corrupt(self, tmp_path: Path) -> None: (tmp_path / "bad.json").write_text("not json") - with patch("reporails_cli.core.analytics.get_analytics_dir", return_value=tmp_path): + with patch("reporails_cli.core.platform.observability.analytics.get_analytics_dir", return_value=tmp_path): assert load_project_analytics("bad") is None @@ -171,8 +210,8 @@ def test_load_returns_none_on_corrupt(self, tmp_path: Path) -> None: class TestRecordScan: def _record(self, target: Path, analytics_dir: Path, score: float = 7.5) -> None: with ( - patch("reporails_cli.core.analytics.get_git_remote", return_value=None), - patch("reporails_cli.core.analytics.get_analytics_dir", return_value=analytics_dir), + patch("reporails_cli.core.platform.observability.analytics.get_git_remote", return_value=None), + patch("reporails_cli.core.platform.observability.analytics.get_analytics_dir", return_value=analytics_dir), ): record_scan( target=target, @@ -184,6 +223,8 @@ def _record(self, target: Path, analytics_dir: Path, score: float = 7.5) -> None instruction_files=1, ) + @pytest.mark.unit + @pytest.mark.subsys_caching def test_creates_new_analytics(self, tmp_path: Path) -> None: target = tmp_path / "project" target.mkdir() @@ -198,6 +239,8 @@ def test_creates_new_analytics(self, tmp_path: Path) -> None: assert data["scan_count"] == 1 assert len(data["history"]) == 1 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_appends_to_existing(self, tmp_path: Path) -> None: target = tmp_path / "project" target.mkdir() @@ -212,6 +255,8 @@ def test_appends_to_existing(self, tmp_path: Path) -> None: assert data["scan_count"] == 2 assert len(data["history"]) == 2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_caps_at_100_entries(self, tmp_path: Path) -> None: target = tmp_path / "project" target.mkdir() diff --git a/tests/unit/test_cache_structural.py b/tests/unit/test_cache_structural.py index c619a16..33d2212 100644 --- a/tests/unit/test_cache_structural.py +++ b/tests/unit/test_cache_structural.py @@ -10,6 +10,8 @@ from pathlib import Path +import pytest + from reporails_cli.core.cache import ProjectCache, structural_hash # --------------------------------------------------------------------------- @@ -18,6 +20,8 @@ class TestStructuralHash: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_deterministic(self, tmp_path: Path) -> None: """Same content produces same structural hash.""" f = tmp_path / "test.md" @@ -28,6 +32,8 @@ def test_deterministic(self, tmp_path: Path) -> None: assert h1.startswith("struct:") assert len(h1) == len("struct:") + 16 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_heading_change_differs(self, tmp_path: Path) -> None: """Changing a heading produces a different structural hash.""" f = tmp_path / "test.md" @@ -37,6 +43,8 @@ def test_heading_change_differs(self, tmp_path: Path) -> None: h2 = structural_hash(f) assert h1 != h2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_constraint_change_differs(self, tmp_path: Path) -> None: """Changing a constraint keyword line changes the hash.""" f = tmp_path / "test.md" @@ -46,6 +54,8 @@ def test_constraint_change_differs(self, tmp_path: Path) -> None: h2 = structural_hash(f) assert h1 != h2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_list_item_change_differs(self, tmp_path: Path) -> None: """Changing a list item changes the hash.""" f = tmp_path / "test.md" @@ -55,6 +65,8 @@ def test_list_item_change_differs(self, tmp_path: Path) -> None: h2 = structural_hash(f) assert h1 != h2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_whitespace_only_change_same(self, tmp_path: Path) -> None: """Adding blank lines between structural elements keeps the same hash.""" f = tmp_path / "test.md" @@ -64,6 +76,8 @@ def test_whitespace_only_change_same(self, tmp_path: Path) -> None: h2 = structural_hash(f) assert h1 == h2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_body_prose_change_same(self, tmp_path: Path) -> None: """Changing non-structural prose (no headings/keywords/lists) keeps the same hash.""" f = tmp_path / "test.md" @@ -73,6 +87,8 @@ def test_body_prose_change_same(self, tmp_path: Path) -> None: h2 = structural_hash(f) assert h1 == h2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_empty_file(self, tmp_path: Path) -> None: """Empty file produces a valid hash.""" f = tmp_path / "empty.md" @@ -87,6 +103,8 @@ def test_empty_file(self, tmp_path: Path) -> None: class TestThreeTierLookup: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_fresh_hit(self, tmp_path: Path) -> None: """Tier 1: content_hash matches → returns results.""" cache = ProjectCache(tmp_path) @@ -100,6 +118,8 @@ def test_fresh_hit(self, tmp_path: Path) -> None: assert result is not None assert result["C6"]["verdict"] == "pass" + @pytest.mark.unit + @pytest.mark.subsys_caching def test_stale_hit(self, tmp_path: Path) -> None: """Tier 2: content_hash differs, structural_hash matches → stale but usable.""" cache = ProjectCache(tmp_path) @@ -118,6 +138,8 @@ def test_stale_hit(self, tmp_path: Path) -> None: assert result is not None assert result["C6"]["verdict"] == "pass" + @pytest.mark.unit + @pytest.mark.subsys_caching def test_invalidated(self, tmp_path: Path) -> None: """Tier 3: both hashes differ → cache invalidated.""" cache = ProjectCache(tmp_path) @@ -134,6 +156,8 @@ def test_invalidated(self, tmp_path: Path) -> None: ) assert result is None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_no_structural_hash_in_query(self, tmp_path: Path) -> None: """Without structural_hash in query, falls back to content-only matching.""" cache = ProjectCache(tmp_path) @@ -158,6 +182,8 @@ def test_no_structural_hash_in_query(self, tmp_path: Path) -> None: class TestV1Migration: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_v1_cache_content_match_still_works(self, tmp_path: Path) -> None: """V1 cache entry (no structural_hash) still works with content_hash match.""" cache = ProjectCache(tmp_path) @@ -170,6 +196,8 @@ def test_v1_cache_content_match_still_works(self, tmp_path: Path) -> None: result = cache.get_cached_judgment("CLAUDE.md", "sha256:v1hash", structural_hash="struct:anything") assert result is not None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_v1_cache_content_mismatch_invalidated(self, tmp_path: Path) -> None: """V1 cache entry with content mismatch → no structural_hash to fall back on → invalidated.""" cache = ProjectCache(tmp_path) diff --git a/tests/unit/test_check_cache.py b/tests/unit/test_check_cache.py index 2752876..1f81fb8 100644 --- a/tests/unit/test_check_cache.py +++ b/tests/unit/test_check_cache.py @@ -2,16 +2,22 @@ from __future__ import annotations -from reporails_cli.core.check_cache import CheckCache -from reporails_cli.core.mechanical.checks import CheckResult +import pytest + +from reporails_cli.core.cache.check_cache import CheckCache +from reporails_cli.core.lint.mechanical.checks import CheckResult class TestCheckCache: + @pytest.mark.unit + @pytest.mark.subsys_caching def test_miss_returns_none(self) -> None: cache = CheckCache() key = cache.key("mechanical", "file_exists", {"path": "x.md"}, "CLAUDE.md:0") assert cache.get(key) is None + @pytest.mark.unit + @pytest.mark.subsys_caching def test_hit_returns_result(self) -> None: cache = CheckCache() result = CheckResult(passed=True, message="found") @@ -19,24 +25,32 @@ def test_hit_returns_result(self) -> None: cache.set(key, result) assert cache.get(key) is result + @pytest.mark.unit + @pytest.mark.subsys_caching def test_different_args_different_keys(self) -> None: cache = CheckCache() k1 = cache.key("mechanical", "file_exists", {"path": "a.md"}, ".:0") k2 = cache.key("mechanical", "file_exists", {"path": "b.md"}, ".:0") assert k1 != k2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_different_targets_different_keys(self) -> None: cache = CheckCache() k1 = cache.key("mechanical", "file_exists", None, "a.md:0") k2 = cache.key("mechanical", "file_exists", None, "b.md:0") assert k1 != k2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_none_args(self) -> None: cache = CheckCache() k1 = cache.key("mechanical", "git_tracked", None, ".:0") k2 = cache.key("mechanical", "git_tracked", None, ".:0") assert k1 == k2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_len(self) -> None: cache = CheckCache() assert len(cache) == 0 @@ -45,6 +59,8 @@ def test_len(self) -> None: cache.set(cache.key("mechanical", "b", None, ".:0"), result) assert len(cache) == 2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_result_with_annotations(self) -> None: cache = CheckCache() result = CheckResult(passed=True, message="ok", annotations={"imports": ["x"]}) @@ -54,6 +70,8 @@ def test_result_with_annotations(self) -> None: assert cached is not None assert cached.annotations == {"imports": ["x"]} + @pytest.mark.unit + @pytest.mark.subsys_caching def test_arg_key_order_does_not_affect_cache_key(self) -> None: """sort_keys=True in json.dumps should make key order irrelevant.""" cache = CheckCache() @@ -61,6 +79,8 @@ def test_arg_key_order_does_not_affect_cache_key(self) -> None: k2 = cache.key("mechanical", "check", {"b": 2, "a": 1}, ".:0") assert k1 == k2 + @pytest.mark.unit + @pytest.mark.subsys_caching def test_overwrite_existing_entry(self) -> None: """Setting a key twice should overwrite the previous result.""" cache = CheckCache() diff --git a/tests/unit/test_classification.py b/tests/unit/test_classification.py index 6e143ad..0e12798 100644 --- a/tests/unit/test_classification.py +++ b/tests/unit/test_classification.py @@ -6,12 +6,12 @@ import pytest -from reporails_cli.core.classification import ( +from reporails_cli.core.classify import ( classify_files, detect_content_format, match_files, ) -from reporails_cli.core.models import ClassifiedFile, FileMatch, FileTypeDeclaration +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileMatch, FileTypeDeclaration # ═══════════════════════════════════════════════════════════════════════ # detect_content_format — individual format detection @@ -21,6 +21,8 @@ class TestDetectContentFormat: """Tests for detect_content_format() region detection.""" + @pytest.mark.unit + @pytest.mark.subsys_classify @pytest.mark.parametrize( "text, expected_format", [ @@ -34,11 +36,15 @@ def test_heading_detection(self, text: str, expected_format: str): result = detect_content_format(text) assert expected_format in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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.unit + @pytest.mark.subsys_classify @pytest.mark.parametrize( "text, expected_format", [ @@ -52,6 +58,8 @@ def test_code_block_detection(self, text: str, expected_format: str): result = detect_content_format(text) assert expected_format in result + @pytest.mark.unit + @pytest.mark.subsys_classify @pytest.mark.parametrize( "text, expected_format", [ @@ -70,17 +78,23 @@ def test_data_block_detection(self, text: str, expected_format: str): assert expected_format in result assert "code_block" not in result # data_block, not code_block + @pytest.mark.unit + @pytest.mark.subsys_classify def test_table_detection(self): text = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |\n" result = detect_content_format(text) assert "table" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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.unit + @pytest.mark.subsys_classify @pytest.mark.parametrize( "text", [ @@ -95,22 +109,30 @@ def test_unordered_list_detection(self, text: str): result = detect_content_format(text) assert "list" in result + @pytest.mark.unit + @pytest.mark.subsys_classify def test_ordered_list_detection(self): text = "1. First step\n2. Second step\n" result = detect_content_format(text) assert "list" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify def test_prose_ignores_special_line_starts(self): """Lines starting with #, |, -, etc. are not prose.""" text = "# heading\n- list\n| table |\n" @@ -119,12 +141,16 @@ def test_prose_ignores_special_line_starts(self): # ── Frontmatter stripping ───────────────────────────────────────── + @pytest.mark.unit + @pytest.mark.subsys_classify 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 == [] + @pytest.mark.unit + @pytest.mark.subsys_classify 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) @@ -133,14 +159,20 @@ def test_frontmatter_stripped_content_after(self): # ── Empty / edge cases ──────────────────────────────────────────── + @pytest.mark.unit + @pytest.mark.subsys_classify def test_empty_string(self): assert detect_content_format("") == [] + @pytest.mark.unit + @pytest.mark.subsys_classify def test_whitespace_only(self): assert detect_content_format(" \n\n \n") == [] # ── Mixed content ───────────────────────────────────────────────── + @pytest.mark.unit + @pytest.mark.subsys_classify def test_mixed_content_detects_all_formats(self): text = ( "# Architecture\n\n" @@ -153,6 +185,8 @@ def test_mixed_content_detects_all_formats(self): result = detect_content_format(text) assert set(result) == {"heading", "prose", "code_block", "data_block", "table", "list"} + @pytest.mark.unit + @pytest.mark.subsys_classify def test_result_is_sorted(self): text = "# Heading\n\nProse text that is long enough.\n- list item\n" result = detect_content_format(text) @@ -160,6 +194,8 @@ def test_result_is_sorted(self): # ── Code-block-aware detection ──────────────────────────────────── + @pytest.mark.unit + @pytest.mark.subsys_classify def test_table_inside_code_block_not_detected(self): """Tables inside fenced code blocks are examples, not real tables.""" text = ( @@ -169,6 +205,8 @@ def test_table_inside_code_block_not_detected(self): assert "table" not in result assert "code_block" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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" @@ -176,6 +214,8 @@ def test_list_inside_code_block_not_detected(self): assert "list" not in result assert "code_block" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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" @@ -183,6 +223,8 @@ def test_prose_inside_code_block_not_detected(self): assert "prose" not in result assert "code_block" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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" @@ -194,41 +236,57 @@ def test_content_outside_code_block_still_detected(self): # ── New inline/block modes ──────────────────────────────────────── + @pytest.mark.unit + @pytest.mark.subsys_classify def test_blockquote_detection(self): text = "> This is a blockquote\n> with multiple lines\n" result = detect_content_format(text) assert "blockquote" in result + @pytest.mark.unit + @pytest.mark.subsys_classify def test_bold_detection(self): text = "This has **bold text** in it for emphasis.\n" result = detect_content_format(text) assert "bold" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify def test_link_detection(self): text = "See [the docs](https://example.com) for details.\n" result = detect_content_format(text) assert "link" in result + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify def test_link_inside_code_block_not_detected(self): text = "```\n[not a link](https://example.com)\n```\n" result = detect_content_format(text) @@ -257,6 +315,8 @@ def _schema_type(self) -> FileTypeDeclaration: properties={"format": "schema"}, ) + @pytest.mark.unit + @pytest.mark.subsys_classify 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") @@ -267,6 +327,8 @@ def test_freeform_gets_content_format(self, tmp_path: Path): assert "heading" in cf assert "prose" in cf + @pytest.mark.unit + @pytest.mark.subsys_classify def test_schema_format_skips_content_format(self, tmp_path: Path): f = tmp_path / "settings.json" f.write_text('{"key": "value"}\n') @@ -274,6 +336,8 @@ def test_schema_format_skips_content_format(self, tmp_path: Path): assert len(result) == 1 assert "content_format" not in result[0].properties + @pytest.mark.unit + @pytest.mark.subsys_classify def test_freeform_empty_file_no_content_format(self, tmp_path: Path): md = tmp_path / "CLAUDE.md" md.write_text("") @@ -281,6 +345,8 @@ def test_freeform_empty_file_no_content_format(self, tmp_path: Path): assert len(result) == 1 assert "content_format" not in result[0].properties + @pytest.mark.unit + @pytest.mark.subsys_classify def test_explicit_content_format_not_overwritten(self, tmp_path: Path): """If content_format is already set in properties, don't overwrite.""" ft = FileTypeDeclaration( @@ -293,6 +359,8 @@ def test_explicit_content_format_not_overwritten(self, tmp_path: Path): result = classify_files(tmp_path, [md], [ft]) assert result[0].properties["content_format"] == ["prose"] + @pytest.mark.unit + @pytest.mark.subsys_classify def test_freeform_list_format(self, tmp_path: Path): """format: [freeform, ...] should also trigger detection.""" ft = FileTypeDeclaration( @@ -321,24 +389,32 @@ def _cf(self, content_format: list[str]) -> ClassifiedFile: properties={"format": "freeform", "content_format": content_format}, ) + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify 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 + @pytest.mark.unit + @pytest.mark.subsys_classify def test_content_format_multi_match(self): """Rule targets multiple formats, file has one of them.""" files = [self._cf(["prose", "list"])] @@ -348,6 +424,8 @@ def test_content_format_multi_match(self): ) assert len(result) == 1 + @pytest.mark.unit + @pytest.mark.subsys_classify def test_file_without_content_format_no_match(self): """File with no content_format property doesn't match explicit criteria.""" files = [ diff --git a/tests/unit/test_client_checks.py b/tests/unit/test_client_checks.py index 98df8bc..15249eb 100644 --- a/tests/unit/test_client_checks.py +++ b/tests/unit/test_client_checks.py @@ -2,8 +2,10 @@ 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 +import pytest + +from reporails_cli.core.lint.client_checks import run_client_checks +from reporails_cli.core.platform.dto.ruleset import Atom, FileRecord, RulesetMap, RulesetSummary def _make_map(atoms: list[Atom]) -> RulesetMap: @@ -37,6 +39,8 @@ def _atom(line: int, charge_value: int, cluster_id: int = 0, position_index: int class TestChargeOrdering: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_inverted_ordering_detected(self) -> None: atoms = [ _atom(10, -1, cluster_id=1, position_index=0), # constraint first @@ -47,6 +51,8 @@ def test_inverted_ordering_detected(self) -> None: assert len(ordering) == 1 assert "before directive" in ordering[0].message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_correct_ordering_no_finding(self) -> None: atoms = [ _atom(10, +1, cluster_id=1, position_index=0), # directive first @@ -58,6 +64,8 @@ def test_correct_ordering_no_finding(self) -> None: class TestOrphanAtoms: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_directive_only_cluster_not_orphan(self) -> None: """Directive-only is valid — no orphan finding. @@ -69,6 +77,8 @@ def test_directive_only_cluster_not_orphan(self) -> None: orphans = [f for f in findings if f.rule == "orphan"] assert len(orphans) == 0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_constraint_only_cluster(self) -> None: atoms = [_atom(10, -1, cluster_id=1, position_index=0)] findings = run_client_checks(_make_map(atoms)) @@ -76,6 +86,8 @@ def test_constraint_only_cluster(self) -> None: assert len(orphans) == 1 assert "prohibition" in orphans[0].message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_balanced_cluster_no_orphan(self) -> None: atoms = [ _atom(10, +1, cluster_id=1, position_index=0), @@ -87,6 +99,8 @@ def test_balanced_cluster_no_orphan(self) -> None: class TestUnformattedCode: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_unformatted_tokens_detected(self) -> None: atoms = [_atom(10, +1, unformatted_code=["pytest"])] findings = run_client_checks(_make_map(atoms)) @@ -94,6 +108,8 @@ def test_unformatted_tokens_detected(self) -> None: assert len(fmt) == 1 assert "pytest" in fmt[0].message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_unformatted_no_finding(self) -> None: atoms = [_atom(10, +1, unformatted_code=[])] findings = run_client_checks(_make_map(atoms)) diff --git a/tests/unit/test_config_command.py b/tests/unit/test_config_command.py index 6f53550..ce9425b 100644 --- a/tests/unit/test_config_command.py +++ b/tests/unit/test_config_command.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import yaml from typer.testing import CliRunner @@ -23,6 +24,8 @@ def _write_global_config(global_home: Path, content: str) -> Path: class TestGlobalSet: """Test config set --global.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_writes_to_home_config(self, tmp_path: Path) -> None: global_home = tmp_path / ".reporails" with patch( @@ -35,22 +38,15 @@ def test_writes_to_home_config(self, tmp_path: Path) -> None: data = yaml.safe_load((global_home / "config.yml").read_text()) assert data["default_agent"] == "claude" - def test_writes_recommended_bool(self, tmp_path: Path) -> None: - global_home = tmp_path / ".reporails" - with patch( - "reporails_cli.interfaces.cli.config_command._global_config_path", - return_value=global_home / "config.yml", - ): - result = runner.invoke(config_app, ["set", "--global", "recommended", "false"]) - assert result.exit_code == 0 - data = yaml.safe_load((global_home / "config.yml").read_text()) - assert data["recommended"] is False - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_rejects_non_global_key(self) -> None: result = runner.invoke(config_app, ["set", "--global", "exclude_dirs", "vendor"]) assert result.exit_code == 2 assert "not supported in global config" in result.output + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_rejects_unknown_key(self) -> None: result = runner.invoke(config_app, ["set", "--global", "bogus", "val"]) assert result.exit_code == 2 @@ -60,6 +56,8 @@ def test_rejects_unknown_key(self) -> None: class TestGlobalGet: """Test config get --global.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_reads_from_home_config(self, tmp_path: Path) -> None: global_home = tmp_path / ".reporails" _write_global_config(global_home, "default_agent: cursor\n") @@ -71,6 +69,8 @@ def test_reads_from_home_config(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "cursor" in result.output + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_not_set(self, tmp_path: Path) -> None: global_home = tmp_path / ".reporails" global_home.mkdir(parents=True) @@ -86,9 +86,11 @@ def test_not_set(self, tmp_path: Path) -> None: class TestGlobalList: """Test config list --global.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_shows_global_only(self, tmp_path: Path) -> None: global_home = tmp_path / ".reporails" - _write_global_config(global_home, "default_agent: claude\nrecommended: true\n") + _write_global_config(global_home, "default_agent: claude\ntier: pro\n") with patch( "reporails_cli.interfaces.cli.config_command._global_config_path", return_value=global_home / "config.yml", @@ -96,8 +98,10 @@ def test_shows_global_only(self, tmp_path: Path) -> None: result = runner.invoke(config_app, ["list", "--global"]) assert result.exit_code == 0 assert "default_agent: claude" in result.output - assert "recommended: True" in result.output + assert "tier: pro" in result.output + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_empty_global(self, tmp_path: Path) -> None: global_home = tmp_path / ".reporails" global_home.mkdir(parents=True) @@ -113,6 +117,8 @@ def test_empty_global(self, tmp_path: Path) -> None: class TestListMerge: """Test config list shows global fallbacks annotated.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_shows_global_fallback_annotated(self, tmp_path: Path) -> None: # Project has exclude_dirs but no default_agent project = tmp_path / "project" @@ -132,6 +138,8 @@ def test_shows_global_fallback_annotated(self, tmp_path: Path) -> None: assert "default_agent: claude (global)" in result.output assert "exclude_dirs:" in result.output + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_project_value_not_annotated(self, tmp_path: Path) -> None: project = tmp_path / "project" cfg_dir = project / ".ails" @@ -154,6 +162,8 @@ def test_project_value_not_annotated(self, tmp_path: Path) -> None: class TestConfigEdgeCases: """Edge cases for config commands.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_set_overwrites_existing_value(self, tmp_path: Path) -> None: """Setting a key twice should persist the second value, not the first.""" global_home = tmp_path / ".reporails" @@ -168,6 +178,8 @@ def test_set_overwrites_existing_value(self, tmp_path: Path) -> None: data = yaml.safe_load((global_home / "config.yml").read_text()) assert data["default_agent"] == "cursor", f"Second set should overwrite first, got {data['default_agent']}" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_malformed_global_config_handled(self, tmp_path: Path) -> None: """Malformed YAML in global config should not crash config list.""" global_home = tmp_path / ".reporails" diff --git a/tests/unit/test_discover.py b/tests/unit/test_discover.py index 4ce28a3..fbad585 100644 --- a/tests/unit/test_discover.py +++ b/tests/unit/test_discover.py @@ -1,19 +1,17 @@ -"""Unit tests for discover.py — backbone v3 detection functions.""" +"""Unit tests for discover.py — per-project metadata detection primitives.""" from __future__ import annotations import json from pathlib import Path -import yaml +import pytest -from reporails_cli.core.discover import ( +from reporails_cli.core.discovery.discover import ( _detect_classification, _detect_commands, _detect_meta, _detect_paths, - generate_backbone_placeholder, - generate_backbone_yaml, ) # --------------------------------------------------------------------------- @@ -22,6 +20,8 @@ class TestDetectClassification: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_python_cli_project(self, tmp_path: Path) -> None: """pyproject.toml with scripts → type=cli, language=[python].""" (tmp_path / "src").mkdir() @@ -36,6 +36,8 @@ def test_python_cli_project(self, tmp_path: Path) -> None: assert result["language"] == ["python"] assert result["runtime"] == "cpython" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_python_library_project(self, tmp_path: Path) -> None: """pyproject.toml without scripts → type=library.""" (tmp_path / "src").mkdir() @@ -45,6 +47,8 @@ def test_python_library_project(self, tmp_path: Path) -> None: assert result["type"] == "library" assert result["language"] == ["python"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_node_project(self, tmp_path: Path) -> None: """package.json → language=[javascript].""" (tmp_path / "src").mkdir() @@ -54,6 +58,8 @@ def test_node_project(self, tmp_path: Path) -> None: assert result["language"] == ["javascript"] assert result["runtime"] == "node" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_typescript_detection(self, tmp_path: Path) -> None: """package.json + tsconfig.json → language=[typescript].""" (tmp_path / "src").mkdir() @@ -62,6 +68,8 @@ def test_typescript_detection(self, tmp_path: Path) -> None: result = _detect_classification(tmp_path) assert result["language"] == ["typescript"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_bun_runtime_detection(self, tmp_path: Path) -> None: """bun.lockb → runtime=bun.""" (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) @@ -69,6 +77,8 @@ def test_bun_runtime_detection(self, tmp_path: Path) -> None: result = _detect_classification(tmp_path) assert result["runtime"] == "bun" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_deno_runtime_detection(self, tmp_path: Path) -> None: """deno.lock → runtime=deno.""" (tmp_path / "package.json").write_text(json.dumps({"name": "myapp"})) @@ -76,6 +86,8 @@ def test_deno_runtime_detection(self, tmp_path: Path) -> None: result = _detect_classification(tmp_path) assert result["runtime"] == "deno" + @pytest.mark.unit + @pytest.mark.subsys_lint 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') @@ -84,6 +96,8 @@ def test_multi_language(self, tmp_path: Path) -> None: assert "python" in result["language"] assert "javascript" in result["language"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_project(self, tmp_path: Path) -> None: """No manifests → all null.""" result = _detect_classification(tmp_path) @@ -92,24 +106,32 @@ def test_empty_project(self, tmp_path: Path) -> None: assert result["framework"] is None assert result["runtime"] is None + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_app_directory(self, tmp_path: Path) -> None: """app/ directory → type=app.""" (tmp_path / "app").mkdir() @@ -117,12 +139,16 @@ def test_app_directory(self, tmp_path: Path) -> None: result = _detect_classification(tmp_path) assert result["type"] == "app" + @pytest.mark.unit + @pytest.mark.subsys_lint 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"] + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -136,6 +162,8 @@ def test_go_project(self, tmp_path: Path) -> None: class TestDetectCommands: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_makefile_targets(self, tmp_path: Path) -> None: """Makefile with standard targets.""" (tmp_path / "Makefile").write_text( @@ -146,6 +174,8 @@ def test_makefile_targets(self, tmp_path: Path) -> None: assert result["test"] == "make test" assert result["lint"] == "make lint" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pyproject_poe_tasks(self, tmp_path: Path) -> None: """Poe tasks in pyproject.toml.""" (tmp_path / "pyproject.toml").write_text( @@ -156,6 +186,8 @@ def test_pyproject_poe_tasks(self, tmp_path: Path) -> None: assert result["lint"] == "poe lint" assert result["format"] == "poe format" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_package_json_scripts(self, tmp_path: Path) -> None: """npm scripts in package.json.""" (tmp_path / "package.json").write_text( @@ -176,12 +208,16 @@ def test_package_json_scripts(self, tmp_path: Path) -> None: assert result["lint"] == "npm run lint" assert result["format"] == "npm run format" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -189,11 +225,15 @@ def test_infer_ruff_from_config(self, tmp_path: Path) -> None: assert result["lint"] == "ruff check" assert result["format"] == "ruff format" + @pytest.mark.unit + @pytest.mark.subsys_lint 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()) + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -208,6 +248,8 @@ def test_poe_takes_priority_over_npm(self, tmp_path: Path) -> None: class TestDetectMeta: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_finds_version_and_changelog(self, tmp_path: Path) -> None: """VERSION + CHANGELOG.md detected.""" (tmp_path / "VERSION").write_text("1.0.0\n") @@ -216,6 +258,8 @@ def test_finds_version_and_changelog(self, tmp_path: Path) -> None: assert result["version_file"] == "VERSION" assert result["changelog"] == "CHANGELOG.md" + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -223,30 +267,40 @@ def test_manifest_priority(self, tmp_path: Path) -> None: result = _detect_meta(tmp_path) assert result["manifest"] == "pyproject.toml" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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/" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_project(self, tmp_path: Path) -> None: """No files → all null.""" result = _detect_meta(tmp_path) @@ -259,6 +313,8 @@ def test_empty_project(self, tmp_path: Path) -> None: class TestDetectPaths: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_standard_layout(self, tmp_path: Path) -> None: """src/ + tests/ + docs/ detected.""" (tmp_path / "src" / "mypackage").mkdir(parents=True) @@ -269,6 +325,8 @@ def test_standard_layout(self, tmp_path: Path) -> None: assert result["tests"] == "tests/" assert result["docs"] == "docs/" + @pytest.mark.unit + @pytest.mark.subsys_lint 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) @@ -276,100 +334,25 @@ def test_src_no_deep_resolve_with_multiple_children(self, tmp_path: Path) -> Non result = _detect_paths(tmp_path) assert result["src"] == "src/" + @pytest.mark.unit + @pytest.mark.subsys_lint 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/" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_scripts_detection(self, tmp_path: Path) -> None: """scripts/ detected.""" (tmp_path / "scripts").mkdir() result = _detect_paths(tmp_path) assert result["scripts"] == "scripts/" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 3fc3269..194c3ac 100644 --- a/tests/unit/test_download_rules_staging.py +++ b/tests/unit/test_download_rules_staging.py @@ -14,7 +14,7 @@ import pytest -from reporails_cli.core.init import ( +from reporails_cli.core.install.updater import ( IncompatibleSchemaError, download_rules_version, update_rules, @@ -74,6 +74,8 @@ def _mock_http_response(self, tarball_bytes: bytes) -> MagicMock: mock_client.__exit__ = MagicMock(return_value=False) return mock_client + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_incompatible_rules_preserve_existing(self, tmp_path: Path): """When schema check fails, existing rules must remain untouched.""" rules_path = tmp_path / "home" / ".reporails" / "rules" @@ -89,9 +91,11 @@ def test_incompatible_rules_preserve_existing(self, tmp_path: Path): mock_client = self._mock_http_response(tarball) with ( - patch("reporails_cli.core.updater.get_reporails_home", return_value=tmp_path / "home" / ".reporails"), - patch("reporails_cli.core.updater.httpx.Client", return_value=mock_client), - patch("reporails_cli.core.updater.copy_bundled_yml_files", return_value=0), + patch( + "reporails_cli.core.install.updater.get_reporails_home", return_value=tmp_path / "home" / ".reporails" + ), + patch("reporails_cli.core.install.updater.httpx.Client", return_value=mock_client), + patch("reporails_cli.core.install.updater.copy_bundled_yml_files", return_value=0), pytest.raises(IncompatibleSchemaError), ): download_rules_version("0.4.0") @@ -101,6 +105,8 @@ def test_incompatible_rules_preserve_existing(self, tmp_path: Path): assert version_file.read_text() == "0.2.0" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_compatible_rules_replace_existing(self, tmp_path: Path): """When schema check passes, existing rules are replaced.""" reporails_home = tmp_path / "home" / ".reporails" @@ -114,10 +120,10 @@ def test_compatible_rules_replace_existing(self, tmp_path: Path): mock_client = self._mock_http_response(tarball) with ( - patch("reporails_cli.core.updater.get_reporails_home", return_value=reporails_home), - patch("reporails_cli.core.updater.httpx.Client", return_value=mock_client), - patch("reporails_cli.core.updater.copy_bundled_yml_files", return_value=0), - patch("reporails_cli.core.updater.write_version_file"), + patch("reporails_cli.core.install.updater.get_reporails_home", return_value=reporails_home), + patch("reporails_cli.core.install.updater.httpx.Client", return_value=mock_client), + patch("reporails_cli.core.install.updater.copy_bundled_yml_files", return_value=0), + patch("reporails_cli.core.install.updater.write_version_file"), ): result_path, count = download_rules_version("0.3.0") @@ -126,6 +132,8 @@ def test_compatible_rules_replace_existing(self, tmp_path: Path): assert (result_path / "core" / "test-rule.yml").exists() assert count > 0 + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_manifest_passes_compatibility(self, tmp_path: Path): """Pre-contract rules (no manifest.yml) should be accepted.""" reporails_home = tmp_path / "home" / ".reporails" @@ -136,16 +144,18 @@ def test_no_manifest_passes_compatibility(self, tmp_path: Path): mock_client = self._mock_http_response(tarball) with ( - patch("reporails_cli.core.updater.get_reporails_home", return_value=reporails_home), - patch("reporails_cli.core.updater.httpx.Client", return_value=mock_client), - patch("reporails_cli.core.updater.copy_bundled_yml_files", return_value=0), - patch("reporails_cli.core.updater.write_version_file"), + patch("reporails_cli.core.install.updater.get_reporails_home", return_value=reporails_home), + patch("reporails_cli.core.install.updater.httpx.Client", return_value=mock_client), + patch("reporails_cli.core.install.updater.copy_bundled_yml_files", return_value=0), + patch("reporails_cli.core.install.updater.write_version_file"), ): result_path, count = download_rules_version("0.1.0") assert result_path.exists() assert count > 0 + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_fresh_install_incompatible_leaves_no_rules(self, tmp_path: Path): """On fresh install (no existing rules), incompatible download leaves rules_path absent.""" reporails_home = tmp_path / "home" / ".reporails" @@ -156,9 +166,9 @@ def test_fresh_install_incompatible_leaves_no_rules(self, tmp_path: Path): mock_client = self._mock_http_response(tarball) with ( - patch("reporails_cli.core.updater.get_reporails_home", return_value=reporails_home), - patch("reporails_cli.core.updater.httpx.Client", return_value=mock_client), - patch("reporails_cli.core.updater.copy_bundled_yml_files", return_value=0), + patch("reporails_cli.core.install.updater.get_reporails_home", return_value=reporails_home), + patch("reporails_cli.core.install.updater.httpx.Client", return_value=mock_client), + patch("reporails_cli.core.install.updater.copy_bundled_yml_files", return_value=0), pytest.raises(IncompatibleSchemaError), ): download_rules_version("0.4.0") @@ -169,6 +179,8 @@ def test_fresh_install_incompatible_leaves_no_rules(self, tmp_path: Path): class TestUpdateRulesIncompatibleSchema: """Verify update_rules returns clean error on incompatible schemas.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_not_updated_with_message(self, tmp_path: Path): """update_rules should catch IncompatibleSchemaError and return clean result.""" reporails_home = tmp_path / "home" / ".reporails" @@ -189,11 +201,11 @@ def test_returns_not_updated_with_message(self, tmp_path: Path): mock_client.__exit__ = MagicMock(return_value=False) with ( - patch("reporails_cli.core.updater.get_reporails_home", return_value=reporails_home), - patch("reporails_cli.core.updater.get_installed_version", return_value="0.2.0"), - patch("reporails_cli.core.updater.get_latest_version", return_value="0.4.0"), - patch("reporails_cli.core.updater.httpx.Client", return_value=mock_client), - patch("reporails_cli.core.updater.copy_bundled_yml_files", return_value=0), + patch("reporails_cli.core.install.updater.get_reporails_home", return_value=reporails_home), + patch("reporails_cli.core.install.updater.get_installed_version", return_value="0.2.0"), + patch("reporails_cli.core.install.updater.get_latest_version", return_value="0.4.0"), + patch("reporails_cli.core.install.updater.httpx.Client", return_value=mock_client), + patch("reporails_cli.core.install.updater.copy_bundled_yml_files", return_value=0), ): result = update_rules(force=True) diff --git a/tests/unit/test_engine_helpers.py b/tests/unit/test_engine_helpers.py index c3fae3e..385d199 100644 --- a/tests/unit/test_engine_helpers.py +++ b/tests/unit/test_engine_helpers.py @@ -8,9 +8,11 @@ from pathlib import Path +import pytest + from reporails_cli.core.cache import ProjectCache, content_hash -from reporails_cli.core.engine_helpers import _filter_cached_judgments -from reporails_cli.core.models import JudgmentRequest, Severity, Violation +from reporails_cli.core.platform.dto.models import JudgmentRequest, Severity, Violation +from reporails_cli.core.platform.runtime.engine_helpers import _filter_cached_judgments def _make_request( @@ -53,6 +55,9 @@ def _make_violation( class TestFilterCachedJudgments: + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_use_cache_false_returns_unchanged(self, tmp_path: Path) -> None: """When use_cache=False, inputs are returned as-is.""" requests = [_make_request()] @@ -69,6 +74,9 @@ def test_use_cache_false_returns_unchanged(self, tmp_path: Path) -> None: assert result_reqs is requests assert result_viols is violations + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_empty_requests_returns_unchanged(self, tmp_path: Path) -> None: """When no judgment requests, returns ([], violations) unchanged.""" violations = [_make_violation()] @@ -84,6 +92,9 @@ def test_empty_requests_returns_unchanged(self, tmp_path: Path) -> None: assert result_reqs == [] assert result_viols is violations + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_cache_hit_pass_filters_out_request(self, tmp_path: Path) -> None: """Cache hit with pass verdict filters the request out, no violation added.""" # Create the instruction file @@ -113,6 +124,9 @@ def test_cache_hit_pass_filters_out_request(self, tmp_path: Path) -> None: assert result_reqs == [] assert result_viols == [] + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_cache_hit_fail_adds_violation(self, tmp_path: Path) -> None: """Cache hit with non-pass verdict filters the request and adds a violation.""" md_file = tmp_path / "CLAUDE.md" @@ -148,6 +162,9 @@ def test_cache_hit_fail_adds_violation(self, tmp_path: Path) -> None: assert v.severity == Severity.HIGH assert v.message == "missing context" + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_cache_miss_keeps_request(self, tmp_path: Path) -> None: """Cache miss (no cached judgment) keeps the request in filtered list.""" md_file = tmp_path / "CLAUDE.md" @@ -168,6 +185,9 @@ def test_cache_miss_keeps_request(self, tmp_path: Path) -> None: assert result_reqs[0] is request assert result_viols == [] + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.subsys_runtime def test_file_deleted_keeps_request(self, tmp_path: Path) -> None: """If the file no longer exists (OSError from content_hash), request is kept.""" # Point location at a file that does not exist diff --git a/tests/unit/test_exit_codes.py b/tests/unit/test_exit_codes.py index 4b2cf47..0f8f649 100644 --- a/tests/unit/test_exit_codes.py +++ b/tests/unit/test_exit_codes.py @@ -11,18 +11,22 @@ from pathlib import Path +import pytest + from tests.conftest import create_temp_rule_file class TestRegexEngineResults: """Test that regex engine returns correct results.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_findings_returns_empty_results( self, tmp_path: Path, ) -> None: """No matches should return valid SARIF with empty results.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -44,12 +48,14 @@ def test_no_findings_returns_empty_results( assert isinstance(results, list) assert len(results) == 0 + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_findings_returned_correctly( self, tmp_path: Path, ) -> None: """Matching patterns should return SARIF results.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -70,12 +76,14 @@ def test_findings_returned_correctly( results = runs[0].get("results", []) assert len(results) > 0, "Expected findings in SARIF output" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_invalid_rule_handled_gracefully( self, tmp_path: Path, ) -> None: """Invalid/incomplete rules should not crash, just be skipped.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation bad_yaml = """\ checks: @@ -90,12 +98,14 @@ def test_invalid_rule_handled_gracefully( assert isinstance(result, dict), "Should return dict even on invalid rules" assert "runs" in result + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_files_matched_is_not_error( self, tmp_path: Path, ) -> None: """'No files matched' should not be treated as an error.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -120,12 +130,14 @@ def test_no_files_matched_is_not_error( class TestSARIFOutputFormat: """Test that SARIF output has the correct structure.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_sarif_has_correct_structure( self, tmp_path: Path, ) -> None: """SARIF output must have runs[].tool.driver.rules[] and runs[].results[].""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -161,12 +173,14 @@ def test_sarif_has_correct_structure( assert "region" in loc assert "startLine" in loc["region"] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_sarif_rule_definitions_present( self, tmp_path: Path, ) -> None: """SARIF tool.driver.rules[] must have matching definitions for results.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: diff --git a/tests/unit/test_funnel.py b/tests/unit/test_funnel.py index 1314f04..9305c59 100644 --- a/tests/unit/test_funnel.py +++ b/tests/unit/test_funnel.py @@ -22,12 +22,16 @@ ) +@pytest.mark.unit +@pytest.mark.subsys_funnel def test_bug_report_url_points_to_github_issues() -> None: """Bug-report URL is the GitHub issues page; renderer prints it as the secondary CTA.""" assert BUG_REPORT_URL.startswith("https://github.com/") assert BUG_REPORT_URL.endswith("/issues") +@pytest.mark.unit +@pytest.mark.subsys_funnel def test_bug_report_new_url_points_to_issue_form() -> None: """The ``/new`` variant is the deep-link target for prefilled bug reports.""" assert BUG_REPORT_NEW_URL.startswith("https://github.com/") @@ -35,6 +39,8 @@ def test_bug_report_new_url_points_to_issue_form() -> None: class TestFormatBugReportUrl: + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_unknown_error_prefills_title_and_body(self) -> None: err = FunnelError(error="unknown_error", message="HTTP 400 (unsupported_payload_version)") url = format_bug_report_url(err) @@ -46,6 +52,8 @@ def test_unknown_error_prefills_title_and_body(self) -> None: assert "labels=bug" in url assert "body=" in url + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_unknown_error_url_encodes_special_chars(self) -> None: err = FunnelError(error="unknown_error", message='HTTP 422 ("validation failed" / atoms)') url = format_bug_report_url(err) @@ -53,21 +61,29 @@ def test_unknown_error_url_encodes_special_chars(self) -> None: assert url.count("?") == 1 assert "%22validation+failed%22" in url or "%22validation%20failed%22" in url + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_unknown_error_with_empty_message_falls_back_to_index(self) -> None: err = FunnelError(error="unknown_error", message="") assert format_bug_report_url(err) == BUG_REPORT_URL + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_known_funnel_error_returns_plain_index(self) -> None: # rate_limit_exceeded is an expected usage signal, not a bug report. err = FunnelError(error="rate_limit_exceeded", tier="anonymous", limit=5, message="any") assert format_bug_report_url(err) == BUG_REPORT_URL + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_payload_too_large_returns_plain_index(self) -> None: err = FunnelError(error="payload_too_large", tier="anonymous", limit=2_097_152, size=8_971_467) assert format_bug_report_url(err) == BUG_REPORT_URL class TestParseErrorBody: + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_rate_limit_body(self) -> None: body = json.dumps( { @@ -84,6 +100,8 @@ def test_rate_limit_body(self) -> None: assert err.limit == 5 assert err.reset_in == 2400 + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_payload_too_large_body(self) -> None: body = json.dumps( { @@ -99,6 +117,8 @@ def test_payload_too_large_body(self) -> None: assert err.size == 8971467 assert err.limit == 2097152 + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_atom_cap_exceeded_body(self) -> None: body = json.dumps( { @@ -117,6 +137,8 @@ def test_atom_cap_exceeded_body(self) -> None: assert err.files == 47 assert err.upgrade_url == "https://reporails.com/contact/atoms" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_unknown_error_returns_unknown_error(self) -> None: body = json.dumps({"error": "some_other_thing", "tier": "pro"}) err = parse_error_body(400, body) @@ -125,6 +147,8 @@ def test_unknown_error_returns_unknown_error(self) -> None: assert err.tier == "pro" assert "some_other_thing" in err.message + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_unknown_error_with_server_message(self) -> None: body = json.dumps({"error": "some_other_thing", "message": "Custom server explanation"}) err = parse_error_body(400, body) @@ -132,25 +156,35 @@ def test_unknown_error_with_server_message(self) -> None: assert err.error == "unknown_error" assert err.message == "Custom server explanation" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_2xx_returns_none(self) -> None: body = json.dumps({"error": "rate_limit_exceeded"}) assert parse_error_body(200, body) is None + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_5xx_returns_none(self) -> None: body = json.dumps({"error": "rate_limit_exceeded"}) assert parse_error_body(500, body) is None + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_invalid_json_returns_unknown_error(self) -> None: err = parse_error_body(429, "not json") assert err is not None assert err.error == "unknown_error" assert "429" in err.message + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_non_dict_returns_unknown_error(self) -> None: err = parse_error_body(429, json.dumps([1, 2, 3])) assert err is not None assert err.error == "unknown_error" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_missing_error_field_returns_unknown_error(self) -> None: err = parse_error_body(429, json.dumps({"tier": "pro"})) assert err is not None @@ -162,15 +196,21 @@ class TestPreflightOversized: """Preflight enforces only the universal absolute caps. Byte-size is the Worker's concern; the CLI never makes tier-specific cap decisions.""" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_under_all_caps(self) -> None: payload = {"files": [], "atoms": [], "clusters": []} assert preflight_oversized(payload, has_api_key=True) is None + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_does_not_check_byte_size(self) -> None: """Even a 50 MB payload passes preflight — Worker enforces byte caps.""" payload = {"files": [], "atoms": [], "clusters": []} assert preflight_oversized(payload, has_api_key=False) is None + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_atom_count_universal_cap(self) -> None: payload = {"files": [], "atoms": [{}] * (UNIVERSAL_ATOM_CAP + 1), "clusters": []} err = preflight_oversized(payload, has_api_key=True) @@ -178,18 +218,24 @@ def test_atom_count_universal_cap(self) -> None: assert err.error == "atom_cap_exceeded" assert err.limit == UNIVERSAL_ATOM_CAP + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_files_over_cap(self) -> None: payload = {"files": [{}] * (WIRE_MAX_FILES + 1), "atoms": [], "clusters": []} err = preflight_oversized(payload, has_api_key=True) assert err is not None assert err.limit == WIRE_MAX_FILES + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_clusters_over_cap(self) -> None: payload = {"files": [], "atoms": [], "clusters": [{}] * (WIRE_MAX_CLUSTERS + 1)} err = preflight_oversized(payload, has_api_key=True) assert err is not None assert err.limit == WIRE_MAX_CLUSTERS + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_anonymous_cta_omits_upgrade_url(self) -> None: # The anonymous CTA's actionable instruction is `ails auth login` # in the message itself; no landing-page URL is appended. @@ -199,6 +245,8 @@ def test_anonymous_cta_omits_upgrade_url(self) -> None: assert err.tier == "anonymous" assert err.upgrade_url == "" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_keyed_cta_uses_contact_section(self) -> None: """With a key the presumed tier is `pro`; CTA points at /contact/.""" payload = {"files": [], "atoms": [{}] * (UNIVERSAL_ATOM_CAP + 1), "clusters": []} @@ -209,32 +257,86 @@ def test_keyed_cta_uses_contact_section(self) -> None: class TestMergeUtm: + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_appends_when_absent(self) -> None: url = "https://reporails.com/contact/rate-limit" assert merge_utm(url) == "https://reporails.com/contact/rate-limit?utm_source=cli" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_preserves_when_present(self) -> None: url = "https://reporails.com/contact?utm_source=mcp" assert merge_utm(url) == url + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_preserves_existing_query_params(self) -> None: url = "https://reporails.com/x?reason=rate" merged = merge_utm(url) assert "reason=rate" in merged assert "utm_source=cli" in merged + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_empty_url_unchanged(self) -> None: assert merge_utm("") == "" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_non_http_url_unchanged(self) -> None: assert merge_utm("javascript:alert(1)") == "javascript:alert(1)" + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_custom_source(self) -> None: url = "https://reporails.com/contact" assert "utm_source=action" in merge_utm(url, source="action") +class TestFunnelErrorResetPhrase: + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_zero_reset_in_renders_empty(self) -> None: + assert FunnelError(error="rate_limit_exceeded", reset_in=0).reset_phrase == "" + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_negative_reset_in_renders_empty(self) -> None: + # Defensive — server clock skew or stale entry should never produce + # "Try again in -5 min." + assert FunnelError(error="rate_limit_exceeded", reset_in=-30).reset_phrase == "" + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_under_one_minute_rounds_up_to_one(self) -> None: + # 30 seconds reads as "<1 min" — telling a user "0 min" is worse + # than telling them "<1 min". + assert FunnelError(error="rate_limit_exceeded", reset_in=30).reset_phrase == "Try again in <1 min. " + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_exact_minute_boundary(self) -> None: + # 60 seconds → 1 minute, but rendered as "<1 min" (the rounding-up + # rule kicks in only above 60s, so the prompt stays calibrated). + assert FunnelError(error="rate_limit_exceeded", reset_in=60).reset_phrase == "Try again in <1 min. " + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_thirty_minutes(self) -> None: + assert FunnelError(error="rate_limit_exceeded", reset_in=1800).reset_phrase == "Try again in ~30 min. " + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_rounds_up_partial_minute(self) -> None: + # 61 s should not render as "~1 min" (which collides with <1 min). + # Rounding up keeps the displayed wait honestly ≥ the real wait. + assert FunnelError(error="rate_limit_exceeded", reset_in=61).reset_phrase == "Try again in ~2 min. " + + class TestFormatCta: + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_anonymous_rate_limit_no_url(self) -> None: # In 0.5.6 the anonymous CTA emits no URL — `ails auth login` is the # action and lives in the message. Renderer must omit the arrow. @@ -243,7 +345,30 @@ def test_anonymous_rate_limit_no_url(self) -> None: assert "Anonymous limit hit" in cta assert "5/hr" in cta assert "→" not in cta + # No reset_in → no retry hint. + assert "Try again" not in cta + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_anonymous_rate_limit_with_reset_in(self) -> None: + # Server reset_in surfaces as a human retry hint between the limit + # blurb and the upgrade CTA. + err = FunnelError(error="rate_limit_exceeded", tier="anonymous", limit=5, reset_in=1800) + cta = format_cta(err) + assert "Anonymous limit hit" in cta + assert "Try again in ~30 min" in cta + assert "ails auth login" in cta + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_pro_rate_limit_with_reset_in(self) -> None: + err = FunnelError(error="rate_limit_exceeded", tier="pro", limit=200, reset_in=120) + cta = format_cta(err) + assert "Hit your hourly limit" in cta + assert "Try again in ~2 min" in cta + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_pro_rate_limit(self) -> None: err = FunnelError( error="rate_limit_exceeded", @@ -253,10 +378,12 @@ def test_pro_rate_limit(self) -> None: ) cta = format_cta(err) assert "Hit your hourly limit" in cta - assert "file an issue" in cta + assert "File an issue" in cta assert "200/hr" in cta assert "utm_source=cli" in cta + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_atom_cap_acknowledges_cap_unchanged(self) -> None: err = FunnelError( error="atom_cap_exceeded", @@ -269,6 +396,8 @@ def test_atom_cap_acknowledges_cap_unchanged(self) -> None: assert "10,000" in cta or "10000" in cta assert "12,396" in cta or "12396" in cta + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_server_message_wins(self) -> None: err = FunnelError( error="rate_limit_exceeded", @@ -282,6 +411,8 @@ def test_server_message_wins(self) -> None: # The default template should not appear when message is set. assert "Hit your hourly limit" not in cta + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_no_url_renders_without_arrow(self) -> None: err = FunnelError(error="rate_limit_exceeded", tier="anonymous", limit=5) cta = format_cta(err) @@ -289,11 +420,15 @@ def test_no_url_renders_without_arrow(self) -> None: class TestLintResponse: + @pytest.mark.unit + @pytest.mark.subsys_funnel def test_default_empty(self) -> None: response = LintResponse() assert response.result is None assert response.funnel_error is None + @pytest.mark.unit + @pytest.mark.subsys_funnel @pytest.mark.parametrize("error_type", ["rate_limit_exceeded", "payload_too_large", "atom_cap_exceeded"]) def test_holds_funnel_error(self, error_type: str) -> None: err = FunnelError(error=error_type, tier="pro") diff --git a/tests/unit/test_gates.py b/tests/unit/test_gates.py index ebeee64..2831c5f 100644 --- a/tests/unit/test_gates.py +++ b/tests/unit/test_gates.py @@ -10,8 +10,8 @@ import pytest -from reporails_cli.core.levels import _property_depth, _type_exists, determine_project_level -from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration, Level +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileTypeDeclaration, Level +from reporails_cli.core.platform.policy.levels import _property_depth, _type_exists, determine_project_level def _ft( @@ -39,6 +39,8 @@ def _cf( class TestPropertyDepth: """Test _property_depth — count divergences from baseline.""" + @pytest.mark.unit + @pytest.mark.subsys_gates def test_all_baseline_returns_zero(self) -> None: props = { "format": "freeform", @@ -49,16 +51,24 @@ def test_all_baseline_returns_zero(self) -> None: } assert _property_depth(props) == 0 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_empty_properties_returns_zero(self) -> None: assert _property_depth({}) == 0 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_one_divergence(self) -> None: assert _property_depth({"format": "frontmatter"}) == 1 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_two_divergences(self) -> None: props = {"format": "frontmatter", "scope": "path_scoped"} assert _property_depth(props) == 2 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_all_divergent(self) -> None: props = { "format": "frontmatter", @@ -69,6 +79,8 @@ def test_all_divergent(self) -> None: } assert _property_depth(props) == 5 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_extra_properties_ignored(self) -> None: """Properties not in baseline don't count.""" props = {"custom_axis": "whatever", "unknown": "value"} @@ -78,27 +90,39 @@ def test_extra_properties_ignored(self) -> None: class TestTypeExists: """Test _type_exists — filesystem pattern matching.""" + @pytest.mark.unit + @pytest.mark.subsys_gates 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 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_exact_file_missing(self, tmp_path: Path) -> None: assert _type_exists(tmp_path, ("CLAUDE.md",)) is False + @pytest.mark.unit + @pytest.mark.subsys_gates 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 + @pytest.mark.unit + @pytest.mark.subsys_gates 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 + @pytest.mark.unit + @pytest.mark.subsys_gates 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 + @pytest.mark.unit + @pytest.mark.subsys_gates 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 @@ -107,11 +131,15 @@ def test_multiple_patterns_any_match(self, tmp_path: Path) -> None: class TestDetermineProjectLevel: """Test determine_project_level — level from property divergence.""" + @pytest.mark.unit + @pytest.mark.subsys_gates 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() + @pytest.mark.unit + @pytest.mark.subsys_gates def test_main_only_returns_l1(self, tmp_path: Path) -> None: """Main file with all-baseline properties → depth 0 → L1.""" classified = [ @@ -128,6 +156,8 @@ def test_main_only_returns_l1(self, tmp_path: Path) -> None: assert level == Level.L1 assert present == {"main"} + @pytest.mark.unit + @pytest.mark.subsys_gates def test_one_divergence_returns_l2(self, tmp_path: Path) -> None: """One property diverges → depth 1 → L2.""" classified = [_cf("scoped_rule", format="frontmatter")] @@ -135,6 +165,8 @@ def test_one_divergence_returns_l2(self, tmp_path: Path) -> None: assert level == Level.L2 assert present == {"scoped_rule"} + @pytest.mark.unit + @pytest.mark.subsys_gates def test_max_depth_across_types(self, tmp_path: Path) -> None: """Level is max depth across all present types + 1.""" classified = [ @@ -144,6 +176,8 @@ def test_max_depth_across_types(self, tmp_path: Path) -> None: level, _ = determine_project_level(tmp_path, [], classified) assert level == Level.L4 # max(0, 3) + 1 = 4 + @pytest.mark.unit + @pytest.mark.subsys_gates def test_capped_at_l6(self, tmp_path: Path) -> None: """Level caps at L6 even with 5+ divergences.""" classified = [ @@ -159,6 +193,8 @@ def test_capped_at_l6(self, tmp_path: Path) -> None: level, _ = determine_project_level(tmp_path, [], classified) assert level == Level.L6 # min(5+1, 6) = 6 + @pytest.mark.unit + @pytest.mark.subsys_gates 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") @@ -177,6 +213,8 @@ def test_filesystem_fallback(self, tmp_path: Path) -> None: assert level == Level.L1 assert "main" in present + @pytest.mark.unit + @pytest.mark.subsys_gates 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") @@ -187,6 +225,8 @@ def test_filesystem_not_double_counted(self, tmp_path: Path) -> None: level, _ = determine_project_level(tmp_path, file_types, classified) assert level == Level.L1 # depth 0 from classified, not 1 from file_types + @pytest.mark.unit + @pytest.mark.subsys_gates @pytest.mark.parametrize( "depth, expected_level", [ diff --git a/tests/unit/test_github_formatter.py b/tests/unit/test_github_formatter.py index 1d51cc6..ba28b3a 100644 --- a/tests/unit/test_github_formatter.py +++ b/tests/unit/test_github_formatter.py @@ -14,7 +14,7 @@ import pytest -from reporails_cli.core.models import ( +from reporails_cli.core.platform.dto.models import ( FrictionEstimate, Level, ScanDelta, @@ -64,6 +64,8 @@ def _make_result( class TestSeverityMapping: """Test _severity_to_command mapping.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic @pytest.mark.parametrize( "severity, expected", [ @@ -80,6 +82,8 @@ def test_severity_to_command(self, severity: Severity, expected: str) -> None: class TestLocationParsing: """Test file:line parsing from violation location.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic @pytest.mark.parametrize( "location, expected_fragment", [ @@ -100,6 +104,8 @@ def test_location_parsing(self, location: str, expected_fragment: str) -> None: class TestEscaping: """Test special character escaping in workflow commands.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic @pytest.mark.parametrize( "input_str, expected", [ @@ -114,6 +120,8 @@ class TestEscaping: def test_escape_workflow_property(self, input_str: str, expected: str) -> None: assert github_formatter._escape_workflow_property(input_str) == expected + @pytest.mark.unit + @pytest.mark.subsys_diagnostic @pytest.mark.parametrize( "input_str, expected", [ @@ -126,6 +134,8 @@ def test_escape_workflow_property(self, input_str: str, expected: str) -> None: def test_escape_workflow_data(self, input_str: str, expected: str) -> None: assert github_formatter._escape_workflow_data(input_str) == expected + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_title_with_special_chars(self) -> None: v = _make_violation( rule_id="CORE:S:0012", @@ -137,11 +147,15 @@ def test_title_with_special_chars(self) -> None: # Colons in rule_id should be escaped in title property assert "[CORE%3AS%3A0012]" in output + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_percent_in_message_escaped_before_other_chars(self) -> None: """Percent must be escaped first to avoid double-escaping.""" result = github_formatter._escape_workflow_data("100%\n") assert result == "100%25%0A" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_percent_in_property_escaped_before_other_chars(self) -> None: result = github_formatter._escape_workflow_property("100%\n:") assert result == "100%25%0A%3A" @@ -150,11 +164,15 @@ def test_percent_in_property_escaped_before_other_chars(self) -> None: class TestAnnotations: """Test format_annotations output.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_empty_violations_returns_empty(self) -> None: result = _make_result(violations=()) output = github_formatter.format_annotations(result) assert output == "" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_single_violation_format(self) -> None: v = _make_violation( rule_id="CORE:S:0012", @@ -170,6 +188,8 @@ def test_single_violation_format(self) -> None: assert "line=45" in output assert "Multi-step procedure found" in output + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_multiple_violations_one_per_line(self) -> None: v1 = _make_violation(severity=Severity.HIGH, location="a.md:1") v2 = _make_violation(severity=Severity.MEDIUM, location="b.md:2") @@ -180,6 +200,8 @@ def test_multiple_violations_one_per_line(self) -> None: assert lines[0].startswith("::error ") assert lines[1].startswith("::warning ") + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_warning_severity(self) -> None: v = _make_violation(severity=Severity.LOW) result = _make_result(violations=(v,)) @@ -190,6 +212,8 @@ def test_warning_severity(self) -> None: class TestFormatResult: """Test full format_result output (annotations + JSON).""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_json_line_is_last(self) -> None: v = _make_violation() result = _make_result(violations=(v,)) @@ -199,6 +223,8 @@ def test_json_line_is_last(self) -> None: assert "score" in data assert "level" in data + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_json_line_contains_score_and_level(self) -> None: result = _make_result(score=8.5, level=Level.L4) output = github_formatter.format_result(result) @@ -207,6 +233,8 @@ def test_json_line_contains_score_and_level(self) -> None: assert data["score"] == 8.5 assert data["level"] == "L4" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_empty_violations_only_json(self) -> None: result = _make_result(violations=()) output = github_formatter.format_result(result) @@ -215,6 +243,8 @@ def test_empty_violations_only_json(self) -> None: data = json.loads(lines[0]) assert data["violations"] == [] + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_delta_included_in_json(self) -> None: result = _make_result(score=7.5) delta = ScanDelta( @@ -229,6 +259,8 @@ def test_delta_included_in_json(self) -> None: assert data["score_delta"] == 0.5 assert data["level_previous"] == "L2" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_annotations_before_json(self) -> None: v = _make_violation(location="CLAUDE.md:10") result = _make_result(violations=(v,)) diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index 361f3bf..963a2a3 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -4,7 +4,9 @@ from pathlib import Path -from reporails_cli.core.models import ClassifiedFile, FileTypeDeclaration +import pytest + +from reporails_cli.core.platform.dto.models import ClassifiedFile, FileTypeDeclaration # ── Helpers ────────────────────────────────────────────────────────── @@ -84,8 +86,10 @@ def _make_checks_yml(rule_dir: Path, yml_content: str) -> None: class TestDiscoverRules: """Tests for discover_rules().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_discovers_rule_in_core(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import discover_rules + from reporails_cli.core.lint.harness import discover_rules _make_rule_dir( tmp_path, @@ -99,8 +103,10 @@ def test_discovers_rule_in_core(self, tmp_path: Path) -> None: assert rules[0].rule_id == "CORE:S:0001" assert rules[0].slug == "test-rule" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_filter_by_rule_id(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import discover_rules + from reporails_cli.core.lint.harness import discover_rules _make_rule_dir( tmp_path, @@ -119,8 +125,10 @@ def test_filter_by_rule_id(self, tmp_path: Path) -> None: assert len(rules) == 1 assert rules[0].rule_id == "CORE:S:0002" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_excludes(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import discover_rules + from reporails_cli.core.lint.harness import discover_rules _make_rule_dir( tmp_path, @@ -132,8 +140,10 @@ def test_excludes(self, tmp_path: Path) -> None: rules = discover_rules(tmp_path, excludes=["CLAUDE:*"]) assert len(rules) == 0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_discovers_agent_rules(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import discover_rules + from reporails_cli.core.lint.harness import discover_rules # Agent dirs sit alongside core/ and need a config.yml agent_dir = tmp_path / "claude" @@ -157,8 +167,10 @@ def test_discovers_agent_rules(self, tmp_path: Path) -> None: class TestLoadAgentConfig: """Tests for load_agent_config().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_loads_file_types_and_excludes(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import load_agent_config + from reporails_cli.core.lint.harness import load_agent_config config_dir = tmp_path / "claude" config_dir.mkdir(parents=True) @@ -178,8 +190,10 @@ def test_loads_file_types_and_excludes(self, tmp_path: Path) -> None: assert "main" in type_names assert excludes == ["CORE:S:0010"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_missing_config_returns_empty(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import load_agent_config + from reporails_cli.core.lint.harness import load_agent_config file_types, excludes = load_agent_config(tmp_path, "nonexistent") assert file_types == [] @@ -192,8 +206,10 @@ def test_missing_config_returns_empty(self, tmp_path: Path) -> None: class TestMechanicalChecks: """Tests for mechanical check execution in harness context.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_exists_pass(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -205,7 +221,7 @@ def test_file_exists_pass(self, tmp_path: Path) -> None: ) _make_fixtures(rule_dir, "# Test\n", None) # pass fixture only - from reporails_cli.core.harness import RuleInfo + from reporails_cli.core.lint.harness import RuleInfo info = RuleInfo( rule_id="CORE:S:0001", @@ -224,8 +240,10 @@ def test_file_exists_pass(self, tmp_path: Path) -> None: result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED + @pytest.mark.unit + @pytest.mark.subsys_lint def test_not_implemented(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -248,8 +266,10 @@ def test_not_implemented(self, tmp_path: Path) -> None: result = run_rule(info, []) assert result.status == HarnessStatus.NOT_IMPLEMENTED + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_fixtures(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -281,8 +301,10 @@ def test_no_fixtures(self, tmp_path: Path) -> None: class TestDeterministicChecks: """Tests for deterministic (regex) check execution in harness context.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pass_fixture_no_violation(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -332,9 +354,11 @@ def test_pass_fixture_no_violation(self, tmp_path: Path) -> None: result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -392,8 +416,10 @@ def test_expect_present_deterministic(self, tmp_path: Path) -> None: class TestSemanticChecks: """Tests for semantic check handling (always skip).""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_semantic_always_passes(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -430,8 +456,10 @@ def test_semantic_always_passes(self, tmp_path: Path) -> None: class TestRunHarness: """Tests for run_harness() batch runner.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_runs_all_discovered_rules(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import run_harness + from reporails_cli.core.lint.harness import run_harness _make_agent_config(tmp_path) rule_dir = _make_rule_dir( @@ -454,22 +482,28 @@ def test_runs_all_discovered_rules(self, tmp_path: Path) -> None: class TestGitMarker: """Tests for .git_marker workaround in git_tracked probe.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_git_marker_detected(self, tmp_path: Path) -> None: - from reporails_cli.core.mechanical.checks import git_tracked + from reporails_cli.core.lint.mechanical.checks import git_tracked (tmp_path / ".git_marker").touch() result = git_tracked(tmp_path, {}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_git_dir_detected(self, tmp_path: Path) -> None: - from reporails_cli.core.mechanical.checks import git_tracked + from reporails_cli.core.lint.mechanical.checks import git_tracked (tmp_path / ".git").mkdir() result = git_tracked(tmp_path, {}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_git_fails(self, tmp_path: Path) -> None: - from reporails_cli.core.mechanical.checks import git_tracked + from reporails_cli.core.lint.mechanical.checks import git_tracked result = git_tracked(tmp_path, {}, []) assert not result.passed @@ -481,9 +515,11 @@ def test_no_git_fails(self, tmp_path: Path) -> None: class TestFixtureDiscovery: """Tests for non-.md fixture file discovery (P1).""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_json_fixture_discovered_in_deterministic_check(self, tmp_path: Path) -> None: """D check should scan non-.md files (e.g. settings.json) in fixtures.""" - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -534,9 +570,11 @@ def test_json_fixture_discovered_in_deterministic_check(self, tmp_path: Path) -> class TestCheckNameFallback: """Tests for mechanical check name fallback (P4a).""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_falls_back_to_name_field(self, tmp_path: Path) -> None: """When 'check' key is absent, use 'name' for dispatch.""" - from reporails_cli.core.harness import _run_mechanical_check + from reporails_cli.core.lint.harness import _run_mechanical_check (tmp_path / "CLAUDE.md").write_text("# Test\n") classified = [ClassifiedFile(path=tmp_path / "CLAUDE.md", file_type="main", properties={})] @@ -547,9 +585,11 @@ def test_falls_back_to_name_field(self, tmp_path: Path) -> None: ) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_check_key_takes_precedence(self, tmp_path: Path) -> None: """When both 'check' and 'name' are present, 'check' wins.""" - from reporails_cli.core.harness import _run_mechanical_check + from reporails_cli.core.lint.harness import _run_mechanical_check (tmp_path / "CLAUDE.md").write_text("# Test\n") classified = [ClassifiedFile(path=tmp_path / "CLAUDE.md", file_type="main", properties={})] @@ -567,18 +607,24 @@ def test_check_key_takes_precedence(self, tmp_path: Path) -> None: class TestCheckAliases: """Tests for mechanical check aliases.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_tracked_alias(self, tmp_path: Path) -> None: - from reporails_cli.core.mechanical.checks import MECHANICAL_CHECKS + from reporails_cli.core.lint.mechanical.checks import MECHANICAL_CHECKS assert MECHANICAL_CHECKS["file_tracked"] is MECHANICAL_CHECKS["git_tracked"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_memory_dir_exists_alias(self) -> None: - from reporails_cli.core.mechanical.checks import MECHANICAL_CHECKS + from reporails_cli.core.lint.mechanical.checks import MECHANICAL_CHECKS assert MECHANICAL_CHECKS["memory_dir_exists"] is MECHANICAL_CHECKS["directory_exists"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_total_size_check_alias(self) -> None: - from reporails_cli.core.mechanical.checks import MECHANICAL_CHECKS + from reporails_cli.core.lint.mechanical.checks import MECHANICAL_CHECKS assert MECHANICAL_CHECKS["total_size_check"] is MECHANICAL_CHECKS["aggregate_byte_size"] @@ -604,8 +650,10 @@ def _make_multi_agent_config(tmp_path: Path, agent: str, prefix: str, instructio class TestPrefixToAgentMap: """Tests for _build_prefix_to_agent_map().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_builds_map_from_configs(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _build_prefix_to_agent_map + from reporails_cli.core.lint.harness import _build_prefix_to_agent_map _make_multi_agent_config(tmp_path, "claude", "CLAUDE", "**/CLAUDE.md") _make_multi_agent_config(tmp_path, "codex", "CODEX", "**/AGENTS.md") @@ -613,8 +661,10 @@ def test_builds_map_from_configs(self, tmp_path: Path) -> None: mapping = _build_prefix_to_agent_map(tmp_path) assert mapping == {"CLAUDE": "claude", "CODEX": "codex"} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_skips_agent_without_prefix(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _build_prefix_to_agent_map + from reporails_cli.core.lint.harness import _build_prefix_to_agent_map config_dir = tmp_path / "generic" config_dir.mkdir(parents=True) @@ -623,8 +673,10 @@ def test_skips_agent_without_prefix(self, tmp_path: Path) -> None: mapping = _build_prefix_to_agent_map(tmp_path) assert mapping == {} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_when_no_agents_dir(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _build_prefix_to_agent_map + from reporails_cli.core.lint.harness import _build_prefix_to_agent_map assert _build_prefix_to_agent_map(tmp_path) == {} @@ -632,21 +684,27 @@ def test_empty_when_no_agents_dir(self, tmp_path: Path) -> None: class TestGetRuleAgent: """Tests for _get_rule_agent().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_matches_prefix(self) -> None: - from reporails_cli.core.harness import _get_rule_agent + from reporails_cli.core.lint.harness import _get_rule_agent prefix_map = {"CLAUDE": "claude", "CODEX": "codex"} assert _get_rule_agent("CLAUDE:S:0004", prefix_map) == "claude" assert _get_rule_agent("CODEX:S:0001", prefix_map) == "codex" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_core_returns_none(self) -> None: - from reporails_cli.core.harness import _get_rule_agent + from reporails_cli.core.lint.harness import _get_rule_agent prefix_map = {"CLAUDE": "claude"} assert _get_rule_agent("CORE:S:0001", prefix_map) is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_rrails_returns_none(self) -> None: - from reporails_cli.core.harness import _get_rule_agent + from reporails_cli.core.lint.harness import _get_rule_agent assert _get_rule_agent("RRAILS:S:0001", {"CLAUDE": "claude"}) is None @@ -654,8 +712,10 @@ def test_rrails_returns_none(self) -> None: class TestMultiAgentHarness: """Tests for multi-agent run_harness().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import run_harness + from reporails_cli.core.lint.harness import run_harness # Set up claude agent _make_multi_agent_config(tmp_path, "claude", "CLAUDE", "**/CLAUDE.md") @@ -695,18 +755,24 @@ def test_discovers_rules_from_multiple_agents(self, tmp_path: Path) -> None: class TestGlobToConcrete: """Tests for _glob_to_concrete().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_double_star_md(self) -> None: - from reporails_cli.core.harness import _glob_to_concrete + from reporails_cli.core.lint.harness import _glob_to_concrete assert _glob_to_concrete("**/*.md") == "scaffold.md" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_nested_glob(self) -> None: - from reporails_cli.core.harness import _glob_to_concrete + from reporails_cli.core.lint.harness import _glob_to_concrete assert _glob_to_concrete(".claude/rules/**/*.md") == ".claude/rules/scaffold.md" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_plain_path_preserved(self) -> None: - from reporails_cli.core.harness import _glob_to_concrete + from reporails_cli.core.lint.harness import _glob_to_concrete assert _glob_to_concrete(".claude/settings.json") == ".claude/settings.json" @@ -714,8 +780,10 @@ def test_plain_path_preserved(self) -> None: class TestScaffoldFixture: """Tests for _scaffold_fixture().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_scaffolds_file_for_file_exists(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -733,8 +801,10 @@ def test_scaffolds_file_for_file_exists(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_scaffolds_git_marker(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -748,8 +818,10 @@ def test_scaffolds_git_marker(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_scaffolds_directory(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -763,8 +835,10 @@ def test_scaffolds_directory(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_scaffold_for_deterministic_only(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -773,8 +847,10 @@ def test_no_scaffold_for_deterministic_only(self, tmp_path: Path) -> None: result = _scaffold_fixture(fixture_dir, checks, []) assert result is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_scaffold_for_semantic_only(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -783,9 +859,11 @@ def test_no_scaffold_for_semantic_only(self, tmp_path: Path) -> None: result = _scaffold_fixture(fixture_dir, checks, []) assert result is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_scaffolds_file_removal_for_file_absent(self, tmp_path: Path) -> None: """Pass scaffold removes the forbidden file for file_absent checks.""" - from reporails_cli.core.harness import _scaffold_fixture + from reporails_cli.core.lint.harness import _scaffold_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -807,8 +885,10 @@ def test_scaffolds_file_removal_for_file_absent(self, tmp_path: Path) -> None: class TestScaffoldFailFixture: """Tests for _scaffold_fail_fixture().""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_filename_mismatch_renames_file(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fail_fixture + from reporails_cli.core.lint.harness import _scaffold_fail_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -830,8 +910,10 @@ def test_filename_mismatch_renames_file(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_count_deficit_reduces_files(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fail_fixture + from reporails_cli.core.lint.harness import _scaffold_fail_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -848,8 +930,10 @@ def test_glob_count_deficit_reduces_files(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_present_creates_forbidden_file(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fail_fixture + from reporails_cli.core.lint.harness import _scaffold_fail_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -863,8 +947,10 @@ def test_file_present_creates_forbidden_file(self, tmp_path: Path) -> None: shutil.rmtree(result) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_scaffold_for_unsupported_checks(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import _scaffold_fail_fixture + from reporails_cli.core.lint.harness import _scaffold_fail_fixture fixture_dir = tmp_path / "fixture" fixture_dir.mkdir() @@ -877,8 +963,10 @@ def test_no_scaffold_for_unsupported_checks(self, tmp_path: Path) -> None: class TestFailScaffoldIntegration: """End-to-end: rules with structural M checks pass via scaffolding.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_filename_matches_pattern_with_scaffold(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, @@ -920,8 +1008,10 @@ def test_filename_matches_pattern_with_scaffold(self, tmp_path: Path) -> None: result = run_rule(info, _fts_claude()) assert result.status == HarnessStatus.PASSED + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_absent_with_scaffold(self, tmp_path: Path) -> None: - from reporails_cli.core.harness import HarnessStatus, RuleInfo, run_rule + from reporails_cli.core.lint.harness import HarnessStatus, RuleInfo, run_rule rule_dir = _make_rule_dir( tmp_path, diff --git a/tests/unit/test_json_formatter.py b/tests/unit/test_json_formatter.py index b01a1d9..955822b 100644 --- a/tests/unit/test_json_formatter.py +++ b/tests/unit/test_json_formatter.py @@ -2,8 +2,10 @@ from __future__ import annotations -from reporails_cli.core.api_client import CrossFileCoordinate, Hint -from reporails_cli.core.merger import CombinedResult, CombinedStats +import pytest + +from reporails_cli.core.platform.adapters.api_client import CrossFileCoordinate, Hint +from reporails_cli.core.platform.runtime.merger import CombinedResult, CombinedStats from reporails_cli.formatters.json import format_combined_result @@ -23,6 +25,8 @@ def _result(**overrides: object) -> CombinedResult: class TestProSection: + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_pro_section_present_with_hints(self) -> None: hints = ( Hint( @@ -38,12 +42,16 @@ def test_pro_section_present_with_hints(self) -> None: assert data["pro"]["errors"] == 2 assert data["pro"]["warnings"] == 6 + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_no_pro_section_without_hints(self) -> None: data = format_combined_result(_result()) assert "pro" not in data class TestCrossFileCoordinatesSection: + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_coordinates_serialized(self) -> None: coords = ( CrossFileCoordinate(file_1="a.md", file_2="b.md", finding_type="conflict", count=2), @@ -55,6 +63,8 @@ def test_coordinates_serialized(self) -> None: assert data["cross_file_coordinates"][0]["type"] == "conflict" assert data["cross_file_coordinates"][0]["count"] == 2 + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_no_coordinates_section_when_empty(self) -> None: data = format_combined_result(_result()) assert "cross_file_coordinates" not in data diff --git a/tests/unit/test_mcp_install.py b/tests/unit/test_mcp_install.py index 00249ae..6f718e3 100644 --- a/tests/unit/test_mcp_install.py +++ b/tests/unit/test_mcp_install.py @@ -5,13 +5,17 @@ import json from pathlib import Path -from reporails_cli.core.mcp_install import detect_mcp_targets, write_mcp_config +import pytest + +from reporails_cli.core.install.mcp_install import detect_mcp_targets, write_mcp_config # --------------------------------------------------------------------------- # detect_mcp_targets # --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_detect_claude_target(tmp_path: Path) -> None: """Claude agent → .mcp.json target.""" (tmp_path / "CLAUDE.md").write_text("# Instructions\n") @@ -22,6 +26,8 @@ def test_detect_claude_target(tmp_path: Path) -> None: assert config_path == tmp_path / ".mcp.json" +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_detect_copilot_target(tmp_path: Path) -> None: """Copilot agent → .vscode/mcp.json target.""" (tmp_path / ".github").mkdir() @@ -33,6 +39,8 @@ def test_detect_copilot_target(tmp_path: Path) -> None: assert config_path == tmp_path / ".vscode" / "mcp.json" +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_detect_multiple_agents(tmp_path: Path) -> None: """Multiple agents detected → multiple targets.""" (tmp_path / "CLAUDE.md").write_text("# Instructions\n") @@ -44,6 +52,8 @@ def test_detect_multiple_agents(tmp_path: Path) -> None: assert "copilot" in agent_ids +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_skips_unsupported_agents(tmp_path: Path) -> None: """Generic agent has no MCP config → skipped.""" (tmp_path / "AGENTS.md").write_text("# Agents\n") @@ -52,6 +62,8 @@ def test_skips_unsupported_agents(tmp_path: Path) -> None: assert "generic" not in agent_ids +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_no_agents_detected(tmp_path: Path) -> None: """Empty project → empty list.""" targets = detect_mcp_targets(tmp_path) @@ -63,6 +75,8 @@ def test_no_agents_detected(tmp_path: Path) -> None: # --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_creates_new_config(tmp_path: Path) -> None: """Creates fresh config when file does not exist.""" config_path = tmp_path / ".mcp.json" @@ -77,6 +91,8 @@ def test_creates_new_config(tmp_path: Path) -> None: assert "reporails-mcp" in entry["command"] or "reporails-mcp" in entry.get("args", []) +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_creates_parent_dirs(tmp_path: Path) -> None: """Creates parent directories when needed.""" config_path = tmp_path / ".vscode" / "mcp.json" @@ -88,6 +104,8 @@ def test_creates_parent_dirs(tmp_path: Path) -> None: assert "reporails" in data["mcpServers"] +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_merges_into_existing(tmp_path: Path) -> None: """Preserves existing mcpServers entries when merging.""" config_path = tmp_path / ".mcp.json" @@ -105,6 +123,8 @@ def test_merges_into_existing(tmp_path: Path) -> None: assert "reporails" in data["mcpServers"] +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_updates_existing_entry(tmp_path: Path) -> None: """Overwrites existing reporails entry (e.g., migrating from ails-mcp).""" config_path = tmp_path / ".mcp.json" @@ -127,6 +147,8 @@ def test_updates_existing_entry(tmp_path: Path) -> None: assert not any(part == "ails-mcp" or part.endswith("/ails-mcp") for part in all_parts) +@pytest.mark.unit +@pytest.mark.subsys_cli_ux def test_handles_malformed_json(tmp_path: Path) -> None: """Recovers from malformed JSON by creating fresh config.""" config_path = tmp_path / ".mcp.json" diff --git a/tests/unit/test_mechanical.py b/tests/unit/test_mechanical.py index 53d0e95..aead147 100644 --- a/tests/unit/test_mechanical.py +++ b/tests/unit/test_mechanical.py @@ -4,7 +4,9 @@ from pathlib import Path -from reporails_cli.core.mechanical.checks import ( +import pytest + +from reporails_cli.core.lint.mechanical.checks import ( MECHANICAL_CHECKS, _safe_float, byte_size, @@ -14,7 +16,7 @@ git_tracked, line_count, ) -from reporails_cli.core.mechanical.checks_advanced import ( +from reporails_cli.core.lint.mechanical.checks_advanced import ( _scope_dir_from_glob, check_import_targets_exist, count_at_least, @@ -22,11 +24,11 @@ file_absent, filename_matches_pattern, ) -from reporails_cli.core.mechanical.runner import ( +from reporails_cli.core.lint.mechanical.runner import ( resolve_location, run_mechanical_checks, ) -from reporails_cli.core.models import Category, Check, ClassifiedFile, FileMatch, Rule, RuleType, Severity +from reporails_cli.core.platform.dto.models import Category, Check, ClassifiedFile, FileMatch, Rule, RuleType, Severity def _cf(root: Path, *rel_paths: str, file_type: str = "main") -> list[ClassifiedFile]: @@ -40,44 +42,60 @@ def _cf_mixed(root: Path, *specs: tuple[str, str]) -> list[ClassifiedFile]: class TestFileExists: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_found(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") result = file_exists(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_not_found(self, tmp_path: Path) -> None: result = file_exists(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed class TestDirectoryExists: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exists(self, tmp_path: Path) -> None: (tmp_path / ".claude" / "rules").mkdir(parents=True) result = directory_exists(tmp_path, {"path": ".claude/rules"}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_missing(self, tmp_path: Path) -> None: result = directory_exists(tmp_path, {"path": ".claude/rules"}, []) assert not result.passed class TestGitTracked: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_git_dir_present(self, tmp_path: Path) -> None: (tmp_path / ".git").mkdir() result = git_tracked(tmp_path, {}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_git(self, tmp_path: Path) -> None: result = git_tracked(tmp_path, {}, []) assert not result.passed class TestLineCount: + @pytest.mark.unit + @pytest.mark.subsys_lint 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}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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}, _cf(tmp_path, "CLAUDE.md")) @@ -86,11 +104,15 @@ def test_exceeds_max(self, tmp_path: Path) -> None: class TestByteSize: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_within_bounds(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("small") result = byte_size(tmp_path, {"max": 1000}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exceeds_max(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("x" * 1000) result = byte_size(tmp_path, {"max": 100}, _cf(tmp_path, "CLAUDE.md")) @@ -99,16 +121,22 @@ def test_exceeds_max(self, tmp_path: Path) -> None: class TestContentAbsent: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_absent(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) @@ -119,6 +147,8 @@ def test_invalid_regex_returns_failure(self, tmp_path: Path) -> None: class TestContentAbsentMultiFile: """content_absent scanning across multiple instruction files.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_found_in_one_of_two_files(self, tmp_path: Path) -> None: """Fails when forbidden pattern appears in any file.""" (tmp_path / "CLAUDE.md").write_text("# Clean content") @@ -134,6 +164,8 @@ def test_pattern_found_in_one_of_two_files(self, tmp_path: Path) -> None: assert not result.passed assert "bad.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_absent_in_all_files(self, tmp_path: Path) -> None: """Passes when forbidden pattern absent from all files.""" (tmp_path / "CLAUDE.md").write_text("# Clean") @@ -148,6 +180,8 @@ def test_pattern_absent_in_all_files(self, tmp_path: Path) -> None: result = content_absent(tmp_path, {"pattern": "FORBIDDEN"}, classified) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_found_in_all_files(self, tmp_path: Path) -> None: """Fails on first match (short-circuit) when all files contain pattern.""" (tmp_path / "CLAUDE.md").write_text("# Has FORBIDDEN") @@ -163,6 +197,8 @@ def test_pattern_found_in_all_files(self, tmp_path: Path) -> None: assert not result.passed assert "CLAUDE.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_regex_pattern_across_files(self, tmp_path: Path) -> None: """Regex pattern (not just literal) works across multiple files.""" (tmp_path / "CLAUDE.md").write_text("# Section\nAll fine here.") @@ -178,6 +214,8 @@ def test_regex_pattern_across_files(self, tmp_path: Path) -> None: assert not result.passed assert "risky.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint 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("") @@ -208,6 +246,8 @@ def _rule(self, rule_id: str, check_name: str, args: dict | None = None) -> Rule ], ) + @pytest.mark.unit + @pytest.mark.subsys_lint 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")} @@ -215,6 +255,8 @@ def test_passing_check_no_violations(self, tmp_path: Path) -> None: violations = run_mechanical_checks(rules, tmp_path, classified) assert len(violations) == 0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_failing_check_produces_violation(self, tmp_path: Path) -> None: rules = {"CORE:S:0001": self._rule("CORE:S:0001", "file_exists")} classified = _cf(tmp_path, "CLAUDE.md") @@ -224,11 +266,15 @@ def test_failing_check_produces_violation(self, tmp_path: Path) -> None: assert violations[0].severity == Severity.CRITICAL assert violations[0].check_id == "CORE:S:0001:check:0001" + @pytest.mark.unit + @pytest.mark.subsys_lint 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, []) assert len(violations) == 0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_rules(self, tmp_path: Path) -> None: (tmp_path / "CLAUDE.md").write_text("# Hello") # file_exists passes, git_tracked fails (no .git) @@ -241,6 +287,8 @@ def test_multiple_rules(self, tmp_path: Path) -> None: assert len(violations) == 1 assert violations[0].rule_id == "CORE:S:0004" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_check_location_overrides_rule_location(self, tmp_path: Path) -> None: """Size checks should use the violating file's path, not the rule-level location.""" (tmp_path / "CLAUDE.md").write_text("short") @@ -261,24 +309,38 @@ def test_check_location_overrides_rule_location(self, tmp_path: Path) -> None: class TestSafeFloat: """Tests for _safe_float type coercion helper.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_string_number(self) -> None: assert _safe_float("100") == 100.0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_int_value(self) -> None: assert _safe_float(42) == 42.0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_float_value(self) -> None: assert _safe_float(3.14) == 3.14 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_string_returns_default(self) -> None: assert _safe_float("invalid") == float("inf") + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_string_custom_default(self) -> None: assert _safe_float("abc", 0.0) == 0.0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_none_returns_default(self) -> None: assert _safe_float(None) == float("inf") + @pytest.mark.unit + @pytest.mark.subsys_lint def test_none_custom_default(self) -> None: assert _safe_float(None, 0.0) == 0.0 @@ -286,22 +348,30 @@ def test_none_custom_default(self) -> None: class TestTypeSafetyInChecks: """Verify mechanical checks handle string args from YAML without crashing.""" + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) # invalid → float("inf"), so any file passes assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) @@ -321,6 +391,8 @@ def _rule_with_match(self, match: FileMatch | None) -> Rule: checks=[], ) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_prefers_main_classified_file(self, tmp_path: Path) -> None: rule = self._rule_with_match(FileMatch()) # match-all classified = _cf_mixed( @@ -330,6 +402,8 @@ def test_prefers_main_classified_file(self, tmp_path: Path) -> None: ) assert resolve_location(rule, classified) == "CLAUDE.md:0" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_prefers_main_for_main_match(self, tmp_path: Path) -> None: rule = self._rule_with_match(FileMatch(type="main")) classified = _cf_mixed( @@ -339,6 +413,8 @@ def test_prefers_main_for_main_match(self, tmp_path: Path) -> None: ) assert resolve_location(rule, classified) == "CLAUDE.md:0" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_falls_back_without_main(self, tmp_path: Path) -> None: rule = self._rule_with_match(FileMatch()) # match-all classified = _cf_mixed( @@ -349,11 +425,15 @@ def test_falls_back_without_main(self, tmp_path: Path) -> None: # No main type — falls back to first classified file assert resolve_location(rule, classified) == "SKILL.md:0" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_config_type_resolves_to_settings(self, tmp_path: Path) -> None: rule = self._rule_with_match(FileMatch(type="config")) classified = _cf_mixed( @@ -371,89 +451,125 @@ def test_config_type_resolves_to_settings(self, tmp_path: Path) -> None: class TestCountAtMost: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_within_threshold(self, tmp_path: Path) -> None: result = count_at_most(tmp_path, {"threshold": 3, "items": ["a", "b"]}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_at_threshold(self, tmp_path: Path) -> None: result = count_at_most(tmp_path, {"threshold": 2, "items": ["a", "b"]}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exceeds_threshold(self, tmp_path: Path) -> None: result = count_at_most(tmp_path, {"threshold": 1, "items": ["a", "b", "c"]}, []) assert not result.passed assert "exceeds" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_list_passes(self, tmp_path: Path) -> None: result = count_at_most(tmp_path, {"threshold": 0}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_default_threshold_zero(self, tmp_path: Path) -> None: result = count_at_most(tmp_path, {"items": ["a"]}, []) assert not result.passed class TestCountAtLeast: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_meets_minimum(self, tmp_path: Path) -> None: result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b", "c"]}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_at_minimum(self, tmp_path: Path) -> None: result = count_at_least(tmp_path, {"threshold": 2, "items": ["a", "b"]}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_below_minimum(self, tmp_path: Path) -> None: result = count_at_least(tmp_path, {"threshold": 3, "items": ["a"]}, []) assert not result.passed assert "below" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_list_fails_default(self, tmp_path: Path) -> None: result = count_at_least(tmp_path, {}, []) assert not result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_default_threshold_one(self, tmp_path: Path) -> None: result = count_at_least(tmp_path, {"items": ["a"]}, []) assert result.passed class TestCheckImportTargetsExist: + @pytest.mark.unit + @pytest.mark.subsys_lint 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"]}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"]}, []) assert not result.passed assert "missing.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_imports_pass(self, tmp_path: Path) -> None: result = check_import_targets_exist(tmp_path, {}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_metadata_key_pass(self, tmp_path: Path) -> None: result = check_import_targets_exist(tmp_path, {"threshold": 5}, []) assert result.passed class TestFilenameMatchesPattern: + @pytest.mark.unit + @pytest.mark.subsys_lint 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$"}, _cf(tmp_path, "CLAUDE.md")) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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$"}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed assert "does not match" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_pattern_fails(self, tmp_path: Path) -> None: result = filename_matches_pattern(tmp_path, {}, _cf(tmp_path, "CLAUDE.md")) assert not result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, _cf(tmp_path, "CLAUDE.md")) @@ -462,25 +578,35 @@ def test_invalid_regex_fails(self, tmp_path: Path) -> None: class TestFileAbsent: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_not_present_passes(self, tmp_path: Path) -> None: result = file_absent(tmp_path, {"pattern": "README.md"}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, []) assert not result.passed assert "Forbidden" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_pattern_no_match_passes(self, tmp_path: Path) -> None: result = file_absent(tmp_path, {"pattern": "**/*.lock"}, []) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint 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"}, []) assert not result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_pattern_fails(self, tmp_path: Path) -> None: result = file_absent(tmp_path, {}, []) assert not result.passed @@ -490,6 +616,8 @@ def test_no_pattern_fails(self, tmp_path: Path) -> None: class TestMatchTypeScoping: """Checks respect rule.match.type via injected _match_type arg.""" + @pytest.mark.unit + @pytest.mark.subsys_lint 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") @@ -505,6 +633,8 @@ def test_filename_matches_pattern_scoped_to_main_file(self, tmp_path: Path) -> N result = filename_matches_pattern(tmp_path, args, classified) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_filename_matches_pattern_unscoped_leaks(self, tmp_path: Path) -> None: """Without _match_type, filename_matches_pattern falls back to all classified files.""" (tmp_path / "CLAUDE.md").write_text("# Main") @@ -521,6 +651,8 @@ def test_filename_matches_pattern_unscoped_leaks(self, tmp_path: Path) -> None: assert not result.passed assert "core-rules.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_absent_scoped_ignores_root_readme(self, tmp_path: Path) -> None: """Bug fix: CORE:S:0035 — README.md at root should not trigger file_absent in skills.""" (tmp_path / "README.md").write_text("# Project readme") @@ -532,6 +664,8 @@ def test_file_absent_scoped_ignores_root_readme(self, tmp_path: Path) -> None: result = file_absent(tmp_path, args, classified) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_absent_scoped_catches_readme_in_skills(self, tmp_path: Path) -> None: """file_absent with scope detects README.md inside skills directory.""" skills = tmp_path / ".claude" / "skills" / "test-skill" @@ -544,12 +678,16 @@ def test_file_absent_scoped_catches_readme_in_skills(self, tmp_path: Path) -> No assert not result.passed assert "README.md" in result.message + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_absent_unscoped_finds_root_readme(self, tmp_path: Path) -> None: """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"}, []) assert not result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint def test_explicit_path_overrides_targets(self, tmp_path: Path) -> None: """Explicit args.path takes priority over classified files.""" (tmp_path / "CLAUDE.md").write_text("# Main") @@ -566,18 +704,28 @@ def test_explicit_path_overrides_targets(self, tmp_path: Path) -> None: class TestScopeDirFromGlob: """Unit tests for _scope_dir_from_glob helper.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_skills_dir_glob(self) -> None: assert _scope_dir_from_glob(".claude/skills/**/*.md") == ".claude/skills" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_wildcard_at_start(self) -> None: assert _scope_dir_from_glob("**/CLAUDE.md") == "" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_glob(self) -> None: assert _scope_dir_from_glob("docs/README.md") == "docs/README.md" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_single_dir(self) -> None: assert _scope_dir_from_glob("src/*.py") == "src" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty(self) -> None: assert _scope_dir_from_glob("") == "" @@ -585,11 +733,17 @@ def test_empty(self) -> None: class TestAliases: """Signal catalog aliases map to existing probes.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_match_is_file_exists(self) -> None: assert MECHANICAL_CHECKS["glob_match"] is MECHANICAL_CHECKS["file_exists"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_max_line_count_is_line_count(self) -> None: assert MECHANICAL_CHECKS["max_line_count"] is MECHANICAL_CHECKS["line_count"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_count_is_file_count(self) -> None: assert MECHANICAL_CHECKS["glob_count"] is MECHANICAL_CHECKS["file_count"] diff --git a/tests/unit/test_merger.py b/tests/unit/test_merger.py index c22cace..b77abae 100644 --- a/tests/unit/test_merger.py +++ b/tests/unit/test_merger.py @@ -4,7 +4,7 @@ import pytest -from reporails_cli.core.api_client import ( +from reporails_cli.core.platform.adapters.api_client import ( CrossFileCoordinate, Diagnostic, FileAnalysis, @@ -12,8 +12,8 @@ QualityResult, RulesetReport, ) -from reporails_cli.core.merger import merge_results -from reporails_cli.core.models import LocalFinding +from reporails_cli.core.platform.dto.models import LocalFinding +from reporails_cli.core.platform.runtime.merger import merge_results @pytest.fixture @@ -32,6 +32,8 @@ def client_findings() -> list[LocalFinding]: class TestMergeResults: + @pytest.mark.unit + @pytest.mark.subsys_lint def test_offline_returns_all_local( self, m_findings: list[LocalFinding], client_findings: list[LocalFinding] ) -> None: @@ -43,18 +45,24 @@ def test_offline_returns_all_local( assert result.stats.client_check_count == 1 assert result.stats.server_diagnostic_count == 0 + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + @pytest.mark.unit + @pytest.mark.subsys_lint 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" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 @@ -62,6 +70,8 @@ def test_stats_counted_correctly(self, m_findings: list[LocalFinding], client_fi assert result.stats.infos == 0 assert result.stats.total_findings == 3 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_server_deduplicates_matching_local(self) -> None: local = [LocalFinding("CLAUDE.md", 10, "warning", "ordering", "local msg", source="client_check")] server = RulesetReport( @@ -80,11 +90,15 @@ def test_server_deduplicates_matching_local(self) -> None: assert result.findings[0].source == "server" assert result.findings[0].message == "server msg" + @pytest.mark.unit + @pytest.mark.subsys_lint @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) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_hints_pass_through(self) -> None: hints = ( Hint( @@ -102,6 +116,8 @@ def test_hints_pass_through(self) -> None: assert result.hints[0].count == 3 assert result.hints[1].diagnostic_type == "CORE:C:0047" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_cross_file_coordinates_pass_through(self) -> None: coords = ( CrossFileCoordinate(file_1="a.md", file_2="b.md", finding_type="conflict", count=2), @@ -112,6 +128,8 @@ def test_cross_file_coordinates_pass_through(self) -> None: assert result.cross_file_coordinates[0].finding_type == "conflict" assert result.cross_file_coordinates[1].count == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_coordinates_and_hints_by_default(self) -> None: result = merge_results([], [], None) assert result.hints == () diff --git a/tests/unit/test_package_levels.py b/tests/unit/test_package_levels.py index f3b3791..96d2e4c 100644 --- a/tests/unit/test_package_levels.py +++ b/tests/unit/test_package_levels.py @@ -2,8 +2,10 @@ from __future__ import annotations -from reporails_cli.core.applicability import get_applicable_rules -from reporails_cli.core.models import Category, FileMatch, Rule, RuleType +import pytest + +from reporails_cli.core.platform.dto.models import Category, FileMatch, Rule, RuleType +from reporails_cli.core.platform.policy.applicability import get_applicable_rules def _make_rule(rule_id: str, match_type: str = "main") -> Rule: @@ -21,6 +23,8 @@ def _make_rule(rule_id: str, match_type: str = "main") -> Rule: class TestGetApplicableRulesTargetFiltering: """Test target-existence-based applicability filtering.""" + @pytest.mark.unit + @pytest.mark.subsys_gates 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")} @@ -29,6 +33,8 @@ def test_rule_included_when_target_present(self) -> None: assert "CORE:S:0001" in result + @pytest.mark.unit + @pytest.mark.subsys_gates 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")} @@ -37,6 +43,8 @@ def test_rule_excluded_when_target_absent(self) -> None: assert "CORE:S:0001" not in result + @pytest.mark.unit + @pytest.mark.subsys_gates def test_supersession_drops_superseded_rule(self) -> None: """If rule A supersedes rule B, and both are applicable, B is dropped.""" rule_a = _make_rule("CORE:S:0010").model_copy(update={"supersedes": "CORE:S:0001"}) diff --git a/tests/unit/test_payload.py b/tests/unit/test_payload.py index 8915896..b2d7132 100644 --- a/tests/unit/test_payload.py +++ b/tests/unit/test_payload.py @@ -7,19 +7,19 @@ import msgpack import pytest -from reporails_cli.core.api_client import _strip_and_serialize -from reporails_cli.core.mapper.mapper import ( +from reporails_cli.core.platform.adapters.api_client import _strip_and_serialize +from reporails_cli.core.platform.adapters.payload import ( + WIRE_SCHEMA_VERSION_V3, + encode_msgpack, + project_payload, +) +from reporails_cli.core.platform.dto.ruleset import ( Atom, ClusterRecord, FileRecord, RulesetMap, RulesetSummary, ) -from reporails_cli.core.payload import ( - WIRE_SCHEMA_VERSION_V3, - encode_msgpack, - project_payload, -) def _atom(idx: int, charge: int = 1, has_emb: bool = True) -> Atom: @@ -88,6 +88,8 @@ def _ruleset(n_atoms: int = 10, n_files: int = 1, n_clusters: int = 3) -> Rulese class TestProjectionShape: + @pytest.mark.unit + @pytest.mark.subsys_server def test_text_fields_dropped(self) -> None: rm = _ruleset(n_atoms=2) proj = project_payload(rm) @@ -97,6 +99,8 @@ def test_text_fields_dropped(self) -> None: assert "heading_context" not in atom assert "hc" not in atom + @pytest.mark.unit + @pytest.mark.subsys_server def test_inline_tokens_become_counts(self) -> None: rm = _ruleset(n_atoms=3) proj = project_payload(rm) @@ -107,6 +111,8 @@ def test_inline_tokens_become_counts(self) -> None: assert isinstance(atom.get("bb"), int) assert isinstance(atom.get("ub"), int) + @pytest.mark.unit + @pytest.mark.subsys_server def test_inline_counts_match_source(self) -> None: rm = _ruleset(n_atoms=4) proj = project_payload(rm) @@ -116,6 +122,8 @@ def test_inline_counts_match_source(self) -> None: assert atom["bb"] == len(src.bold_tokens) assert atom["ub"] == len(src.unformatted_code) + @pytest.mark.unit + @pytest.mark.subsys_server def test_cluster_centroids_dropped(self) -> None: rm = _ruleset(n_clusters=5) proj = project_payload(rm) @@ -124,6 +132,8 @@ def test_cluster_centroids_dropped(self) -> None: assert "centroid_b64" not in cluster assert "ce" not in cluster + @pytest.mark.unit + @pytest.mark.subsys_server def test_embedding_packed_as_bytes(self) -> None: rm = _ruleset(n_atoms=1) proj = project_payload(rm) @@ -131,6 +141,8 @@ def test_embedding_packed_as_bytes(self) -> None: assert isinstance(atom["e"], bytes) assert len(atom["e"]) == 384 + @pytest.mark.unit + @pytest.mark.subsys_server def test_schema_version_is_3(self) -> None: rm = _ruleset() proj = project_payload(rm) @@ -138,11 +150,15 @@ def test_schema_version_is_3(self) -> None: class TestEncoding: + @pytest.mark.unit + @pytest.mark.subsys_server def test_leading_version_byte(self) -> None: rm = _ruleset(n_atoms=1) encoded = encode_msgpack(project_payload(rm)) assert encoded[0] == WIRE_SCHEMA_VERSION_V3 == 3 + @pytest.mark.unit + @pytest.mark.subsys_server def test_round_trip_decode(self) -> None: rm = _ruleset(n_atoms=2, n_files=1, n_clusters=1) proj = project_payload(rm) @@ -155,6 +171,8 @@ def test_round_trip_decode(self) -> None: class TestShrinkage: + @pytest.mark.unit + @pytest.mark.subsys_server @pytest.mark.parametrize( "n_atoms,n_files,n_clusters,min_shrink", [ diff --git a/tests/unit/test_project_config.py b/tests/unit/test_project_config.py index c06f808..d20e00e 100644 --- a/tests/unit/test_project_config.py +++ b/tests/unit/test_project_config.py @@ -5,54 +5,54 @@ from pathlib import Path from unittest.mock import patch -from reporails_cli.core.bootstrap import get_package_paths, get_project_config +import pytest + +from reporails_cli.core.platform.config.bootstrap import get_package_paths, get_project_config class TestGetProjectConfig: """Test get_project_config loading from .ails/config.yml.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux 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.recommended is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux 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\n") + make_config_file("framework_version: '0.1.0'\npackages:\n - custom\ndisabled_rules:\n - S1\n") config = get_project_config(tmp_path) assert config.framework_version == "0.1.0" - assert config.packages == ["recommended"] + assert config.packages == ["custom"] assert config.disabled_rules == ["S1"] - def test_recommended_true_by_default(self, tmp_path: Path, make_config_file) -> None: - make_config_file("packages:\n - custom\n") - config = get_project_config(tmp_path) - assert config.recommended is True - - def test_recommended_opt_out(self, tmp_path: Path, make_config_file) -> None: - make_config_file("recommended: false\n") - config = get_project_config(tmp_path) - assert config.recommended is False - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_defaults_on_malformed_yaml(self, tmp_path: Path, make_config_file) -> None: make_config_file(": : :\n bad yaml [[[") config = get_project_config(tmp_path) assert config.packages == [] assert config.disabled_rules == [] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_malformed_yaml_inherits_global(self, tmp_path: Path, make_config_file) -> None: - from reporails_cli.core.models import GlobalConfig + from reporails_cli.core.platform.dto.models import GlobalConfig make_config_file(": : :\n bad yaml [[[") with patch( - "reporails_cli.core.config.get_global_config", - return_value=GlobalConfig(default_agent="claude", recommended=False), + "reporails_cli.core.platform.config.config.get_global_config", + return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) assert config.default_agent == "claude" - assert config.recommended is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_defaults_on_empty_file(self, tmp_path: Path, make_config_file) -> None: make_config_file("") config = get_project_config(tmp_path) @@ -62,81 +62,68 @@ def test_returns_defaults_on_empty_file(self, tmp_path: Path, make_config_file) class TestGlobalDefaultsMerged: """Test that get_project_config() falls back to global config for unset values.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_inherits_global_default_agent(self, tmp_path: Path, make_config_file) -> None: """Project without default_agent inherits global value.""" - from reporails_cli.core.models import GlobalConfig + from reporails_cli.core.platform.dto.models import GlobalConfig make_config_file("packages:\n - custom\n") with patch( - "reporails_cli.core.config.get_global_config", + "reporails_cli.core.platform.config.config.get_global_config", return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) assert config.default_agent == "claude" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_project_overrides_global_default_agent(self, tmp_path: Path, make_config_file) -> None: """Project default_agent wins over global.""" - from reporails_cli.core.models import GlobalConfig + from reporails_cli.core.platform.dto.models import GlobalConfig make_config_file("default_agent: cursor\n") with patch( - "reporails_cli.core.config.get_global_config", + "reporails_cli.core.platform.config.config.get_global_config", return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) assert config.default_agent == "cursor" - def test_inherits_global_recommended(self, tmp_path: Path, make_config_file) -> None: - """Project without recommended key inherits global value.""" - from reporails_cli.core.models import GlobalConfig - - make_config_file("packages:\n - custom\n") - with patch( - "reporails_cli.core.config.get_global_config", - return_value=GlobalConfig(recommended=False), - ): - config = get_project_config(tmp_path) - assert config.recommended is False - - def test_project_overrides_global_recommended(self, tmp_path: Path, make_config_file) -> None: - """Project explicit recommended: false wins over global true.""" - from reporails_cli.core.models import GlobalConfig - - make_config_file("recommended: false\n") - with patch( - "reporails_cli.core.config.get_global_config", - return_value=GlobalConfig(recommended=True), - ): - config = get_project_config(tmp_path) - assert config.recommended is False - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_config_file_inherits_global(self, tmp_path: Path) -> None: """No .ails/config.yml — global defaults apply.""" - from reporails_cli.core.models import GlobalConfig + from reporails_cli.core.platform.dto.models import GlobalConfig with patch( - "reporails_cli.core.config.get_global_config", - return_value=GlobalConfig(default_agent="claude", recommended=False), + "reporails_cli.core.platform.config.config.get_global_config", + return_value=GlobalConfig(default_agent="claude"), ): config = get_project_config(tmp_path) assert config.default_agent == "claude" - assert config.recommended is False class TestGetPackagePaths: """Test get_package_paths resolution.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_existing_dirs(self, tmp_path: Path) -> None: pkg_dir = tmp_path / ".ails" / "packages" / "recommended" pkg_dir.mkdir(parents=True) paths = get_package_paths(tmp_path, ["recommended"]) assert paths == [pkg_dir] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_skips_missing_dirs(self, tmp_path: Path) -> None: (tmp_path / ".ails" / "packages").mkdir(parents=True) paths = get_package_paths(tmp_path, ["nonexistent"]) assert paths == [] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_mixed_existing_and_missing(self, tmp_path: Path) -> None: pkg_base = tmp_path / ".ails" / "packages" (pkg_base / "exists").mkdir(parents=True) @@ -144,10 +131,14 @@ def test_mixed_existing_and_missing(self, tmp_path: Path) -> None: assert len(paths) == 1 assert paths[0].name == "exists" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_empty_packages_list(self, tmp_path: Path) -> None: paths = get_package_paths(tmp_path, []) assert paths == [] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_resolves_from_global_packages(self, tmp_path: Path) -> None: """Global ~/.reporails/packages/ is checked as fallback.""" global_pkg = tmp_path / "global_home" / "packages" / "recommended" @@ -157,12 +148,14 @@ def test_resolves_from_global_packages(self, tmp_path: Path) -> None: project.mkdir() with patch( - "reporails_cli.core.bootstrap.get_global_packages_path", + "reporails_cli.core.platform.config.bootstrap.get_global_packages_path", return_value=tmp_path / "global_home" / "packages", ): paths = get_package_paths(project, ["recommended"]) assert paths == [global_pkg] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_project_local_overrides_global(self, tmp_path: Path) -> None: """Project-local package takes priority over global.""" global_pkg = tmp_path / "global_home" / "packages" / "recommended" @@ -173,12 +166,14 @@ def test_project_local_overrides_global(self, tmp_path: Path) -> None: local_pkg.mkdir(parents=True) with patch( - "reporails_cli.core.bootstrap.get_global_packages_path", + "reporails_cli.core.platform.config.bootstrap.get_global_packages_path", return_value=tmp_path / "global_home" / "packages", ): paths = get_package_paths(project, ["recommended"]) assert paths == [local_pkg] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_mixed_local_and_global(self, tmp_path: Path) -> None: """One package local, another global.""" global_pkg = tmp_path / "global_home" / "packages" / "recommended" @@ -189,7 +184,7 @@ def test_mixed_local_and_global(self, tmp_path: Path) -> None: local_pkg.mkdir(parents=True) with patch( - "reporails_cli.core.bootstrap.get_global_packages_path", + "reporails_cli.core.platform.config.bootstrap.get_global_packages_path", return_value=tmp_path / "global_home" / "packages", ): paths = get_package_paths(project, ["custom", "recommended"]) @@ -229,8 +224,10 @@ def _create_rule(directory: Path, rule_id: str, title: str) -> None: class TestLoadRulesWithPackages: """Test load_rules with project packages and disabled rules.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_additional_path_overrides_framework(self, tmp_path: Path) -> None: - from reporails_cli.core.registry import load_rules + from reporails_cli.core.platform.adapters.registry import load_rules # Create framework rules dir rules_dir = tmp_path / "rules" @@ -253,8 +250,10 @@ def test_additional_path_overrides_framework(self, tmp_path: Path) -> None: assert "CORE:S:0001" in rules assert rules["CORE:S:0001"].title == "Custom S1" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_disabled_rules_excluded(self, tmp_path: Path) -> None: - from reporails_cli.core.registry import load_rules + from reporails_cli.core.platform.adapters.registry import load_rules # Create framework rules rules_dir = tmp_path / "rules" @@ -280,8 +279,10 @@ def test_disabled_rules_excluded(self, tmp_path: Path) -> None: assert "CORE:S:0001" not in rules assert "CORE:S:0002" in rules + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_disabled_nonexistent_rule_harmless(self, tmp_path: Path) -> None: - from reporails_cli.core.registry import load_rules + from reporails_cli.core.platform.adapters.registry import load_rules rules_dir = tmp_path / "rules" core_dir = rules_dir / "core" / "structure" @@ -302,8 +303,10 @@ def test_disabled_nonexistent_rule_harmless(self, tmp_path: Path) -> None: ) assert "CORE:S:0001" in rules + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_project_root_backward_compat(self, tmp_path: Path) -> None: - from reporails_cli.core.registry import load_rules + from reporails_cli.core.platform.adapters.registry import load_rules rules_dir = tmp_path / "rules" core_dir = rules_dir / "core" / "structure" diff --git a/tests/unit/test_recommended.py b/tests/unit/test_recommended.py deleted file mode 100644 index 8ea9f76..0000000 --- a/tests/unit/test_recommended.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Unit tests for recommended rules package download and installation.""" - -from __future__ import annotations - -import tarfile -from io import BytesIO -from pathlib import Path -from unittest.mock import MagicMock, patch - -from reporails_cli.core.init import ( - RECOMMENDED_VERSION, - download_recommended, - is_recommended_installed, -) - - -def _make_archive(files: dict[str, str], prefix: str = "recommended-0.0.1") -> bytes: - """Create a tar.gz archive in memory with given files under a prefix directory. - - Args: - files: Mapping of relative path to file content - prefix: Top-level directory name (GitHub archive style) - - Returns: - Bytes of the tar.gz archive - """ - buf = BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tar: - for name, content in files.items(): - full_name = f"{prefix}/{name}" if prefix else name - data = content.encode("utf-8") - info = tarfile.TarInfo(name=full_name) - info.size = len(data) - tar.addfile(info, BytesIO(data)) - buf.seek(0) - return buf.read() - - -class TestIsRecommendedInstalled: - """Test is_recommended_installed checks.""" - - def test_not_installed_when_missing(self, tmp_path: Path) -> None: - with patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=tmp_path / "packages" / "recommended", - ): - assert is_recommended_installed() is False - - def test_not_installed_when_empty(self, tmp_path: Path) -> None: - pkg_dir = tmp_path / "packages" / "recommended" - pkg_dir.mkdir(parents=True) - with patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=pkg_dir, - ): - assert is_recommended_installed() is False - - def test_installed_when_has_content(self, tmp_path: Path) -> None: - pkg_dir = tmp_path / "packages" / "recommended" - pkg_dir.mkdir(parents=True) - (pkg_dir / "levels.yml").write_text("levels: {}") - with patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=pkg_dir, - ): - assert is_recommended_installed() is True - - -class TestDownloadRecommended: - """Test download_recommended extraction and version tracking.""" - - def test_extracts_and_strips_prefix(self, tmp_path: Path) -> None: - """Archive with GitHub-style prefix is extracted correctly.""" - pkg_dir = tmp_path / "packages" / "recommended" - archive = _make_archive( - { - "levels.yml": "levels:\n L2:\n rules: [AILS_R1]\n", - "AILS_R1.md": "---\nid: AILS_R1\ntitle: Test\n---\n", - "AILS_R1.yml": "rules: []\n", - } - ) - - mock_response = MagicMock() - mock_response.content = archive - mock_response.raise_for_status = MagicMock() - - mock_client = MagicMock() - mock_client.__enter__ = MagicMock(return_value=mock_client) - mock_client.__exit__ = MagicMock(return_value=False) - mock_client.get = MagicMock(return_value=mock_response) - - with ( - patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=pkg_dir, - ), - patch("reporails_cli.core.download.httpx.Client", return_value=mock_client), - ): - result = download_recommended(version=RECOMMENDED_VERSION) - - assert result == pkg_dir - assert (pkg_dir / "levels.yml").exists() - assert (pkg_dir / "AILS_R1.md").exists() - assert (pkg_dir / "AILS_R1.yml").exists() - - # Version file written - version_file = pkg_dir / ".version" - assert version_file.exists() - assert version_file.read_text().strip() == RECOMMENDED_VERSION - - def test_clears_existing_before_download(self, tmp_path: Path) -> None: - """Old content is removed before extracting new.""" - pkg_dir = tmp_path / "packages" / "recommended" - pkg_dir.mkdir(parents=True) - (pkg_dir / "old_file.txt").write_text("stale") - - archive = _make_archive({"new_file.yml": "content: true"}) - - mock_response = MagicMock() - mock_response.content = archive - mock_response.raise_for_status = MagicMock() - - mock_client = MagicMock() - mock_client.__enter__ = MagicMock(return_value=mock_client) - mock_client.__exit__ = MagicMock(return_value=False) - mock_client.get = MagicMock(return_value=mock_response) - - with ( - patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=pkg_dir, - ), - patch("reporails_cli.core.download.httpx.Client", return_value=mock_client), - ): - download_recommended(version=RECOMMENDED_VERSION) - - assert not (pkg_dir / "old_file.txt").exists() - assert (pkg_dir / "new_file.yml").exists() - - def test_writes_version_file(self, tmp_path: Path) -> None: - """Version file should contain the requested version after download.""" - pkg_dir = tmp_path / "packages" / "recommended" - archive = _make_archive({"test.yml": "ok: true"}) - - mock_response = MagicMock() - mock_response.content = archive - mock_response.raise_for_status = MagicMock() - - mock_client = MagicMock() - mock_client.__enter__ = MagicMock(return_value=mock_client) - mock_client.__exit__ = MagicMock(return_value=False) - mock_client.get = MagicMock(return_value=mock_response) - - with ( - patch( - "reporails_cli.core.download.get_recommended_package_path", - return_value=pkg_dir, - ), - patch("reporails_cli.core.download.httpx.Client", return_value=mock_client), - ): - result = download_recommended(version=RECOMMENDED_VERSION) - - assert result == pkg_dir - version_file = pkg_dir / ".version" - assert version_file.exists() - assert version_file.read_text().strip() == RECOMMENDED_VERSION diff --git a/tests/unit/test_regex_engine.py b/tests/unit/test_regex_engine.py index 6c4a664..1f3a414 100644 --- a/tests/unit/test_regex_engine.py +++ b/tests/unit/test_regex_engine.py @@ -10,14 +10,15 @@ from pathlib import Path from typing import Any +import pytest import yaml -from reporails_cli.core.regex.compiler import ( +from reporails_cli.core.lint.regex.compiler import ( CompiledCheck, _compile_pattern, compile_rules, ) -from reporails_cli.core.regex.runner import ( +from reporails_cli.core.lint.regex.runner import ( _file_matches_path_filter, _match_check, run_validation, @@ -63,6 +64,8 @@ def _sarif_rule_ids(sarif: dict[str, Any]) -> list[str]: class TestCompilerEdgeCases: """Test compiler resilience against malformed and degenerate inputs.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_yaml_file(self, tmp_path: Path) -> None: """Empty YAML file should produce no checks, no errors.""" p = tmp_path / "empty.yml" @@ -71,18 +74,24 @@ def test_empty_yaml_file(self, tmp_path: Path) -> None: assert result.checks == [] assert result.skipped == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_yaml_with_no_rules_key(self, tmp_path: Path) -> None: """YAML without 'rules' key should be silently skipped.""" p = _write_rule(tmp_path, {"metadata": {"version": "1.0"}}) result = compile_rules([p]) assert result.checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint 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, {"checks": []}) result = compile_rules([p]) assert result.checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_rule_with_no_operator(self, tmp_path: Path) -> None: """Rule with no recognized operator should be skipped.""" p = _write_rule(tmp_path, {"checks": [{"id": "TEST-001", "message": "bad"}]}) @@ -90,6 +99,8 @@ def test_rule_with_no_operator(self, tmp_path: Path) -> None: assert result.checks == [] assert "TEST-001" in result.skipped + @pytest.mark.unit + @pytest.mark.subsys_lint def test_rule_with_unknown_operator(self, tmp_path: Path) -> None: """Rule with unsupported operator should be skipped gracefully.""" p = _write_rule( @@ -99,6 +110,8 @@ def test_rule_with_unknown_operator(self, tmp_path: Path) -> None: result = compile_rules([p]) assert "TEST-002" in result.skipped + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_regex_pattern(self, tmp_path: Path) -> None: """Invalid regex should be caught and rule skipped.""" p = _write_rule( @@ -109,6 +122,8 @@ def test_invalid_regex_pattern(self, tmp_path: Path) -> None: assert result.checks == [] assert "BAD-REGEX" in result.skipped + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_regex_in_pattern_either(self, tmp_path: Path) -> None: """Invalid regex inside pattern-either should skip the entire rule.""" p = _write_rule( @@ -126,11 +141,15 @@ def test_invalid_regex_in_pattern_either(self, tmp_path: Path) -> None: result = compile_rules([p]) assert "BAD-EITHER" in result.skipped + @pytest.mark.unit + @pytest.mark.subsys_lint def test_nonexistent_yml_path(self, tmp_path: Path) -> None: """Non-existent path should be silently skipped.""" result = compile_rules([tmp_path / "does-not-exist.yml"]) assert result.checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_binary_yml_file(self, tmp_path: Path) -> None: """Binary file disguised as .yml should be handled gracefully.""" p = tmp_path / "binary.yml" @@ -138,6 +157,8 @@ def test_binary_yml_file(self, tmp_path: Path) -> None: result = compile_rules([p]) assert result.checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_yaml_bomb(self, tmp_path: Path) -> None: """YAML with deeply nested anchors/aliases (billion laughs variant). @@ -149,6 +170,8 @@ def test_yaml_bomb(self, tmp_path: Path) -> None: result = compile_rules([p]) assert result.checks == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_either_empty_list(self, tmp_path: Path) -> None: """pattern-either with empty list should produce None.""" p = _write_rule( @@ -159,6 +182,8 @@ def test_pattern_either_empty_list(self, tmp_path: Path) -> None: assert result.checks == [] assert "EMPTY-EITHER" in result.skipped + @pytest.mark.unit + @pytest.mark.subsys_lint def test_patterns_only_negative(self, tmp_path: Path) -> None: """patterns block with ONLY pattern-not-regex (no positive patterns). @@ -184,6 +209,8 @@ def test_patterns_only_negative(self, tmp_path: Path) -> None: assert check.patterns == () assert len(check.negative_patterns) == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_missing_id_and_message(self, tmp_path: Path) -> None: """Rule without id or message should use defaults.""" p = _write_rule( @@ -195,6 +222,8 @@ def test_missing_id_and_message(self, tmp_path: Path) -> None: assert result.checks[0].id == "unknown" assert result.checks[0].message == "" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_severity_normalization(self, tmp_path: Path) -> None: """Various severity values should normalize correctly.""" rules: list[dict[str, Any]] = [ @@ -214,6 +243,8 @@ def test_severity_normalization(self, tmp_path: Path) -> None: assert severities["SEV-7"] == "warning" # LOW assert severities["SEV-8"] == "warning" # empty + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_yml_files(self, tmp_path: Path) -> None: """Multiple YAML files should merge checks.""" p1 = _write_rule( @@ -255,49 +286,69 @@ def _make_check( path_includes=(), ) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_single_pattern_match(self) -> None: check = self._make_check(patterns=["hello"]) assert _match_check(check, "hello world") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_single_pattern_no_match(self) -> None: check = self._make_check(patterns=["hello"]) assert _match_check(check, "goodbye world") == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_and_all_match(self) -> None: """All patterns must match (AND logic).""" check = self._make_check(patterns=["hello", "world"]) assert _match_check(check, "hello world") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_and_partial_match(self) -> None: """If only some AND patterns match, result should be empty.""" check = self._make_check(patterns=["hello", "missing"]) assert _match_check(check, "hello world") == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_negative_blocks_match(self) -> None: """Negative pattern should block a positive match.""" check = self._make_check(patterns=["hello"], negative=["world"]) assert _match_check(check, "hello world") == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_negative_allows_when_absent(self) -> None: """No negative match → positive match goes through.""" check = self._make_check(patterns=["hello"], negative=["missing"]) assert _match_check(check, "hello world") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_either_any_match(self) -> None: """Any pattern in either list should match (OR logic).""" check = self._make_check(either=["hello", "goodbye"]) assert _match_check(check, "goodbye world") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_either_none_match(self) -> None: check = self._make_check(either=["missing", "absent"]) assert _match_check(check, "hello world") == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_either_returns_all_matching(self) -> None: """either returns all matching patterns (not just first).""" check = self._make_check(either=["hello", "world"]) matches = _match_check(check, "hello world") assert len(matches) == 2 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_either_returns_only_matching(self) -> None: """either should only return patterns that actually match.""" check = self._make_check(either=["hello", "missing"]) @@ -305,16 +356,22 @@ def test_either_returns_only_matching(self) -> None: assert len(matches) == 1 assert matches[0].group(0) == "hello" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_content(self) -> None: """Empty string should not match any pattern.""" check = self._make_check(patterns=["something"]) assert _match_check(check, "") == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_pattern_matches_everything(self) -> None: """Empty regex matches everything (this is valid Python re behavior).""" check = self._make_check(patterns=[""]) assert _match_check(check, "anything") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_negative_only_no_positive(self) -> None: """patterns=() with negatives only: positive loop returns [], so overall returns [] regardless of negative. @@ -329,23 +386,31 @@ def test_negative_only_no_positive(self) -> None: # The loop iterates over 0 patterns → matches stays [] assert matches == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiline_dotall_behavior(self) -> None: """Patterns should match across lines (DOTALL flag).""" check = self._make_check(patterns=["hello.*world"]) content = "hello\nworld" assert _match_check(check, content) != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiline_caret_anchor(self) -> None: """^ should match at start of each line (MULTILINE flag).""" check = self._make_check(patterns=["^## Section"]) content = "intro\n## Section\ndetail" assert _match_check(check, content) != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_unicode_content(self) -> None: """Unicode content should be handled correctly.""" check = self._make_check(patterns=["(?i)résumé"]) assert _match_check(check, "My Résumé") != [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_unicode_dash_character_class(self) -> None: """Character class with Unicode dashes (em-dash, en-dash) should work. @@ -366,6 +431,8 @@ def test_unicode_dash_character_class(self) -> None: class TestRunValidation: """Test run_validation SARIF output shape and correctness.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_basic_match_sarif_shape(self, tmp_path: Path) -> None: """Verify SARIF output has all required fields.""" rule_yml = _write_rule( @@ -399,6 +466,8 @@ def test_basic_match_sarif_shape(self, tmp_path: Path) -> None: assert loc["region"]["startLine"] == 2 assert "snippet" in loc["region"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_line_number_first_line(self, tmp_path: Path) -> None: """Match on first line should report line 1.""" rule_yml = _write_rule( @@ -410,6 +479,8 @@ def test_line_number_first_line(self, tmp_path: Path) -> None: results = _sarif_results(run_validation([rule_yml], tmp_path)) assert results[0]["locations"][0]["physicalLocation"]["region"]["startLine"] == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_line_number_last_line(self, tmp_path: Path) -> None: """Match on last line should report correct line number.""" rule_yml = _write_rule( @@ -422,6 +493,8 @@ def test_line_number_last_line(self, tmp_path: Path) -> None: results = _sarif_results(run_validation([rule_yml], tmp_path)) assert results[0]["locations"][0]["physicalLocation"]["region"]["startLine"] == 4 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_match_empty_results(self, tmp_path: Path) -> None: """No matches should produce SARIF with empty results.""" rule_yml = _write_rule( @@ -433,6 +506,8 @@ def test_no_match_empty_results(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], tmp_path) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_matches_same_file(self, tmp_path: Path) -> None: """Multiple matches in one file should produce multiple results.""" rule_yml = _write_rule( @@ -449,6 +524,8 @@ def test_multiple_matches_same_file(self, tmp_path: Path) -> None: results = _sarif_results(run_validation([rule_yml], tmp_path)) assert len(results) == 1 # search returns first match only + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_rules_match_same_file(self, tmp_path: Path) -> None: """Multiple rules matching the same file should each produce results.""" rule_yml = _write_rule( @@ -466,6 +543,8 @@ def test_multiple_rules_match_same_file(self, tmp_path: Path) -> None: assert "R-A" in rule_ids assert "R-B" in rule_ids + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_target_directory(self, tmp_path: Path) -> None: """Directory with no .md files should produce empty results.""" rules_dir = tmp_path / "rules" @@ -481,6 +560,8 @@ def test_empty_target_directory(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], target) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_binary_target_file_skipped(self, tmp_path: Path) -> None: """Binary files should be skipped silently.""" rule_yml = _write_rule( @@ -493,6 +574,8 @@ def test_binary_target_file_skipped(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], tmp_path) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_utf8_encoding_error(self, tmp_path: Path) -> None: """File with invalid UTF-8 should be skipped.""" rule_yml = _write_rule( @@ -505,6 +588,8 @@ def test_utf8_encoding_error(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], tmp_path) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_snippet_truncation(self, tmp_path: Path) -> None: """Very long matches should be truncated in snippet.""" rule_yml = _write_rule( @@ -527,6 +612,8 @@ def test_snippet_truncation(self, tmp_path: Path) -> None: class TestPathFiltering: """Test path include filter edge cases.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_glob_star_star_slash_star_md(self) -> None: """**/*.md should match any .md file.""" assert _file_matches_path_filter("docs/README.md", ("**/*.md",)) @@ -534,23 +621,33 @@ def test_glob_star_star_slash_star_md(self) -> None: assert _file_matches_path_filter("deep/nested/file.md", ("**/*.md",)) assert not _file_matches_path_filter("file.txt", ("**/*.md",)) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exact_filename(self) -> None: """Exact filename match.""" assert _file_matches_path_filter("CLAUDE.md", ("CLAUDE.md",)) assert not _file_matches_path_filter("OTHER.md", ("CLAUDE.md",)) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_empty_path_includes(self) -> None: """Empty path_includes should match everything.""" assert _file_matches_path_filter("anything.txt", ()) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_template_placeholder_skipped(self) -> None: """Patterns containing {{ should be skipped.""" assert not _file_matches_path_filter("CLAUDE.md", ("{{instruction_files}}",)) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_dotslash_prefix_stripped(self) -> None: """./prefix should be stripped from file paths.""" assert _file_matches_path_filter("./CLAUDE.md", ("CLAUDE.md",)) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_path_includes_with_rule(self, tmp_path: Path) -> None: """Rules with path filters should only match specified files.""" rule_yml = _write_rule( @@ -585,6 +682,8 @@ def test_path_includes_with_rule(self, tmp_path: Path) -> None: class TestNegativePatterns: """Test the pattern-not-regex operator — zero framework rules use this.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_positive_with_negative_match_blocks(self, tmp_path: Path) -> None: """When negative pattern matches, the rule should NOT fire.""" rule_yml = _write_rule( @@ -605,6 +704,8 @@ def test_positive_with_negative_match_blocks(self, tmp_path: Path) -> None: _write_target(tmp_path, "password is hashed with bcrypt\n") assert _sarif_results(run_validation([rule_yml], tmp_path)) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_positive_without_negative_fires(self, tmp_path: Path) -> None: """When negative pattern doesn't match, positive should fire.""" rule_yml = _write_rule( @@ -627,6 +728,8 @@ def test_positive_without_negative_fires(self, tmp_path: Path) -> None: assert len(results) == 1 assert results[0]["ruleId"] == "NEG-2" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_multiple_negatives_all_must_not_match(self, tmp_path: Path) -> None: """All negative patterns block the match (AND-NOT logic).""" rule_yml = _write_rule( @@ -658,6 +761,8 @@ def test_multiple_negatives_all_must_not_match(self, tmp_path: Path) -> None: results = _sarif_results(run_validation([rule_yml], tmp_path)) assert len(results) == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_negative_only_never_fires(self, tmp_path: Path) -> None: """patterns block with ONLY negative patterns never produces matches. @@ -690,6 +795,8 @@ def test_negative_only_never_fires(self, tmp_path: Path) -> None: class TestPerformance: """Test performance and catastrophic backtracking resistance.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_catastrophic_backtracking_protection(self, tmp_path: Path) -> None: """Pattern with nested quantifiers on adversarial input. @@ -712,6 +819,8 @@ def test_catastrophic_backtracking_protection(self, tmp_path: Path) -> None: # 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" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_greedy_dot_star_large_file(self, tmp_path: Path) -> None: """Greedy .* between two patterns on a large file. @@ -731,6 +840,8 @@ def test_greedy_dot_star_large_file(self, tmp_path: Path) -> None: assert elapsed < 2.0, f"Greedy .* took {elapsed:.1f}s" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_many_files_performance(self, tmp_path: Path) -> None: """Scanning 100 files should complete quickly.""" rule_yml = _write_rule( @@ -747,6 +858,8 @@ def test_many_files_performance(self, tmp_path: Path) -> None: assert len(_sarif_results(sarif)) == 100 assert elapsed < 5.0, f"100 files took {elapsed:.1f}s" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_validation_completes_on_simple_pattern(self, tmp_path: Path) -> None: """run_validation produces correct results — sanity check the regex library wiring.""" rule_yml = _write_rule( @@ -766,6 +879,8 @@ def test_validation_completes_on_simple_pattern(self, tmp_path: Path) -> None: class TestIntegration: """Full pipeline integration tests with edge cases.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_expect_present_interaction_with_regex(self, tmp_path: Path) -> None: """Test that regex results can be consumed by expect=present deterministic checks. @@ -784,6 +899,8 @@ def test_expect_present_interaction_with_regex(self, tmp_path: Path) -> None: # Should produce a match (expect logic happens downstream in pipeline) assert len(results) == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exclude_dirs(self, tmp_path: Path) -> None: """--exclude-dir should prevent scanning those directories.""" rule_yml = _write_rule( @@ -802,6 +919,8 @@ def test_exclude_dirs(self, tmp_path: Path) -> None: assert any("CLAUDE.md" in u for u in uris) assert not any("vendor" in u for u in uris) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_symlink_extra_targets(self, tmp_path: Path) -> None: """Extra targets (from symlinks) should be scanned.""" rule_yml = _write_rule( @@ -823,6 +942,8 @@ def test_symlink_extra_targets(self, tmp_path: Path) -> None: results = _sarif_results(sarif) assert len(results) == 1 + @pytest.mark.unit + @pytest.mark.subsys_lint 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( @@ -838,6 +959,8 @@ 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) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_zero_byte_file(self, tmp_path: Path) -> None: """Zero-byte file should not crash.""" rule_yml = _write_rule( @@ -849,6 +972,8 @@ def test_zero_byte_file(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], tmp_path) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_file_with_only_newlines(self, tmp_path: Path) -> None: """File with only newlines should not crash.""" rule_yml = _write_rule( @@ -860,6 +985,8 @@ def test_file_with_only_newlines(self, tmp_path: Path) -> None: sarif = run_validation([rule_yml], tmp_path) assert _sarif_results(sarif) == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_very_long_line(self, tmp_path: Path) -> None: """File with a single very long line should not crash.""" rule_yml = _write_rule( @@ -878,6 +1005,8 @@ def test_very_long_line(self, tmp_path: Path) -> None: assert len(results) == 1 assert elapsed < 5.0 + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_either_all_sub_unsupported(self, tmp_path: Path) -> None: """pattern-either where no sub-entry has pattern-regex should skip.""" rule_yml = _write_rule( diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index c422879..e7c3872 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -6,8 +6,8 @@ import pytest -from reporails_cli.core.models import Category, PatternConfidence, Rule, RuleType -from reporails_cli.core.registry import build_rule +from reporails_cli.core.platform.adapters.registry import build_rule +from reporails_cli.core.platform.dto.models import Category, PatternConfidence, Rule, RuleType MINIMAL_FRONTMATTER = { "id": "CORE:S:0001", @@ -22,6 +22,8 @@ class TestBuildRuleBackedBy: """Test backed_by parsing in build_rule (now plain string list).""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_backed_by_parsed(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -32,10 +34,14 @@ def test_backed_by_parsed(self) -> None: assert rule.backed_by[0] == "anthropic-docs" assert rule.backed_by[1] == "community-practice" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_backed_by_empty_when_absent(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.backed_by == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_backed_by_skips_non_string_entries(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -53,6 +59,8 @@ def test_backed_by_skips_non_string_entries(self) -> None: class TestBuildRuleSources: """Test that sources accepts string lists.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_sources_as_strings(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -61,6 +69,8 @@ def test_sources_as_strings(self) -> None: rule = build_rule(fm, Path("test.md"), None) assert rule.sources == ["https://example.com/doc1", "https://example.com/doc2"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_sources_default_empty(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.sources == [] @@ -69,6 +79,8 @@ def test_sources_default_empty(self) -> None: class TestBuildRuleBasic: """Test basic build_rule construction.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_minimal_rule(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.id == "CORE:S:0001" @@ -79,6 +91,8 @@ def test_minimal_rule(self) -> None: assert rule.match is not None assert rule.match.type == "main" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_checks_parsed_new_format(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -97,6 +111,8 @@ def test_checks_parsed_new_format(self) -> None: assert rule.checks[1].type == "deterministic" assert rule.checks[1].check is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_mechanical_check_with_args(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -114,6 +130,8 @@ def test_mechanical_check_with_args(self) -> None: rule = build_rule(fm, Path("test.md"), None) assert rule.checks[0].args == {"max": 300} + @pytest.mark.unit + @pytest.mark.subsys_lint def test_supersedes_parsed(self) -> None: fm = { **MINIMAL_FRONTMATTER, @@ -126,16 +144,22 @@ def test_supersedes_parsed(self) -> None: class TestBuildRulePatternConfidence: """Test pattern_confidence parsing in build_rule.""" + @pytest.mark.unit + @pytest.mark.subsys_lint @pytest.mark.parametrize("level", ["very_high", "high", "medium", "low", "very_low"]) def test_confidence_level_parsed(self, level: str) -> None: fm = {**MINIMAL_FRONTMATTER, "pattern_confidence": level} rule = build_rule(fm, Path("test.md"), None) assert rule.pattern_confidence == PatternConfidence(level) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_none_when_absent(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.pattern_confidence is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_invalid_value_raises(self) -> None: fm = {**MINIMAL_FRONTMATTER, "pattern_confidence": "bogus"} with pytest.raises(ValueError): @@ -145,20 +169,28 @@ def test_invalid_value_raises(self) -> None: class TestBuildRuleNewFields: """Test inherited and depends_on parsing.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_inherited_parsed(self) -> None: fm = {**MINIMAL_FRONTMATTER, "inherited": "CORE:S:0038"} rule = build_rule(fm, Path("test.md"), None) assert rule.inherited == "CORE:S:0038" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_inherited_none_when_absent(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.inherited is None + @pytest.mark.unit + @pytest.mark.subsys_lint def test_depends_on_parsed(self) -> None: fm = {**MINIMAL_FRONTMATTER, "depends_on": ["CORE:S:0001", "CORE:S:0002"]} rule = build_rule(fm, Path("test.md"), None) assert rule.depends_on == ["CORE:S:0001", "CORE:S:0002"] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_depends_on_empty_when_absent(self) -> None: rule = build_rule(MINIMAL_FRONTMATTER, Path("test.md"), None) assert rule.depends_on == [] @@ -167,9 +199,11 @@ def test_depends_on_empty_when_absent(self) -> None: class TestApplyInheritance: """Test _apply_inheritance merges checks without removing parent.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_inheritance_merges_checks(self) -> None: - from reporails_cli.core.models import Check - from reporails_cli.core.registry import _apply_inheritance + from reporails_cli.core.platform.adapters.registry import _apply_inheritance + from reporails_cli.core.platform.dto.models import Check parent_check = Check(id="CORE.S.0038.has_frontmatter", type="mechanical", check="frontmatter_present") child_check = Check(id="CLAUDE.S.0015.has_paths_key", type="mechanical", check="frontmatter_key") @@ -203,8 +237,10 @@ def test_inheritance_merges_checks(self) -> None: assert child.checks[0].id == "CORE.S.0038.has_frontmatter" assert child.checks[1].id == "CLAUDE.S.0015.has_paths_key" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_inheritance_missing_parent_is_noop(self) -> None: - from reporails_cli.core.registry import _apply_inheritance + from reporails_cli.core.platform.adapters.registry import _apply_inheritance rules: dict[str, Rule] = { "CLAUDE:S:0015": Rule( @@ -224,8 +260,10 @@ def test_inheritance_missing_parent_is_noop(self) -> None: class TestValidateDependsOn: """Test _validate_depends_on cycle detection.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_cycle_passes_silently(self) -> None: - from reporails_cli.core.registry import _validate_depends_on + from reporails_cli.core.platform.adapters.registry import _validate_depends_on rules: dict[str, Rule] = { "A": Rule( @@ -235,8 +273,10 @@ def test_no_cycle_passes_silently(self) -> None: } _validate_depends_on(rules) # Should not raise + @pytest.mark.unit + @pytest.mark.subsys_lint def test_cycle_logs_warning(self, caplog: pytest.LogCaptureFixture) -> None: - from reporails_cli.core.registry import _validate_depends_on + from reporails_cli.core.platform.adapters.registry import _validate_depends_on rules: dict[str, Rule] = { "A": Rule( @@ -254,6 +294,8 @@ def test_cycle_logs_warning(self, caplog: pytest.LogCaptureFixture) -> None: class TestInferAgentFromRuleId: """Test infer_agent_from_rule_id prefix logic.""" + @pytest.mark.unit + @pytest.mark.subsys_lint @pytest.mark.parametrize( ("rule_id", "expected"), [ @@ -266,6 +308,6 @@ class TestInferAgentFromRuleId: ], ) def test_infer(self, rule_id: str, expected: str) -> None: - from reporails_cli.core.registry import infer_agent_from_rule_id + from reporails_cli.core.platform.adapters.registry import infer_agent_from_rule_id assert infer_agent_from_rule_id(rule_id) == expected diff --git a/tests/unit/test_rule_runner.py b/tests/unit/test_rule_runner.py index eced3b1..3930c58 100644 --- a/tests/unit/test_rule_runner.py +++ b/tests/unit/test_rule_runner.py @@ -10,10 +10,12 @@ class TestRunMProbes: """Verify run_m_probes dispatches mechanical and deterministic checks.""" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.discovery.agents import get_all_instruction_files + from reporails_cli.core.lint.rule_runner import run_m_probes files = get_all_instruction_files(level2_project) if not files: @@ -21,11 +23,13 @@ def test_returns_list(self, dev_rules_dir: Path, level2_project: Path) -> None: findings = run_m_probes(level2_project, files) assert isinstance(findings, list) + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.discovery.agents import get_all_instruction_files + from reporails_cli.core.lint.rule_runner import run_m_probes + from reporails_cli.core.platform.dto.models import LocalFinding files = get_all_instruction_files(level2_project) if not files: @@ -35,10 +39,12 @@ def test_findings_are_local_finding(self, dev_rules_dir: Path, level2_project: P assert isinstance(f, LocalFinding) assert f.source == "m_probe" + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.discovery.agents import get_all_instruction_files + from reporails_cli.core.lint.rule_runner import run_m_probes files = get_all_instruction_files(level2_project) if not files: @@ -48,10 +54,12 @@ def test_findings_sorted_by_severity(self, dev_rules_dir: Path, level2_project: for i in range(len(findings) - 1): assert severity_order.get(findings[i].severity, 9) <= severity_order.get(findings[i + 1].severity, 9) + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.discovery.agents import get_all_instruction_files + from reporails_cli.core.lint.rule_runner import run_m_probes files = get_all_instruction_files(level2_project) if not files: @@ -62,9 +70,11 @@ def test_agent_specific_rules_load(self, dev_rules_dir: Path, level2_project: Pa # At minimum, CORE rules still fire. assert any(r.startswith("CORE:") for r in rules_hit) + @pytest.mark.unit + @pytest.mark.subsys_lint 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 + from reporails_cli.core.platform.adapters.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 26c9ae2..9dad908 100644 --- a/tests/unit/test_rule_validation.py +++ b/tests/unit/test_rule_validation.py @@ -8,15 +8,19 @@ from pathlib import Path +import pytest + from tests.conftest import create_temp_rule_file class TestPatternOperators: """Test that all pattern operators are handled correctly.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_regex_matches(self, tmp_path: Path) -> None: """Simple pattern-regex should match content.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -37,9 +41,11 @@ def test_pattern_regex_matches(self, tmp_path: Path) -> None: results = result["runs"][0]["results"] assert len(results) > 0, "pattern-regex should find TODO" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_pattern_either_matches_any(self, tmp_path: Path) -> None: """pattern-either should match if any sub-pattern matches.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -63,9 +69,11 @@ def test_pattern_either_matches_any(self, tmp_path: Path) -> None: results = result["runs"][0]["results"] assert len(results) > 0, "pattern-either should match 'jest'" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_patterns_block_with_not_regex(self, tmp_path: Path) -> None: """patterns block with pattern-not-regex (AND + negation).""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -90,9 +98,11 @@ def test_patterns_block_with_not_regex(self, tmp_path: Path) -> None: results = result["runs"][0]["results"] assert len(results) > 0, "patterns block should match file without ## Commands" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_patterns_block_negation_suppresses(self, tmp_path: Path) -> None: """patterns block with pattern-not-regex should NOT match when negation hits.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -117,9 +127,11 @@ def test_patterns_block_negation_suppresses(self, tmp_path: Path) -> None: results = result["runs"][0]["results"] assert len(results) == 0, "pattern-not-regex should suppress match when ## Commands exists" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_pattern_operator_skipped(self, tmp_path: Path) -> None: """Rule with no recognized pattern operator should be skipped.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -140,9 +152,11 @@ def test_no_pattern_operator_skipped(self, tmp_path: Path) -> None: class TestPathFiltering: """Test that path include filters work correctly.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_path_filter_includes_matching(self, tmp_path: Path) -> None: """Files matching path include patterns should be scanned.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -167,9 +181,11 @@ def test_path_filter_includes_matching(self, tmp_path: Path) -> None: assert any(".md" in u for u in uris), "Should match .md file" assert not any(".txt" in u for u in uris), "Should not match .txt file" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_path_filter_scans_all_md(self, tmp_path: Path) -> None: """Rules without path filters should scan all markdown files.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: @@ -191,9 +207,11 @@ def test_no_path_filter_scans_all_md(self, tmp_path: Path) -> None: class TestLineNumbers: """Test that line numbers are correctly reported.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_line_number_accuracy(self, tmp_path: Path) -> None: """Line numbers in SARIF results should be accurate.""" - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation rule_yaml = """\ checks: diff --git a/tests/unit/test_safe_extract.py b/tests/unit/test_safe_extract.py index 684f45f..a35b030 100644 --- a/tests/unit/test_safe_extract.py +++ b/tests/unit/test_safe_extract.py @@ -8,7 +8,7 @@ import pytest -from reporails_cli.core.download import _safe_extractall, _validate_rules_structure +from reporails_cli.core.install.download import _safe_extractall, _validate_rules_structure class TestSafeExtractall: @@ -25,26 +25,36 @@ def _make_tar(self, members: list[tuple[str, bytes]]) -> tarfile.TarFile: buf.seek(0) return tarfile.open(fileobj=buf, mode="r:gz") + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_normal_extraction(self, tmp_path: Path) -> None: tar = self._make_tar([("hello.txt", b"world")]) _safe_extractall(tar, tmp_path) assert (tmp_path / "hello.txt").read_text() == "world" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_blocks_absolute_path(self, tmp_path: Path) -> None: tar = self._make_tar([("/etc/passwd", b"malicious")]) with pytest.raises(RuntimeError, match="Unsafe path"): _safe_extractall(tar, tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_blocks_path_traversal(self, tmp_path: Path) -> None: tar = self._make_tar([("../escape.txt", b"malicious")]) with pytest.raises(RuntimeError, match="Unsafe path"): _safe_extractall(tar, tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_blocks_nested_traversal(self, tmp_path: Path) -> None: tar = self._make_tar([("foo/../../escape.txt", b"malicious")]) with pytest.raises(RuntimeError, match="Unsafe path"): _safe_extractall(tar, tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_blocks_symlink_outside(self, tmp_path: Path) -> None: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: @@ -56,6 +66,8 @@ def test_blocks_symlink_outside(self, tmp_path: Path) -> None: with tarfile.open(fileobj=buf, mode="r:gz") as tar, pytest.raises(RuntimeError, match="Unsafe symlink"): _safe_extractall(tar, tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_allows_safe_nested_paths(self, tmp_path: Path) -> None: tar = self._make_tar([("core/rules/test.yml", b"data")]) _safe_extractall(tar, tmp_path) @@ -65,21 +77,29 @@ def test_allows_safe_nested_paths(self, tmp_path: Path) -> None: class TestValidateRulesStructure: """Verify _validate_rules_structure checks for expected directories.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_valid_structure(self, tmp_path: Path) -> None: (tmp_path / "core").mkdir() (tmp_path / "schemas").mkdir() _validate_rules_structure(tmp_path) # Should not raise + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_missing_core(self, tmp_path: Path) -> None: (tmp_path / "schemas").mkdir() with pytest.raises(RuntimeError, match="core"): _validate_rules_structure(tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_missing_schemas(self, tmp_path: Path) -> None: (tmp_path / "core").mkdir() with pytest.raises(RuntimeError, match="schemas"): _validate_rules_structure(tmp_path) + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_empty_dir_fails(self, tmp_path: Path) -> None: with pytest.raises(RuntimeError): _validate_rules_structure(tmp_path) diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index 778b84e..95679cb 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -13,14 +13,16 @@ from pathlib import Path -from reporails_cli.core.agents import ( +import pytest + +from reporails_cli.core.classify import classify_files, load_file_types +from reporails_cli.core.discovery.agents import ( clear_agent_cache, detect_agents, get_all_instruction_files, get_all_scannable_files, ) -from reporails_cli.core.classification import classify_files, load_file_types -from reporails_cli.core.engine_helpers import _find_project_root +from reporails_cli.core.platform.runtime.engine_helpers import _find_project_root def _is_external(f: Path, scope: Path) -> bool: @@ -89,6 +91,8 @@ class TestDetectAgentsScope: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_child_does_not_see_parent_files(self, tmp_path: Path) -> None: """Running from child does NOT surface parent files — cwd is project root. @@ -109,6 +113,8 @@ def test_child_does_not_see_parent_files(self, tmp_path: Path) -> None: for f in local_files: assert str(f).startswith(str(child)), f"File outside child scope: {f}" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_parent_sees_hierarchical_files(self, tmp_path: Path) -> None: """Running from project root: root CLAUDE.md is main, descendant is nested.""" _make_nested_project(tmp_path) @@ -130,6 +136,8 @@ class TestInstructionFilesScope: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_child_scope_bounded_by_target(self, tmp_path: Path) -> None: """From child, files outside cwd's subtree are NOT in scope.""" child = _make_nested_project(tmp_path) @@ -140,6 +148,8 @@ def test_child_scope_bounded_by_target(self, tmp_path: Path) -> None: for f in local_files: assert str(f).startswith(str(child)), f"File outside child subtree: {f}" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_child_finds_own_files_only(self, tmp_path: Path) -> None: """From child, only child's own files surface — parent files are out of scope.""" child = _make_nested_project(tmp_path) @@ -160,6 +170,8 @@ class TestScannableFilesScope: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_child_scope_bounded_by_project_root(self, tmp_path: Path) -> None: child = _make_nested_project(tmp_path) files = get_all_scannable_files(child) @@ -169,6 +181,8 @@ def test_child_scope_bounded_by_project_root(self, tmp_path: Path) -> None: for f in local_files: assert str(f).startswith(str(tmp_path)), f"File outside project root: {f}" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_child_does_not_include_parent_rules(self, tmp_path: Path) -> None: """parent.md lives in tmp_path/.claude/rules/ — path_scoped descendant from child, not in scope.""" child = _make_nested_project(tmp_path) @@ -184,6 +198,8 @@ class TestProjectRootVsScanRoot: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_project_root_above_scan_root(self, tmp_path: Path) -> None: """Backbone project root above child: ancestor walk reaches it, files outside it are excluded.""" child = _make_backbone_project(tmp_path) @@ -200,6 +216,8 @@ def test_project_root_above_scan_root(self, tmp_path: Path) -> None: names = {f.name for f in files} assert "CLAUDE.md" in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_project_root_equals_scan_root_when_no_parent(self, tmp_path: Path) -> None: """Standalone project: project_root == scan_root.""" (tmp_path / ".git").mkdir() @@ -219,6 +237,8 @@ class TestPreDetectedAgentsBypass: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_parent_agents_passed_to_child_scannable(self, tmp_path: Path) -> None: """Even if parent-scoped agents are passed, scannable files are from those agents.""" child = _make_nested_project(tmp_path) @@ -236,6 +256,8 @@ def test_parent_agents_passed_to_child_scannable(self, tmp_path: Path) -> None: parent_files = [f for f in files if not str(f).startswith(str(child))] assert len(parent_files) > 0, "Confirms parent agents leak (by design of agents param)" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_engine_uses_scan_root_agents(self, tmp_path: Path) -> None: """The engine detects agents at scan_root; ancestor walk surfaces parent files within project root.""" child = _make_backbone_project(tmp_path) @@ -267,6 +289,8 @@ class TestAncestorWalkAndClassification: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_target_files_only_no_ancestor_walk(self, tmp_path: Path) -> None: """Cwd is project root: parent CLAUDE.md files are NOT surfaced.""" (tmp_path / ".git").mkdir() @@ -283,6 +307,8 @@ def test_target_files_only_no_ancestor_walk(self, tmp_path: Path) -> None: assert (tmp_path / "CLAUDE.md").as_posix() not in names assert (a / "CLAUDE.md").as_posix() not in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_files_above_target_are_out_of_scope(self, tmp_path: Path) -> None: """A file above the target is excluded even if a `.git` lives between them.""" (tmp_path / "CLAUDE.md").write_text("# outside") @@ -301,6 +327,8 @@ def test_files_above_target_are_out_of_scope(self, tmp_path: Path) -> None: assert (repo / "CLAUDE.md").as_posix() not in names assert (tmp_path / "CLAUDE.md").as_posix() not in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_nested_classified_not_as_main(self, tmp_path: Path) -> None: """A descendant CLAUDE.md must classify as child_instruction, not main. @@ -319,6 +347,8 @@ def test_nested_classified_not_as_main(self, tmp_path: Path) -> None: assert types[(tmp_path / "CLAUDE.md").as_posix()] == "main" assert types[(sub / "CLAUDE.md").as_posix()] == "child_instruction" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_sibling_tree_excluded_from_subdir_run(self, tmp_path: Path) -> None: """Running from a subdirectory: parent + sibling files are all out of scope.""" (tmp_path / ".git").mkdir() @@ -339,6 +369,8 @@ def test_sibling_tree_excluded_from_subdir_run(self, tmp_path: Path) -> None: assert (a / "CLAUDE.md").as_posix() not in names, "Parent CLAUDE.md must NOT surface — cwd is project root" assert (sibling / "CLAUDE.md").as_posix() not in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_local_override_at_target(self, tmp_path: Path) -> None: """CLAUDE.local.md surfaces at cwd; ancestors are out of scope.""" (tmp_path / ".git").mkdir() @@ -355,6 +387,8 @@ def test_local_override_at_target(self, tmp_path: Path) -> None: assert (a / "CLAUDE.local.md").as_posix() in names assert (tmp_path / "CLAUDE.local.md").as_posix() not in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_activepieces_shape(self, tmp_path: Path) -> None: """Monorepo regression: root AGENTS.md is main; per-package files are nested. @@ -399,6 +433,8 @@ def test_activepieces_shape(self, tmp_path: Path) -> None: for p in nested_claude_paths: assert claude_types.get(p.as_posix()) == "child_instruction", f"{p} must NOT be classified as main" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_copilot_root_only_pattern(self, tmp_path: Path) -> None: """`.github/copilot-instructions.md` is project-root-only — resolved from cwd.""" (tmp_path / ".git").mkdir() @@ -423,6 +459,8 @@ class TestProjectConfigSurfaceAdjustments: def setup_method(self) -> None: clear_agent_cache() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_codex_fallback_filenames_surface(self, tmp_path: Path) -> None: """`agents.codex.fallback_filenames` adds candidate main files for codex. @@ -450,6 +488,8 @@ def test_codex_fallback_filenames_surface(self, tmp_path: Path) -> None: assert types.get("AGENTS.md") == "main" assert types.get("TEAM_GUIDE.md") == "main", "fallback filename must classify as main" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_surface_exclude_drops_files(self, tmp_path: Path) -> None: """`surfaces...exclude` filters out matching files.""" (tmp_path / ".git").mkdir() @@ -468,6 +508,8 @@ def test_surface_exclude_drops_files(self, tmp_path: Path) -> None: assert (tmp_path / "CLAUDE.md").as_posix() in names assert (legacy / "CLAUDE.md").as_posix() not in names, "exclude pattern must drop matching files" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_config_local_layered_overrides_committed(self, tmp_path: Path) -> None: """`.ails/config.local.yml` layers on top of `.ails/config.yml`.""" (tmp_path / ".git").mkdir() @@ -491,6 +533,8 @@ def test_config_local_layered_overrides_committed(self, tmp_path: Path) -> None: assert (keep / "CLAUDE.md").as_posix() in names assert (drop / "CLAUDE.md").as_posix() not in names + @pytest.mark.unit + @pytest.mark.subsys_lint def test_exclude_unions_across_overlapping_surfaces(self, tmp_path: Path) -> None: """An exclude on one surface drops matches from sibling surfaces too. @@ -500,8 +544,8 @@ def test_exclude_unions_across_overlapping_surfaces(self, tmp_path: Path) -> Non agent-wide union closes the gap: any exclude declared anywhere for the agent applies to every surface of that agent. """ - from reporails_cli.core.agent_discovery import discover_from_config - from reporails_cli.core.config import get_project_config + from reporails_cli.core.discovery.agent_discovery import discover_from_config + from reporails_cli.core.platform.config.config import get_project_config (tmp_path / ".git").mkdir() (tmp_path / "AGENTS.md").write_text("# main") diff --git a/tests/unit/test_scorecard.py b/tests/unit/test_scorecard.py index 4b0d92d..c7aa0b7 100644 --- a/tests/unit/test_scorecard.py +++ b/tests/unit/test_scorecard.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from pathlib import Path +import pytest + from reporails_cli.formatters.text.scorecard import compute_surface_scores @@ -27,6 +29,8 @@ class _Result: class TestComputeSurfaceScores: """Surface classification under absolute vs relative paths.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_root_main_file_with_absolute_path_classifies_as_main(self, tmp_path: Path) -> None: """A single root-level CLAUDE.md with an absolute mapper path tags `main`, not `nested`. @@ -44,6 +48,8 @@ def test_root_main_file_with_absolute_path_classifies_as_main(self, tmp_path: Pa assert names.get("Main") == 1 assert "Nested" not in names, "root CLAUDE.md must not appear as a Nested surface" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_subdirectory_main_file_classifies_as_nested(self, tmp_path: Path) -> None: """A `packages/web/CLAUDE.md` does belong in the Nested surface.""" nested_path = (tmp_path / "packages" / "web" / "CLAUDE.md").as_posix() diff --git a/tests/unit/test_self_update.py b/tests/unit/test_self_update.py index a54b225..21ee262 100644 --- a/tests/unit/test_self_update.py +++ b/tests/unit/test_self_update.py @@ -10,7 +10,9 @@ import subprocess from unittest.mock import MagicMock, patch -from reporails_cli.core.self_update import ( +import pytest + +from reporails_cli.core.install.self_update import ( InstallMethod, _build_upgrade_command, _verify_installed_version, @@ -29,15 +31,19 @@ class TestDetectInstallMethod: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_dev_install_detected(self) -> None: """Editable installs should return DEV.""" mock_dist = MagicMock() mock_dist.read_text.side_effect = lambda name: ( json.dumps({"dir_info": {"editable": True}}) if name == "direct_url.json" else None ) - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.DEV + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_uv_installer(self) -> None: """INSTALLER=uv should return UV.""" mock_dist = MagicMock() @@ -46,9 +52,11 @@ def test_uv_installer(self) -> None: "INSTALLER": "uv\n", }.get(name) mock_dist.files = [] - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.UV + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pip_installer(self) -> None: """INSTALLER=pip should return PIP.""" mock_dist = MagicMock() @@ -57,9 +65,11 @@ def test_pip_installer(self) -> None: "INSTALLER": "pip\n", }.get(name) mock_dist.files = [] - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.PIP + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pipx_location(self) -> None: """Dist path containing 'pipx' should return PIPX.""" mock_dist = MagicMock() @@ -73,22 +83,28 @@ def test_pipx_location(self) -> None: mock_dist._path = ( "/home/user/.local/pipx/venvs/reporails-cli/lib/python3.12/site-packages/reporails_cli-0.1.3.dist-info" ) - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.PIPX + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_distribution_not_found(self) -> None: """Missing package should return UNKNOWN.""" - with patch("reporails_cli.core.self_update.distribution", side_effect=Exception("not found")): + with patch("reporails_cli.core.install.self_update.distribution", side_effect=Exception("not found")): assert detect_install_method() == InstallMethod.UNKNOWN + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_no_installer_file_defaults_to_pip(self) -> None: """When INSTALLER is None, default to PIP.""" mock_dist = MagicMock() mock_dist.read_text.return_value = None mock_dist.files = [] - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.PIP + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_corrupt_direct_url_falls_through(self) -> None: """Corrupt direct_url.json should not crash, falls through to INSTALLER.""" mock_dist = MagicMock() @@ -97,7 +113,7 @@ def test_corrupt_direct_url_falls_through(self) -> None: "INSTALLER": "uv\n", }.get(name) mock_dist.files = [] - with patch("reporails_cli.core.self_update.distribution", return_value=mock_dist): + with patch("reporails_cli.core.install.self_update.distribution", return_value=mock_dist): assert detect_install_method() == InstallMethod.UV @@ -107,30 +123,42 @@ def test_corrupt_direct_url_falls_through(self) -> None: class TestBuildUpgradeCommand: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_uv_latest(self) -> None: cmd = _build_upgrade_command(InstallMethod.UV, None) assert cmd[:2] == ["uv", "pip"] assert "--refresh-package" in cmd assert "reporails-cli" in cmd + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_uv_pinned(self) -> None: cmd = _build_upgrade_command(InstallMethod.UV, "1.0.0") assert "reporails-cli==1.0.0" in cmd + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pip_latest(self) -> None: cmd = _build_upgrade_command(InstallMethod.PIP, None) assert "--no-cache-dir" in cmd assert "--upgrade" in cmd assert "reporails-cli" in cmd + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pip_pinned(self) -> None: cmd = _build_upgrade_command(InstallMethod.PIP, "2.0.0") assert "reporails-cli==2.0.0" in cmd + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pipx_latest(self) -> None: cmd = _build_upgrade_command(InstallMethod.PIPX, None) assert cmd == ["pipx", "upgrade", "reporails-cli"] + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pipx_pinned(self) -> None: cmd = _build_upgrade_command(InstallMethod.PIPX, "1.5.0") assert cmd == ["pipx", "install", "--force", "reporails-cli==1.5.0"] @@ -142,21 +170,27 @@ def test_pipx_pinned(self) -> None: class TestVerifyInstalledVersion: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_success(self) -> None: mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "1.2.3\n" - with patch("reporails_cli.core.self_update.subprocess.run", return_value=mock_result): + with patch("reporails_cli.core.install.self_update.subprocess.run", return_value=mock_result): assert _verify_installed_version() == "1.2.3" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_failure_returns_none(self) -> None: mock_result = MagicMock() mock_result.returncode = 1 - with patch("reporails_cli.core.self_update.subprocess.run", return_value=mock_result): + with patch("reporails_cli.core.install.self_update.subprocess.run", return_value=mock_result): assert _verify_installed_version() is None + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_exception_returns_none(self) -> None: - with patch("reporails_cli.core.self_update.subprocess.run", side_effect=OSError("boom")): + with patch("reporails_cli.core.install.self_update.subprocess.run", side_effect=OSError("boom")): assert _verify_installed_version() is None @@ -166,10 +200,12 @@ def test_exception_returns_none(self) -> None: class TestUpgradeCli: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_dev_install_refuses(self) -> None: """Dev installs should return without running subprocess.""" with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.DEV), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.DEV), patch("reporails_cli.__version__", "0.1.0"), ): result = upgrade_cli() @@ -177,9 +213,11 @@ def test_dev_install_refuses(self) -> None: assert result.method == InstallMethod.DEV assert "uv sync" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_unknown_method_refuses(self) -> None: with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.UNKNOWN), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.UNKNOWN), patch("reporails_cli.__version__", "0.1.0"), ): result = upgrade_cli() @@ -187,27 +225,33 @@ def test_unknown_method_refuses(self) -> None: assert result.method == InstallMethod.UNKNOWN assert "manually" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_already_at_latest(self) -> None: with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.PIP), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.PIP), patch("reporails_cli.__version__", "1.0.0"), - patch("reporails_cli.core.update_check._fetch_latest_cli_version", return_value="1.0.0"), - patch("reporails_cli.core.update_check._is_newer", return_value=False), + patch("reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value="1.0.0"), + patch("reporails_cli.core.install.update_check._is_newer", return_value=False), ): result = upgrade_cli() assert result.updated is False assert "Already at" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_pypi_unreachable(self) -> None: with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.PIP), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.PIP), patch("reporails_cli.__version__", "0.1.0"), - patch("reporails_cli.core.update_check._fetch_latest_cli_version", return_value=None), + patch("reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value=None), ): result = upgrade_cli() assert result.updated is False assert "PyPI" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_successful_upgrade(self) -> None: mock_run = MagicMock() mock_run.returncode = 0 @@ -215,42 +259,46 @@ def test_successful_upgrade(self) -> None: mock_run.stderr = "" with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.UV), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.UV), patch("reporails_cli.__version__", "0.1.0"), - patch("reporails_cli.core.update_check._fetch_latest_cli_version", return_value="0.2.0"), - patch("reporails_cli.core.update_check._is_newer", return_value=True), - patch("reporails_cli.core.self_update.subprocess.run", return_value=mock_run), - patch("reporails_cli.core.self_update._verify_installed_version", return_value="0.2.0"), + patch("reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value="0.2.0"), + patch("reporails_cli.core.install.update_check._is_newer", return_value=True), + patch("reporails_cli.core.install.self_update.subprocess.run", return_value=mock_run), + patch("reporails_cli.core.install.self_update._verify_installed_version", return_value="0.2.0"), ): result = upgrade_cli() assert result.updated is True assert result.new_version == "0.2.0" assert result.method == InstallMethod.UV + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_subprocess_failure(self) -> None: mock_run = MagicMock() mock_run.returncode = 1 mock_run.stderr = "Permission denied" with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.PIP), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.PIP), patch("reporails_cli.__version__", "0.1.0"), - patch("reporails_cli.core.update_check._fetch_latest_cli_version", return_value="0.2.0"), - patch("reporails_cli.core.update_check._is_newer", return_value=True), - patch("reporails_cli.core.self_update.subprocess.run", return_value=mock_run), + patch("reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value="0.2.0"), + patch("reporails_cli.core.install.update_check._is_newer", return_value=True), + patch("reporails_cli.core.install.self_update.subprocess.run", return_value=mock_run), ): result = upgrade_cli() assert result.updated is False assert "Permission denied" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_subprocess_timeout(self) -> None: with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.PIP), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.PIP), patch("reporails_cli.__version__", "0.1.0"), - patch("reporails_cli.core.update_check._fetch_latest_cli_version", return_value="0.2.0"), - patch("reporails_cli.core.update_check._is_newer", return_value=True), + patch("reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value="0.2.0"), + patch("reporails_cli.core.install.update_check._is_newer", return_value=True), patch( - "reporails_cli.core.self_update.subprocess.run", + "reporails_cli.core.install.self_update.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=[], timeout=120), ), ): @@ -258,6 +306,8 @@ def test_subprocess_timeout(self) -> None: assert result.updated is False assert "timed out" in result.message + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_explicit_target_version(self) -> None: """When target_version is passed, skip PyPI fetch.""" mock_run = MagicMock() @@ -265,11 +315,11 @@ def test_explicit_target_version(self) -> None: mock_run.stderr = "" with ( - patch("reporails_cli.core.self_update.detect_install_method", return_value=InstallMethod.UV), + patch("reporails_cli.core.install.self_update.detect_install_method", return_value=InstallMethod.UV), patch("reporails_cli.__version__", "0.1.0"), - patch("reporails_cli.core.update_check._is_newer", return_value=True), - patch("reporails_cli.core.self_update.subprocess.run", return_value=mock_run), - patch("reporails_cli.core.self_update._verify_installed_version", return_value="0.3.0"), + patch("reporails_cli.core.install.update_check._is_newer", return_value=True), + patch("reporails_cli.core.install.self_update.subprocess.run", return_value=mock_run), + patch("reporails_cli.core.install.self_update._verify_installed_version", return_value="0.3.0"), ): result = upgrade_cli(target_version="0.3.0") assert result.updated is True @@ -284,23 +334,29 @@ def test_explicit_target_version(self) -> None: class TestFormatUpdateMessageCliFlag: """Verify the message now tells users to run `ails update --cli`.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_cli_update_shows_cli_flag(self) -> None: - from reporails_cli.core.update_check import UpdateNotification, format_update_message + from reporails_cli.core.install.update_check import UpdateNotification, format_update_message n = UpdateNotification(cli_current="0.1.0", cli_latest="0.2.0") msg = format_update_message(n) assert "ails update --cli" in msg + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_rules_update_shows_plain_update(self) -> None: - from reporails_cli.core.update_check import UpdateNotification, format_update_message + from reporails_cli.core.install.update_check import UpdateNotification, format_update_message n = UpdateNotification(rules_current="0.0.1", rules_latest="0.0.2") msg = format_update_message(n) assert "ails update" in msg assert "--cli" not in msg + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_both_updates_shows_both_commands(self) -> None: - from reporails_cli.core.update_check import UpdateNotification, format_update_message + from reporails_cli.core.install.update_check import UpdateNotification, format_update_message n = UpdateNotification( cli_current="0.1.0", diff --git a/tests/unit/test_stopwords.py b/tests/unit/test_stopwords.py index aa39e75..c23b67c 100644 --- a/tests/unit/test_stopwords.py +++ b/tests/unit/test_stopwords.py @@ -7,7 +7,7 @@ import pytest import yaml -from reporails_cli.core.stopwords import ( +from reporails_cli.core.classify.stopwords import ( PatternParts, _split_alternation, _strip_flags, @@ -16,12 +16,14 @@ is_guard, recompose, ) -from reporails_cli.core.stopwords_sync import check_staleness, sync_vocab +from reporails_cli.core.classify.stopwords_sync import check_staleness, sync_vocab # ── decompose / recompose ──────────────────────────────────────────── class TestDecompose: + @pytest.mark.unit + @pytest.mark.subsys_map @pytest.mark.parametrize( "pattern, expected_flags, expected_prefix, expected_terms, expected_suffix", [ @@ -56,12 +58,18 @@ def test_decompose_patterns( assert parts.terms == expected_terms assert parts.suffix == expected_suffix + @pytest.mark.unit + @pytest.mark.subsys_map def test_decompose_returns_none_for_no_alternation(self) -> None: assert decompose(r"^---\n") is None + @pytest.mark.unit + @pytest.mark.subsys_map def test_decompose_returns_none_for_single_term_group(self) -> None: assert decompose("(?:single)") is None + @pytest.mark.unit + @pytest.mark.subsys_map 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)") @@ -70,11 +78,15 @@ def test_decompose_nested_groups_in_terms(self) -> None: assert parts.terms[0] == r"(?:confirm|ask)\s+before" assert parts.terms[1] == "risky" + @pytest.mark.unit + @pytest.mark.subsys_map 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,}"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_decompose_escaped_pipe_not_split(self) -> None: parts = decompose(r"(?:a\|b|c)") assert parts is not None @@ -82,18 +94,24 @@ def test_decompose_escaped_pipe_not_split(self) -> None: class TestRecompose: + @pytest.mark.unit + @pytest.mark.subsys_map def test_round_trip_simple(self) -> None: original = "(?i)(?:foo|bar|baz)" parts = decompose(original) assert parts is not None assert recompose(parts) == original + @pytest.mark.unit + @pytest.mark.subsys_map 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 + @pytest.mark.unit + @pytest.mark.subsys_map def test_recompose_with_new_terms(self) -> None: parts = PatternParts( flags="(?i)", @@ -105,6 +123,8 @@ def test_recompose_with_new_terms(self) -> None: result = recompose(parts, ["new1", "new2"]) assert result == r"(?i)\b(?:new1|new2)\b" + @pytest.mark.unit + @pytest.mark.subsys_map @pytest.mark.parametrize( "pattern", [ @@ -124,35 +144,51 @@ def test_round_trip_real_patterns(self, pattern: str) -> None: class TestHelpers: + @pytest.mark.unit + @pytest.mark.subsys_map def test_strip_flags_single(self) -> None: flags, rest = _strip_flags("(?i)abc") assert flags == "(?i)" assert rest == "abc" + @pytest.mark.unit + @pytest.mark.subsys_map def test_strip_flags_multiple(self) -> None: flags, rest = _strip_flags("(?i)(?s)abc") assert flags == "(?i)(?s)" assert rest == "abc" + @pytest.mark.unit + @pytest.mark.subsys_map def test_strip_flags_none(self) -> None: flags, rest = _strip_flags("abc") assert flags == "" assert rest == "abc" + @pytest.mark.unit + @pytest.mark.subsys_map def test_split_alternation_simple(self) -> None: assert _split_alternation("a|b|c") == ["a", "b", "c"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_split_alternation_nested_groups(self) -> None: result = _split_alternation("(?:a|b)|c") assert result == ["(?:a|b)", "c"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_split_alternation_char_class(self) -> None: result = _split_alternation("[a|b]|c") assert result == ["[a|b]", "c"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_is_guard_true(self) -> None: assert is_guard(r"(?s)\A[\s\S]+") + @pytest.mark.unit + @pytest.mark.subsys_map def test_is_guard_false(self) -> None: assert not is_guard("(?i)(?:foo|bar)") @@ -165,6 +201,8 @@ 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") + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_standalone_pattern_regex(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -180,6 +218,8 @@ def test_extract_standalone_pattern_regex(self, tmp_path: Path) -> None: assert vocab is not None assert vocab["has_terms"] == ["foo", "bar", "baz"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_mechanical_content_absent(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -196,6 +236,8 @@ def test_extract_mechanical_content_absent(self, tmp_path: Path) -> None: assert vocab is not None assert vocab["fast_check"] == ["a", "b", "c"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_patterns_array_with_guard(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -214,6 +256,8 @@ def test_extract_patterns_array_with_guard(self, tmp_path: Path) -> None: assert vocab is not None assert vocab["find_keywords"] == ["MUST", "NEVER", "ALWAYS"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_patterns_array_both_extractable(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -235,6 +279,8 @@ def test_extract_patterns_array_both_extractable(self, tmp_path: Path) -> None: assert vocab["dual_check"]["pattern-regex"] == ["push", "deploy", "delete"] assert vocab["dual_check"]["pattern-not-regex"] == ["risky", "dangerous"] + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_skips_semantic(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -245,6 +291,8 @@ def test_extract_skips_semantic(self, tmp_path: Path) -> None: vocab = extract_vocab(tmp_path) assert vocab is None + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_skips_no_alternation(self, tmp_path: Path) -> None: self._write_checks( tmp_path, @@ -259,6 +307,8 @@ def test_extract_skips_no_alternation(self, tmp_path: Path) -> None: vocab = extract_vocab(tmp_path) assert vocab is None + @pytest.mark.unit + @pytest.mark.subsys_map def test_extract_no_checks_yml(self, tmp_path: Path) -> None: assert extract_vocab(tmp_path) is None @@ -283,6 +333,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", []) + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_standalone_pattern_regex(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -301,6 +353,8 @@ def test_sync_standalone_pattern_regex(self, tmp_path: Path) -> None: checks = self._read_checks(tmp_path) assert checks[0]["pattern-regex"] == "(?i)(?:foo|bar|baz)" + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_mechanical_content_absent(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -320,6 +374,8 @@ def test_sync_mechanical_content_absent(self, tmp_path: Path) -> None: checks = self._read_checks(tmp_path) assert checks[0]["args"]["pattern"] == "(?:a|b|c)" + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_patterns_array_guard_plus_vocab(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -342,6 +398,8 @@ def test_sync_patterns_array_guard_plus_vocab(self, tmp_path: Path) -> None: entry = checks[0]["patterns"][1] assert entry["pattern-not-regex"] == r"\b(MUST|NEVER|ALWAYS)\b" + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_nested_format(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -369,6 +427,8 @@ def test_sync_nested_format(self, tmp_path: Path) -> None: 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)" + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_no_change_when_terms_match(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -385,6 +445,8 @@ def test_sync_no_change_when_terms_match(self, tmp_path: Path) -> None: assert result.updated == 0 assert result.skipped == 1 + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_dry_run_does_not_write(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -401,6 +463,8 @@ def test_sync_dry_run_does_not_write(self, tmp_path: Path) -> None: sync_vocab(tmp_path, dry_run=True) assert (tmp_path / "checks.yml").read_text(encoding="utf-8") == original + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_missing_check_suffix(self, tmp_path: Path) -> None: self._setup_rule( tmp_path, @@ -417,6 +481,8 @@ def test_sync_missing_check_suffix(self, tmp_path: Path) -> None: assert result.skipped == 1 assert any("nonexistent" in m for m in result.messages) + @pytest.mark.unit + @pytest.mark.subsys_map def test_sync_preserves_wrapper(self, tmp_path: Path) -> None: """Sync preserves flags, prefix, suffix from the original pattern.""" self._setup_rule( @@ -439,6 +505,8 @@ def test_sync_preserves_wrapper(self, tmp_path: Path) -> None: class TestRoundTrip: + @pytest.mark.unit + @pytest.mark.subsys_map @pytest.mark.parametrize( "original_pattern", [ @@ -480,6 +548,8 @@ def test_extract_then_sync_preserves_pattern(self, tmp_path: Path, original_patt class TestStaleness: + @pytest.mark.unit + @pytest.mark.subsys_map def test_stale_when_terms_differ(self, tmp_path: Path) -> None: (tmp_path / "checks.yml").write_text( yaml.dump( @@ -503,6 +573,8 @@ def test_stale_when_terms_differ(self, tmp_path: Path) -> None: assert result is not None assert result.stale is True + @pytest.mark.unit + @pytest.mark.subsys_map def test_not_stale_when_terms_match(self, tmp_path: Path) -> None: (tmp_path / "checks.yml").write_text( yaml.dump( @@ -526,5 +598,7 @@ def test_not_stale_when_terms_match(self, tmp_path: Path) -> None: assert result is not None assert result.stale is False + @pytest.mark.unit + @pytest.mark.subsys_map 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 3b8ddc7..3076c1b 100644 --- a/tests/unit/test_summary.py +++ b/tests/unit/test_summary.py @@ -5,6 +5,8 @@ import importlib.util from pathlib import Path +import pytest + # Load summary.py as a module since action/ is not a package _summary_path = Path(__file__).resolve().parents[2] / "action" / "summary.py" _spec = importlib.util.spec_from_file_location("summary", _summary_path) @@ -45,10 +47,14 @@ def _finding(severity: str = "warning", rule: str = "CORE:S:0001", line: int = 1 class TestHeaderTable: """Status/findings/files/mode header table.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_status_pass_no_findings(self): md = generate_summary(_result()) assert "Pass" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_findings_count(self): md = generate_summary( _result( @@ -58,6 +64,8 @@ def test_findings_count(self): ) assert "**2**" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_file_count(self): md = generate_summary( _result( @@ -66,10 +74,14 @@ def test_file_count(self): ) assert "| Files | 2 |" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_offline_mode(self): md = generate_summary(_result(offline=True)) assert "offline" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_online_mode(self): md = generate_summary(_result(offline=False)) assert "online" in md @@ -83,10 +95,14 @@ def test_online_mode(self): class TestStatus: """Status line derivation from findings.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_no_findings_pass(self): md = generate_summary(_result()) assert "Pass" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_errors_fail(self): md = generate_summary( _result( @@ -96,6 +112,8 @@ def test_errors_fail(self): ) assert "Fail" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_warnings_only(self): md = generate_summary( _result( @@ -114,6 +132,8 @@ def test_warnings_only(self): class TestFindingsTable: """Findings detail table rendering.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_row_rendered(self): md = generate_summary( _result( @@ -125,6 +145,8 @@ def test_row_rendered(self): assert "CLAUDE.md" in md assert "Missing section" in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_long_message_truncated(self): long_msg = "A" * 100 md = generate_summary( @@ -135,10 +157,14 @@ def test_long_message_truncated(self): assert "AAA..." in md assert "A" * 78 not in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_empty_findings_no_table(self): md = generate_summary(_result()) assert "### Findings" not in md + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_severity_icons(self): findings = [_finding(severity=s) for s in ("error", "warning", "medium", "info")] md = generate_summary( @@ -159,24 +185,32 @@ def test_severity_icons(self): class TestMain: """main() reads from REPORAILS_RESULT env var.""" + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_no_env_var(self, capsys, monkeypatch): monkeypatch.delenv("REPORAILS_RESULT", raising=False) main() out = capsys.readouterr().out assert "No results available" in out + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_empty_env_var(self, capsys, monkeypatch): monkeypatch.setenv("REPORAILS_RESULT", " ") main() out = capsys.readouterr().out assert "No results available" in out + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_invalid_json(self, capsys, monkeypatch): monkeypatch.setenv("REPORAILS_RESULT", "not-json") main() out = capsys.readouterr().out assert "Failed to parse" in out + @pytest.mark.unit + @pytest.mark.subsys_diagnostic def test_valid_json(self, capsys, monkeypatch): import json diff --git a/tests/unit/test_symlink_detection.py b/tests/unit/test_symlink_detection.py index eb5aa56..d636dc9 100644 --- a/tests/unit/test_symlink_detection.py +++ b/tests/unit/test_symlink_detection.py @@ -13,7 +13,7 @@ import pytest -from reporails_cli.core.applicability import ( +from reporails_cli.core.discovery.features import ( detect_features_filesystem, resolve_symlinked_files, ) @@ -24,6 +24,8 @@ class TestResolveSymlinkedFiles: """Test resolve_symlinked_files() helper.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_symlinks_returns_empty(self, tmp_path: Path) -> None: """Regular files → no resolved symlinks.""" project = tmp_path / "project" @@ -34,6 +36,8 @@ def test_no_symlinks_returns_empty(self, tmp_path: Path) -> None: assert result == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_external_symlink_detected(self, tmp_path: Path) -> None: """CLAUDE.md symlink to file outside project → detected.""" project = tmp_path / "project" @@ -52,6 +56,8 @@ def test_external_symlink_detected(self, tmp_path: Path) -> None: assert len(result) == 1 assert result[0] == external.resolve() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_internal_symlink_not_included(self, tmp_path: Path) -> None: """CLAUDE.md symlink to file within project → NOT included.""" project = tmp_path / "project" @@ -70,6 +76,8 @@ def test_internal_symlink_not_included(self, tmp_path: Path) -> None: # Internal symlinks are handled by OpenGrep, so not included assert result == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_broken_symlink_ignored(self, tmp_path: Path) -> None: """Broken symlink → ignored, no crash.""" project = tmp_path / "project" @@ -82,6 +90,8 @@ def test_broken_symlink_ignored(self, tmp_path: Path) -> None: assert result == [] + @pytest.mark.unit + @pytest.mark.subsys_lint def test_circular_symlink_warns(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Circular symlink → skipped with warning log.""" import logging @@ -95,12 +105,14 @@ def test_circular_symlink_warns(self, tmp_path: Path, caplog: pytest.LogCaptureF os.symlink(str(b), str(a)) os.symlink(str(a), str(b)) - with caplog.at_level(logging.WARNING, logger="reporails_cli.core.applicability"): + with caplog.at_level(logging.WARNING, logger="reporails_cli.core.platform.policy.applicability"): result = resolve_symlinked_files(project) assert result == [] assert any("Circular symlink detected" in msg for msg in caplog.messages) + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_instruction_files_returns_empty(self, tmp_path: Path) -> None: """Project with no instruction files → empty list.""" project = tmp_path / "project" @@ -114,6 +126,8 @@ def test_no_instruction_files_returns_empty(self, tmp_path: Path) -> None: class TestSymlinkFeatureDetection: """Test symlink handling in detect_features_filesystem.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_symlinked_claude_md_detected_as_existing(self, tmp_path: Path) -> None: """Symlinked CLAUDE.md (external target) is detected as existing.""" project = tmp_path / "project" @@ -130,6 +144,8 @@ def test_symlinked_claude_md_detected_as_existing(self, tmp_path: Path) -> None: assert features.has_claude_md is True assert features.has_instruction_file is True + @pytest.mark.unit + @pytest.mark.subsys_lint def test_resolved_symlinks_stored_in_features(self, tmp_path: Path) -> None: """External symlinks are stored in features.resolved_symlinks.""" project = tmp_path / "project" @@ -146,6 +162,8 @@ def test_resolved_symlinks_stored_in_features(self, tmp_path: Path) -> None: assert len(features.resolved_symlinks) == 1 assert features.resolved_symlinks[0] == external.resolve() + @pytest.mark.unit + @pytest.mark.subsys_lint def test_no_symlinks_empty_resolved(self, tmp_path: Path) -> None: """Regular project has empty resolved_symlinks.""" project = tmp_path / "project" @@ -160,6 +178,8 @@ def test_no_symlinks_empty_resolved(self, tmp_path: Path) -> None: class TestRegexExtraTargets: """Test extra_targets parameter in regex run_validation.""" + @pytest.mark.unit + @pytest.mark.subsys_lint def test_extra_targets_scanned(self, tmp_path: Path) -> None: """Extra targets should be included in the scan.""" # Create a rule that matches "SHARED_CONTENT" @@ -186,7 +206,7 @@ def test_extra_targets_scanned(self, tmp_path: Path) -> None: external.parent.mkdir() external.write_text("SHARED_CONTENT is here\n") - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation sarif = run_validation( [yml_file], @@ -197,6 +217,8 @@ def test_extra_targets_scanned(self, tmp_path: Path) -> None: results = sarif.get("runs", [{}])[0].get("results", []) assert len(results) > 0, "Extra target file should be scanned and matched" + @pytest.mark.unit + @pytest.mark.subsys_lint 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" @@ -216,7 +238,7 @@ def test_extra_targets_none_is_noop(self, tmp_path: Path) -> None: project.mkdir() (project / "test.md").write_text("Hello World\n") - from reporails_cli.core.regex import run_validation + from reporails_cli.core.lint.regex import run_validation sarif = run_validation([yml_file], project) diff --git a/tests/unit/test_update_check.py b/tests/unit/test_update_check.py index 7f36a39..29f0d46 100644 --- a/tests/unit/test_update_check.py +++ b/tests/unit/test_update_check.py @@ -1,7 +1,7 @@ """Update check unit tests. -Tests the 24-hour cached update check for CLI (PyPI), framework (GitHub), -and recommended (GitHub). All network calls and filesystem access are mocked. +Tests the 24-hour cached update check for CLI (PyPI) and framework (GitHub). +All network calls and filesystem access are mocked. """ from __future__ import annotations @@ -12,7 +12,7 @@ import pytest -from reporails_cli.core.update_check import ( +from reporails_cli.core.install.update_check import ( UpdateNotification, _is_newer, check_for_updates, @@ -26,6 +26,8 @@ class TestUpdateNotification: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux @pytest.mark.parametrize( "current,latest,expected", [ @@ -38,6 +40,8 @@ def test_has_cli_update(self, current: str | None, latest: str | None, expected: n = UpdateNotification(cli_current=current, cli_latest=latest) assert n.has_cli_update is expected + @pytest.mark.unit + @pytest.mark.subsys_cli_ux @pytest.mark.parametrize( "current,latest,expected", [ @@ -50,28 +54,14 @@ def test_has_rules_update(self, current: str | None, latest: str | None, expecte n = UpdateNotification(rules_current=current, rules_latest=latest) assert n.has_rules_update is expected - @pytest.mark.parametrize( - "current,latest,expected", - [ - ("0.1.0", "0.2.0", True), - ("0.1.0", "0.1.0", False), - (None, None, False), - ("0.1.0", None, False), - (None, "0.2.0", False), - ], - ) - def test_has_recommended_update(self, current: str | None, latest: str | None, expected: bool) -> None: - n = UpdateNotification(recommended_current=current, recommended_latest=latest) - assert n.has_recommended_update is expected - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_has_any_update_true_for_cli(self) -> None: n = UpdateNotification(cli_current="0.1.0", cli_latest="0.2.0") assert n.has_any_update is True - def test_has_any_update_true_for_recommended(self) -> None: - n = UpdateNotification(recommended_current="0.1.0", recommended_latest="0.2.0") - assert n.has_any_update is True - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_has_any_update_false_when_all_none(self) -> None: n = UpdateNotification() assert n.has_any_update is False @@ -83,21 +73,33 @@ def test_has_any_update_false_when_all_none(self) -> None: class TestIsNewer: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_newer_version(self) -> None: assert _is_newer("0.1.0", "0.2.0") is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_same_version(self) -> None: assert _is_newer("0.1.0", "0.1.0") is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_older_version(self) -> None: assert _is_newer("0.2.0", "0.1.0") is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_strips_v_prefix(self) -> None: assert _is_newer("v0.1.0", "v0.2.0") is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_mixed_v_prefix(self) -> None: assert _is_newer("0.1.0", "v0.2.0") is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_invalid_version_returns_false(self) -> None: assert _is_newer("not-a-version", "0.1.0") is False @@ -108,23 +110,23 @@ def test_invalid_version_returns_false(self) -> None: class TestFormatUpdateMessage: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_cli_only(self) -> None: n = UpdateNotification(cli_current="0.1.0", cli_latest="0.2.0") msg = format_update_message(n) assert "CLI 0.1.0 → 0.2.0" in msg assert "ails update" in msg + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_rules_only(self) -> None: n = UpdateNotification(rules_current="0.0.1", rules_latest="0.0.2") msg = format_update_message(n) assert "framework 0.0.1 → 0.0.2" in msg - def test_recommended_only(self) -> None: - n = UpdateNotification(recommended_current="0.1.0", recommended_latest="0.2.0") - msg = format_update_message(n) - assert "recommended 0.1.0 → 0.2.0" in msg - assert "ails update" in msg - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_both_updates(self) -> None: n = UpdateNotification( cli_current="0.1.0", @@ -136,20 +138,6 @@ def test_both_updates(self) -> None: assert "CLI 0.1.0 → 0.2.0" in msg assert "framework 0.0.1 → 0.0.2" in msg - def test_all_three_updates(self) -> None: - n = UpdateNotification( - cli_current="0.1.0", - cli_latest="0.2.0", - rules_current="0.0.1", - rules_latest="0.0.2", - recommended_current="0.1.0", - recommended_latest="0.2.0", - ) - msg = format_update_message(n) - assert "CLI 0.1.0 → 0.2.0" in msg - assert "framework 0.0.1 → 0.0.2" in msg - assert "recommended 0.1.0 → 0.2.0" in msg - # --------------------------------------------------------------------------- # Cache helpers @@ -159,9 +147,11 @@ def test_all_three_updates(self) -> None: class TestCacheReadWrite: """Test _read_cache and _write_cache via the public check_for_updates.""" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_fresh_cache_is_used(self, tmp_path: pytest.TempPathFactory) -> None: """A cache written < 24h ago should be read back without fetching.""" - from reporails_cli.core import update_check + from reporails_cli.core.install import update_check cache_dir = tmp_path / "cache" cache_dir.mkdir() @@ -172,22 +162,23 @@ def test_fresh_cache_is_used(self, tmp_path: pytest.TempPathFactory) -> None: "last_checked": datetime.now(UTC).isoformat(), "latest_cli_version": "99.0.0", "latest_rules_version": "99.0.0", - "latest_recommended_version": "99.0.0", } ) ) with patch.object(update_check, "_get_cache_path", return_value=cache_file): - from reporails_cli.core.update_check import _read_cache + from reporails_cli.core.install.update_check import _read_cache cached = _read_cache() assert cached is not None assert cached["latest_cli_version"] == "99.0.0" - assert cached["latest_recommended_version"] == "99.0.0" + assert cached["latest_rules_version"] == "99.0.0" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_expired_cache_returns_none(self, tmp_path: pytest.TempPathFactory) -> None: - from reporails_cli.core import update_check + from reporails_cli.core.install import update_check cache_dir = tmp_path / "cache" cache_dir.mkdir() @@ -199,32 +190,35 @@ def test_expired_cache_returns_none(self, tmp_path: pytest.TempPathFactory) -> N "last_checked": old_time.isoformat(), "latest_cli_version": "99.0.0", "latest_rules_version": "99.0.0", - "latest_recommended_version": "99.0.0", } ) ) with patch.object(update_check, "_get_cache_path", return_value=cache_file): - from reporails_cli.core.update_check import _read_cache + from reporails_cli.core.install.update_check import _read_cache cached = _read_cache() assert cached is None + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_missing_cache_returns_none(self, tmp_path: pytest.TempPathFactory) -> None: - from reporails_cli.core import update_check + from reporails_cli.core.install import update_check cache_file = tmp_path / "cache" / "update-check.json" with patch.object(update_check, "_get_cache_path", return_value=cache_file): - from reporails_cli.core.update_check import _read_cache + from reporails_cli.core.install.update_check import _read_cache cached = _read_cache() assert cached is None + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_corrupt_cache_returns_none(self, tmp_path: pytest.TempPathFactory) -> None: - from reporails_cli.core import update_check + from reporails_cli.core.install import update_check cache_dir = tmp_path / "cache" cache_dir.mkdir() @@ -232,37 +226,12 @@ def test_corrupt_cache_returns_none(self, tmp_path: pytest.TempPathFactory) -> N cache_file.write_text("not json at all") with patch.object(update_check, "_get_cache_path", return_value=cache_file): - from reporails_cli.core.update_check import _read_cache + from reporails_cli.core.install.update_check import _read_cache cached = _read_cache() assert cached is None - def test_cache_without_recommended_still_works(self, tmp_path: pytest.TempPathFactory) -> None: - """Old cache format without recommended field should still parse.""" - from reporails_cli.core import update_check - - cache_dir = tmp_path / "cache" - cache_dir.mkdir() - cache_file = cache_dir / "update-check.json" - cache_file.write_text( - json.dumps( - { - "last_checked": datetime.now(UTC).isoformat(), - "latest_cli_version": "1.0.0", - "latest_rules_version": "1.0.0", - } - ) - ) - - with patch.object(update_check, "_get_cache_path", return_value=cache_file): - from reporails_cli.core.update_check import _read_cache - - cached = _read_cache() - - assert cached is not None - assert cached.get("latest_recommended_version") is None - # --------------------------------------------------------------------------- # _fetch_latest_cli_version @@ -270,8 +239,10 @@ def test_cache_without_recommended_still_works(self, tmp_path: pytest.TempPathFa class TestFetchLatestCliVersion: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_success(self) -> None: - from reporails_cli.core.update_check import _fetch_latest_cli_version + from reporails_cli.core.install.update_check import _fetch_latest_cli_version mock_resp = MagicMock() mock_resp.json.return_value = {"info": {"version": "1.2.3"}} @@ -282,22 +253,24 @@ def test_success(self) -> None: mock_client.__exit__ = MagicMock(return_value=False) mock_client.get.return_value = mock_resp - with patch("reporails_cli.core.update_check.httpx.Client", return_value=mock_client): + with patch("reporails_cli.core.install.update_check.httpx.Client", return_value=mock_client): result = _fetch_latest_cli_version() assert result == "1.2.3" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_network_error_returns_none(self) -> None: import httpx - from reporails_cli.core.update_check import _fetch_latest_cli_version + from reporails_cli.core.install.update_check import _fetch_latest_cli_version mock_client = MagicMock() mock_client.__enter__ = MagicMock(return_value=mock_client) mock_client.__exit__ = MagicMock(return_value=False) mock_client.get.side_effect = httpx.ConnectError("connection refused") - with patch("reporails_cli.core.update_check.httpx.Client", return_value=mock_client): + with patch("reporails_cli.core.install.update_check.httpx.Client", return_value=mock_client): result = _fetch_latest_cli_version() assert result is None @@ -309,25 +282,22 @@ def test_network_error_returns_none(self) -> None: class TestCheckForUpdates: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_notification_when_cli_outdated(self) -> None: with ( patch( - "reporails_cli.core.update_check._read_cache", + "reporails_cli.core.install.update_check._read_cache", return_value={ "latest_cli_version": "99.0.0", "latest_rules_version": "0.0.1", - "latest_recommended_version": "0.1.0", }, ), patch("reporails_cli.__version__", "0.1.0"), patch( - "reporails_cli.core.bootstrap.get_installed_version", + "reporails_cli.core.platform.config.bootstrap.get_installed_version", return_value="0.0.1", ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.1.0", - ), ): result = check_for_updates() @@ -335,25 +305,22 @@ def test_returns_notification_when_cli_outdated(self) -> None: assert result.has_cli_update is True assert result.cli_latest == "99.0.0" + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_notification_when_rules_outdated(self) -> None: with ( patch( - "reporails_cli.core.update_check._read_cache", + "reporails_cli.core.install.update_check._read_cache", return_value={ "latest_cli_version": "0.1.0", "latest_rules_version": "99.0.0", - "latest_recommended_version": "0.1.0", }, ), patch("reporails_cli.__version__", "0.1.0"), patch( - "reporails_cli.core.bootstrap.get_installed_version", + "reporails_cli.core.platform.config.bootstrap.get_installed_version", return_value="0.0.1", ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.1.0", - ), ): result = check_for_updates() @@ -361,81 +328,46 @@ def test_returns_notification_when_rules_outdated(self) -> None: assert result.has_rules_update is True assert result.rules_latest == "99.0.0" - def test_returns_notification_when_recommended_outdated(self) -> None: - with ( - patch( - "reporails_cli.core.update_check._read_cache", - return_value={ - "latest_cli_version": "0.1.0", - "latest_rules_version": "0.0.1", - "latest_recommended_version": "99.0.0", - }, - ), - patch("reporails_cli.__version__", "0.1.0"), - patch( - "reporails_cli.core.bootstrap.get_installed_version", - return_value="0.0.1", - ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.1.0", - ), - ): - result = check_for_updates() - - assert result is not None - assert result.has_recommended_update is True - assert result.recommended_latest == "99.0.0" - + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_none_when_current(self) -> None: with ( patch( - "reporails_cli.core.update_check._read_cache", + "reporails_cli.core.install.update_check._read_cache", return_value={ "latest_cli_version": "0.1.0", "latest_rules_version": "0.0.1", - "latest_recommended_version": "0.1.0", }, ), patch("reporails_cli.__version__", "0.1.0"), patch( - "reporails_cli.core.bootstrap.get_installed_version", + "reporails_cli.core.platform.config.bootstrap.get_installed_version", return_value="0.0.1", ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.1.0", - ), ): result = check_for_updates() assert result is None + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_fetches_when_cache_expired(self) -> None: with ( - patch("reporails_cli.core.update_check._read_cache", return_value=None), + patch("reporails_cli.core.install.update_check._read_cache", return_value=None), patch( - "reporails_cli.core.update_check._fetch_latest_cli_version", + "reporails_cli.core.install.update_check._fetch_latest_cli_version", return_value="99.0.0", ), patch( - "reporails_cli.core.init.get_latest_version", + "reporails_cli.core.install.updater.get_latest_version", return_value="99.0.0", ), - patch( - "reporails_cli.core.init.get_latest_recommended_version", - return_value="99.0.0", - ), - patch("reporails_cli.core.update_check._write_cache"), + patch("reporails_cli.core.install.update_check._write_cache"), patch("reporails_cli.__version__", "0.1.0"), patch( - "reporails_cli.core.bootstrap.get_installed_version", + "reporails_cli.core.platform.config.bootstrap.get_installed_version", return_value="0.0.1", ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", - return_value="0.1.0", - ), ): result = check_for_updates() @@ -443,33 +375,32 @@ def test_fetches_when_cache_expired(self) -> None: assert result.has_cli_update is True assert result.has_rules_update is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_none_on_exception(self) -> None: with patch( - "reporails_cli.core.update_check._read_cache", + "reporails_cli.core.install.update_check._read_cache", side_effect=OSError("boom"), ): result = check_for_updates() assert result is None + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_returns_none_when_no_installed_rules(self) -> None: """If rules aren't installed yet, no rules update should be reported.""" with ( patch( - "reporails_cli.core.update_check._read_cache", + "reporails_cli.core.install.update_check._read_cache", return_value={ "latest_cli_version": "0.1.0", "latest_rules_version": "99.0.0", - "latest_recommended_version": None, }, ), patch("reporails_cli.__version__", "0.1.0"), patch( - "reporails_cli.core.bootstrap.get_installed_version", - return_value=None, - ), - patch( - "reporails_cli.core.bootstrap.get_installed_recommended_version", + "reporails_cli.core.platform.config.bootstrap.get_installed_version", return_value=None, ), ): @@ -484,17 +415,21 @@ def test_returns_none_when_no_installed_rules(self) -> None: class TestPromptForUpdates: + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_skip_on_flag(self) -> None: mock_console = MagicMock() result = prompt_for_updates(mock_console, no_update_check=True) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_skip_on_config(self) -> None: mock_console = MagicMock() with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=False), ), ): @@ -502,26 +437,30 @@ def test_skip_on_config(self) -> None: result = prompt_for_updates(mock_console) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_skip_on_no_updates(self) -> None: mock_console = MagicMock() with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), - patch("reporails_cli.core.update_check.check_for_updates", return_value=None), + patch("reporails_cli.core.install.update_check.check_for_updates", return_value=None), ): mock_stdout.isatty.return_value = True result = prompt_for_updates(mock_console) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_skip_on_non_tty(self) -> None: mock_console = MagicMock() with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), ): @@ -529,6 +468,8 @@ def test_skip_on_non_tty(self) -> None: result = prompt_for_updates(mock_console) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_user_accepts(self) -> None: mock_console = MagicMock() mock_console.input.return_value = "" # default = yes @@ -541,19 +482,21 @@ def test_user_accepts(self) -> None: mock_update_result = MagicMock(updated=True, previous_version="0.0.1", new_version="0.0.2") with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), - patch("reporails_cli.core.update_check.check_for_updates", return_value=notification), - patch("reporails_cli.core.init.update_rules", return_value=mock_update_result), + patch("reporails_cli.core.install.update_check.check_for_updates", return_value=notification), + patch("reporails_cli.core.install.updater.update_rules", return_value=mock_update_result), ): mock_stdout.isatty.return_value = True result = prompt_for_updates(mock_console) assert result is True + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_user_declines(self) -> None: mock_console = MagicMock() mock_console.input.return_value = "n" @@ -564,18 +507,20 @@ def test_user_declines(self) -> None: ) with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), - patch("reporails_cli.core.update_check.check_for_updates", return_value=notification), + patch("reporails_cli.core.install.update_check.check_for_updates", return_value=notification), ): mock_stdout.isatty.return_value = True result = prompt_for_updates(mock_console) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_eof_treated_as_no(self) -> None: mock_console = MagicMock() mock_console.input.side_effect = EOFError @@ -586,18 +531,20 @@ def test_eof_treated_as_no(self) -> None: ) with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), - patch("reporails_cli.core.update_check.check_for_updates", return_value=notification), + patch("reporails_cli.core.install.update_check.check_for_updates", return_value=notification), ): mock_stdout.isatty.return_value = True result = prompt_for_updates(mock_console) assert result is False + @pytest.mark.unit + @pytest.mark.subsys_cli_ux def test_cli_only_update_no_install_prompt(self) -> None: """CLI-only updates are shown but not auto-installed.""" mock_console = MagicMock() @@ -608,12 +555,12 @@ def test_cli_only_update_no_install_prompt(self) -> None: ) with ( - patch("reporails_cli.core.update_check.sys.stdout") as mock_stdout, + patch("reporails_cli.core.install.update_check.sys.stdout") as mock_stdout, patch( - "reporails_cli.core.bootstrap.get_global_config", + "reporails_cli.core.platform.config.bootstrap.get_global_config", return_value=MagicMock(auto_update_check=True), ), - patch("reporails_cli.core.update_check.check_for_updates", return_value=notification), + patch("reporails_cli.core.install.update_check.check_for_updates", return_value=notification), ): mock_stdout.isatty.return_value = True result = prompt_for_updates(mock_console) diff --git a/uv.lock b/uv.lock index 7e27cd5..b7dbb2c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.12, <3.14" [[package]] name = "annotated-doc" @@ -93,13 +93,6 @@ wheels = [ { 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]] @@ -153,28 +146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { 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]] @@ -215,22 +186,6 @@ wheels = [ { 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" }, ] @@ -319,28 +274,6 @@ wheels = [ { 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]] @@ -388,32 +321,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, - { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, - { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, - { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] @@ -441,21 +348,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, @@ -512,22 +404,6 @@ wheels = [ { 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]] @@ -596,22 +472,6 @@ wheels = [ { 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" }, ] @@ -647,14 +507,6 @@ wheels = [ { 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" }, @@ -798,31 +650,6 @@ wheels = [ { 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" }, @@ -909,32 +736,6 @@ wheels = [ { 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]] @@ -965,28 +766,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, - { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, - { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, - { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, - { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, - { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, ] [[package]] @@ -1040,28 +819,6 @@ wheels = [ { 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]] @@ -1102,20 +859,6 @@ wheels = [ { 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]] @@ -1194,24 +937,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] @@ -1244,22 +969,6 @@ wheels = [ { 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]] @@ -1286,12 +995,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] @@ -1351,27 +1054,6 @@ wheels = [ { 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]] @@ -1398,13 +1080,6 @@ wheels = [ { 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]] @@ -1476,31 +1151,6 @@ wheels = [ { 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]] @@ -1560,22 +1210,6 @@ wheels = [ { 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]] @@ -1654,34 +1288,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, @@ -1823,9 +1429,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] @@ -1854,24 +1457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1942,43 +1527,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] [[package]] name = "reporails-cli" -version = "0.5.8" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -2150,35 +1703,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -2237,18 +1761,6 @@ wheels = [ { 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]] @@ -2290,26 +1802,6 @@ wheels = [ { 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]] @@ -2402,14 +1894,6 @@ wheels = [ { 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]] @@ -2453,20 +1937,6 @@ wheels = [ { 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]] @@ -2543,14 +2013,6 @@ wheels = [ { 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]] @@ -2760,27 +2222,5 @@ wheels = [ { 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" }, ]