diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2a551ae1..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 00000000..a7872cd7 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,69 @@ +name: PR Checks + +# Pre-merge tier (v0.7.14, ADR-027 rule 1): pull requests run the static +# quality gates plus a small smoke battery — core, golden, and dogfood on one +# Python version. The full battery × version grid stays merge-gated on main +# (ci.yml); this tier exists to catch lint/type breakage, output-contract +# drift, and corpus damage before merge, in about two minutes. +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: ruff check + run: python -m ruff check src/ tests/ + + - name: ruff format --check + run: python -m ruff format --check src/ tests/ + + - name: mypy + run: python -m mypy src/ + + smoke: + name: smoke (core + golden + dogfood, py3.11) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Run smoke battery + run: > + python -m pytest -q + tests/test_validate.py tests/test_parser.py tests/test_schema.py + tests/test_identity.py tests/test_frontmatter.py + tests/test_metadata_identity.py tests/test_idgen.py + tests/test_ci_batteries.py tests/test_corpus.py + tests/test_golden.py tests/test_dogfood.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a69d9b6b..531720f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,34 @@ permissions: contents: read jobs: + # Static quality gates (v0.7.14): lint, format, and types fail the suite + # just like a battery. One Python version is enough — the rules target 3.11. + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: ruff check + run: python -m ruff check src/ tests/ + + - name: ruff format --check + run: python -m ruff format --check src/ tests/ + + - name: mypy + run: python -m mypy src/ + pytest: name: ${{ matrix.battery.name }} (py${{ matrix.python-version }}) runs-on: ubuntu-latest @@ -24,14 +52,17 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] # One battery per .py service, plus grouped core / cli / artifacts. - # Every tests/test_*.py belongs to exactly one battery (no orphans). + # Every tests/test_*.py belongs to exactly one battery (no orphans) — + # enforced by tests/test_ci_batteries.py in the core battery. battery: - name: core - paths: "tests/test_validate.py tests/test_parser.py tests/test_schema.py tests/test_identity.py" + paths: "tests/test_validate.py tests/test_parser.py tests/test_schema.py tests/test_identity.py tests/test_frontmatter.py tests/test_metadata_identity.py tests/test_idgen.py tests/test_ci_batteries.py tests/test_corpus.py" - name: cli paths: "tests/test_cli.py" - name: artifacts paths: "tests/test_design.py tests/test_roadmap.py tests/test_prompt.py tests/test_decision_metadata.py" + - name: create + paths: "tests/test_create.py tests/test_templates.py" - name: diff paths: "tests/test_diff.py" - name: improve @@ -40,12 +71,18 @@ jobs: paths: "tests/test_index.py" - name: ingest paths: "tests/test_ingest.py" + - name: init + paths: "tests/test_init.py" - name: inspect paths: "tests/test_inspect.py" + - name: migrate + paths: "tests/test_migrate.py" - name: portfolio paths: "tests/test_portfolio.py" - name: relationships paths: "tests/test_relationships.py tests/test_relationships_cmd.py tests/test_relationship_validation.py" + - name: resolve + paths: "tests/test_resolve.py" - name: review paths: "tests/test_review.py" - name: stats @@ -76,3 +113,26 @@ jobs: - name: Run ${{ matrix.battery.name }} battery run: python -m pytest -q ${{ matrix.battery.paths }} + + # Coverage visibility (v0.7.14): one full-suite run on py3.11 with a + # term-missing report in the job log. Report-only — no threshold gate yet + # (measure first, gate later; see the v0.7.14 roadmap Non-Goals). + coverage: + name: coverage (py3.11, report-only) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Run full suite with coverage + run: python -m pytest -q --cov=src/rac --cov-report=term-missing diff --git a/.gitignore b/.gitignore index aabdf028..be02738a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ __pycache__/ build/ dist/ .pytest_cache/ + +# Coverage data (pytest --cov; reported in CI, never committed) +.coverage +.coverage.* +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index decc48cf..4a52f9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ details, release history over commit history. ### Added +- CI battery integrity (v0.7.14): eight test files (~1,300 lines, including + all coverage for `rac new` and `rac migrate`) were missing from the CI + battery matrix and never ran; they are restored, and a new guard test + fails the suite if any test file is ever orphaned again. +- Static quality gates (v0.7.14): ruff (lint + format) and mypy now gate CI; + pull requests run the gates plus a fast smoke battery (ADR-027 amended), + while the full battery grid stays merge-gated on `main`. CLI output is + unchanged — all golden files are byte-identical. +- Test coverage is reported on every CI run (report-only, currently 97%) + (v0.7.14). + +### Changed + +- Repository corpus traversal is defined once in core (`walk_corpus`) and + consumed by every repository command — behavior and output unchanged + (v0.7.14). + - `rac migrate metadata ` — migrate existing recognized artifacts onto canonical frontmatter identity: idempotent, byte-preserving, with `--dry-run` preview; unrecognized documents are reported, never guessed at diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..53489d47 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# RAC — agent session context + +This file is a router. Canonical agent guidance lives in `rac/prompts/`, +where the RAC corpus gates validate it. Do not add rules here — add them +to the corpus artifact and they load through the imports below. + +## Loaded every session + +@rac/prompts/rac-agent-session-start.md +@rac/prompts/rac-agent-commit-guidelines.md + +## Situational prompts — read when the task calls for it, do not import + +- Pull request preparation: `rac/prompts/rac-agent-pr-guidelines.md` +- Minor release gate: `rac/prompts/rac-agent-release-gate-minor.md` +- Major release gate: `rac/prompts/rac-agent-release-gate-major.md` +- Refactoring and simplification: `rac/prompts/rac-agent-simplification-guidelines.md` +- Context compression: `rac/prompts/rac-agent-compression.md` + +## Working corpus + +- Current series: `rac/roadmaps/v0.7.x-trust/` (next up: v0.7.14) +- Next series under scoping: `rac/roadmaps/v0.8.*.md` +- Decisions (ADRs): `rac/decisions/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38478750..4b57e981 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,11 +17,15 @@ pip install -e '.[dev]' ## Verify a change -Three commands; all must pass before you open a pull request: +These commands must all pass before you open a pull request: ```bash pytest +ruff check src/ tests/ +ruff format --check src/ tests/ +mypy src/ + rac validate rac/ rac relationships rac/ --validate diff --git a/docs/testing.md b/docs/testing.md index 34bff03c..8459906e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -41,6 +41,28 @@ Tests are organized per capability — `test_validate.py`, `test_inspect.py`, including negative cases (an invalid file, or a neighboring artifact type that must *not* classify as the new one). +Every `tests/test_*.py` must belong to exactly one CI battery in +`.github/workflows/tests.yml` — `tests/test_ci_batteries.py` fails the suite if a +file is orphaned, duplicated, or stale, so add new test files to the matrix as you +create them. + +## Lint, format, and types + +CI gates on ruff and mypy (configured in `pyproject.toml`); run them locally +before pushing: + +```bash +.venv/bin/python -m ruff check src/ tests/ +.venv/bin/python -m ruff format --check src/ tests/ # or without --check to apply +.venv/bin/python -m mypy src/ +``` + +Coverage is reported (not gated) in CI; the same view locally: + +```bash +.venv/bin/python -m pytest -q --cov=src/rac --cov-report=term-missing +``` + ## Source layout The package uses a `src/` layout. The import package `rac` is organized into layers @@ -57,10 +79,13 @@ src/rac/ ## Verify before a pull request -1. **Run the suite** — it must pass: +1. **Run the suite and the static gates** — all must pass: ```bash .venv/bin/python -m pytest + .venv/bin/python -m ruff check src/ tests/ + .venv/bin/python -m ruff format --check src/ tests/ + .venv/bin/python -m mypy src/ ``` 2. **Review your artifact changes** with RAC's own tooling: diff --git a/pyproject.toml b/pyproject.toml index f2bd7b9b..f0e5b7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,10 @@ ingest-all = ["markitdown[docx,pdf,pptx,xlsx,xls]"] # every supported format # dev also pulls the libraries used to *generate* fixture files in the tests. dev = [ "pytest>=7.0", + "pytest-cov", + "ruff", + "mypy", + "types-PyYAML", "markitdown[docx,pdf,pptx,xlsx,xls]", "python-docx", "python-pptx", @@ -74,3 +78,25 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +# Quality gates pinned by v0.7.14 (Initiative 2). Golden tests pin CLI output +# byte-for-byte, so lint/format fixes must never change string contents. +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] + +[tool.mypy] +python_version = "3.11" +files = ["src"] +disallow_untyped_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +# MarkItDown ships no type stubs; it is imported lazily inside rac.services.ingest. +module = "markitdown.*" +ignore_missing_imports = true diff --git a/rac/decisions/adr-027-ci-test-topology.md b/rac/decisions/adr-027-ci-test-topology.md index 105d8f13..674b5039 100644 --- a/rac/decisions/adr-027-ci-test-topology.md +++ b/rac/decisions/adr-027-ci-test-topology.md @@ -48,16 +48,24 @@ deliberate. RAC's CI test topology is governed by three rules. -### 1. Tests run on merge to `main`, not on pull requests +### 1. The full suite runs on merge to `main`; pull requests get a light pre-merge tier -`ci.yml` triggers on `push:` to `main` (a merged PR or a direct push) and on -`workflow_dispatch` (manual). It does **not** trigger on `pull_request`. +*Amended by v0.7.14. As originally accepted, pull requests received no automated +feedback at all; the full battery grid ran only post-merge.* -The explicit, accepted consequence: **a pull request receives no automated test -feedback before it merges.** A regression is caught by the post-merge run on `main` -and then blocks the next release through the gate (rule 2), rather than being caught -on the PR itself. `workflow_dispatch` is the escape hatch — the full battery grid can -be run against any branch on demand from the Actions tab. +`ci.yml` triggers on `push:` to `main` (a merged PR or a direct push) and on +`workflow_dispatch` (manual). It does **not** trigger on `pull_request` — the full +battery × version grid remains merge-gated. + +Pull requests run a deliberately small pre-merge tier instead +(`.github/workflows/pr-checks.yml`, v0.7.14): the static quality gates (`ruff +check`, `ruff format --check`, `mypy src/`) plus a smoke battery (core, golden, +dogfood on Python 3.11). This catches lint/type breakage, output-contract drift, +and corpus damage before merge for roughly two minutes of Actions time, while the +exhaustive grid still runs on `main`. A regression the smoke tier misses is caught +by the post-merge run and then blocks the next release through the gate (rule 2). +`workflow_dispatch` remains the escape hatch — the full grid can be run against any +branch on demand from the Actions tab. Runs on `main` use `concurrency` with `cancel-in-progress: false`, so every merge is fully tested and a later merge does not cancel an in-flight run. @@ -118,13 +126,16 @@ changes it on purpose, with this context in view, rather than by accident. ### Negative -- Pull requests get no pre-merge test signal by default; regressions surface - post-merge on `main`. Mitigated by `workflow_dispatch` and the release gate. +- Pull requests get only the smoke tier, not the full grid (v0.7.14; originally + no signal at all); a version-specific or service-specific regression outside the + smoke set still surfaces post-merge on `main`. Mitigated by `workflow_dispatch` + and the release gate. - More jobs per run (batteries × versions ≈ 33). They are short and run in parallel, but the checks list is longer. - The battery list must be kept in sync: a new `tests/test_*.py` that is not added to a - battery will not run in CI. Guarded by a coverage check at change time; a CI - self-check could enforce it later. + battery will not run in CI. Enforced since v0.7.14 by `tests/test_ci_batteries.py` + (in the core battery), which fails on orphaned, duplicated, or stale entries — + this gap went unnoticed for eight test files before the check existed. ## Alternatives Considered @@ -142,8 +153,9 @@ Keep the `pull_request` trigger so PRs are checked before they merge. - Doubles runs (push + PR) and spends Actions minutes on branches that may be rebased or abandoned. -Deferred, not rejected — to be reconsidered if outside contributors need pre-merge -gating (see Review Date). +Partially adopted by v0.7.14: pull requests run lint plus a smoke battery (rule 1), +not the full grid. Full pre-merge gating remains deferred — to be reconsidered if +outside contributors need it (see Review Date). ### Single job parameterized only by Python version (the prior shape) diff --git a/rac/prompts/rac-agent-commit-guidelines.md b/rac/prompts/rac-agent-commit-guidelines.md index 2fadcbd0..51a8064c 100644 --- a/rac/prompts/rac-agent-commit-guidelines.md +++ b/rac/prompts/rac-agent-commit-guidelines.md @@ -241,15 +241,50 @@ Do: - follow the same commit standard - describe the actual product change - preserve human-readable history +- include the artifact path in the body of roadmap commits + (`Implements rac/roadmaps/...`) Do not add: -- generated-by footers -- AI assistant attribution -- tool-specific signatures +- generated-by footers or "Generated with ..." lines +- AI assistant attribution, including `Co-Authored-By:` trailers that + name a tool +- tool-specific signatures or session links + (for example `https://claude.ai/code/...` URLs) + +Agent harnesses commonly append these by default. Strip them before +committing — the standard overrides any harness default. The commit belongs to the project history, not the tool used to create it. +### Commit Identity + +Author and committer must both be the maintainer identity used on `main`, +never a tool identity. + +Bad: + +```text +Author: Claude +``` + +Good: + +```text +Author: Tom Ballard +``` + +Agents must set both fields before committing (`--author` plus +`GIT_COMMITTER_NAME` / `GIT_COMMITTER_EMAIL`, or repository `git config`) +and verify before pushing: + +```bash +git log -1 --format='%an <%ae> / %cn <%ce>' +``` + +If either field is wrong, amend and re-push before the work is considered +delivered. + ## Success Criteria A healthy RAC commit history should read like: diff --git a/rac/prompts/rac-agent-instructions.md b/rac/prompts/rac-agent-instructions.md deleted file mode 100644 index adc5c6dc..00000000 --- a/rac/prompts/rac-agent-instructions.md +++ /dev/null @@ -1,24 +0,0 @@ -# RAC Agent Instructions - -Before coding: -- Refresh from origin/main unless told otherwise. -- Read the target roadmap file and relevant ADRs. -- Produce a plan before implementation. -- Do not expand release scope beyond the roadmap. - -Release discipline: -- Work on a feature branch, not main. -- Do not include Claude attribution in commits. -- After GitHub merge, refresh local main. -- Prune merged branches when asked. - -Architecture: -- Prefer schema-driven artifact behavior. -- Do not add artifact-specific validation paths if a generic path can handle it. -- Keep classification separate from validation. -- Invalid but recognizable artifacts may still classify as their artifact type. - -Testing: -- Add negative boundary tests for each new artifact type. -- Test that adjacent artifact types do not misclassify as each other. -- Run pytest before commit. \ No newline at end of file diff --git a/rac/prompts/rac-agent-pr-guidelines.md b/rac/prompts/rac-agent-pr-guidelines.md index 335b0aa7..1a480630 100644 --- a/rac/prompts/rac-agent-pr-guidelines.md +++ b/rac/prompts/rac-agent-pr-guidelines.md @@ -189,6 +189,8 @@ Implemented with AI assistance under the roadmap contract. Final scope, review, and acceptance decisions were made by the maintainer. +This section is the only sanctioned AI disclosure in a pull request. + ## Constraints * Do not create generic release notes. @@ -200,6 +202,15 @@ Final scope, review, and acceptance decisions were made by the maintainer. * Keep implementation rationale in the PR, not individual commits. * Keep commit messages concise and separate from PR documentation. * Prioritize future maintainability over marketing language. +* Do not include tool attribution: no "Generated by ..." footers, no + AI-tool signatures, no session links. Agent platforms commonly append + these to the PR body on creation or update — after every create or + edit, re-read the stored body and strip anything appended below your + final section. The same applies to angle-bracket placeholders: GitHub's + sanitizer silently deletes them, so verify the stored body matches what + you submitted. The Implementation Process section above is the only + sanctioned AI disclosure (mirrors the commit identity and footer rules + in `rac-agent-commit-guidelines.md`). ## Related Requirements diff --git a/rac/prompts/rac-agent-session-start.md b/rac/prompts/rac-agent-session-start.md index 05bbd2ca..16df08e8 100644 --- a/rac/prompts/rac-agent-session-start.md +++ b/rac/prompts/rac-agent-session-start.md @@ -10,14 +10,28 @@ Core principles: - CLI contracts matter: human output, JSON output, exit codes, and templates must be specified. - Version numbers are scope fences. - Prefer schema/artifact-spec-driven behavior over artifact-specific branches. +- Keep classification separate from validation. - Invalid but recognizable artifacts may still classify as their artifact type, then fail validation. Before coding: -1. Refresh from `origin/main`. -2. Confirm branch state. -3. Read the relevant roadmap item. +1. Refresh from `origin/main` unless told otherwise. +2. Confirm branch state; work on a feature branch, never on main. +3. Read the relevant roadmap item; do not expand release scope beyond it. 4. Check against ADRs. 5. Produce an implementation contract. 6. Wait for approval. -Do not implement until I approve the plan. \ No newline at end of file +Do not implement until I approve the plan. + +Testing: +- Add negative boundary tests for each new artifact type. +- Test that adjacent artifact types do not misclassify as each other. +- Run pytest before commit. + +Before pushing: +- `rac validate rac/` and `rac relationships rac/ --validate` exit 0. +- `rac review rac/` reports no priority 1-2 findings. +- Commits follow `rac/prompts/rac-agent-commit-guidelines.md`: format, + maintainer identity on author and committer, no tool attribution. + +After a GitHub merge, refresh local main. Prune merged branches when asked. diff --git a/rac/roadmaps/v0.7.x-trust/v0.7.14-audit-hardening.md b/rac/roadmaps/v0.7.x-trust/v0.7.14-audit-hardening.md new file mode 100644 index 00000000..72da94b8 --- /dev/null +++ b/rac/roadmaps/v0.7.x-trust/v0.7.14-audit-hardening.md @@ -0,0 +1,187 @@ +--- +schema_version: 1 +id: RAC-KTQAAPF5ZDZX +type: roadmap +--- +# RAC v0.7.14 — Audit Hardening + +## Status + +Planned + +## Context + +A full technical audit of the repository (2026-06-09) preceded v0.8.x scoping. +The product code is healthy: clean CLI → services → core layering with no +upward or circular imports, a deterministic core, full type-hint coverage, +zero security findings, and accurate documentation. Every gap found lives in +the trust infrastructure rather than the product: + +- Eight of thirty test files (~1,306 lines, 21.6% of the suite) are missing + from the CI battery matrix in `.github/workflows/tests.yml` and never run + in CI: `test_create.py`, `test_resolve.py`, `test_migrate.py`, + `test_init.py`, `test_frontmatter.py`, `test_metadata_identity.py`, + `test_templates.py`, `test_idgen.py`. This contradicts the matrix's own + "no orphans" comment and leaves the only two file-mutating commands + (`rac new`, `rac migrate`) without any CI coverage. +- No lint, format, or type-check gate exists anywhere, although the codebase + is fully type-hinted and would pass such gates today with minimal change. +- The walk → parse → classify loop is duplicated near-identically in six + services (validate, portfolio, inspect, stats, relationships, index, plus + migrate). v0.8.0's service API and the Explorer would multiply its + consumers. +- A `.DS_Store` file is committed despite being ignored, and no coverage + signal exists to catch gaps like the orphaned batteries. + +v0.8.x builds an interactive Explorer on top of the service layer. These gaps +close first: gates that do not run cannot gate, and the corpus traversal seam +should exist once — in core — before the Explorer consumes it. + +## Outcomes + +- Every `tests/test_*.py` provably runs in CI, enforced by a meta-test + rather than a comment. +- CI fails on lint, format, and type errors. +- Repository corpus traversal exists exactly once, in core, and every + repository command consumes it. +- Test coverage is visible on every CI run. +- The repository contains no committed junk files. +- ADR-027's trigger policy is re-priced now that the full suite actually + runs post-merge, and reaffirmed or amended deliberately. + +## Initiatives + +### Initiative 1 — CI Battery Integrity + +Restore the eight orphaned test files to the battery matrix, one battery per +service (ADR-027): + +- `create`: `tests/test_create.py tests/test_templates.py` +- `init`: `tests/test_init.py` +- `resolve`: `tests/test_resolve.py` +- `migrate`: `tests/test_migrate.py` +- `core` (extended): `tests/test_frontmatter.py`, + `tests/test_metadata_identity.py`, `tests/test_idgen.py` + +Add `tests/test_ci_batteries.py`: parse `tests.yml` with `yaml.safe_load` +(PyYAML is already a core dependency), collect every battery path, glob +`tests/test_*.py`, and assert set equality. The meta-test joins the `core` +battery, so the guard guards itself. Any rot discovered in the eight +unexercised files is repaired inside this initiative. + +### Initiative 2 — Static Quality Gates + +Adopt ruff (lint plus format) and mypy: + +- `[tool.ruff]` and `[tool.mypy]` configured in `pyproject.toml`, + targeting Python 3.11. +- A lint job added to `tests.yml` (single Python version) that gates CI + alongside the batteries: `ruff check`, `ruff format --check`, `mypy src/`. +- Golden tests pin output as contract: this release regenerates no golden + file. Formatting that would rewrap user-visible strings is excluded + rather than accepted. + +### Initiative 3 — Canonical Corpus Traversal + +Extract the walk → parse → classify loop into core, where deterministic +analysis lives (ADR-015). Representative interface: + + walk_corpus(directory, recursive=True) -> Iterator[CorpusEntry] + +where `CorpusEntry` carries the path, parsed `Product`, and classified +artifact type. The helper composes only existing core functions +(`find_markdown_files`, `parse_file`, `classify`) and iterates lazily so +memory behavior is unchanged. + +Migrate the six duplicated service loops one service per commit, running +that service's battery plus the golden battery after each. This is the +traversal seam that v0.8.0's service API stabilizes. + +### Initiative 4 — Hygiene and Visibility + +- Remove the committed `.DS_Store` (`git rm --cached`); `.gitignore` + already prevents recurrence. +- Add `pytest-cov` to the dev extras and report coverage in CI. No + threshold gate this release: measure first, gate later. +- Amend or reaffirm ADR-027 with the battery gap repaired: decide whether + lint plus a smoke battery should also run on pull requests, or whether + main-only remains the priced trade-off. + +## Implementation Contract + +The following decisions are pinned for v0.7.14. + +### Battery assignments + +Exactly the assignments listed in Initiative 1; every current and future +`tests/test_*.py` must belong to exactly one battery, enforced by +`tests/test_ci_batteries.py`. + +### Tooling baselines + +- ruff rule families to start: `E, F, W, I, UP, B`; violations fixed + mechanically, suppressions used only where a fix would change behavior. +- mypy: `disallow_untyped_defs` for `src/`; tests exempt this release. + +### Output stability + +All 24 golden files remain byte-identical through every initiative. A +golden diff in any commit of this release is a defect, not an update. + +## Success Measures + +- Removing any battery entry from `tests.yml` fails the test suite via the + meta-test. +- All thirty test files appear in CI logs across Python 3.11–3.13. +- `ruff check`, `ruff format --check`, and `mypy src/` pass locally and + gate CI. +- `git ls-files` reports no `.DS_Store`. +- The parse-and-classify loop has one definition; six services import it. +- Golden files are byte-identical to their state before this release. + +## Assumptions + +- The eight orphaned test files largely still pass; any rot is small enough + to repair within Initiative 1. +- ruff and mypy findings are mechanical and do not require behavioral + rewrites. +- v0.8.x scoping can treat `walk_corpus` as the stable traversal seam. + +## Risks + +- Tests unexercised in CI may have rotted unnoticed: run all eight locally + before touching the matrix; repairs are in scope. +- `ruff format` may rewrap human-output template strings in + `output/human.py`: goldens gate this; prefer targeted excludes over + regenerating goldens. +- Traversal consolidation touches six services: one service per commit, + each verified by its battery plus goldens, bounds the blast radius. + +## Non-Goals + +- Splitting `services/relationships.py` (552 lines, three concerns) — + deferred to v0.8.0 service-API shaping. +- Caching the repository index for repeated resolve/find calls — designed + into the Explorer's long-lived runtime (v0.8.2), not retrofitted here. +- Shipping a `py.typed` marker — decided alongside v0.8.0's public service + API surface. +- Coverage threshold gates — visibility first. +- Restructuring `cli.py` (925 lines of linear parser registration: large, + not complex). + +## Related Decisions + +- ADR-027 +- ADR-015 +- ADR-007 + +## Dependencies + +- v0.7.13 Metadata Migration (merged) + +## Follow-On Work + +- v0.8.0 service API: relationships package split, `py.typed`, public + surface definition. +- v0.8.2 interactive runtime: a held repository index with invalidation, + replacing per-call `build_repository_index()` in resolve and find. diff --git a/src/rac/cli.py b/src/rac/cli.py index 3e696df4..9d384ac7 100644 --- a/src/rac/cli.py +++ b/src/rac/cli.py @@ -43,17 +43,17 @@ import sys from pathlib import Path -from . import __version__ from rac import output as outputs from rac.core.classification import score_artifacts from rac.core.markdown import parse, parse_file +from rac.core.models import Product from rac.core.schema import available_schemas, schema_reference -from rac.core.validation import has_errors, validate from rac.core.templates import ( TemplateNotFound, TemplateResourceMissing, available_templates, ) +from rac.core.validation import has_errors, validate from rac.services.create import ( IdGenerationExhausted, MissingRepositoryConfig, @@ -62,6 +62,9 @@ create_artifact, ) from rac.services.diff import diff as diff_asts +from rac.services.improve import improve_product +from rac.services.index import build_repository_index +from rac.services.ingest import ConversionError, UnsupportedDocument, ingest from rac.services.init import ( DEFAULT_KEY, InvalidRepositoryKey, @@ -69,46 +72,45 @@ RepositoryKeyConflict, init_repository, ) -from rac.services.improve import improve_product -from rac.services.index import build_repository_index -from rac.services.ingest import ConversionError, UnsupportedDocument, ingest from rac.services.inspect import build_inspection, inspect_directory -from rac.services.portfolio import build_portfolio_summary -from rac.services.review import build_review from rac.services.migrate import migrate_metadata -from rac.services.resolve import ( - OUTCOME_DUPLICATE, - OUTCOME_RESOLVED, - find_artifacts, - resolve_artifact, -) +from rac.services.portfolio import build_portfolio_summary from rac.services.relationships import ( build_relationship_report, build_relationship_report_file, validate_relationships, validate_relationships_file, ) +from rac.services.resolve import ( + OUTCOME_DUPLICATE, + OUTCOME_RESOLVED, + find_artifacts, + resolve_artifact, +) +from rac.services.review import build_review from rac.services.stats import collect_stats from rac.services.validate import validate_directory +from . import __version__ + EXIT_OK = 0 EXIT_VALIDATION_FAILED = 1 EXIT_USAGE = 2 -def _read(path: str): +def _read(path: str) -> Product: """Parse a file, or print an error and exit with EXIT_USAGE.""" try: return parse_file(path) except FileNotFoundError: print(f"rac: file not found: {path}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except OSError as exc: print(f"rac: cannot read {path}: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None -def _read_validate_input(target: str): +def _read_validate_input(target: str) -> Product: """Parse validation input from a Markdown file or stdin.""" if target == "-": return parse(sys.stdin.read(), source_path="-") @@ -180,7 +182,7 @@ def cmd_ingest(args: argparse.Namespace) -> int: result = ingest(args.file) except UnsupportedDocument as exc: # unhandled type / missing extra print(f"rac: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except ConversionError as exc: # recognized file, failed to convert print(f"rac: {exc}", file=sys.stderr) return EXIT_VALIDATION_FAILED @@ -220,8 +222,7 @@ def _read_markdown_input(target: str, command: str) -> str: raise SystemExit(EXIT_USAGE) if path.suffix.lower() not in (".md", ".markdown"): print( - f"rac: {command} expects a Markdown file; " - f"convert it first with: rac ingest {target}", + f"rac: {command} expects a Markdown file; convert it first with: rac ingest {target}", file=sys.stderr, ) raise SystemExit(EXIT_USAGE) @@ -229,7 +230,7 @@ def _read_markdown_input(target: str, command: str) -> str: return path.read_text(encoding="utf-8") except OSError as exc: print(f"rac: cannot read {target}: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None def cmd_inspect(args: argparse.Namespace) -> int: @@ -246,13 +247,13 @@ def cmd_inspect(args: argparse.Namespace) -> int: # Single file (or stdin). text = _read_markdown_input(args.file, "inspect") product = parse(text) - result = build_inspection(product) + inspection = build_inspection(product) if args.verbose and not args.json: - print(outputs.render_inspect_verbose(result, score_artifacts(product))) + print(outputs.render_inspect_verbose(inspection, score_artifacts(product))) elif args.json: - print(outputs.render_inspect_json(result)) + print(outputs.render_inspect_json(inspection)) else: - print(outputs.render_inspect_human(result)) + print(outputs.render_inspect_human(inspection)) # A completed inspection always succeeds — Unknown is a valid outcome. return EXIT_OK @@ -336,13 +337,13 @@ def cmd_relationships(args: argparse.Namespace) -> int: return EXIT_OK if report.ok else EXIT_VALIDATION_FAILED if is_dir: - report = build_relationship_report(args.path, recursive=not args.top_level) + rel_report = build_relationship_report(args.path, recursive=not args.top_level) else: - report = build_relationship_report_file(args.path) + rel_report = build_relationship_report_file(args.path) if args.json: - print(outputs.render_relationships_json(report)) + print(outputs.render_relationships_json(rel_report)) else: - print(outputs.render_relationships_human(report)) + print(outputs.render_relationships_human(rel_report)) # A completed inspection always succeeds — finding no relationships is a valid # outcome, not an error (REQ-010). return EXIT_OK @@ -393,14 +394,14 @@ def cmd_new(args: argparse.Namespace) -> int: created = create_artifact(args.type, args.output_path) except TemplateNotFound as exc: # unsupported type → usage error print(f"rac: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except ( OutputPathExists, OutputDirectoryMissing, MissingRepositoryConfig, ) as exc: print(f"rac: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except ( TemplateResourceMissing, # broken installation MalformedRepositoryConfig, # unreadable .rac/config.yaml @@ -419,9 +420,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: if not Path(args.directory).is_dir(): print(f"rac: not a directory: {args.directory}", file=sys.stderr) raise SystemExit(EXIT_USAGE) - result = resolve_artifact( - args.directory, args.id, recursive=not args.top_level - ) + result = resolve_artifact(args.directory, args.id, recursive=not args.top_level) if args.json: print(outputs.render_resolve_json(result)) else: @@ -470,7 +469,7 @@ def cmd_migrate(args: argparse.Namespace) -> int: ) except MissingRepositoryConfig as exc: print(f"rac: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except (MalformedRepositoryConfig, IdGenerationExhausted) as exc: print(f"rac: {exc}", file=sys.stderr) return EXIT_VALIDATION_FAILED @@ -491,7 +490,7 @@ def cmd_init(args: argparse.Namespace) -> int: result = init_repository(args.directory, key=args.key) except InvalidRepositoryKey as exc: print(f"rac: {exc}", file=sys.stderr) - raise SystemExit(EXIT_USAGE) + raise SystemExit(EXIT_USAGE) from None except (RepositoryKeyConflict, MalformedRepositoryConfig) as exc: print(f"rac: {exc}", file=sys.stderr) return EXIT_VALIDATION_FAILED @@ -517,9 +516,7 @@ def build_parser() -> argparse.ArgumentParser: # Shared parent so `--version` works on the root parser *and* every # subcommand (e.g. `rac ingest foo.docx --version`). version_parent = argparse.ArgumentParser(add_help=False) - version_parent.add_argument( - "--version", action="version", version=version_str - ) + version_parent.add_argument("--version", action="version", version=version_str) parser = argparse.ArgumentParser( prog="rac", @@ -582,9 +579,7 @@ def build_parser() -> argparse.ArgumentParser: ) p_ingest.add_argument("file", help="Path to the source document.") ingest_dest = p_ingest.add_mutually_exclusive_group() - ingest_dest.add_argument( - "-o", "--output", help="Write Markdown here instead of printing it." - ) + ingest_dest.add_argument("-o", "--output", help="Write Markdown here instead of printing it.") ingest_dest.add_argument( "--stdout", action="store_true", @@ -678,9 +673,7 @@ def build_parser() -> argparse.ArgumentParser: help="Inspect explicit relationships across a directory (or single file).", parents=[version_parent], ) - p_relationships.add_argument( - "path", help="A directory to scan, or a single Markdown file." - ) + p_relationships.add_argument("path", help="A directory to scan, or a single Markdown file.") p_relationships.add_argument( "--json", action="store_true", help="Emit JSON instead of human-readable text." ) diff --git a/src/rac/core/artifacts.py b/src/rac/core/artifacts.py index 4e6ff82c..72621ed0 100644 --- a/src/rac/core/artifacts.py +++ b/src/rac/core/artifacts.py @@ -190,12 +190,8 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "What becomes easier or harder as a result?", "What trade-offs are you accepting?", ), - "status": ( - "Is this Proposed, Accepted, Superseded, or Deprecated?", - ), - "category": ( - "Which area: Architecture, Product, Process, Technical, or Other?", - ), + "status": ("Is this Proposed, Accepted, Superseded, or Deprecated?",), + "category": ("Which area: Architecture, Product, Process, Technical, or Other?",), "alternatives considered": ( "What other options were weighed?", "Why were they not chosen?", @@ -247,12 +243,8 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "How will the team know the roadmap is succeeding?", "What observable signals would show progress?", ), - "assumptions": ( - "What must be true for this roadmap to remain valid?", - ), - "risks": ( - "What could prevent these outcomes from being achieved?", - ), + "assumptions": ("What must be true for this roadmap to remain valid?",), + "risks": ("What could prevent these outcomes from being achieved?",), }, # Artifact-scoped: this only normalizes "success metrics" when scoring a # document against the Roadmap spec (see rac.core.classification._mapped), so it @@ -312,9 +304,7 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "What should the model avoid?", "Are there tone, format, safety, or scope constraints?", ), - "examples": ( - "What examples would make the desired behavior clearer?", - ), + "examples": ("What examples would make the desired behavior clearer?",), "evaluation": ( "What makes a good response?", "How can the user tell whether the prompt worked?", @@ -354,7 +344,8 @@ def _relationship_descriptions(*sections: str) -> dict[str, str]: "context": "The product area, situation, or experience this design addresses", "user need": "The user, audience, task, pain point, or goal this design supports", "design": "The proposed experience, interaction, layout, flow, or behavior", - "constraints": "Technical, product, accessibility, platform, or implementation constraints", + "constraints": "Technical, product, accessibility, platform, or implementation" + " constraints", "rationale": "Why this design approach was chosen", "alternatives": "Other approaches considered and why they were not chosen", "accessibility": "Accessibility needs and expectations for the design", diff --git a/src/rac/core/classification.py b/src/rac/core/classification.py index 8c7174b0..0aa5f6c5 100644 --- a/src/rac/core/classification.py +++ b/src/rac/core/classification.py @@ -15,7 +15,7 @@ from dataclasses import dataclass -from .artifacts import ARTIFACT_SPECS +from .artifacts import ARTIFACT_SPECS, ArtifactSpec from .models import Product # Below this best-fit score, the document is reported as Unknown rather than @@ -47,7 +47,7 @@ class Classification: missing_sections: list[str] -def _mapped(product: Product, spec) -> set[str]: +def _mapped(product: Product, spec: ArtifactSpec) -> set[str]: """The document's ``##`` headings, with this spec's synonyms applied. The single source of synonym-aware section matching, shared by scoring @@ -56,7 +56,7 @@ def _mapped(product: Product, spec) -> set[str]: return {spec.synonyms.get(h, h) for h in product.sections} -def missing_sections(product: Product, spec) -> tuple[list[str], list[str]]: +def missing_sections(product: Product, spec: ArtifactSpec) -> tuple[list[str], list[str]]: """Return ``(missing_required, missing_recommended)`` for ``spec``. Synonym-aware and in schema declaration order. Independent of confidence diff --git a/src/rac/core/corpus.py b/src/rac/core/corpus.py new file mode 100644 index 00000000..1881d17c --- /dev/null +++ b/src/rac/core/corpus.py @@ -0,0 +1,50 @@ +"""Canonical corpus traversal — the walk → parse → classify seam (v0.7.14). + +Every repository command that inventories Markdown artifacts performs the +same three steps: discover files (:func:`rac.core.fs.find_markdown_files`), +parse each into a :class:`~rac.core.models.Product` +(:func:`rac.core.markdown.parse_file`), and classify the result +(:func:`rac.core.classification.classify`). v0.7.14 extracts that loop here — +in core, where deterministic analysis lives (ADR-015) — so the services and +the v0.8.x Explorer consume one traversal definition instead of seven copies. + +Iteration is lazy and ordering is ``find_markdown_files``' sorted order, so +consumers' output (and the golden files that pin it) is unchanged. Parse +errors keep bubbling to the caller, matching the loops this replaces. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +from .classification import Classification, classify +from .fs import find_markdown_files +from .markdown import parse_file +from .models import Product + + +@dataclass(frozen=True) +class CorpusEntry: + """One Markdown document encountered during a corpus walk.""" + + path: Path + product: Product + classification: Classification + + @property + def artifact_type(self) -> str: + """The classified type (``"unknown"`` is a valid outcome, REQ-010).""" + return self.classification.type + + +def walk_corpus(directory: str, *, recursive: bool = True) -> Iterator[CorpusEntry]: + """Yield every Markdown document under ``directory`` as a :class:`CorpusEntry`. + + Deterministic: files arrive in ``find_markdown_files``' sorted order, and + parsing/classification are pure (ADR-002). + """ + for path in find_markdown_files(directory, recursive=recursive): + product = parse_file(str(path)) + yield CorpusEntry(path=path, product=product, classification=classify(product)) diff --git a/src/rac/core/frontmatter.py b/src/rac/core/frontmatter.py index 69d23c72..66d7df49 100644 --- a/src/rac/core/frontmatter.py +++ b/src/rac/core/frontmatter.py @@ -40,7 +40,7 @@ class _StrictLoader(yaml.SafeLoader): """SafeLoader that rejects duplicate mapping keys (ADR-025).""" -def _no_duplicates(loader: _StrictLoader, node: yaml.MappingNode): +def _no_duplicates(loader: _StrictLoader, node: yaml.MappingNode) -> dict: seen: set = set() for key_node, _ in node.value: key = loader.construct_object(key_node, deep=True) @@ -53,9 +53,7 @@ def _no_duplicates(loader: _StrictLoader, node: yaml.MappingNode): return loader.construct_mapping(node, deep=True) -_StrictLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicates -) +_StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicates) @dataclass @@ -109,9 +107,7 @@ def parse_frontmatter(raw: str) -> tuple[ArtifactMetadata | None, list[Issue]]: _issue("malformed-frontmatter", f"frontmatter is not valid YAML: {exc.problem}") ] except yaml.YAMLError as exc: - return None, [ - _issue("malformed-frontmatter", f"frontmatter is not valid YAML: {exc}") - ] + return None, [_issue("malformed-frontmatter", f"frontmatter is not valid YAML: {exc}")] if not isinstance(data, dict): return None, [ @@ -212,8 +208,7 @@ def parse_frontmatter(raw: str) -> tuple[ArtifactMetadata | None, list[Issue]]: ) else: parsed_relationships = { - kind: [normalize_id(t) for t in targets] - for kind, targets in relationships.items() + kind: [normalize_id(t) for t in targets] for kind, targets in relationships.items() } metadata = ArtifactMetadata( diff --git a/src/rac/core/identity.py b/src/rac/core/identity.py index c515579c..64bb66fe 100644 --- a/src/rac/core/identity.py +++ b/src/rac/core/identity.py @@ -53,9 +53,7 @@ def _legacy_identifier(product: Product, spec: ArtifactSpec | None) -> str: return "" -def artifact_identifier( - product: Product, spec: ArtifactSpec | None, path: str -) -> str: +def artifact_identifier(product: Product, spec: ArtifactSpec | None, path: str) -> str: """The deterministic identifier for the artifact at ``path``. Precedence (first match wins): @@ -84,9 +82,7 @@ def artifact_identifier( return prefix.group(0) if prefix else stem -def artifact_identifiers( - product: Product, spec: ArtifactSpec | None, path: str -) -> list[str]: +def artifact_identifiers(product: Product, spec: ArtifactSpec | None, path: str) -> list[str]: """Every identifier this artifact answers to, canonical first (v0.7.11). The canonical identifier leads (same value :func:`artifact_identifier` @@ -115,9 +111,7 @@ def _add(value: str) -> None: return ids -def identity_conflict( - product: Product, spec: ArtifactSpec | None -) -> tuple[str, str] | None: +def identity_conflict(product: Product, spec: ArtifactSpec | None) -> tuple[str, str] | None: """Detect conflicting frontmatter and legacy declared identity (v0.7.11). Returns ``(frontmatter_id, legacy_id)`` when both are declared and differ diff --git a/src/rac/core/idgen.py b/src/rac/core/idgen.py index 50dd8a5f..afd70fab 100644 --- a/src/rac/core/idgen.py +++ b/src/rac/core/idgen.py @@ -16,7 +16,7 @@ import secrets import time -from typing import Callable +from collections.abc import Callable # Crockford base32: no I, L, O, U (visually ambiguous). ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" diff --git a/src/rac/core/markdown.py b/src/rac/core/markdown.py index 9640592a..04964986 100644 --- a/src/rac/core/markdown.py +++ b/src/rac/core/markdown.py @@ -52,7 +52,7 @@ def _content_lines(content: str, start_line: int) -> list[tuple[str, int]]: return pairs -def _classify_requirement_line(text: str, line: int): +def _classify_requirement_line(text: str, line: int) -> Requirement | MalformedRequirement: """Return either a :class:`Requirement` or :class:`MalformedRequirement`.""" m = _BRACKET_RE.match(text) if not m: @@ -63,9 +63,7 @@ def _classify_requirement_line(text: str, line: int): if not _CANONICAL_ID_RE.match(req_id): return MalformedRequirement(raw=text, line=line, bad_id=req_id) if not desc: - return MalformedRequirement( - raw=text, line=line, bad_id=req_id, empty_text=True - ) + return MalformedRequirement(raw=text, line=line, bad_id=req_id, empty_text=True) return Requirement(id=req_id, text=desc, line=line) @@ -122,9 +120,7 @@ def parse(text: str, source_path: str = "") -> Product: if title is None: title = heading_text.strip() else: - extra_title_lines.append( - (tok.map[0] + 1 + offset) if tok.map else 0 - ) + extra_title_lines.append((tok.map[0] + 1 + offset) if tok.map else 0) section = None # content directly under the title is ignored current_h2 = None elif tok.tag == "h2": diff --git a/src/rac/core/schema.py b/src/rac/core/schema.py index d4f817ab..5bd5e5a3 100644 --- a/src/rac/core/schema.py +++ b/src/rac/core/schema.py @@ -32,16 +32,11 @@ def to_dict(self) -> dict: "recommended": [_snake(s) for s in self.recommended], "optional": [_snake(s) for s in self.optional], "descriptions": { - _snake(section): description - for section, description in self.descriptions.items() - }, - "guidance": { - _snake(section): list(lines) - for section, lines in self.guidance.items() + _snake(section): description for section, description in self.descriptions.items() }, + "guidance": {_snake(section): list(lines) for section, lines in self.guidance.items()}, "metadata": { - _snake(section): list(values) - for section, values in self.metadata.items() + _snake(section): list(values) for section, values in self.metadata.items() }, } @@ -86,14 +81,8 @@ def _reference_from_spec(spec: ArtifactSpec) -> SchemaReference: recommended=list(spec.recommended), optional=list(spec.optional), descriptions=dict(spec.descriptions), - guidance={ - section: list(lines) - for section, lines in spec.guidance.items() - }, - metadata={ - section: list(values) - for section, values in spec.metadata.items() - }, + guidance={section: list(lines) for section, lines in spec.guidance.items()}, + metadata={section: list(values) for section, values in spec.metadata.items()}, ) @@ -107,9 +96,7 @@ def _template_section(ref: SchemaReference, section: str) -> TemplateSection: ) -def _starter_body( - ref: SchemaReference, section: str, metadata_values: list[str] -) -> str: +def _starter_body(ref: SchemaReference, section: str, metadata_values: list[str]) -> str: """Validation-safe starter body for one section.""" if metadata_values: return _metadata_default(section, metadata_values) @@ -132,34 +119,25 @@ def _free_text_todo(section: str) -> str: messages = { "problem": "TODO: describe the problem being solved and who experiences it.", "success metrics": "TODO: describe how success will be measured.", - "risks": ( - "TODO: describe implementation, delivery, operational, " - "or adoption risks." - ), + "risks": ("TODO: describe implementation, delivery, operational, or adoption risks."), "assumptions": "TODO: describe conditions assumed to be true.", "outcomes": "TODO: describe the outcomes this roadmap is intended to achieve.", "initiatives": "TODO: describe the major initiatives that support the outcomes.", "success measures": "TODO: describe how progress or success will be measured.", "objective": "TODO: describe what this prompt is intended to achieve.", "input": ( - "TODO: describe the information, context, or source material the " - "prompt expects." - ), - "instructions": ( - "TODO: describe the steps, rules, or approach the model should follow." + "TODO: describe the information, context, or source material the prompt expects." ), + "instructions": ("TODO: describe the steps, rules, or approach the model should follow."), "output": "TODO: describe the expected response format or result.", "constraints": "TODO: describe any boundaries or restrictions.", "examples": "TODO: provide example inputs and outputs if useful.", "evaluation": "TODO: describe how the output should be judged.", "context": "TODO: describe the situation, constraints, and background.", "decision": "TODO: describe the decision that has been made.", - "consequences": ( - "TODO: describe the expected positive and negative consequences." - ), + "consequences": ("TODO: describe the expected positive and negative consequences."), "alternatives considered": ( - "TODO: describe the options that were considered and why they " - "were not chosen." + "TODO: describe the options that were considered and why they were not chosen." ), } return messages.get(section, f"TODO: describe {section}.") @@ -168,12 +146,9 @@ def _free_text_todo(section: str) -> str: def _design_free_text_todo(section: str) -> str: messages = { "context": "TODO: describe the design context and why this design exists.", - "user need": ( - "TODO: describe who this design is for and what they need to accomplish." - ), + "user need": ("TODO: describe who this design is for and what they need to accomplish."), "design": ( - "TODO: describe the proposed experience, interaction, layout, flow, " - "or system behavior." + "TODO: describe the proposed experience, interaction, layout, flow, or system behavior." ), "constraints": ( "TODO: describe technical, product, accessibility, platform, or " @@ -182,9 +157,7 @@ def _design_free_text_todo(section: str) -> str: "rationale": "TODO: explain why this design approach was chosen.", "alternatives": "TODO: describe alternatives that were considered.", "accessibility": "TODO: describe accessibility considerations.", - "style guidance": ( - "TODO: describe visual, tone, layout, or interaction style guidance." - ), + "style guidance": ("TODO: describe visual, tone, layout, or interaction style guidance."), "open questions": "TODO: list unresolved design questions.", } return messages.get(section, _free_text_todo(section)) diff --git a/src/rac/core/templates.py b/src/rac/core/templates.py index c09c6fca..617a0737 100644 --- a/src/rac/core/templates.py +++ b/src/rac/core/templates.py @@ -58,5 +58,5 @@ def load_template(artifact_type: str) -> str: resource = resources.files("rac.templates").joinpath(f"{artifact_type}.md") try: return resource.read_text(encoding="utf-8") - except FileNotFoundError: - raise TemplateResourceMissing(artifact_type) + except FileNotFoundError as exc: + raise TemplateResourceMissing(artifact_type) from exc diff --git a/src/rac/core/validation.py b/src/rac/core/validation.py index 2d0b2e5c..101e7058 100644 --- a/src/rac/core/validation.py +++ b/src/rac/core/validation.py @@ -20,9 +20,7 @@ # Vague verbs that tend to hide unspecified behavior. AMBIGUOUS_VERBS = ("support", "handle", "allow", "enable") -_AMBIGUOUS_RE = re.compile( - r"\b(" + "|".join(AMBIGUOUS_VERBS) + r")\b", re.IGNORECASE -) +_AMBIGUOUS_RE = re.compile(r"\b(" + "|".join(AMBIGUOUS_VERBS) + r")\b", re.IGNORECASE) def has_errors(issues: list[Issue]) -> bool: @@ -130,8 +128,7 @@ def _validate_decision(product: Product) -> list[Issue]: Issue( "error", f"invalid-decision-{field_name}", - f"## {field_name.title()} value {value!r} is not one of: " - f"{', '.join(allowed)}.", + f"## {field_name.title()} value {value!r} is not one of: {', '.join(allowed)}.", ) ) @@ -274,9 +271,7 @@ def _validate_requirement(product: Product) -> list[Issue]: ) if not product.has_problem_section: - issues.append( - Issue("error", "missing-problem", "File is missing a ## Problem section.") - ) + issues.append(Issue("error", "missing-problem", "File is missing a ## Problem section.")) if not product.has_requirements_section: issues.append( @@ -352,9 +347,7 @@ def _validate_requirement(product: Product) -> list[Issue]: # --- Warnings: empty problem -------------------------------------------- if product.has_problem_section and not (product.problem or "").strip(): - issues.append( - Issue("warning", "empty-problem", "## Problem section is empty.") - ) + issues.append(Issue("warning", "empty-problem", "## Problem section is empty.")) # --- Warnings: too many requirements ------------------------------------ if len(product.requirements) > MAX_REQUIREMENTS: diff --git a/src/rac/output/__init__.py b/src/rac/output/__init__.py index 6c6429b7..4cb6965a 100644 --- a/src/rac/output/__init__.py +++ b/src/rac/output/__init__.py @@ -9,23 +9,23 @@ from .human import ( render_diff_human, render_dir_inspect_human, + render_find_human, render_improve_human, render_index_human, - render_find_human, render_init_human, - render_migrate_human, - render_new_human, - render_resolve_human, - render_templates_human, render_inspect_human, render_inspect_verbose, + render_migrate_human, + render_new_human, render_portfolio_human, render_relationship_validation_human, render_relationships_human, + render_resolve_human, render_review_human, render_schema_human, render_schema_list_human, render_stats_human, + render_templates_human, render_unknown_schema, render_validate_dir_human, render_validation_human, @@ -33,23 +33,23 @@ from .json import ( render_diff_json, render_dir_inspect_json, + render_find_json, render_improve_json, render_index_json, render_ingest_json, - render_find_json, render_init_json, + render_inspect_json, render_migrate_json, render_new_json, - render_resolve_json, - render_templates_json, - render_inspect_json, render_portfolio_json, render_relationship_validation_json, render_relationships_json, + render_resolve_json, render_review_json, render_schema_json, render_schema_list_json, render_stats_json, + render_templates_json, render_validate_dir_json, render_validation_json, ) diff --git a/src/rac/output/_shared.py b/src/rac/output/_shared.py index 46ba135b..87f52e44 100644 --- a/src/rac/output/_shared.py +++ b/src/rac/output/_shared.py @@ -14,8 +14,7 @@ # Shown when guidance cannot be produced. Ordering everywhere is required-first, # then recommended (schema declaration order within each). _UNKNOWN_MESSAGE = ( - "Unable to generate improvement guidance.\n" - "Artifact type could not be determined." + "Unable to generate improvement guidance.\nArtifact type could not be determined." ) diff --git a/src/rac/output/human.py b/src/rac/output/human.py index 4f4f1a65..6cfbfffd 100644 --- a/src/rac/output/human.py +++ b/src/rac/output/human.py @@ -15,14 +15,15 @@ from rac.core.schema import SchemaReference from rac.services.create import CreatedArtifact from rac.services.improve import ImprovementResult -from rac.services.init import InitResult from rac.services.index import RepositoryIndex +from rac.services.init import InitResult from rac.services.inspect import DirectoryInspection, InspectionResult from rac.services.migrate import ( STATUS_MIGRATED, STATUS_SKIPPED_UNKNOWN, MigrationReport, ) +from rac.services.portfolio import PortfolioSummary from rac.services.relationships import ( ISSUE_DUPLICATE_IDENTIFIER, ISSUE_SELF_REFERENCE, @@ -93,15 +94,11 @@ def render_validation_human(product: Product, issues: list[Issue]) -> str: lines.append(f" {_red('error')} [{issue.code}] {_loc(file, issue.line)}") lines.append(f" {issue.message}") for issue in warnings: - lines.append( - f" {_yellow('warning')} [{issue.code}] {_loc(file, issue.line)}" - ) + lines.append(f" {_yellow('warning')} [{issue.code}] {_loc(file, issue.line)}") lines.append(f" {issue.message}") lines.append("") - lines.append( - f"{len(errors)} error(s), {len(warnings)} warning(s)." - ) + lines.append(f"{len(errors)} error(s), {len(warnings)} warning(s).") return "\n".join(lines) @@ -122,15 +119,11 @@ def render_validate_dir_human(result: DirectoryValidation) -> str: for issue in f.issues: if issue.severity != "error": continue - lines.append( - f" {_red('error')} [{issue.code}] {_loc(f.path, issue.line)}" - ) + lines.append(f" {_red('error')} [{issue.code}] {_loc(f.path, issue.line)}") lines.append(f" {issue.message}") lines.append("") - skipped = ( - f", {result.skipped} skipped (unknown type)" if result.skipped else "" - ) + skipped = f", {result.skipped} skipped (unknown type)" if result.skipped else "" verdict = _green("PASS") if result.ok else _red("FAIL") lines.append( f"{verdict} {result.directory} — " @@ -217,15 +210,11 @@ def missing_block(label: str, names: list[str]) -> None: missing_block("Features Missing Metrics", s.missing_metrics) missing_block("Features Missing Risks", s.missing_risks) - lines.append( - f"Average Requirements Per Feature: {s.average_requirements:.1f}" - ) + lines.append(f"Average Requirements Per Feature: {s.average_requirements:.1f}") largest = s.largest_feature if largest is not None: - lines.append( - f"Largest Feature: {largest.name} ({largest.requirements} requirements)" - ) + lines.append(f"Largest Feature: {largest.name} ({largest.requirements} requirements)") else: lines.append("Largest Feature: (none)") @@ -326,8 +315,7 @@ def breakdown(label: str, counts: dict[str, int]) -> None: _bold("Unrecognized"), "============", "", - f"{count} {noun} matched no known artifact schema " - "(not errors — see ADR-010):", + f"{count} {noun} matched no known artifact schema (not errors — see ADR-010):", ] for u in s.unrecognized: lines.append(f" {u.path}") @@ -388,9 +376,7 @@ def _append_decision_metadata(lines: list[str], result: InspectionResult) -> Non lines.extend(f" {label}: {value}" for label, value in shown) -def render_inspect_verbose( - result: InspectionResult, scores: list[TypeScore] -) -> str: +def render_inspect_verbose(result: InspectionResult, scores: list[TypeScore]) -> str: """Explainable single-file output: matches, misses, and the score math.""" chosen = next((s for s in scores if s.name == result.type), None) if chosen is None: # Unknown — explain via the closest candidate @@ -537,8 +523,7 @@ def render_relationships_human(report: RelationshipReport) -> str: if counts: lines += ["", _bold("By Type:")] lines.extend( - f"- {_relationship_label(section)}: {count}" - for section, count in counts.items() + f"- {_relationship_label(section)}: {count}" for section, count in counts.items() ) # Per-artifact detail (REQ-005), only for artifacts that declare relationships. @@ -604,7 +589,7 @@ def render_relationship_validation_human(report: RelationshipValidation) -> str: # --- portfolio --------------------------------------------------------------- -def render_portfolio_human(s) -> str: +def render_portfolio_human(s: PortfolioSummary) -> str: """Human-readable `rac portfolio` output.""" lines = [ _bold("Repository Summary"), @@ -632,7 +617,8 @@ def render_portfolio_human(s) -> str: _bold("Completeness"), "------------", "", - f" {s.completeness:.0%} ({s.filled_slots} / {s.recommended_slots} recommended slots filled)", + f" {s.completeness:.0%} " + f"({s.filled_slots} / {s.recommended_slots} recommended slots filled)", "", _bold("Relationships"), "-------------", @@ -723,7 +709,9 @@ def render_review_human(r: ReviewReport) -> str: icon = ( _red("✗") if issue.severity == "error" - else _yellow("!") if issue.severity == "warning" else "·" + else _yellow("!") + if issue.severity == "warning" + else "·" ) lines.append(f" {icon} {issue.identifier}") lines.append(f" {issue.message}") @@ -768,9 +756,7 @@ def render_index_human(index: RepositoryIndex) -> str: title_w = max(len(e.title or "—") for e in index.artifacts) for e in index.artifacts: title = e.title or "—" - lines.append( - f" {e.id:<{id_w}} {e.type:<{type_w}} {title:<{title_w}} {e.path}" - ) + lines.append(f" {e.id:<{id_w}} {e.type:<{type_w}} {title:<{title_w}} {e.path}") return "\n".join(lines) @@ -797,10 +783,7 @@ def render_new_human(created: CreatedArtifact) -> str: def render_init_human(result: InitResult) -> str: """Human `rac init` output: the established identity namespace.""" verb = "Initialized" if result.created else "Already initialized:" - return ( - f"{verb} repository key {result.repository_key}\n" - f"Config: {result.config_path}" - ) + return f"{verb} repository key {result.repository_key}\nConfig: {result.config_path}" # --- resolve / find (v0.7.12) ------------------------------------------------- @@ -809,6 +792,7 @@ def render_init_human(result: InitResult) -> str: def render_resolve_human(result: ResolutionResult) -> str: """Human `rac resolve` output for a resolved artifact.""" artifact = result.artifact + assert artifact is not None # resolved outcome implies an artifact return ( f"{_bold(artifact.id)}\n" f"\n" @@ -824,10 +808,7 @@ def render_find_human(result: SearchResult) -> str: return f"No artifacts match {result.query!r}." id_w = max(len(m.id) for m in result.matches) type_w = max(len(m.type) for m in result.matches) - lines = [ - f"{m.id:<{id_w}} {m.type:<{type_w}} {m.title or '—'}" - for m in result.matches - ] + lines = [f"{m.id:<{id_w}} {m.type:<{type_w}} {m.title or '—'}" for m in result.matches] lines.append("") lines.append(f"{result.match_count} match(es) for {result.query!r}.") return "\n".join(lines) diff --git a/src/rac/output/json.py b/src/rac/output/json.py index 32c675dc..2d8010f6 100644 --- a/src/rac/output/json.py +++ b/src/rac/output/json.py @@ -15,17 +15,17 @@ from rac.services.create import CreatedArtifact from rac.services.improve import ImprovementResult from rac.services.index import RepositoryIndex -from rac.services.init import InitResult from rac.services.ingest import IngestResult +from rac.services.init import InitResult from rac.services.inspect import DirectoryInspection, InspectionResult from rac.services.migrate import MigrationReport +from rac.services.portfolio import PortfolioSummary from rac.services.relationships import RelationshipReport, RelationshipValidation from rac.services.resolve import ResolutionResult, SearchResult from rac.services.review import ReviewReport from rac.services.stats import PortfolioStats from rac.services.validate import DirectoryValidation - # --- validate --------------------------------------------------------------- @@ -96,8 +96,7 @@ def render_stats_json(s: PortfolioStats) -> str: else None ), "requirements_by_feature": [ - {"name": f.name, "requirements": f.requirements} - for f in s.requirements_by_feature + {"name": f.name, "requirements": f.requirements} for f in s.requirements_by_feature ], "invalid": [{"file": f.path, "errors": f.error_codes} for f in s.invalid], } @@ -115,9 +114,7 @@ def render_stats_json(s: PortfolioStats) -> str: payload["roadmaps"] = { "count": s.roadmap_count, "valid": s.valid_roadmaps, - "invalid": [ - {"file": r.path, "errors": r.error_codes} for r in s.invalid_roadmaps - ], + "invalid": [{"file": r.path, "errors": r.error_codes} for r in s.invalid_roadmaps], } # Additive: only present when the portfolio contains prompts. Lightweight by # design — count and validity only (no prompt quality metrics). @@ -125,9 +122,7 @@ def render_stats_json(s: PortfolioStats) -> str: payload["prompts"] = { "count": s.prompt_count, "valid": s.valid_prompts, - "invalid": [ - {"file": p.path, "errors": p.error_codes} for p in s.invalid_prompts - ], + "invalid": [{"file": p.path, "errors": p.error_codes} for p in s.invalid_prompts], } # Additive: only present when the portfolio contains designs. Lightweight by # design — count and validity only (no design quality or rendering metrics). @@ -135,9 +130,7 @@ def render_stats_json(s: PortfolioStats) -> str: payload["designs"] = { "count": s.design_count, "valid": s.valid_designs, - "invalid": [ - {"file": d.path, "errors": d.error_codes} for d in s.invalid_designs - ], + "invalid": [{"file": d.path, "errors": d.error_codes} for d in s.invalid_designs], } # Additive: only present when the portfolio contains documents that matched # no known artifact schema (ADR-010). Surfaced, not errors; ``confidence`` is @@ -154,8 +147,7 @@ def render_stats_json(s: PortfolioStats) -> str: # Declared-presence counts (REQ-011), snake_case keys — not resolution. if s.relationship_counts: payload["relationships"] = { - section.replace(" ", "_"): count - for section, count in s.relationship_counts.items() + section.replace(" ", "_"): count for section, count in s.relationship_counts.items() } return json.dumps(payload, indent=2) @@ -177,10 +169,7 @@ def render_dir_inspect_json(d: DirectoryInspection) -> str: "counts": d.counts, "unknown": d.unknown_count, }, - "files": [ - {"path": f.path, "type": f.type, "confidence": f.confidence} - for f in d.files - ], + "files": [{"path": f.path, "type": f.type, "confidence": f.confidence} for f in d.files], } return json.dumps(payload, indent=2) @@ -253,7 +242,7 @@ def render_ingest_json(result: IngestResult, output_path: str | None) -> str: # --- portfolio --------------------------------------------------------------- -def render_portfolio_json(s) -> str: +def render_portfolio_json(s: PortfolioSummary) -> str: """JSON `rac portfolio` output (stable contract, ADR-007).""" return json.dumps(s.to_dict(), indent=2) diff --git a/src/rac/services/create.py b/src/rac/services/create.py index 11c2e5f9..93a6858b 100644 --- a/src/rac/services/create.py +++ b/src/rac/services/create.py @@ -26,9 +26,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Callable from rac.core.idgen import generate_id from rac.core.templates import load_template @@ -73,9 +73,7 @@ class IdGenerationExhausted(Exception): def __init__(self, attempts: int): self.attempts = attempts - super().__init__( - f"could not generate a unique artifact ID in {attempts} attempts" - ) + super().__init__(f"could not generate a unique artifact ID in {attempts} attempts") @dataclass @@ -99,13 +97,7 @@ def to_dict(self) -> dict: def render_frontmatter(artifact_id: str, artifact_type: str) -> str: """Canonical generated frontmatter, stable key order (v0.7.11 contract).""" - return ( - "---\n" - "schema_version: 1\n" - f"id: {artifact_id}\n" - f"type: {artifact_type}\n" - "---\n" - ) + return f"---\nschema_version: 1\nid: {artifact_id}\ntype: {artifact_type}\n---\n" def render_artifact(artifact_type: str, frontmatter: str | None = None) -> str: @@ -122,10 +114,7 @@ def _assign_id( id_generator: Callable[[str], str], ) -> str: """One repository-unique ID: generate, check the index, retry bounded.""" - existing = { - entry.id.upper() - for entry in build_repository_index(repository_root).artifacts - } + existing = {entry.id.upper() for entry in build_repository_index(repository_root).artifacts} for _ in range(_MAX_ID_ATTEMPTS): candidate = id_generator(repository_key) if candidate.upper() not in existing: diff --git a/src/rac/services/diff.py b/src/rac/services/diff.py index 6cd8c80c..b51f9d49 100644 --- a/src/rac/services/diff.py +++ b/src/rac/services/diff.py @@ -47,9 +47,7 @@ def diff(old: Product, new: Product) -> Diff: result.added_requirements.append(new_req) elif old_req.text != new_req.text: result.modified_requirements.append( - RequirementChange( - id=req_id, old_text=old_req.text, new_text=new_req.text - ) + RequirementChange(id=req_id, old_text=old_req.text, new_text=new_req.text) ) # Removed: in old but not new (preserves old-file order). @@ -57,12 +55,8 @@ def diff(old: Product, new: Product) -> Diff: if req_id not in new_reqs: result.removed_requirements.append(old_req) - result.added_metrics = _ordered_difference( - new.success_metrics, old.success_metrics - ) - result.removed_metrics = _ordered_difference( - old.success_metrics, new.success_metrics - ) + result.added_metrics = _ordered_difference(new.success_metrics, old.success_metrics) + result.removed_metrics = _ordered_difference(old.success_metrics, new.success_metrics) result.added_risks = _ordered_difference(new.risks, old.risks) result.removed_risks = _ordered_difference(old.risks, new.risks) diff --git a/src/rac/services/improve.py b/src/rac/services/improve.py index b4d7f272..20320885 100644 --- a/src/rac/services/improve.py +++ b/src/rac/services/improve.py @@ -21,8 +21,8 @@ from rac.core.artifacts import ArtifactSpec, spec_for from rac.core.classification import classify, missing_sections -from rac.core.models import Product from rac.core.markdown import parse, parse_file +from rac.core.models import Product def supports_improve(spec: ArtifactSpec) -> bool: diff --git a/src/rac/services/index.py b/src/rac/services/index.py index a78ad640..9d2b97aa 100644 --- a/src/rac/services/index.py +++ b/src/rac/services/index.py @@ -19,10 +19,8 @@ from dataclasses import dataclass, field from rac.core.artifacts import spec_for -from rac.core.classification import classify -from rac.core.fs import find_markdown_files +from rac.core.corpus import walk_corpus from rac.core.identity import artifact_identifier, artifact_identifiers -from rac.core.markdown import parse_file @dataclass @@ -79,24 +77,19 @@ def to_dict(self) -> dict: } -def build_repository_index( - directory: str, recursive: bool = True -) -> RepositoryIndex: +def build_repository_index(directory: str, recursive: bool = True) -> RepositoryIndex: """Walk ``directory`` and inventory every Markdown artifact (one parse each).""" artifacts: list[IndexEntry] = [] - for path in find_markdown_files(directory, recursive=recursive): - product = parse_file(str(path)) - artifact_type = classify(product).type - spec = spec_for(artifact_type) # None for Unknown + for entry in walk_corpus(directory, recursive=recursive): + path, product = entry.path, entry.product + spec = spec_for(entry.artifact_type) # None for Unknown artifacts.append( IndexEntry( id=artifact_identifier(product, spec, str(path)), - type=artifact_type, + type=entry.artifact_type, title=product.title, path=str(path), aliases=artifact_identifiers(product, spec, str(path)), ) ) - return RepositoryIndex( - directory=directory, recursive=recursive, artifacts=artifacts - ) + return RepositoryIndex(directory=directory, recursive=recursive, artifacts=artifacts) diff --git a/src/rac/services/ingest.py b/src/rac/services/ingest.py index 1d9de858..125b737d 100644 --- a/src/rac/services/ingest.py +++ b/src/rac/services/ingest.py @@ -53,7 +53,7 @@ class MarkdownConverter: """Pass-through for files already in Markdown — needs no extra dependency.""" name = "markdown" - extensions = (".md", ".markdown") + extensions: tuple[str, ...] = (".md", ".markdown") def convert(self, path: Path) -> str: return path.read_text(encoding="utf-8") @@ -69,7 +69,7 @@ class MarkItDownConverter: name = "markitdown" # HTML needs no extra (built into MarkItDown); the others come from the # corresponding markitdown extras, exposed as our granular ingest extras. - extensions = (".docx", ".pdf", ".html", ".htm", ".pptx", ".xls", ".xlsx") + extensions: tuple[str, ...] = (".docx", ".pdf", ".html", ".htm", ".pptx", ".xls", ".xlsx") def convert(self, path: Path) -> str: try: diff --git a/src/rac/services/init.py b/src/rac/services/init.py index a9bd18a7..ca9f145b 100644 --- a/src/rac/services/init.py +++ b/src/rac/services/init.py @@ -98,16 +98,14 @@ def _read_config(config_path: Path) -> RepositoryConfig: try: data = yaml.safe_load(config_path.read_text(encoding="utf-8")) except yaml.YAMLError as exc: - raise MalformedRepositoryConfig(str(config_path), f"invalid YAML: {exc}") + raise MalformedRepositoryConfig(str(config_path), f"invalid YAML: {exc}") from exc if not isinstance(data, dict) or not isinstance(data.get("repository_key"), str): raise MalformedRepositoryConfig( str(config_path), "missing required string field 'repository_key'" ) key = data["repository_key"] if not KEY_RE.match(key): - raise MalformedRepositoryConfig( - str(config_path), f"invalid repository_key: {key!r}" - ) + raise MalformedRepositoryConfig(str(config_path), f"invalid repository_key: {key!r}") return RepositoryConfig(repository_key=key, config_path=str(config_path)) @@ -135,12 +133,8 @@ def init_repository(directory: str, key: str = DEFAULT_KEY) -> InitResult: if config_path.is_file(): existing = _read_config(config_path) if existing.repository_key != key: - raise RepositoryKeyConflict( - existing.repository_key, key, str(config_path) - ) - return InitResult( - repository_key=key, config_path=str(config_path), created=False - ) + raise RepositoryKeyConflict(existing.repository_key, key, str(config_path)) + return InitResult(repository_key=key, config_path=str(config_path), created=False) config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(f"repository_key: {key}\n", encoding="utf-8") return InitResult(repository_key=key, config_path=str(config_path), created=True) diff --git a/src/rac/services/inspect.py b/src/rac/services/inspect.py index 0964944f..0e34c7d8 100644 --- a/src/rac/services/inspect.py +++ b/src/rac/services/inspect.py @@ -15,9 +15,9 @@ from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.core.classification import classify -from rac.core.fs import find_markdown_files -from rac.core.models import Product +from rac.core.corpus import walk_corpus from rac.core.markdown import parse, parse_file +from rac.core.models import Product from .relationships import extract_relationships @@ -166,9 +166,7 @@ def inspect_file(path: str) -> InspectionResult: def inspect_directory(directory: str, recursive: bool = True) -> DirectoryInspection: """Inspect every Markdown file under ``directory`` and aggregate the types.""" files = [] - for path in find_markdown_files(directory, recursive=recursive): - result = inspect_file(str(path)) - files.append( - FileInspection(path=str(path), type=result.type, confidence=result.confidence) - ) + for entry in walk_corpus(directory, recursive=recursive): + c = entry.classification + files.append(FileInspection(path=str(entry.path), type=c.type, confidence=c.confidence)) return DirectoryInspection(directory=directory, recursive=recursive, files=files) diff --git a/src/rac/services/migrate.py b/src/rac/services/migrate.py index 94ffd46d..5c679d58 100644 --- a/src/rac/services/migrate.py +++ b/src/rac/services/migrate.py @@ -20,10 +20,8 @@ from pathlib import Path from rac.core.artifacts import spec_for -from rac.core.classification import classify -from rac.core.fs import find_markdown_files +from rac.core.corpus import walk_corpus from rac.core.idgen import generate_id -from rac.core.markdown import parse_file from rac.services.create import ( IdGenerationExhausted, MissingRepositoryConfig, @@ -121,10 +119,7 @@ def migrate_metadata( raise MissingRepositoryConfig(directory) repository_root = str(Path(config.config_path).parent.parent) - issued = { - entry.id.upper() - for entry in build_repository_index(repository_root).artifacts - } + issued = {entry.id.upper() for entry in build_repository_index(repository_root).artifacts} def _next_id() -> str: for _ in range(_MAX_ID_ATTEMPTS): @@ -135,22 +130,17 @@ def _next_id() -> str: raise IdGenerationExhausted(_MAX_ID_ATTEMPTS) files: list[FileMigration] = [] - for path in find_markdown_files(directory, recursive=recursive): - path = Path(path) - product = parse_file(str(path)) + for entry in walk_corpus(directory, recursive=recursive): + path, product = entry.path, entry.product if product.metadata is not None or product.metadata_issues: # Any frontmatter presence — valid, malformed, or unterminated — # means migration keeps its hands off (Initiative 4: never modify # an existing envelope). Validation owns reporting broken ones. - files.append( - FileMigration(path=str(path), status=STATUS_ALREADY_CANONICAL) - ) + files.append(FileMigration(path=str(path), status=STATUS_ALREADY_CANONICAL)) continue - artifact_type = classify(product).type + artifact_type = entry.artifact_type if spec_for(artifact_type) is None: - files.append( - FileMigration(path=str(path), status=STATUS_SKIPPED_UNKNOWN) - ) + files.append(FileMigration(path=str(path), status=STATUS_SKIPPED_UNKNOWN)) continue artifact_id = _next_id() if not dry_run: @@ -166,6 +156,4 @@ def _next_id() -> str: type=artifact_type, ) ) - return MigrationReport( - directory=directory, recursive=recursive, dry_run=dry_run, files=files - ) + return MigrationReport(directory=directory, recursive=recursive, dry_run=dry_run, files=files) diff --git a/src/rac/services/portfolio.py b/src/rac/services/portfolio.py index 61a6df01..aa39a1e3 100644 --- a/src/rac/services/portfolio.py +++ b/src/rac/services/portfolio.py @@ -27,10 +27,9 @@ from dataclasses import dataclass, field from rac.core.artifacts import ARTIFACT_SPECS, spec_for -from rac.core.classification import classify, missing_sections -from rac.core.fs import find_markdown_files +from rac.core.classification import missing_sections +from rac.core.corpus import walk_corpus from rac.core.identity import artifact_identifier -from rac.core.markdown import parse_file from rac.core.validation import has_errors, validate from .relationships import ( @@ -60,7 +59,7 @@ class AttentionItem: path: str identifier: str # artifact identifier or filename stem - severity: str # "error" | "warning" + severity: str # "error" | "warning" code: str message: str @@ -84,7 +83,7 @@ class PortfolioSummary: directory: str recursive: bool - by_type: dict[str, int] # {type: count} incl. unknown + by_type: dict[str, int] # {type: count} incl. unknown valid_artifacts: int invalid_artifacts: int recommended_slots: int @@ -112,9 +111,7 @@ def health_score(self) -> int: validity = self.valid_artifacts / total if total else 1.0 completeness = self.completeness checked = self.relationships.total - rel_integrity = ( - (checked - self.relationships.broken) / checked if checked else 1.0 - ) + rel_integrity = (checked - self.relationships.broken) / checked if checked else 1.0 raw = 0.5 * validity + 0.25 * completeness + 0.25 * rel_integrity return round(100 * raw) @@ -152,11 +149,8 @@ def to_dict(self) -> dict: } -def build_portfolio_summary( - directory: str, recursive: bool = True -) -> PortfolioSummary: +def build_portfolio_summary(directory: str, recursive: bool = True) -> PortfolioSummary: """Walk ``directory`` and compute a full repository intelligence summary.""" - paths = find_markdown_files(directory, recursive=recursive) # --- per-artifact pass --------------------------------------------------- by_type: dict[str, int] = {spec.name: 0 for spec in ARTIFACT_SPECS} @@ -173,9 +167,9 @@ def build_portfolio_summary( # second identifier pass. path_to_identifier: dict[str, str] = {} - for path in paths: - product = parse_file(str(path)) - artifact_type = classify(product).type + for entry in walk_corpus(directory, recursive=recursive): + path, product = entry.path, entry.product + artifact_type = entry.artifact_type by_type[artifact_type] = by_type.get(artifact_type, 0) + 1 spec = spec_for(artifact_type) diff --git a/src/rac/services/relationships.py b/src/rac/services/relationships.py index d8cb9c8e..10a31a08 100644 --- a/src/rac/services/relationships.py +++ b/src/rac/services/relationships.py @@ -21,10 +21,10 @@ from rac.core.artifacts import ArtifactSpec, spec_for from rac.core.classification import classify -from rac.core.fs import find_markdown_files +from rac.core.corpus import walk_corpus from rac.core.identity import artifact_identifier, artifact_identifiers -from rac.core.models import Product from rac.core.markdown import parse_file +from rac.core.models import Product # The cross-artifact "Related X" sections. These populate the ``relationships`` # dict in ``rac inspect`` output. ``related designs`` is included so every peer @@ -102,9 +102,7 @@ def extract_relationships(product: Product, spec: ArtifactSpec) -> dict[str, lis return _collect(product, spec, RELATED_SECTIONS) -def extract_relationships_full( - product: Product, spec: ArtifactSpec -) -> dict[str, list[str]]: +def extract_relationships_full(product: Product, spec: ArtifactSpec) -> dict[str, list[str]]: """Cross-artifact references for ``rac relationships`` — *including* Supersedes. The repository-level relationship command treats Supersedes as a first-class @@ -200,7 +198,7 @@ def relationship_count(self) -> int: def _resolution_labels( artifacts: list[ArtifactRelationships], - items: list[tuple[str, "Product", "ArtifactSpec | None"]], + items: list[tuple[str, Product, ArtifactSpec | None]], ) -> dict[str, str]: """Human-friendly labels for every uniquely-resolved reference (v0.7.12). @@ -226,17 +224,16 @@ def _resolution_labels( continue canonical, spec, title = info[next(iter(paths))] type_name = spec.name if spec else "unknown" - labels[key] = ( - f"{title or canonical} ({type_name} · {canonical})" - ) + labels[key] = f"{title or canonical} ({type_name} · {canonical})" return labels def _build_report( - directory: str, paths: list, recursive: bool + directory: str, + items: list[tuple[str, Product, ArtifactSpec | None]], + recursive: bool, ) -> RelationshipReport: - """Assemble a :class:`RelationshipReport` from ``paths`` (already ordered).""" - items = _parsed_items(paths) + """Assemble a :class:`RelationshipReport` from ``items`` (already ordered).""" artifacts: list[ArtifactRelationships] = [] for path, product, spec in items: relationships = extract_relationships_full(product, spec) if spec else {} @@ -251,18 +248,15 @@ def _build_report( return RelationshipReport( directory=directory, recursive=recursive, - total_files=len(paths), + total_files=len(items), artifacts=artifacts, labels=_resolution_labels(artifacts, items), ) -def build_relationship_report( - directory: str, recursive: bool = True -) -> RelationshipReport: +def build_relationship_report(directory: str, recursive: bool = True) -> RelationshipReport: """Inspect explicit relationships across a directory of Markdown files.""" - paths = find_markdown_files(directory, recursive=recursive) - return _build_report(directory, paths, recursive) + return _build_report(directory, _corpus_items(directory, recursive), recursive) def build_relationship_report_file(path: str) -> RelationshipReport: @@ -270,7 +264,7 @@ def build_relationship_report_file(path: str) -> RelationshipReport: Same model as a directory report, with one file and ``recursive=False``. """ - return _build_report(path, [path], recursive=False) + return _build_report(path, _parsed_items([path]), recursive=False) # --- Relationship validation (v0.7.2) ---------------------------------------- @@ -351,6 +345,16 @@ def _parsed_items(paths: list) -> list[tuple[str, Product, ArtifactSpec | None]] return items +def _corpus_items( + directory: str, recursive: bool +) -> list[tuple[str, Product, ArtifactSpec | None]]: + """Every document under ``directory`` as ``(path, product, spec)`` (one walk).""" + return [ + (str(entry.path), entry.product, spec_for(entry.artifact_type)) + for entry in walk_corpus(directory, recursive=recursive) + ] + + # Identifier index: {casefold(ident) -> [(path, display_ident), ...]} _IdentIndex = dict[str, list[tuple[str, str]]] @@ -419,9 +423,7 @@ def _resolve_references( resolved_targets.add(targets[0]) continue # resolved uniquely to another artifact issues.append( - RelationshipIssue( - code=code, source_path=path, relationship=section, target=ref - ) + RelationshipIssue(code=code, source_path=path, relationship=section, target=ref) ) return checked, issues, resolved_targets @@ -444,14 +446,10 @@ def _validate( duplicates.append((display, sorted(p for p, _ in entries))) for display, dup_paths in sorted(duplicates, key=lambda d: d[0].casefold()): issues.append( - RelationshipIssue( - code=ISSUE_DUPLICATE_IDENTIFIER, identifier=display, paths=dup_paths - ) + RelationshipIssue(code=ISSUE_DUPLICATE_IDENTIFIER, identifier=display, paths=dup_paths) ) - checked, ref_issues, _ = _resolve_references( - items, _build_resolution_index(items) - ) + checked, ref_issues, _ = _resolve_references(items, _build_resolution_index(items)) issues.extend(ref_issues) return RelationshipValidation( @@ -462,11 +460,9 @@ def _validate( ) -def validate_relationships( - directory: str, recursive: bool = True -) -> RelationshipValidation: +def validate_relationships(directory: str, recursive: bool = True) -> RelationshipValidation: """Validate explicit relationship references across a directory.""" - items = _parsed_items(find_markdown_files(directory, recursive=recursive)) + items = _corpus_items(directory, recursive) return _validate(directory, items, recursive) @@ -511,17 +507,12 @@ class RelationshipSummary: issues: list[RelationshipIssue] = field(default_factory=list) -def summarize_relationships( - directory: str, recursive: bool = True -) -> RelationshipSummary: +def summarize_relationships(directory: str, recursive: bool = True) -> RelationshipSummary: """Aggregate relationship health across a directory (v0.7.3).""" - paths = find_markdown_files(directory, recursive=recursive) - items = _parsed_items(paths) + items = _corpus_items(directory, recursive) if not items: - return RelationshipSummary( - total=0, valid=0, broken=0, orphaned=0, coverage=1.0 - ) + return RelationshipSummary(total=0, valid=0, broken=0, orphaned=0, coverage=1.0) index = _build_resolution_index(items) checked, ref_issues, resolved_targets = _resolve_references(items, index) diff --git a/src/rac/services/resolve.py b/src/rac/services/resolve.py index 86d0dcf0..b3a278e3 100644 --- a/src/rac/services/resolve.py +++ b/src/rac/services/resolve.py @@ -62,6 +62,7 @@ class ResolutionResult: def to_dict(self) -> dict: if self.outcome == OUTCOME_RESOLVED: + assert self.artifact is not None # resolved outcome implies an artifact return {"schema_version": "1", **self.artifact.to_dict()} payload: dict = { "schema_version": "1", @@ -95,9 +96,7 @@ def to_dict(self) -> dict: } -def resolve_artifact( - directory: str, artifact_id: str, recursive: bool = True -) -> ResolutionResult: +def resolve_artifact(directory: str, artifact_id: str, recursive: bool = True) -> ResolutionResult: """Resolve ``artifact_id`` to exactly one artifact under ``directory``. Matching is case-insensitive against every identifier an artifact answers diff --git a/src/rac/services/review.py b/src/rac/services/review.py index 8e7e1211..c4a0d7ba 100644 --- a/src/rac/services/review.py +++ b/src/rac/services/review.py @@ -52,13 +52,13 @@ class ReviewIssue: """One prioritized finding with its deterministic next step.""" - priority: int # 1 (highest impact) – 4 - severity: str # "error" | "warning" | "info" + priority: int # 1 (highest impact) – 4 + severity: str # "error" | "warning" | "info" path: str identifier: str # artifact identifier or filename stem code: str message: str - action: str # a runnable command or concrete edit + action: str # a runnable command or concrete edit def to_dict(self) -> dict: return { @@ -93,9 +93,7 @@ def ok(self) -> bool: Priority 1–2 findings (invalid artifacts, broken relationships) fail the review; priority 3–4 findings are advisory. """ - return not any( - i.priority <= PRIORITY_BROKEN_RELATIONSHIP for i in self.issues - ) + return not any(i.priority <= PRIORITY_BROKEN_RELATIONSHIP for i in self.issues) @property def actions(self) -> list[str]: diff --git a/src/rac/services/stats.py b/src/rac/services/stats.py index 79c67558..622d84dc 100644 --- a/src/rac/services/stats.py +++ b/src/rac/services/stats.py @@ -20,8 +20,7 @@ from dataclasses import dataclass, field from rac.core.artifacts import spec_for -from rac.core.fs import find_markdown_files -from rac.core.markdown import parse_file +from rac.core.corpus import walk_corpus from rac.core.validation import validate from .inspect import build_inspection @@ -289,8 +288,8 @@ def collect_stats(directory: str) -> PortfolioStats: """ stats = PortfolioStats(directory=directory) rel_counts: dict[str, int] = {} - for path in find_markdown_files(directory): - product = parse_file(str(path)) + for entry in walk_corpus(directory): + path, product = entry.path, entry.product name = product.title or path.stem result = build_inspection(product) # Declared relationship-presence counts span every artifact type, so they @@ -373,8 +372,6 @@ def collect_stats(directory: str) -> PortfolioStats: ) # Order the relationship counts by the canonical vocabulary for stable output. stats.relationship_counts = { - section: rel_counts[section] - for section in RELATIONSHIP_SECTIONS - if section in rel_counts + section: rel_counts[section] for section in RELATIONSHIP_SECTIONS if section in rel_counts } return stats diff --git a/src/rac/services/validate.py b/src/rac/services/validate.py index b34f6e25..76536f0d 100644 --- a/src/rac/services/validate.py +++ b/src/rac/services/validate.py @@ -15,9 +15,7 @@ from dataclasses import asdict, dataclass from rac.core.artifacts import spec_for -from rac.core.classification import classify -from rac.core.fs import find_markdown_files -from rac.core.markdown import parse_file +from rac.core.corpus import walk_corpus from rac.core.models import Issue from rac.core.validation import has_errors, validate @@ -33,7 +31,7 @@ class FileValidation: path: str artifact_type: str # canonical artifact name, or "unknown" - status: str # STATUS_VALID | STATUS_INVALID | STATUS_SKIPPED + status: str # STATUS_VALID | STATUS_INVALID | STATUS_SKIPPED issues: list[Issue] def to_dict(self) -> dict: @@ -97,13 +95,13 @@ def to_dict(self) -> dict: def validate_directory(directory: str, recursive: bool = True) -> DirectoryValidation: """Validate every recognized artifact under ``directory``. - Files are processed in sorted path order (``find_markdown_files``), so the + Files are processed in sorted path order (``walk_corpus``), so the result — and everything rendered from it — is deterministic. """ files: list[FileValidation] = [] - for path in find_markdown_files(directory, recursive=recursive): - product = parse_file(str(path)) - artifact_type = classify(product).type + for entry in walk_corpus(directory, recursive=recursive): + path, product = entry.path, entry.product + artifact_type = entry.artifact_type if spec_for(artifact_type) is None: # Unknown artifacts: not validated (portfolio semantics) — the # requirement fallback is a single-file compatibility path only. diff --git a/tests/test_ci_batteries.py b/tests/test_ci_batteries.py new file mode 100644 index 00000000..0d0536ba --- /dev/null +++ b/tests/test_ci_batteries.py @@ -0,0 +1,41 @@ +"""CI battery guard (v0.7.14): every tests/test_*.py runs in CI. + +ADR-027 makes the battery matrix in .github/workflows/tests.yml an explicit, +static enumeration, and accepts that the list must be kept in sync by hand. +This test is the enforcement it anticipated: it fails whenever a test file +is missing from the matrix (a silent orphan that CI would never run) or is +listed in more than one battery. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).parent.parent +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "tests.yml" +TESTS_DIR = REPO_ROOT / "tests" + + +def _battery_paths() -> list[str]: + data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + batteries = data["jobs"]["pytest"]["strategy"]["matrix"]["battery"] + paths: list[str] = [] + for battery in batteries: + paths.extend(battery["paths"].split()) + return paths + + +def test_every_test_file_belongs_to_exactly_one_battery(): + listed = _battery_paths() + + duplicates = sorted({p for p in listed if listed.count(p) > 1}) + assert duplicates == [], f"test files in more than one battery: {duplicates}" + + actual = sorted(str(p.relative_to(REPO_ROOT)) for p in TESTS_DIR.glob("test_*.py")) + orphans = sorted(set(actual) - set(listed)) + assert orphans == [], f"test files missing from the CI battery matrix: {orphans}" + + stale = sorted(set(listed) - set(actual)) + assert stale == [], f"battery entries with no matching test file: {stale}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 094ffe2f..6b236db1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,12 +5,11 @@ import json import pytest +from conftest import fixture_path from rac import __version__ from rac.cli import main -from conftest import fixture_path - @pytest.mark.parametrize( "argv", diff --git a/tests/test_corpus.py b/tests/test_corpus.py new file mode 100644 index 00000000..8d1e759a --- /dev/null +++ b/tests/test_corpus.py @@ -0,0 +1,48 @@ +"""Tests for the canonical corpus traversal seam (v0.7.14).""" + +from __future__ import annotations + +from pathlib import Path + +from rac.core.classification import classify +from rac.core.corpus import CorpusEntry, walk_corpus +from rac.core.fs import find_markdown_files + +FIXTURES = str(Path(__file__).parent / "fixtures") + + +def test_walk_yields_every_markdown_file_in_sorted_order(): + entries = list(walk_corpus(FIXTURES)) + assert [e.path for e in entries] == find_markdown_files(FIXTURES) + + +def test_entries_carry_product_and_classification(): + entries = list(walk_corpus(FIXTURES)) + assert entries, "fixture corpus must not be empty" + for entry in entries: + assert entry.classification == classify(entry.product) + assert entry.artifact_type == entry.classification.type + + +def test_unknown_is_a_valid_outcome(tmp_path): + (tmp_path / "note.md").write_text("just some prose\n", encoding="utf-8") + [entry] = list(walk_corpus(str(tmp_path))) + assert entry.artifact_type == "unknown" + + +def test_recursive_flag_limits_walk_to_top_level(tmp_path): + (tmp_path / "top.md").write_text("# Top\n", encoding="utf-8") + nested = tmp_path / "nested" + nested.mkdir() + (nested / "deep.md").write_text("# Deep\n", encoding="utf-8") + + all_paths = [e.path.name for e in walk_corpus(str(tmp_path))] + top_only = [e.path.name for e in walk_corpus(str(tmp_path), recursive=False)] + assert all_paths == ["deep.md", "top.md"] + assert top_only == ["top.md"] + + +def test_walk_is_lazy(): + iterator = walk_corpus(FIXTURES) + first = next(iterator) + assert isinstance(first, CorpusEntry) diff --git a/tests/test_create.py b/tests/test_create.py index 503e670f..e320f736 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -13,6 +13,7 @@ import pytest +from rac.cli import main from rac.core.artifacts import ARTIFACT_SPECS from rac.core.classification import classify from rac.core.markdown import parse @@ -28,7 +29,6 @@ render_frontmatter, ) from rac.services.init import init_repository -from rac.cli import main SPEC_NAMES = [spec.name for spec in ARTIFACT_SPECS] @@ -80,10 +80,11 @@ def test_create_is_deterministic_apart_from_id(repo): init_repository(str(repo)) # already initialized; just being explicit # Second create with the same injected generator collides with a's ID in # the index, so use a different fixed ID and compare bodies. - create_artifact( - "requirement", str(b), id_generator=lambda key: f"{key}-01JY4M8X2QZ8" - ) - strip = lambda text: text.split("---\n", 2)[2] + create_artifact("requirement", str(b), id_generator=lambda key: f"{key}-01JY4M8X2QZ8") + + def strip(text: str) -> str: + return text.split("---\n", 2)[2] + assert strip(a.read_text(encoding="utf-8")) == strip(b.read_text(encoding="utf-8")) @@ -91,9 +92,7 @@ def test_create_regenerates_on_id_collision(repo): existing = repo / "existing.md" create_artifact("decision", str(existing), id_generator=fixed_generator) ids = iter([f"RAC-{FIXED_SUFFIX}", "RAC-01JY4M8X2QZ9"]) - created = create_artifact( - "decision", str(repo / "next.md"), id_generator=lambda key: next(ids) - ) + created = create_artifact("decision", str(repo / "next.md"), id_generator=lambda key: next(ids)) assert created.id == "RAC-01JY4M8X2QZ9" @@ -105,18 +104,14 @@ def test_create_exhausts_bounded_retries_on_persistent_collision(repo): def test_create_uses_repository_key_from_config(tmp_path): init_repository(str(tmp_path), key="PROJ") - created = create_artifact( - "decision", str(tmp_path / "d.md"), id_generator=fixed_generator - ) + created = create_artifact("decision", str(tmp_path / "d.md"), id_generator=fixed_generator) assert created.id == f"PROJ-{FIXED_SUFFIX}" def test_create_discovers_config_upward(repo): nested = repo / "docs" / "decisions" nested.mkdir(parents=True) - created = create_artifact( - "decision", str(nested / "d.md"), id_generator=fixed_generator - ) + created = create_artifact("decision", str(nested / "d.md"), id_generator=fixed_generator) assert created.id == f"RAC-{FIXED_SUFFIX}" @@ -148,18 +143,12 @@ def test_render_artifact_prepends_frontmatter_when_given(): def test_render_frontmatter_stable_key_order(): assert render_frontmatter("RAC-01JY4M8X2QZ7", "decision") == ( - "---\n" - "schema_version: 1\n" - "id: RAC-01JY4M8X2QZ7\n" - "type: decision\n" - "---\n" + "---\nschema_version: 1\nid: RAC-01JY4M8X2QZ7\ntype: decision\n---\n" ) def test_created_artifact_json_contract(repo): - created = create_artifact( - "roadmap", str(repo / "r.md"), id_generator=fixed_generator - ) + created = create_artifact("roadmap", str(repo / "r.md"), id_generator=fixed_generator) assert created.to_dict() == { "schema_version": "1", "created": True, @@ -260,7 +249,8 @@ def test_cli_new_and_service_write_identical_bodies(repo, capsys): svc_out = repo / "svc.md" main(["new", "prompt", str(cli_out)]) create_artifact("prompt", str(svc_out), id_generator=fixed_generator) - strip = lambda text: text.split("---\n", 2)[2] - assert strip(cli_out.read_text(encoding="utf-8")) == strip( - svc_out.read_text(encoding="utf-8") - ) + + def strip(text: str) -> str: + return text.split("---\n", 2)[2] + + assert strip(cli_out.read_text(encoding="utf-8")) == strip(svc_out.read_text(encoding="utf-8")) diff --git a/tests/test_decision_metadata.py b/tests/test_decision_metadata.py index a29828aa..07e7e1c6 100644 --- a/tests/test_decision_metadata.py +++ b/tests/test_decision_metadata.py @@ -10,13 +10,13 @@ import json +from conftest import fixture_path + from rac.cli import main -from rac.services.inspect import inspect_file from rac.core.markdown import parse_file -from rac.services.stats import collect_stats from rac.core.validation import has_errors, validate - -from conftest import fixture_path +from rac.services.inspect import inspect_file +from rac.services.stats import collect_stats def codes(issues): diff --git a/tests/test_design.py b/tests/test_design.py index 57e7e603..db9c1be0 100644 --- a/tests/test_design.py +++ b/tests/test_design.py @@ -11,18 +11,18 @@ import io import json +from conftest import fixture_path + import rac.services.improve as improve_mod -from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.cli import main +from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.core.classification import classify -from rac.services.improve import improve_file, improve_text, supports_improve -from rac.services.inspect import inspect_file from rac.core.markdown import parse, parse_file from rac.core.schema import available_schemas, schema_reference -from rac.services.stats import collect_stats from rac.core.validation import has_errors, validate - -from conftest import fixture_path +from rac.services.improve import improve_file, improve_text, supports_improve +from rac.services.inspect import inspect_file +from rac.services.stats import collect_stats def _stdin(monkeypatch, text: str) -> None: @@ -104,11 +104,7 @@ def test_design_missing_required_section_fails(): def test_design_missing_required_error_codes_are_hyphenated(): missing_user_need = ( - "# D\n\n" - "## Context\n\nc\n\n" - "## Design\n\nd\n\n" - "## Constraints\n\nx\n\n" - "## Rationale\n\nr\n" + "# D\n\n## Context\n\nc\n\n## Design\n\nd\n\n## Constraints\n\nx\n\n## Rationale\n\nr\n" ) product = parse(missing_user_need) assert classify(product).type == "design" @@ -116,12 +112,7 @@ def test_design_missing_required_error_codes_are_hyphenated(): def test_design_missing_title_fails(): - text = ( - "## Context\n\nc\n\n" - "## User Need\n\nu\n\n" - "## Design\n\nd\n\n" - "## Constraints\n\nx\n" - ) + text = "## Context\n\nc\n\n## User Need\n\nu\n\n## Design\n\nd\n\n## Constraints\n\nx\n" assert "missing-title" in {i.code for i in validate(parse(text))} @@ -237,11 +228,7 @@ def test_minimal_design_reports_missing_recommended(): def test_incomplete_design_reports_missing_required_and_recommended(): result = improve_text( - "# D\n\n" - "## Context\n\nc\n\n" - "## User Need\n\nu\n\n" - "## Design\n\nd\n\n" - "## Rationale\n\nr\n" + "# D\n\n## Context\n\nc\n\n## User Need\n\nu\n\n## Design\n\nd\n\n## Rationale\n\nr\n" ) assert result.type == "design" assert result.missing_required == ["constraints"] @@ -278,8 +265,7 @@ def test_improve_human_lists_missing_with_guidance(capsys): def test_improve_does_not_modify_the_design(tmp_path): f = tmp_path / "design.md" content = ( - "# D\n\n## Context\n\nc\n\n## User Need\n\nu\n\n" - "## Design\n\nd\n\n## Constraints\n\nx\n" + "# D\n\n## Context\n\nc\n\n## User Need\n\nu\n\n## Design\n\nd\n\n## Constraints\n\nx\n" ) f.write_text(content) before = f.stat().st_mtime_ns @@ -309,9 +295,7 @@ def test_design_guidance_has_no_work_management_fields(): ] spec = spec_for("design") assert spec is not None - blob = " ".join( - line for lines in spec.guidance.values() for line in lines - ).casefold() + blob = " ".join(line for lines in spec.guidance.values() for line in lines).casefold() for field in forbidden_fields: assert field not in blob diff --git a/tests/test_diff.py b/tests/test_diff.py index f851dd22..4fa8cc9a 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -2,11 +2,11 @@ from __future__ import annotations -from rac.services.diff import diff -from rac.core.markdown import parse_file - from conftest import fixture_path +from rac.core.markdown import parse_file +from rac.services.diff import diff + def make_diff(): old = parse_file(fixture_path("diff", "old.md")) diff --git a/tests/test_dogfood.py b/tests/test_dogfood.py index bfc695af..b3d79077 100644 --- a/tests/test_dogfood.py +++ b/tests/test_dogfood.py @@ -29,10 +29,7 @@ def test_corpus_artifacts_validate_clean(): def test_corpus_relationships_resolve(): report = validate_relationships(CORPUS) - issues = [ - f"{i.code}: {i.target or i.identifier} ({i.source_path})" - for i in report.issues - ] + issues = [f"{i.code}: {i.target or i.identifier} ({i.source_path})" for i in report.issues] assert report.ok, f"corpus relationship issues: {issues}" diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py index 551592de..e3a295e8 100644 --- a/tests/test_frontmatter.py +++ b/tests/test_frontmatter.py @@ -73,9 +73,7 @@ def test_split_mid_document_rule_is_not_frontmatter(): def test_parse_valid_frontmatter(): - metadata, issues = parse_frontmatter( - "schema_version: 1\nid: RAC-01JY4M8X2QZ7\ntype: decision" - ) + metadata, issues = parse_frontmatter("schema_version: 1\nid: RAC-01JY4M8X2QZ7\ntype: decision") assert issues == [] assert metadata.schema_version == 1 assert metadata.id == "RAC-01JY4M8X2QZ7" @@ -84,9 +82,7 @@ def test_parse_valid_frontmatter(): def test_parse_normalizes_id_case(): - metadata, issues = parse_frontmatter( - "schema_version: 1\nid: rac-01jy4m8x2qz7" - ) + metadata, issues = parse_frontmatter("schema_version: 1\nid: rac-01jy4m8x2qz7") assert issues == [] assert metadata.id == "RAC-01JY4M8X2QZ7" @@ -188,15 +184,16 @@ def test_body_line_numbers_stay_file_accurate(): def test_requirement_line_numbers_stay_file_accurate(): # 3 frontmatter lines; "[REQ-1A] bad" is body line 9 → file line 12. text = ( - "---\nschema_version: 1\n---\n" - "# F\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-1A] bad\n" + "---\nschema_version: 1\n---\n# F\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-1A] bad\n" ) product = parse(text) assert product.malformed_requirements[0].line == 12 def test_validation_surfaces_frontmatter_issues(): - text = "---\nschema_version: 99\n---\n# T\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n" + text = ( + "---\nschema_version: 99\n---\n# T\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n" + ) issues = validate(parse(text)) assert has_errors(issues) assert "unsupported-schema-version" in [i.code for i in issues] diff --git a/tests/test_golden.py b/tests/test_golden.py index dd1ce060..97070cf7 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -35,8 +35,16 @@ ("validate_dir_json", ["validate", "tests/fixtures/portfolio", "--json"], 1), ("stats_human", ["stats", "tests/fixtures/valid"], 0), ("stats_json", ["stats", "tests/fixtures/valid", "--json"], 0), - ("diff_human", ["diff", "examples/example_dashboard_v1.md", "examples/example_dashboard_v2.md"], 0), - ("diff_json", ["diff", "examples/example_dashboard_v1.md", "examples/example_dashboard_v2.md", "--json"], 0), + ( + "diff_human", + ["diff", "examples/example_dashboard_v1.md", "examples/example_dashboard_v2.md"], + 0, + ), + ( + "diff_json", + ["diff", "examples/example_dashboard_v1.md", "examples/example_dashboard_v2.md", "--json"], + 0, + ), ("schema_requirement_human", ["schema", "requirement"], 0), ("schema_requirement_template", ["schema", "requirement", "--template"], 0), ("review_human", ["review", "tests/fixtures/portfolio"], 1), @@ -45,12 +53,20 @@ ("templates_json", ["templates", "--json"], 0), ("resolve_human", ["resolve", "RAC-01JY4M8X2QZ7", "tests/fixtures/resolve"], 0), ("resolve_json", ["resolve", "RAC-01JY4M8X2QZ7", "tests/fixtures/resolve", "--json"], 0), - ("resolve_not_found_json", ["resolve", "RAC-ZZZZZZZZZZZZ", "tests/fixtures/resolve", "--json"], 1), + ( + "resolve_not_found_json", + ["resolve", "RAC-ZZZZZZZZZZZZ", "tests/fixtures/resolve", "--json"], + 1, + ), ("find_human", ["find", "markdown", "tests/fixtures/resolve"], 0), ("find_json", ["find", "markdown", "tests/fixtures/resolve", "--json"], 0), ("relationships_resolved_human", ["relationships", "tests/fixtures/resolve"], 0), ("migrate_dry_run_human", ["migrate", "metadata", "tests/fixtures/migrate", "--dry-run"], 0), - ("migrate_dry_run_json", ["migrate", "metadata", "tests/fixtures/migrate", "--dry-run", "--json"], 0), + ( + "migrate_dry_run_json", + ["migrate", "metadata", "tests/fixtures/migrate", "--dry-run", "--json"], + 0, + ), ] diff --git a/tests/test_identity.py b/tests/test_identity.py index a066bf5f..b2515e1d 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -32,7 +32,10 @@ def test_identifier_spec_id_field(): def test_identifier_recognized_prefix_from_stem(): # Step 3: leading - prefix of the filename stem. product = parse("# Parser Strategy\n\n## Context\n\nc\n") - assert artifact_identifier(product, spec_for("decision"), "/x/adr-004-parser-strategy.md") == "adr-004" + assert ( + artifact_identifier(product, spec_for("decision"), "/x/adr-004-parser-strategy.md") + == "adr-004" + ) def test_identifier_falls_back_to_full_stem(): diff --git a/tests/test_idgen.py b/tests/test_idgen.py index 07d97d03..3f7dd585 100644 --- a/tests/test_idgen.py +++ b/tests/test_idgen.py @@ -39,7 +39,9 @@ def test_deterministic_under_injected_clock_and_entropy(): def test_distinct_entropy_distinct_ids_same_millisecond(): - clock = lambda: 1750000000.0 + def clock() -> float: + return 1750000000.0 + a = generate_id("RAC", clock=clock, entropy=lambda bits: 1) b = generate_id("RAC", clock=clock, entropy=lambda bits: 2) assert a != b diff --git a/tests/test_improve.py b/tests/test_improve.py index 5597ddc9..571e42d9 100644 --- a/tests/test_improve.py +++ b/tests/test_improve.py @@ -12,22 +12,19 @@ from pathlib import Path import pytest +from conftest import fixture_path +from rac.cli import main from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.core.classification import classify -from rac.cli import main +from rac.core.markdown import parse_file +from rac.core.validation import validate from rac.services.improve import improve_file, improve_text, supports_improve -from rac.core.markdown import parse, parse_file from rac.services.stats import collect_stats -from rac.core.validation import validate - -from conftest import fixture_path # A requirement missing a *required* section (Requirements) but still classifying # as a requirement — it keeps enough recommended sections to clear the threshold. -NO_REQUIREMENTS = ( - "# Feature\n\n## Problem\n\np\n\n## Success Metrics\n\n- m\n\n## Risks\n\n- r\n" -) +NO_REQUIREMENTS = "# Feature\n\n## Problem\n\np\n\n## Success Metrics\n\n- m\n\n## Risks\n\n- r\n" # --- service layer ---------------------------------------------------------- diff --git a/tests/test_index.py b/tests/test_index.py index 800cdac7..ce3696f7 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -12,8 +12,8 @@ import pytest -from rac.services.index import build_repository_index from rac.cli import main +from rac.services.index import build_repository_index # Reuse the portfolio fixture that already holds all five types + an unknown. ALL_TYPES = Path(__file__).parent / "fixtures" / "portfolio_summary" / "all_types" @@ -196,10 +196,7 @@ def test_cli_index_json_output(capsys): payload = json.loads(capsys.readouterr().out) assert payload["schema_version"] == "1" assert payload["artifact_count"] == 6 - assert all( - set(e) == {"id", "type", "title", "path", "aliases"} - for e in payload["artifacts"] - ) + assert all(set(e) == {"id", "type", "title", "path", "aliases"} for e in payload["artifacts"]) def test_cli_index_not_a_directory(tmp_path): diff --git a/tests/test_ingest.py b/tests/test_ingest.py index 28804161..454ebd52 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from conftest import fixture_path from rac.cli import main from rac.services.ingest import ( @@ -17,9 +18,6 @@ supported_extensions, ) -from conftest import fixture_path - - # --- service layer ---------------------------------------------------------- @@ -79,9 +77,7 @@ def test_cli_write_and_overwrite_guard(tmp_path, capsys): main(["ingest", fixture_path("ingest", "sample.md"), "-o", str(out)]) assert exc.value.code == 2 # --force overwrites. - assert main( - ["ingest", fixture_path("ingest", "sample.md"), "-o", str(out), "--force"] - ) == 0 + assert main(["ingest", fixture_path("ingest", "sample.md"), "-o", str(out), "--force"]) == 0 def test_cli_unsupported_exits_two(tmp_path): diff --git a/tests/test_init.py b/tests/test_init.py index 7cc7436d..ebb21e3b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,6 +11,7 @@ import pytest +from rac.cli import main from rac.services.init import ( InvalidRepositoryKey, MalformedRepositoryConfig, @@ -18,8 +19,6 @@ init_repository, load_repository_config, ) -from rac.cli import main - # --- service ----------------------------------------------------------------- @@ -47,9 +46,7 @@ def test_reinit_different_key_conflicts(tmp_path): assert load_repository_config(str(tmp_path)).repository_key == "RAC" -@pytest.mark.parametrize( - "key", ["", "R", "lowercase", "1LEADING", "TOOLONGKEY1", "BAD-CHARS"] -) +@pytest.mark.parametrize("key", ["", "R", "lowercase", "1LEADING", "TOOLONGKEY1", "BAD-CHARS"]) def test_invalid_keys_rejected(tmp_path, key): with pytest.raises(InvalidRepositoryKey): init_repository(str(tmp_path), key=key) diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 8089792f..f0ecb34b 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -7,18 +7,17 @@ from pathlib import Path import pytest +from conftest import fixture_path +from rac.cli import main from rac.core.artifacts import ARTIFACT_SPECS from rac.core.classification import CONFIDENCE_THRESHOLD, score_artifacts -from rac.cli import main +from rac.core.markdown import parse from rac.services.inspect import ( inspect_directory, inspect_file, inspect_text, ) -from rac.core.markdown import parse - -from conftest import fixture_path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -181,12 +180,16 @@ def test_dogfood_directory_targets(): # roadmap formats may remain Unknown until their schemas are formalized. roadmap = inspect_directory(str(REPO_ROOT / "rac/roadmaps")) paths_by_type = {f.path: f.type for f in roadmap.files} - assert paths_by_type[ - str(REPO_ROOT / "rac/roadmaps/v0.5.x-awareness/v0.5.2-schema.md") - ] == "requirement" - assert paths_by_type[ - str(REPO_ROOT / "rac/roadmaps/v0.6.x-relationships/v0.6.0-roadmap-artifacts.md") - ] == "requirement" + assert ( + paths_by_type[str(REPO_ROOT / "rac/roadmaps/v0.5.x-awareness/v0.5.2-schema.md")] + == "requirement" + ) + assert ( + paths_by_type[ + str(REPO_ROOT / "rac/roadmaps/v0.6.x-relationships/v0.6.0-roadmap-artifacts.md") + ] + == "requirement" + ) # The well-formed ADRs classify as Decision. adr = REPO_ROOT / "rac/decisions/adr-010-documents-are-not-artifacts.md" assert inspect_file(str(adr)).type == "decision" diff --git a/tests/test_metadata_identity.py b/tests/test_metadata_identity.py index 06aacf55..921f9373 100644 --- a/tests/test_metadata_identity.py +++ b/tests/test_metadata_identity.py @@ -48,10 +48,7 @@ def test_frontmatter_id_wins_precedence(): def test_identity_independent_of_filename_and_path(): product = parse(FRONTMATTER + DECISION_BODY) for path in ("a.md", "moved/elsewhere/renamed.md", "adr-001-x.md"): - assert ( - artifact_identifier(product, _spec(product), path) - == "RAC-01JY4M8X2QZ7" - ) + assert artifact_identifier(product, _spec(product), path) == "RAC-01JY4M8X2QZ7" def test_legacy_id_section_still_works_without_frontmatter(): @@ -61,10 +58,7 @@ def test_legacy_id_section_still_works_without_frontmatter(): def test_filename_fallback_unchanged_for_legacy_artifacts(): product = parse(DECISION_BODY) - assert ( - artifact_identifier(product, _spec(product), "adr-004-parser-strategy.md") - == "adr-004" - ) + assert artifact_identifier(product, _spec(product), "adr-004-parser-strategy.md") == "adr-004" def test_matching_frontmatter_and_legacy_identity_accepted(): @@ -93,9 +87,7 @@ def test_index_exposes_frontmatter_ids(tmp_path): def test_legacy_references_resolve_after_frontmatter_adoption(tmp_path): # Migration alias (Initiative 7): adopting a canonical ID must not break # existing human-readable references to the legacy identity. - (tmp_path / "adr-015-explorer.md").write_text( - FRONTMATTER + DECISION_BODY, encoding="utf-8" - ) + (tmp_path / "adr-015-explorer.md").write_text(FRONTMATTER + DECISION_BODY, encoding="utf-8") (tmp_path / "consumer.md").write_text( DECISION_BODY + "\n## Related Decisions\n\n- ADR-015: Explorer\n", encoding="utf-8", @@ -105,9 +97,7 @@ def test_legacy_references_resolve_after_frontmatter_adoption(tmp_path): def test_canonical_id_references_resolve(tmp_path): - (tmp_path / "target.md").write_text( - FRONTMATTER + DECISION_BODY, encoding="utf-8" - ) + (tmp_path / "target.md").write_text(FRONTMATTER + DECISION_BODY, encoding="utf-8") (tmp_path / "consumer.md").write_text( DECISION_BODY + "\n## Related Decisions\n\n- RAC-01JY4M8X2QZ7\n", encoding="utf-8", @@ -119,9 +109,7 @@ def test_canonical_id_references_resolve(tmp_path): def test_alias_never_creates_duplicate_identity(tmp_path): # Same file answering to several aliases is not a duplicate; duplicates # require two files sharing a *canonical* identifier. - (tmp_path / "adr-015-explorer.md").write_text( - FRONTMATTER + DECISION_BODY, encoding="utf-8" - ) + (tmp_path / "adr-015-explorer.md").write_text(FRONTMATTER + DECISION_BODY, encoding="utf-8") report = validate_relationships(str(tmp_path)) assert report.ok diff --git a/tests/test_migrate.py b/tests/test_migrate.py index 1e976b55..19da5a15 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -11,6 +11,7 @@ import pytest +from rac.cli import main from rac.core.markdown import parse_file from rac.core.validation import has_errors, validate from rac.services.create import MissingRepositoryConfig, create_artifact @@ -22,7 +23,6 @@ migrate_metadata, ) from rac.services.resolve import OUTCOME_RESOLVED, resolve_artifact -from rac.cli import main LEGACY_DECISION = """# A Legacy Decision @@ -158,9 +158,7 @@ def test_report_json_contract(repo): STATUS_MIGRATED, STATUS_SKIPPED_UNKNOWN, } - unknown = next( - f for f in payload["files"] if f["status"] == STATUS_SKIPPED_UNKNOWN - ) + unknown = next(f for f in payload["files"] if f["status"] == STATUS_SKIPPED_UNKNOWN) assert unknown["id"] is None and unknown["type"] is None diff --git a/tests/test_parser.py b/tests/test_parser.py index 9702b832..db69805f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,10 +2,10 @@ from __future__ import annotations -from rac.core.markdown import parse, parse_file - from conftest import fixture_path +from rac.core.markdown import parse, parse_file + def test_parses_basic_structure(): p = parse_file(fixture_path("valid", "feature.md")) @@ -45,12 +45,7 @@ def test_bullet_and_plain_requirements_both_parse(): def test_malformed_lines_are_captured_not_dropped(): - text = ( - "# T\n\n## Problem\n\nx\n\n## Requirements\n\n" - "[REQ-1A] bad id\n" - "no id at all\n" - "[REQ-002]\n" - ) + text = "# T\n\n## Problem\n\nx\n\n## Requirements\n\n[REQ-1A] bad id\nno id at all\n[REQ-002]\n" p = parse(text) assert p.requirements == [] kinds = {(m.bad_id, m.empty_text) for m in p.malformed_requirements} diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 3d8db758..7e003927 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -3,18 +3,16 @@ from __future__ import annotations import json -import os from pathlib import Path import pytest +from rac.cli import main from rac.services.portfolio import ( ATTENTION_BROKEN_RELATIONSHIP, ATTENTION_INVALID, - ATTENTION_MISSING_RECOMMENDED, build_portfolio_summary, ) -from rac.cli import main FIXTURES = Path(__file__).parent / "fixtures" / "portfolio_summary" diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 0c2c98fb..a82bf945 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -12,20 +12,18 @@ import io import json -import pytest +from conftest import fixture_path import rac.services.improve as improve_mod -from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.cli import main +from rac.core.artifacts import spec_for from rac.core.classification import classify -from rac.services.improve import improve_file, improve_text, supports_improve -from rac.services.inspect import inspect_file from rac.core.markdown import parse, parse_file from rac.core.schema import available_schemas, schema_reference -from rac.services.stats import collect_stats from rac.core.validation import has_errors, validate - -from conftest import fixture_path +from rac.services.improve import improve_file, supports_improve +from rac.services.inspect import inspect_file +from rac.services.stats import collect_stats def _stdin(monkeypatch, text: str) -> None: @@ -262,8 +260,7 @@ def test_improve_human_lists_missing_with_guidance(capsys): def test_improve_does_not_modify_the_prompt(tmp_path): f = tmp_path / "prompt.md" content = ( - "# P\n\n## Objective\n\no\n\n## Input\n\ni\n\n## Instructions\n\nx\n\n" - "## Output\n\ny\n" + "# P\n\n## Objective\n\no\n\n## Input\n\ni\n\n## Instructions\n\nx\n\n## Output\n\ny\n" ) f.write_text(content) before = f.stat().st_mtime_ns @@ -286,9 +283,7 @@ def test_prompt_guidance_has_no_work_management_fields(): ] spec = spec_for("prompt") assert spec is not None - blob = " ".join( - line for lines in spec.guidance.values() for line in lines - ).casefold() + blob = " ".join(line for lines in spec.guidance.values() for line in lines).casefold() for field in forbidden_fields: assert field not in blob diff --git a/tests/test_relationship_validation.py b/tests/test_relationship_validation.py index cfe75f41..49e0bdf7 100644 --- a/tests/test_relationship_validation.py +++ b/tests/test_relationship_validation.py @@ -13,6 +13,7 @@ import json import pytest +from conftest import fixture_path from rac.cli import main from rac.core.classification import classify @@ -26,8 +27,6 @@ validate_relationships_file, ) -from conftest import fixture_path - def _scenario(name: str) -> str: return fixture_path("relationship_validation", name) diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 5d4255bc..8c1ce806 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -15,16 +15,14 @@ import json import pytest +from conftest import fixture_path from rac.cli import main -from rac.services.inspect import inspect_file, inspect_text from rac.core.markdown import parse +from rac.core.validation import has_errors, validate +from rac.services.inspect import inspect_file, inspect_text from rac.services.relationships import parse_references -from rac.core.schema import schema_reference from rac.services.stats import collect_stats -from rac.core.validation import has_errors, validate - -from conftest import fixture_path # Each fixture and the relationship keys (snake_case) it should expose via inspect. # Note: keys are spec-driven — only relationship sections declared optional for diff --git a/tests/test_relationships_cmd.py b/tests/test_relationships_cmd.py index a98dfcb8..6b19a7e7 100644 --- a/tests/test_relationships_cmd.py +++ b/tests/test_relationships_cmd.py @@ -14,6 +14,7 @@ import json import pytest +from conftest import fixture_path from rac.cli import main from rac.services.relationships import ( @@ -21,8 +22,6 @@ build_relationship_report_file, ) -from conftest import fixture_path - # Reference counts (individual edges) over fixtures/relationships/, canonical order. EXPECTED_COUNTS = { "related_requirements": 5, @@ -187,7 +186,7 @@ def test_unknown_artifact_counted_but_not_extracted(tmp_path): f = tmp_path / "notes.md" f.write_text("# Notes\n\n## Random Musings\n\nstuff\n\n## Related Decisions\n\n- ADR-004\n") report = build_relationship_report(str(tmp_path)) - assert report.total_files == 1 # counted + assert report.total_files == 1 # counted assert report.artifacts_with_relationships == 0 # spec-driven: nothing extracted assert report.relationship_count == 0 assert report.artifacts == [] diff --git a/tests/test_resolve.py b/tests/test_resolve.py index e67576cd..06d9bbc2 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -13,6 +13,7 @@ import pytest +from rac.cli import main from rac.services.resolve import ( OUTCOME_DUPLICATE, OUTCOME_NOT_FOUND, @@ -20,7 +21,6 @@ find_artifacts, resolve_artifact, ) -from rac.cli import main CANONICAL_ID = "RAC-01JY4M8X2QZ7" @@ -63,12 +63,8 @@ @pytest.fixture def repo(tmp_path): (tmp_path / "decisions").mkdir() - (tmp_path / "decisions" / "markdown-first.md").write_text( - DECISION, encoding="utf-8" - ) - (tmp_path / "decisions" / "adr-002-legacy.md").write_text( - LEGACY_DECISION, encoding="utf-8" - ) + (tmp_path / "decisions" / "markdown-first.md").write_text(DECISION, encoding="utf-8") + (tmp_path / "decisions" / "adr-002-legacy.md").write_text(LEGACY_DECISION, encoding="utf-8") return tmp_path @@ -127,9 +123,7 @@ def test_duplicate_id_never_resolved_by_path_order(repo): def test_empty_repository_not_found(tmp_path): - assert resolve_artifact(str(tmp_path), "RAC-01JY4M8X2QZ7").outcome == ( - OUTCOME_NOT_FOUND - ) + assert resolve_artifact(str(tmp_path), "RAC-01JY4M8X2QZ7").outcome == (OUTCOME_NOT_FOUND) # --- search -------------------------------------------------------------------- @@ -305,9 +299,7 @@ def test_relationship_json_keeps_stored_references_unchanged(repo, capsys): rc = main(["relationships", str(repo), "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - consumer = next( - a for a in payload["artifacts"] if a["path"].endswith("consumer.md") - ) + consumer = next(a for a in payload["artifacts"] if a["path"].endswith("consumer.md")) # Stored references only — no resolved labels in the JSON contract. assert consumer["relationships"]["related_decisions"] == [CANONICAL_ID] assert "labels" not in payload diff --git a/tests/test_roadmap.py b/tests/test_roadmap.py index af09cf20..d8dd9659 100644 --- a/tests/test_roadmap.py +++ b/tests/test_roadmap.py @@ -12,20 +12,18 @@ import io import json -import pytest +from conftest import fixture_path import rac.services.improve as improve_mod -from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.cli import main +from rac.core.artifacts import ARTIFACT_SPECS, spec_for from rac.core.classification import classify -from rac.services.improve import improve_file, improve_text, supports_improve -from rac.services.inspect import inspect_file from rac.core.markdown import parse, parse_file from rac.core.schema import available_schemas, schema_reference -from rac.services.stats import collect_stats from rac.core.validation import has_errors, validate - -from conftest import fixture_path +from rac.services.improve import improve_file, improve_text, supports_improve +from rac.services.inspect import inspect_file +from rac.services.stats import collect_stats def _stdin(monkeypatch, text: str) -> None: @@ -233,9 +231,7 @@ def test_improve_separates_missing_required_from_recommended(): def test_improve_template_orders_required_before_recommended(monkeypatch, capsys): # Only Outcomes present -> Initiatives (required) must precede the recommended # sections in the emitted template (REQ-005). - text = ( - "# R\n\n## Outcomes\n\n- o\n\n## Assumptions\n\n- a\n\n## Risks\n\n- r\n" - ) + text = "# R\n\n## Outcomes\n\n- o\n\n## Assumptions\n\n- a\n\n## Risks\n\n- r\n" _stdin(monkeypatch, text) rc = main(["improve", "-", "--template"]) assert rc == 0 @@ -361,9 +357,7 @@ def test_improve_support_is_artifact_spec_driven(): # No artifact-specific improve engine exists: the public surface is generic. public = {name for name in vars(improve_mod) if not name.startswith("_")} artifact_specific = { - name - for name in public - if any(t in name for t in ("roadmap", "requirement", "decision")) + name for name in public if any(t in name for t in ("roadmap", "requirement", "decision")) } assert artifact_specific == set() @@ -383,8 +377,6 @@ def test_roadmap_guidance_has_no_work_management_fields(): ] spec = spec_for("roadmap") assert spec is not None - blob = " ".join( - line for lines in spec.guidance.values() for line in lines - ).casefold() + blob = " ".join(line for lines in spec.guidance.values() for line in lines).casefold() for field in forbidden_fields: assert field not in blob diff --git a/tests/test_schema.py b/tests/test_schema.py index c39daefa..a045f13d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,13 +6,12 @@ import json import pytest +from conftest import fixture_path -from rac.core.artifacts import spec_for from rac.cli import main +from rac.core.artifacts import spec_for from rac.core.schema import available_schemas, schema_reference -from conftest import fixture_path - def test_available_schemas_are_registered_artifacts(): assert available_schemas() == [ @@ -39,12 +38,7 @@ def test_schema_list_human(capsys): rc = main(["schema", "--list"]) assert rc == 0 assert capsys.readouterr().out == ( - "Available Schemas:\n" - "- requirement\n" - "- decision\n" - "- roadmap\n" - "- prompt\n" - "- design\n" + "Available Schemas:\n- requirement\n- decision\n- roadmap\n- prompt\n- design\n" ) @@ -52,9 +46,7 @@ def test_schema_list_json(capsys): rc = main(["schema", "--list", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload == { - "schemas": ["requirement", "decision", "roadmap", "prompt", "design"] - } + assert payload == {"schemas": ["requirement", "decision", "roadmap", "prompt", "design"]} def test_schema_human_requirement(capsys): diff --git a/tests/test_stats.py b/tests/test_stats.py index f5ebad11..25e76ca2 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -5,12 +5,11 @@ import json import pytest +from conftest import fixture_path from rac.cli import main from rac.services.stats import collect_stats -from conftest import fixture_path - def test_collect_counts(): s = collect_stats(fixture_path("portfolio")) @@ -111,9 +110,7 @@ def test_cli_stats_exits_one_when_no_valid_features(tmp_path, capsys): def test_cli_stats_exits_zero_with_one_valid_feature(tmp_path): - (tmp_path / "ok.md").write_text( - "# Ok\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n" - ) + (tmp_path / "ok.md").write_text("# Ok\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n") (tmp_path / "broken.md").write_text("## Problem\n\nno title\n") # One valid feature present -> exit 0 even though a broken file exists. assert main(["stats", str(tmp_path)]) == 0 diff --git a/tests/test_validate.py b/tests/test_validate.py index d7e49ba0..9b18ed7b 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -2,11 +2,20 @@ from __future__ import annotations -from rac.core.markdown import parse, parse_file -from rac.core.validation import has_errors, validate +import json from conftest import fixture_path +from rac.cli import main +from rac.core.markdown import parse, parse_file +from rac.core.validation import has_errors, validate +from rac.services.validate import ( + STATUS_INVALID, + STATUS_SKIPPED, + STATUS_VALID, + validate_directory, +) + def codes(issues): return {i.code for i in issues} @@ -54,9 +63,7 @@ def test_missing_problem(): def test_missing_requirements(): - assert "missing-requirements" in codes( - validate_fixture("invalid", "missing_requirements.md") - ) + assert "missing-requirements" in codes(validate_fixture("invalid", "missing_requirements.md")) def test_malformed_id(): @@ -103,16 +110,6 @@ def test_too_many_requirements_warning(): # Directory validation (v0.7.9) — `rac validate ` # --------------------------------------------------------------------------- -import json - -from rac.cli import main -from rac.services.validate import ( - STATUS_INVALID, - STATUS_SKIPPED, - STATUS_VALID, - validate_directory, -) - def test_directory_counts_valid_and_invalid(): result = validate_directory(fixture_path("portfolio"))