From 23c532677c108ada3dd757a5ae990db6e892cdae Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 17 Jun 2026 11:35:30 +0200 Subject: [PATCH] chore(release): promote dev to main for v1.23.1 --- .claude-plugin/plugin.json | 2 +- .claude/skills/promote/SKILL.md | 12 +- CHANGELOG.md | 116 ++++++++++++ CLAUDE.md | 13 +- FOUNDATION.md | 32 +++- README.md | 7 +- REPO_STRUCTURE.md | 5 + claude-hooks/block_protected_branches.sh | 31 ++-- docs/api-digest.md | 15 +- docs/cli-reference.md | 17 +- docs/configuration.md | 108 +++++++++++ docs/release-process.md | 70 +++++++ pyproject.toml | 1 + skills/test/SKILL.md | 43 +++++ src/forge/config.py | 72 ++++++-- src/forge/forge_config.py | 226 +++++++++++++++++++++++ src/forge/git_utils.py | 32 ++++ src/forge/install_bootstrap.py | 1 + src/forge/next_prep.py | 43 +++-- src/forge/verify_docstring_coverage.py | 97 +++++++--- src/forge/verify_plugin_version.py | 103 +++++++---- tests/test_config.py | 35 ++++ tests/test_forge_config.py | 102 ++++++++++ tests/test_git_utils.py | 45 +++-- tests/test_install_bootstrap.py | 7 +- tests/test_next_prep.py | 83 ++++++++- tests/test_verify_docstring_coverage.py | 78 +++++++- tests/test_verify_plugin_version.py | 67 ++++++- 28 files changed, 1287 insertions(+), 176 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/configuration.md create mode 100644 docs/release-process.md create mode 100644 skills/test/SKILL.md create mode 100644 src/forge/forge_config.py create mode 100644 tests/test_forge_config.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b33248d..baf770f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "1.21.0", + "version": "1.23.1", "description": "Automate Python CI/CD and code-quality standards — deterministic CLIs + a drop-in pre-commit hook, runnable with or without an AI agent. This optional Claude Code plugin orchestrates them; the gate is the CLI, never the model.", "author": { "name": "Jean Simonnet", diff --git a/.claude/skills/promote/SKILL.md b/.claude/skills/promote/SKILL.md index 6da1dfb..2889531 100644 --- a/.claude/skills/promote/SKILL.md +++ b/.claude/skills/promote/SKILL.md @@ -5,11 +5,15 @@ description: Forge-only — open a dev→main promotion PR when a MINOR or MAJOR # Promote dev → main (forge-only) +> **Spec:** [`docs/release-process.md`](../../../docs/release-process.md) is the +> source of truth for the versioning + promotion model and its invariant→test +> contract. This skill is the *operational* runbook for that model. + Opens a `dev → main` promotion PR after a MINOR (`Y+1, Z→0`) or MAJOR (`X+1, Y→0, Z→0`) bump to `.claude-plugin/plugin.json` lands on `dev`. PATCH-only bumps (`Z+1`) do NOT trigger promotion — `dev` -accumulates patches between minor releases per CLAUDE.md "plugin -manifest version is rolling-next." +accumulates patches between minor releases per +`docs/release-process.md` §1 (rolling-next). **Scope: forge repo only.** Lives at `.claude/skills/promote/SKILL.md` (project-local, not shipped via the @@ -62,7 +66,7 @@ Promotion pending — promote these in order (2): Skip entirely when it reports **"Up to date — nothing to promote"** (`main`'s minor ≥ `dev`'s minor; patch differences accumulate on `dev` -between releases per CLAUDE.md "rolling-next"), or when a promotion PR +between releases per `docs/release-process.md` §1), or when a promotion PR (base `main`) is already open (Step 2). Set `$NEW` to the **first** (lowest) listed release and promote that one. @@ -112,7 +116,7 @@ gh pr create --base main --head "release/v$NEW" \ $(git log --oneline origin/main..release/v$NEW) ## After merge -- [ ] Tag handling per CLAUDE.md \"Dual-track tag cadence\": \`/next\` (\`forge-next-prep --tag\`) already created \`v$NEW\` on the dev commit. Confirm the tag exists; follow that section for any main-side tagging. +- [ ] Tag handling per \`docs/release-process.md\` §2 (dual-track tag cadence): \`/next\` (\`forge-next-prep --tag\`) already created \`v$NEW\` on the dev commit. Confirm the tag exists; follow that section for any main-side tagging. - [ ] If more minors remain behind, promote the next one (repeat from Step 1). " ``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f18907 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +Notable changes to forge, by release on **`main`**. + +forge's slow channel (`@main`) ships **minor releases only** — patches +accumulate on `dev` between minors and fold into the next minor's +promotion. Pin `@main` to track the entries below; pin `@dev` for every +patch. Each entry corresponds to one `dev → main` promotion. + +**Reading this as a forge consumer.** You're usually jumping several +minors at once: read every entry newer than your current version, top to +bottom, and read each **⚠️ Upgrade notes** lane first — that's the +actions your repo may need (breaking changes, config, new mandatory +behavior). Releases without that lane are additive or internal and need +nothing from you. + +**Format.** Per release: an optional **⚠️ Upgrade notes** lane, then +change groups by conventional-commit type (**Features / Fixes / Refactor +/ Tooling / Docs / Chore**) mirroring the promotion squash message. +Follows [Keep a Changelog](https://keepachangelog.com/) in spirit; +versions follow forge's rolling-next convention. + +## v1.21.0 — 2026-06-12 + +### Features +- Require a `Requires:` line atop every issue (FOUNDATION convention). + +### Refactor / Tooling +- Promotion model: a dedicated `release/vX.Y.Z` branch is now required + (never a direct `dev → main` merge), with staged catch-up one minor at + a time, surfaced by the new read-only `forge-next-prep + --promotion-status` CLI. +- Remove dead `tomllib` import guards now that the Python floor is 3.11. + +## v1.20.0 — 2026-06-12 + +### ⚠️ Upgrade notes +- **Python floor raised to 3.11.** `forge-scripts` no longer installs on + Python 3.10 (it uses `datetime.UTC` / `tomllib`, both 3.11+ stdlib). + Move your repo and CI to Python ≥ 3.11 before upgrading forge. +- **Slow-tests CI recipe changed.** If you adopt the slow-tests report, + pass `--durations` explicitly on the pytest command — + `pytest --durations=25 --durations-min=1.0 | tee code_health/pytest.log`. + A bare `pytest` yields an empty report: the durations flags live in + forge's *own* `pyproject.toml`, not yours. + +### Features +- `forge-slow-tests-report` CLI: parses pytest `--durations`, merges + across batches, and ranks the slowest tests — a read-only reporter for + CI and local runs (#29). +- Raise the Python floor to 3.11 — `requires-python >= 3.11`, ruff target + `py311` (#29). + +### Tests / Docs +- Test-doc audit fixes; document the dev tag cadence; the CI recipe now + passes `--durations` explicitly so the slow-tests report works in any + consumer repo regardless of its pytest config (#27, #29). + +## v1.19.0 — 2026-06-12 + +### Features +- Consumer hook-extension directories — `post-merge.d` / `post-checkout.d` + run consumer `*.sh` scripts after the managed hook (sorted, + failure-tolerant, interactive-only, and surviving hook refresh). + Additive and opt-in; drop scripts in those dirs to use it. + +## v1.18.0 — 2026-06-12 + +### ⚠️ Upgrade notes +- **New `block_branch_deletion` hook.** Claude Code agents can no longer + delete a protected remote branch (`base_branch` / `dev_branch`). No + action unless you relied on an agent doing that — run the delete + yourself with `! …` instead. + +### Features +- `block_branch_deletion` hook — blocks agents from deleting protected + remote branches. + +## v1.17.0 — 2026-06-12 + +### ⚠️ Upgrade notes +- **Hook-version sidecar.** Managed git hooks now read their version from + a per-clone `.githooks/.forge-hook-version` file (keeps tracked + `.githooks/*` byte-stable across bumps). Add `.githooks/.forge-hook-version` + to your `.gitignore` — the installer does not write the ignore rule for + you. +- **Two new foundation agents** — `forge:test-advisor` + `forge:test-writer` + become available after `/plugin update forge@forge` + `/reload-plugins`. + +### Features +- Add the `forge:test-advisor` + `forge:test-writer` foundation agents + and the testing-documentation policy they enforce (fixtures excluded + from `Args`, structured mock docs, Null-Objects-over-Mock; interrogate + `ignore-nested-functions` + ruff `D417` in tests) — 12 foundation + agents total. +- Per-clone conda env name via `.conda_env_name`, so parallel forge + clones each get their own environment (opt-in: drop a `.conda_env_name` + file at the repo root). + +### Fixes +- `forge-post-merge` now accepts git's squash-flag positional argument + (it had been exiting 2 on every merge, killing the drift check and the + hook self-refresh). +- Store the git-hook version in a gitignored sidecar so tracked + `.githooks/*` stay byte-stable across version bumps. + +### Docs / Chore +- Complete the README CLI and pre-commit reference tables. +- Share forge-standard CI permissions; allow `-D` for merged branches. + +## v1.16.1 — 2026-06-11 + +### Chore +- Initial published artifacts: git hooks, `docs/api-digest.md`, and + `docs/cli-reference.md` generated at forge 1.16.1; README refreshed + around the guardrails thesis. diff --git a/CLAUDE.md b/CLAUDE.md index d667e5c..ecaeaa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,13 +13,12 @@ ## Forge-specific rules - **Version derivation**: pip package version comes from the latest git tag via setuptools-scm. There is no manual `version = "x.y.z"` in `pyproject.toml`. Release flow: `git tag vX.Y.Z && git push origin vX.Y.Z`. -- **Plugin manifest version is rolling-next**: `.claude-plugin/plugin.json["version"]` always names the version about to be released. `step_plugin_version` in `forge-precommit` enforces `plugin.json > latest_tag` on every commit (skipped on the release commit itself). Workflow: - 1. On the release PR, set `plugin.json["version"]` to the version about to be tagged (e.g. `"1.1.2"`). - 2. Merge the PR, then `git tag vX.Y.Z` at the merge commit. The guard skips that single commit (`HEAD` == tag commit). - 3. Immediately after tagging, the next PR must bump `plugin.json` to `"X.Y.(Z+1)"` (or a higher minor/major) — otherwise commits fail the guard. -- **Dual-track tag cadence** (disambiguates "the merge commit" in step 2 above for forge's `base_branch=main` / `dev_branch=dev` model): **tag `dev` `vX.Y.Z` after *every* merge to dev.** This is the dev-channel release and is mandatory — `forge-next-prep --tag` (run by `/next` Phase 1) does it automatically when `plugin.json` is ahead of the latest tag, so **never drop `--tag`**. `main` is advanced and tagged **only at the minor-boundary promotion** (`/promote`), where the minor `vX.Y.0` is (re-)tagged on main's squash commit. Net: `@dev` consumers receive every version (patch + minor); `@main` receives minors only. **Skipping a promotion does NOT mean skipping the dev tag** — the per-merge dev tag and the dev→main promotion are independent steps. (Caveat: because promotion squashes, a minor re-tagged onto main's squash commit is not reachable from dev's history; that is expected — `@dev` resolves the tag on the dev commit, `@main` resolves it on the main commit.) -- **`/promote` skill (forge-only)**: lives at `.claude/skills/promote/SKILL.md` — project-local, **not** shipped via the forge plugin. After merging a PR to `dev` that bumped `plugin.json` past a minor boundary (MINOR or MAJOR), invoke `/promote` to open the `dev → main` promotion PR with a release-summary squash message. Forge-private because the dev/main two-branch model + rolling-next convention are specific to forge; consumer plugin authors may follow trunk-based, gitflow, or other release models. -- **Semver policy for the plugin bump** — when the next-PR bump in step 3 above lands, choose the increment deliberately, not reflexively. The plugin's public surface is: every CLI in `[project.scripts]`, every agent name + canonical Output shape under `agents/`, every Claude Code hook under `claude-hooks/`, every skill under `skills/`, every FOUNDATION rule a consumer can rely on. +- **Release process — versioning, tag cadence, and `dev → main` promotion** are specified in **[`docs/release-process.md`](docs/release-process.md)**, the single source of truth + the **invariant→test contract**. Do not restate its mechanics elsewhere; point here. Operational summary: + - `.claude-plugin/plugin.json["version"]` is **rolling-next** — always names the version about to be released; `step_plugin_version` enforces `plugin.json > latest_tag` on every commit (skipped when `HEAD` reproduces a tagged release's tree). After tagging `vX.Y.Z`, the next PR must bump it. + - Tag `dev` `vX.Y.Z` after **every** merge to dev (`forge-next-prep --tag`, run by `/next` — **never drop `--tag`**); tag `main` **only** at the minor-boundary promotion. `@dev` gets every version, `@main` minors only. + - Promote `dev → main` **one minor at a time** via a `release/vX.Y.Z` branch (never `--head dev`) — the **`/promote`** skill (`.claude/skills/promote/SKILL.md`, forge-only, not shipped via the plugin). + - **Every versioning/promotion invariant names an enforcing test in the spec.** Do NOT change that code without the matching spec test staying green — e.g. the guard skips on *any* tagged-release tree (not just the latest), required for staged catch-up, locked by `test_main_skips_when_head_reproduces_older_tag`. This contract exists because #43 silently broke that behavior with no test to catch it. +- **Semver policy for the plugin bump** — when the post-tag rolling-next bump PR lands (see [`docs/release-process.md`](docs/release-process.md) §1), choose the increment deliberately, not reflexively. The plugin's public surface is: every CLI in `[project.scripts]`, every agent name + canonical Output shape under `agents/`, every Claude Code hook under `claude-hooks/`, every skill under `skills/`, every FOUNDATION rule a consumer can rely on. - **PATCH (Z+1)** — internal-only changes: bug fix in an existing CLI / agent / hook with no behavior change visible to consumers; refactor with identical externals; doc typo fix; non-blocking new audit check; CLAUDE.md edits that affect only forge contributors. - **MINOR (Y+1, Z→0)** — additive, backward-compatible: new CLI, new agent, new hook, new skill, new mandatory rule in FOUNDATION or `_TEMPLATE.md` that does NOT break existing consumer artifacts (e.g. a new reporter-agent header that older agents simply lack — audit flags it but does not refuse a commit), new mode/flag on an existing CLI or agent, new pre-commit step that self-skips when not opted in. - **MAJOR (X+1, Y→0, Z→0)** — breaking: CLI renamed / removed / argument-incompatible; agent renamed / removed / Output shape changed in a way that breaks existing parsers (e.g. `pr-manager`); hook semantics inverted (allow → block); FOUNDATION rule promoted from soft to blocking; manifest layout change. Any consumer upgrade that requires action beyond `forge-upgrade` is MAJOR. diff --git a/FOUNDATION.md b/FOUNDATION.md index 44d2bad..6c9c485 100644 --- a/FOUNDATION.md +++ b/FOUNDATION.md @@ -129,8 +129,11 @@ cheaper than reverting. divergence first: `git fetch origin && git log origin/`. - **NEVER add Claude/AI attribution** in commits, PRs, or merge messages (no `Co-Authored-By`, no `Generated with Claude`, no AI references). -- **NEVER push directly to `main`**. Always create a feature branch first. - The `block_protected_branches` hook enforces this for agents. +- **NEVER push directly to a protected branch** (`base_branch` / + `dev_branch`, default `main` / `dev`). Always create a feature branch + first. The `block_protected_branches` hook enforces this for agents — + defaulting to the same `main` + `dev` set as the sibling + `block_branch_deletion` hook. - **NEVER merge PRs autonomously.** Merging is the user's decision — produce the squash-merge message and wrap-up comment, then stop. The `block_pr_merge` hook enforces this for agents (blocks `gh pr merge` and @@ -481,9 +484,28 @@ layer would be redundant with ruff. every standard interrogate key (threshold, `exclude`, `ignore-*` flags). The foundation default threshold is `fail-under = 90`; tighten per §4 ("threshold = current passing baseline. Raise over -time."). Opt into badge generation with -`[tool.forge.docstring_coverage] badge = true` — writes -`.badges/DocstringCoverage.svg` for README embedding. +time."). + +Forge reads interrogate's **native** section directly and does **not** +wrap it: re-exposing a third-party tool's whole config surface under a +forge namespace (plus a key-name mapping to maintain) is a needless +wrapper — the tool's own section is the right home, exactly as forge +reads `ruff.toml` rather than copying it. Only keys interrogate has no +concept of live under `[tool.forge.docstring_coverage]`: `badge = true` +(writes `.badges/DocstringCoverage.svg`) and `paths` (a per-tool scan-root +override that otherwise defaults to the repo-wide layout +`[tool.forge].source_dirs + test_dirs`). **Project layout** is itself a +`[tool.forge]` single-ground-truth: `source_dirs` (default `["src"]`) and +`test_dirs` (default `["tests"]`) — split source-vs-test so a source-only +tool doesn't pull test dirs in — so every layout-aware tool reads the +repo's roots from one place. **Config-home rule:** a forge tool that +wraps a third-party library reads the library's native config section +directly; only forge-specific keys are namespaced under +`[tool.forge.]`. `forge-config --list` enumerates every +`[tool.forge.*]` key forge reads and names the native sections (like +`[tool.interrogate]`) it reads too — so the config surface is +discoverable without doc-hunting, and `install-forge-bootstrap` surfaces +it as a post-install nudge. ### Testing documentation standards diff --git a/README.md b/README.md index d7c69a1..2cf68d7 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,11 @@ edit `.githooks/pre-commit` directly. No plugin system, no config file. | Category | Items | |---|---| -| **CLIs** (pip package, no Claude required) | `install-forge-bootstrap` (one-shot umbrella), `forge-upgrade` (two-phase upgrade flow), `forge-precommit` (full sequence dispatcher), `fix-forge-ruff` (ruff phase), `verify-forge-docstrings`, `verify-forge-docstring-coverage`, `verify-forge-repo-structure`, `verify-forge-test-naming`, `verify-forge-manifest`, `verify-forge-plugin-version`, `verify-forge-cli-wiring`, `forge-continuation-append`, `forge-next-prep`, `install-forge-labels`, `forge-doctor`, `install-forge-githooks`, `install-forge-claude-md` | +| **CLIs** (pip package, no Claude required) | `install-forge-bootstrap` (one-shot umbrella), `forge-upgrade` (two-phase upgrade flow), `forge-precommit` (full sequence dispatcher), `fix-forge-ruff` (ruff phase), `verify-forge-docstrings`, `verify-forge-docstring-coverage`, `verify-forge-repo-structure`, `verify-forge-test-naming`, `verify-forge-manifest`, `verify-forge-plugin-version`, `verify-forge-cli-wiring`, `forge-continuation-append`, `forge-next-prep`, `forge-config` (config reference + setup advisor — see [`docs/configuration.md`](docs/configuration.md)), `install-forge-labels`, `forge-doctor`, `install-forge-githooks`, `install-forge-claude-md` | | **Audit-pack CLIs** (pip package, optional `[audit]` extras) | `forge-audit-dup`, `forge-audit-deps`, `forge-audit-suppressions`, `forge-audit-orphans`, `forge-audit-data`, `forge-audit-claims`, `forge-audit-agents` (non-blocking template-conformance audit), `forge-audit-all` — see [`docs/audit-pack.md`](docs/audit-pack.md) | | **Git hooks** (drop-in, no Claude required) | `.githooks/pre-commit` (dispatcher), `.githooks/post-merge` + `.githooks/post-checkout` (auto-warn on FOUNDATION.md drift) | | **Process docs** | `docs/security.md`, `docs/audit-pack.md`, `docs/cli-reference.md` (generated CLI reference), `docs/api-digest.md` (generated index of all top-level functions/classes, public API + internal helpers); foundation engineering principles at `FOUNDATION.md` | -| **Claude Code plugin** (optional) | Agents (`pr-manager`, `precommit-fixer`, `git-commit-push`, `design-checker`, `docs-types-checker`, `security-checker`, `issue-triage`, `perf-optimizer`, `weekly-summary`, `knowledge-search`, `test-advisor`, `test-writer`); skills (`commit`, `pr`, `next`, `triage`, `weekly`, `fix`, `review`); Claude Code hooks (`block_protected_branches`, `block_force_push`, `block_pr_merge`, `block_branch_deletion`, `block_no_verify`, `block_install_deps`, `block_claude_attribution`, `block_continuation_delete`, `block_protected_files`, `check_commit_format`, `check_foundation_sync`, `warn_pr_checks`, `block_raw_ruff`, `block_raw_git`) | +| **Claude Code plugin** (optional) | Agents (`pr-manager`, `precommit-fixer`, `git-commit-push`, `design-checker`, `docs-types-checker`, `security-checker`, `issue-triage`, `perf-optimizer`, `weekly-summary`, `knowledge-search`, `test-advisor`, `test-writer`); skills (`commit`, `pr`, `next`, `triage`, `weekly`, `fix`, `review`, `test`); Claude Code hooks (`block_protected_branches`, `block_force_push`, `block_pr_merge`, `block_branch_deletion`, `block_no_verify`, `block_install_deps`, `block_claude_attribution`, `block_continuation_delete`, `block_protected_files`, `check_commit_format`, `check_foundation_sync`, `warn_pr_checks`, `block_raw_ruff`, `block_raw_git`) | Everything in the first three rows is **Claude-independent** — works from any shell, CI, or IDE. @@ -164,10 +164,11 @@ order. Idempotent — re-run safely after every forge upgrade. | 5 | `forge-gen-cli-reference` | `docs/cli-reference.md` (generated from each CLI's `--help`) | | 6 | `forge-audit-deps --tree` | `code_health/audit_deps_tree.log` (dependency tree) | | 7 | `forge-doctor` | Verifies the install | +| 8 | `forge-config` | Post-install nudge: what `[tool.forge.*]` config forge reads + what to set ([`docs/configuration.md`](docs/configuration.md)) | Flags: `--check` (dry-run), `--skip ` (repeatable; slugs are `githooks`, `claude-md`, `labels`, `api-digest`, `cli-reference`, -`audit-deps`, `doctor`), `--strict` (abort on first failure; default is +`audit-deps`, `doctor`, `config`), `--strict` (abort on first failure; default is continue-on-fail). Want to run an installer on its own? Each step is also a standalone CLI diff --git a/REPO_STRUCTURE.md b/REPO_STRUCTURE.md index 0424a5b..f714aa8 100644 --- a/REPO_STRUCTURE.md +++ b/REPO_STRUCTURE.md @@ -29,6 +29,7 @@ Code. - next_prep.py: `forge-next-prep` — refresh main, optional rolling-next tag bump, prune stale branches; used by `/next` skill - continuation_append.py: `forge-continuation-append` — single source of truth for `.plan/CONTINUATION.md` append format; called by `forge:git-commit-push` and `forge:pr-manager` - slow_tests_report.py: `forge-slow-tests-report` — parses pytest `--durations` sections from a log (or stdin), merges across batches, prints the slowest tests; read-only CI/local reporter (exempt in `cli_wiring_exempt.toml`) + - forge_config.py: `forge-config` — lists every `[tool.forge.*]` key forge reads (value/default + description), names native sections like `[tool.interrogate]`, and advises on recommended-but-unset config; read-only, surfaced by `install-forge-bootstrap` - fix_ruff.py: `fix-forge-ruff` — runs `ruff format` + `ruff check --fix --unsafe-fixes`, re-stages modified tracked files, writes `code_health/ruff.log` - verify_docstrings.py: `verify-forge-docstrings` — docstring accuracy - verify_docstring_coverage.py: `verify-forge-docstring-coverage` — full-codebase docstring coverage % (interrogate wrapper) + optional `.badges/DocstringCoverage.svg` @@ -96,6 +97,7 @@ subdirectory holds a single `SKILL.md`: - next/: clean up state and pick next task - pr/: full PR finalization flow - review/: address PR review comments +- test/: write tests via the test agents (advisor → writer → review → precommit-fixer) - triage/: issue backlog triage - weekly/: weekly summary report @@ -187,6 +189,8 @@ Forge's own bootstrap tooling (not a consumer pattern): - ci-access.md: how a consumer's CI runner pulls forge - claude-code-plugin.md: optional Claude Code plugin install + extension - cli-reference.md: generated CLI reference (`forge-gen-cli-reference`) +- configuration.md: complete `[tool.forge.*]` config reference + setup guide (written counterpart to `forge-config --list`) +- release-process.md: forge-only single source of truth for versioning + dev→main promotion + the invariant→test contract - customizing-precommit.md: adding repo-specific steps to `.githooks/pre-commit` - security.md: security policy and review documentation - standalone-installers.md: per-installer reference for manual usage (sibling of `install-forge-bootstrap`) @@ -204,6 +208,7 @@ Forge's own bootstrap tooling (not a consumer pattern): - FOUNDATION.md: shared engineering principles (single source of truth) - README.md: main repository documentation - REPO_STRUCTURE.md: this file + - CHANGELOG.md: main-only release history (Keep a Changelog format) - CONTRIBUTING.md: contribution guidelines - LICENSE: MIT license diff --git a/claude-hooks/block_protected_branches.sh b/claude-hooks/block_protected_branches.sh index e8b9f4b..7408a0c 100755 --- a/claude-hooks/block_protected_branches.sh +++ b/claude-hooks/block_protected_branches.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Block direct `git commit` / `git push` on protected branches. Defaults -# to blocking `main`; the protected list can be overridden by setting +# to blocking `main` and `dev` (FOUNDATION §2); the protected list can be +# overridden by setting # `[tool.forge] base_branch` / `dev_branch` in pyproject.toml (used by # forge's own repo for its release workflow — most consumers leave it # alone). @@ -13,12 +14,13 @@ # intentional: the hook fires on every `git commit` / `git push` and # must not require forge-scripts to be installed or importable. # -# Failure posture: advisory and default-permissive. On any failure -# (missing python3, no pyproject, malformed TOML, tomllib unavailable on -# Python 3.10), the protected list collapses to `["main"]`. Blocking -# every commit because of a parse failure would be more disruptive than -# trusting the contributor to know what branch they're on; GitHub branch -# protection remains the authoritative gate. +# Failure posture: fail safe to the default protected set. On any +# failure (missing python3, no pyproject, malformed TOML, tomllib +# unavailable on Python 3.10), the protected list collapses to +# `["main", "dev"]` — the documented default (FOUNDATION §2), matching +# the sibling `block_branch_deletion` hook. A parse failure never +# *narrows* protection; GitHub branch protection remains the +# authoritative gate. set -e INPUT=$(cat) COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') @@ -38,33 +40,30 @@ branch=$(git -C "${REPO_ROOT:-.}" branch --show-current 2>/dev/null) # Read the protected-branch list from pyproject.toml. python3 is already a # hard dependency of every forge repo (forge IS Python), so this is safe. -# Defaults to "main" when: +# Defaults to "main" + "dev" when: # - pyproject.toml is missing # - tomllib is unavailable (Python < 3.11) # - [tool.forge] is empty or absent # Emits one branch per line. Portable to bash 3.2 (macOS default) — no # mapfile / readarray. -protected=$(python3 - "${REPO_ROOT:-.}" <<'PY' 2>/dev/null || echo main +protected=$(python3 - "${REPO_ROOT:-.}" <<'PY' 2>/dev/null || printf 'main\ndev\n' import sys from pathlib import Path try: import tomllib except ImportError: - print("main") - raise SystemExit(0) + print("main"); print("dev"); raise SystemExit(0) root = Path(sys.argv[1]) pp = root / "pyproject.toml" if not pp.is_file(): - print("main") - raise SystemExit(0) + print("main"); print("dev"); raise SystemExit(0) try: data = tomllib.loads(pp.read_text()) except Exception: - print("main") - raise SystemExit(0) + print("main"); print("dev"); raise SystemExit(0) section = data.get("tool", {}).get("forge", {}) base = section.get("base_branch", "main") -dev = section.get("dev_branch", "main") +dev = section.get("dev_branch", "dev") print(base) if dev != base: print(dev) diff --git a/docs/api-digest.md b/docs/api-digest.md index bcb89cb..74adb2e 100644 --- a/docs/api-digest.md +++ b/docs/api-digest.md @@ -4,7 +4,7 @@ A compact index of this codebase's symbols — every top-level function and clas > **Generated file — do not edit by hand.** Regenerate with `forge-gen-api-digest`; check for drift with `forge-gen-api-digest --check`. -_39 modules, 356 symbols._ +_40 modules, 362 symbols._ ## `forge._hook_helpers` @@ -160,6 +160,7 @@ _39 modules, 356 symbols._ - `class ForgeConfig` — Branch-name configuration sourced from ``[tool.forge]``. - `dual_track(self) -> bool` — Return ``True`` when base and dev are distinct branches. +- `read_pyproject_raw(repo_root: Path) -> dict` — Return the full parsed ``pyproject.toml`` dict, or ``{}`` on failure. - `load_config(repo_root: Path) -> ForgeConfig` — Read ``[tool.forge]`` from *repo_root*'s ``pyproject.toml``. ## `forge.continuation_append` @@ -192,6 +193,14 @@ _39 modules, 356 symbols._ - `_validate_dirs(repo_root: Path, dirs: list[str]) -> list[str]` _(internal)_ — Ensure every entry in *dirs* resolves inside *repo_root*. - `main() -> int` — Apply ruff fixes and write ``code_health/ruff.log``. +## `forge.forge_config` + +- `class ConfigKey` — One ``[tool.forge.*]`` key forge reads in a consumer repo. +- `_lookup(data: dict, path: tuple[str, ...]) -> object` _(internal)_ — Return the value at *path* in nested *data*, or ``_UNSET`` if absent. +- `_section_of(key: ConfigKey) -> str` _(internal)_ — Return the section header (path without the leaf key) for *key*. +- `build_report(data: dict) -> list[str]` — Build the ``forge-config`` report lines from parsed pyproject data. +- `main() -> int` — Entry point for ``forge-config``. + ## `forge.gen_api_digest` - `class Symbol` — One top-level symbol extracted from a module. @@ -242,6 +251,7 @@ _39 modules, 356 symbols._ - `configure_cli_logging() -> None` — Apply forge's canonical CLI logging setup. - `emit(msg: str) -> None` — Write *msg* to stdout with a trailing newline. - `parse_semver(version: str) -> tuple[int, int, int] | None` — Parse the leading ``X.Y.Z`` (optional ``v`` prefix) of a version string. +- `latest_v_tag(root: Path) -> str | None` — Return the highest ``v*`` git tag by semver sort, or ``None`` if none. - `require_cli(name: str, *, caller: str | None = None) -> None` — Abort with a clear install hint if *name* isn't on PATH. - `write_step_log(repo_root: Path, name: str, output: str) -> Path` — Write *output* to ``code_health/.log`` under *repo_root*. - `capturing_to_step_log(repo_root: Path, name: str) -> Iterator[None]` — Tee root-logger output into ``code_health/.log`` for the block. @@ -319,7 +329,6 @@ _39 modules, 356 symbols._ - `_promotion_status_lines(repo_root: Path, dev_branch: str, base_branch: str) -> list[str]` _(internal)_ — Build the read-only promotion-status report. - `_git(*args: str, cwd: Path | None = None, check: bool = True) -> str` _(internal)_ — Run ``git`` with *args*, return stripped stdout. - `_read_plugin_version(repo_root: Path) -> str | None` _(internal)_ — Return ``.claude-plugin/plugin.json["version"]`` or ``None`` if absent. -- `_latest_v_tag(repo_root: Path) -> str | None` _(internal)_ — Return the highest ``v*`` git tag by sort-V, or ``None`` if none. - `_is_newer(plugin_ver: str, latest_tag: str | None) -> bool` _(internal)_ — Return True when ``v`` would sort *after* ``latest_tag``. - `_maybe_tag_release(repo_root: Path) -> str | None` _(internal)_ — Tag and push ``v`` when newer than the latest tag. - `_gone_branches(repo_root: Path) -> list[str]` _(internal)_ — Return local branch names whose tracking remote is ``[origin/...: gone]``. @@ -425,11 +434,11 @@ _39 modules, 356 symbols._ ## `forge.verify_docstring_coverage` -- `_read_pyproject(repo_root: Path) -> dict` _(internal)_ — Load ``pyproject.toml`` from *repo_root*, or ``{}`` when absent. - `_interrogate_config(data: dict) -> tuple[InterrogateConfig, float, list[str]]` _(internal)_ — Build the interrogate config + threshold + excludes from TOML data. - `_badge_enabled(data: dict) -> bool` _(internal)_ — Return True when the consumer opted into badge generation. - `_write_badge(repo_root: Path, results: object) -> Path` _(internal)_ — Write a coverage SVG badge under ``.badges/`` and return its path. - `_emit_missing_list(results: object) -> None` _(internal)_ — Print a parseable ``MISSING:`` section listing every undocumented symbol. +- `_scan_paths(data: dict, repo_root: Path) -> list[str]` _(internal)_ — Resolve the docstring-coverage scan roots from config, safely. - `main() -> int` — CLI entry point for ``verify-forge-docstring-coverage``. ## `forge.verify_docstrings` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e4f40b2..9bf1f36 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -195,6 +195,20 @@ options: code_health/audit_.log. ``` +## forge-config + +```text +usage: forge-config [-h] [--list] + +List the [tool.forge.*] config forge reads in this repo, with current values / +defaults, the native tool sections forge reads, and advice on recommended-but- +unset keys. + +options: + -h, --help show this help message and exit + --list List forge config + advice (the default action). +``` + ## forge-continuation-append ```text @@ -439,7 +453,8 @@ options: --check Dry-run. Each step that supports --check runs in check mode; others just print their intent. --skip SLUG Skip a step by slug. Repeatable. Known slugs: githooks, claude- - md, labels, api-digest, cli-reference, audit-deps, doctor. + md, labels, api-digest, cli-reference, audit-deps, doctor, + config. --strict Abort on the first failed step. Default is continue-on-fail. ``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4de6ca7 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,108 @@ +# Configuring forge in your repo + +forge reads all of its configuration from your repo's **`pyproject.toml`** — +there is no separate `forge.toml` or config file to manage. This page is the +complete reference. + +**For a live view of what forge reads in *your* repo right now** — current +values, unset defaults, and what you should add — run: + +```bash +forge-config --list +``` + +`install-forge-bootstrap` prints the same advisor as a post-install nudge. The +two are the runtime counterpart to this page: this doc is the reference, the +CLI is the per-repo answer. + +--- + +## Where forge config lives — two homes, on purpose + +1. **`[tool.forge.*]`** — forge's own namespace. Every key forge invented. +2. **A third-party tool's native section** (e.g. `[tool.interrogate]`) — when + forge wraps a real tool, it reads the tool's **own** config section directly + and does **not** copy it under `[tool.forge.*]`. Re-exposing a tool's whole + config under a forge namespace would be a needless wrapper; forge defers to + the native section, exactly as it reads `ruff.toml` rather than duplicating + it. These native sections are listed below so you know forge reads them. + (FOUNDATION §8 "config-home rule".) + +You never edit forge's source to configure it — only `pyproject.toml`. + +--- + +## Quick start + +Most repos need only this: + +```toml +[tool.forge] +base_branch = "main" +dev_branch = "dev" # omit for a single-branch repo — it defaults to base_branch ("main") +``` + +Everything else has a sensible default and is opt-in. Run `forge-config --list` +to see what, if anything, is worth adding for your repo. + +--- + +## `[tool.forge]` + +| Key | Default | What it does | Set it when | +|---|---|---|---| +| `base_branch` | `"main"` | Slow-channel / release branch. Protected from direct agent push; the `dev → main` promotion target. | Your release branch isn't `main`. | +| `dev_branch` | `"main"` (= `base_branch`) | Fast-channel integration branch. Protected from direct agent push. **Defaults to `base_branch`** → single-track, only one branch protected. | You run dual-track — set e.g. `dev_branch = "dev"` to opt in. | +| `source_dirs` | `["src"]` | Repo **source** roots — the single ground truth for your project layout, consumed by layout-aware tools (e.g. docstring-coverage scan roots). | Your source lives outside `src/` — e.g. `source_dirs = ["src", "projects"]`. | +| `test_dirs` | `["tests"]` | Repo **test** roots. Kept separate from `source_dirs` so a source-only tool doesn't pull test dirs in. | Your tests aren't under `tests/`. | + +## `[tool.forge.cli_wiring]` + +| Key | Default | What it does | Set it when | +|---|---|---|---| +| `enabled` | `false` | Opt into the `cli_wiring` pre-commit step: every `[project.scripts]` entry must be reachable from a wiring source (install bootstrap, pre-commit, audit, hooks, agents, skills…). | Your repo ships `[project.scripts]` and follows forge's layout and you want unreachable CLIs caught. | + +## `[tool.forge.docstring_coverage]` + +Forge-specific keys for the docstring-coverage reporter. (The coverage *gate* +itself — threshold, excludes, ignores — lives in `[tool.interrogate]` below; +these are the keys interrogate has no concept of.) + +| Key | Default | What it does | Set it when | +|---|---|---|---| +| `paths` | `[tool.forge].source_dirs + test_dirs` | Per-tool **override** of the scan roots for the coverage report and badge. Defaults to the repo-wide layout above; set this only when docstring-coverage should scan something different. Paths resolving outside the repo are rejected. | You want coverage scoped differently from the rest of forge — otherwise prefer setting `[tool.forge].source_dirs` once. | +| `badge` | `false` | Generate **interrogate's own** coverage badge (via `interrogate.badge_gen`) to `.badges/DocstringCoverage.svg` for README embedding. forge invokes interrogate as a library, so this opt-in triggers the badge programmatically. | You want a coverage badge in your README. | + +## `[tool.interrogate]` — native section, read by forge + +This is **interrogate's own** config section (not forge's). forge reads it +directly for the docstring-coverage gate; you configure it exactly as you would +for standalone interrogate. + +| Key | Default | What it does | +|---|---|---| +| `fail-under` | `90` | Coverage threshold. Set to your current passing baseline, raise over time (FOUNDATION §4, §8). | +| `exclude` | – | Globs to exclude from the scan. | +| `ignore-*` | `false` | The standard interrogate ignore flags (`ignore-init-method`, `ignore-nested-functions`, …) — forge passes them through. | + +Example: + +```toml +[tool.interrogate] +fail-under = 100 +ignore-nested-functions = true +``` + +--- + +## Discovering and verifying your setup + +```bash +forge-config --list +``` + +prints every `[tool.forge.*]` key forge reads (current value or ``), +names the native sections like `[tool.interrogate]` it reads, and lists a +**Suggested setup** block for recommended-but-unset keys — so you (or an agent) +know precisely what to add. Run it after `install-forge-bootstrap`, after a +`forge-upgrade`, or any time you're unsure what forge is reading. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..4acfaf3 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,70 @@ +# Release process (forge-only) + +**This is the single source of truth for forge's versioning and +`dev → main` promotion.** It is the *spec*; the code conforms to it. Every +invariant below names the **test that enforces it** — the executable spec +that goes red if code drifts. If you change versioning or promotion code, +change this doc and its tests **first**, then make the code match. + +> Forge-only. The dual-track (`dev`/`main`) model and rolling-next +> convention are specific to forge; consumer plugin authors may use +> trunk-based, gitflow, or another model. CLAUDE.md's release bullets and +> the `/promote` skill **point here** — they do not restate the mechanics +> (FOUNDATION §12, single source of truth). + +--- + +## 1. Rolling-next versioning + +`.claude-plugin/plugin.json["version"]` **always names the version about to +be released** — never the last-released version. + +- The pre-commit step `plugin_version` (`verify-forge-plugin-version`) + enforces `plugin.json["version"] > latest tag` on every commit. +- After tagging `vX.Y.Z`, the next PR must bump `plugin.json` to the next + rolling-next version, or its commits fail the guard. + +## 2. Dual-track tag cadence + +- **`dev` is tagged `vX.Y.Z` after *every* merge to `dev`** — patch and + minor alike. `forge-next-prep --tag` (run by `/next`) does this when + `plugin.json` is ahead of the latest tag. **Never drop `--tag`.** +- **`main` is tagged only at the minor-boundary promotion** — the minor + `vX.Y.0` is (re-)tagged on main's squash commit. +- Net: `@dev` consumers receive every version; `@main` receives **minors + only**. Patches accumulate on `dev` and fold into the next minor. +- Because promotion squashes, a minor re-tagged on main's squash commit is + not reachable from `dev`'s history — expected. `@dev` resolves the tag on + the dev commit; `@main` on the main commit. + +## 3. Promotion: staged catch-up + +- **Never merge `dev` directly into `main`.** A promotion PR's head is + always a dedicated `release/vX.Y.Z` branch. +- **One minor at a time, ascending.** When `main` is several minors behind, + promote each minor as its own clean squash commit; never lump multiple + minors into one PR. +- The release branch's tree reproduces the target minor's release tree + (cut from `main`, bring the minor's tree, verify `git diff` is empty). +- Run via the `/promote` skill, which uses `forge-next-prep + --promotion-status` for the ordered pending list. + +## 4. Invariants the code MUST satisfy → enforcing tests + +This table is the anti-regression contract. **Do not change a behavior in +the left column without its test (right column) staying green.** A change +that violates an invariant must turn its test red — that is how a future +"alignment fix" is stopped from silently amputating a working behavior +(this is exactly how the #43 regression slipped through: it changed an +invariant that had no test). + +| Invariant | Where | Enforcing test | +|---|---|---| +| Latest tag resolved **globally** (semver-max, branch-independent) — never ancestry-scoped `git describe`, so the guard and the auto-tagger agree in the dual-track case | `git_utils.latest_v_tag` | `tests/test_git_utils.py::test_latest_v_tag_returns_highest_sorted` | +| Rolling-next guard skips when HEAD's tree reproduces **ANY** `v*` tag (not only the latest) — required so a `release/vX.Y.Z` branch for a minor *below* the global-max tag still passes | `verify_plugin_version._is_release_commit` | `tests/test_verify_plugin_version.py::test_main_skips_when_head_reproduces_older_tag` | +| Guard fails when a real content change leaves `plugin.json ≤ latest tag` | `verify_plugin_version.main` | `tests/test_verify_plugin_version.py::test_fail_when_version_not_strictly_greater` | +| `--promotion-status` lists pending **minors only** (`X.Y.0`); interleaved patch tags fold into the next minor | `next_prep._promotion_status_lines` | `tests/test_next_prep.py::test_promotion_status_excludes_patch_tags` | +| `forge-next-prep --tag` tags + pushes only when `plugin.json` is strictly newer than the latest tag (idempotent) | `next_prep._maybe_tag_release` | `tests/test_next_prep.py::test_maybe_tag_release_creates_and_pushes_new_tag` | + +When you add a versioning/promotion behavior, add a row here **and** its +test. When you find an invariant with no test, that gap is a bug to close. diff --git a/pyproject.toml b/pyproject.toml index 0a2c093..bc4173a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ install-forge-bootstrap = "forge.install_bootstrap:main" forge-upgrade = "forge.upgrade:main" forge-pr-squash-comment = "forge.pr_squash_comment:main" forge-slow-tests-report = "forge.slow_tests_report:main" +forge-config = "forge.forge_config:main" forge-audit-agents = "forge.audit.agents:main" forge-audit-deps = "forge.audit.deps:main" forge-audit-dup = "forge.audit.dup:main" diff --git a/skills/test/SKILL.md b/skills/test/SKILL.md new file mode 100644 index 0000000..0517f39 --- /dev/null +++ b/skills/test/SKILL.md @@ -0,0 +1,43 @@ +--- +name: test +description: Write tests for a target by chaining the forge test agents - test-advisor recommends, test-writer writes, test-advisor reviews, then precommit-fixer cleans. Use when the user wants tests written for a module, path, or feature. +--- + +# Test-Writing Flow + +Write tests for the target in `$ARGUMENTS` (a module, a path, or a feature +description) by chaining the forge test agents in order. This flow only +sequences forge-owned agents + the forge testing-documentation policy — +nothing consumer-specific. + +1. **`forge:test-advisor`** — analyze the target and recommend tests: + ``` + Agent(subagent_type="forge:test-advisor", prompt="Analyze the target and recommend tests for: $ARGUMENTS") + ``` + +2. **`forge:test-writer`** — write the tests per those recommendations: + ``` + Agent(subagent_type="forge:test-writer", prompt="Write the tests test-advisor recommended for: $ARGUMENTS\n\n") + ``` + +3. **`forge:test-advisor`** (review pass) — check the written tests against the + forge testing-documentation policy: + ``` + Agent(subagent_type="forge:test-advisor", prompt="Review the tests just written for $ARGUMENTS against the forge testing-documentation policy. Report any violations.") + ``` + If the review flags issues, loop back to step 2 to address them before + continuing. + +4. **`forge:precommit-fixer`** — clear lint / docstring violations in the new + test files: + ``` + Agent(subagent_type="forge:precommit-fixer", prompt="Clear all pre-commit failures.") + ``` + +## Notes + +- The agents are foundation agents (`forge:` prefix). A consumer that layers + stricter test rules ships a wrapper agent (e.g. `test-writer-`) per + FOUNDATION §3 / §16 and points this flow at it. +- **Does NOT commit.** After the tests pass review, run `/forge:commit` (or the + `commit` skill) to commit them — same split as every other forge skill. diff --git a/src/forge/config.py b/src/forge/config.py index 54739bf..13d5d52 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -23,7 +23,7 @@ import logging import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -42,24 +42,37 @@ DEFAULT_BASE_BRANCH = "main" DEFAULT_DEV_BRANCH = "main" +# Repo-wide project layout. The single ground truth for "what are this +# repo's source / test roots", shared by every layout-consuming tool +# (docstring-coverage scan roots, etc.) so the answer lives in one place. +# Split into source vs test (semantic) rather than a flat union, so a +# tool that wants only source roots (e.g. api-digest) can take +# ``source_dirs`` without test dirs leaking in. +DEFAULT_SOURCE_DIRS = ("src",) +DEFAULT_TEST_DIRS = ("tests",) + @dataclass(frozen=True) class ForgeConfig: - """Branch-name configuration sourced from ``[tool.forge]``. + """Repo configuration sourced from ``[tool.forge]``. - The release-channel semantics (what each branch represents, - cadence trade-offs) live in FOUNDATION §6. This class just - carries the names. + Release-channel semantics live in FOUNDATION §6; the project-layout + rationale in §8 / `docs/configuration.md`. This class carries the + `[tool.forge]` values forge reads repo-wide. Attributes: base_branch: Name of the slow channel (typically ``"main"``). dev_branch: Name of the fast channel (typically ``"dev"``). Equal to ``base_branch`` when the consumer hasn't opted into dual-track. + source_dirs: Repo source roots (default ``["src"]``). + test_dirs: Repo test roots (default ``["tests"]``). """ base_branch: str = DEFAULT_BASE_BRANCH dev_branch: str = DEFAULT_DEV_BRANCH + source_dirs: list[str] = field(default_factory=lambda: list(DEFAULT_SOURCE_DIRS)) + test_dirs: list[str] = field(default_factory=lambda: list(DEFAULT_TEST_DIRS)) @property def dual_track(self) -> bool: @@ -75,6 +88,38 @@ def dual_track(self) -> bool: return self.base_branch != self.dev_branch +def read_pyproject_raw(repo_root: Path) -> dict: + """Return the full parsed ``pyproject.toml`` dict, or ``{}`` on failure. + + The canonical "load the whole TOML, degrade to empty on missing / + unreadable / unparseable" reader shared by every forge config + consumer (``load_config`` here, plus the docstring-coverage step and + the ``forge-config`` advisor). Deliberately forgiving — config reads + happen in hot paths and any failure should degrade to defaults, not + block the workflow. + + Args: + repo_root: Git repo root containing ``pyproject.toml``. + + Returns: + Parsed TOML data, or an empty dict when the file is missing, + unreadable, or not valid TOML. + """ + pyproject = repo_root / "pyproject.toml" + if not pyproject.is_file(): + return {} + try: + text = pyproject.read_text() + except OSError as exc: + logger.debug("forge.config: could not read %s (%s)", pyproject, exc) + return {} + try: + return tomllib.loads(text) + except ValueError as exc: + logger.debug("forge.config: could not parse %s (%s)", pyproject, exc) + return {} + + def load_config(repo_root: Path) -> ForgeConfig: """Read ``[tool.forge]`` from *repo_root*'s ``pyproject.toml``. @@ -94,21 +139,10 @@ def load_config(repo_root: Path) -> ForgeConfig: single-branch flow. Override ``dev_branch`` in ``[tool.forge]`` to opt in. """ - pyproject = repo_root / "pyproject.toml" - if not pyproject.is_file(): - return ForgeConfig() - try: - text = pyproject.read_text() - except OSError as exc: - logger.debug("forge.config: could not read %s (%s)", pyproject, exc) - return ForgeConfig() - try: - data = tomllib.loads(text) - except ValueError as exc: - logger.debug("forge.config: could not parse %s (%s)", pyproject, exc) - return ForgeConfig() - section = data.get("tool", {}).get("forge", {}) + section = read_pyproject_raw(repo_root).get("tool", {}).get("forge", {}) return ForgeConfig( base_branch=section.get("base_branch", DEFAULT_BASE_BRANCH), dev_branch=section.get("dev_branch", DEFAULT_DEV_BRANCH), + source_dirs=list(section.get("source_dirs", DEFAULT_SOURCE_DIRS)), + test_dirs=list(section.get("test_dirs", DEFAULT_TEST_DIRS)), ) diff --git a/src/forge/forge_config.py b/src/forge/forge_config.py new file mode 100644 index 0000000..75e72b7 --- /dev/null +++ b/src/forge/forge_config.py @@ -0,0 +1,226 @@ +"""forge-config — show what forge config this repo sets, and what it should. + +Answers "which ``[tool.*]`` sections does forge actually read, and what +do I still need to set?" in one command, so a consumer never has to hunt +through docs or source. + +Two responsibilities: + +1. **List** every ``[tool.forge.*]`` key forge reads, its current value + (or ```` when unset), and a one-line description. +2. **Advise** — for any recommended-but-unset key, print what to add. + +Transparency over wrapping: forge reads some third-party tools from +their **own native sections** (notably ``[tool.interrogate]`` for +docstring coverage). Rather than hide that behind a forge namespace, +this CLI names it explicitly — forge reads ``[tool.interrogate]``; it is +the tool's own config, not a forge wrapper. Forge-specific keys the tool +has no concept of (``badge``, ``paths``) live under +``[tool.forge.docstring_coverage]``. + +Read-only: prints a report and exits ``0``. Surfaced as a post-install +nudge by ``install-forge-bootstrap`` and on demand via ``forge-config``. +""" + +from __future__ import annotations + +import argparse +import logging +from dataclasses import dataclass +from pathlib import Path + +from forge.config import read_pyproject_raw +from forge.git_utils import configure_cli_logging + + +configure_cli_logging() +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ConfigKey: + """One ``[tool.forge.*]`` key forge reads in a consumer repo. + + Attributes: + path: Full key path under the TOML root, e.g. + ``("tool", "forge", "base_branch")``. + default: Value forge falls back to when the key is unset. + description: One-line purpose, shown beside the value. + recommended: When ``True``, the advisor nudges the consumer to + set the key if it is currently absent. + """ + + path: tuple[str, ...] + default: object + description: str + recommended: bool = False + + +# The forge config surface, declared once. ``forge-config`` is the single +# place that enumerates what forge reads — there is no metadata registry +# elsewhere to keep in sync, so new ``[tool.forge.*]`` keys are added here. +CONFIG_KEYS: tuple[ConfigKey, ...] = ( + ConfigKey( + ("tool", "forge", "base_branch"), + "main", + "Slow-channel / release branch (protected; promotion target).", + ), + ConfigKey( + ("tool", "forge", "dev_branch"), + "main", + "Fast-channel integration branch (protected). Defaults to " + "base_branch (single-track); set to e.g. 'dev' to opt into " + "dual-track.", + ), + ConfigKey( + ("tool", "forge", "source_dirs"), + ["src"], + "Repo source roots (repo-wide layout). Consumed by layout-aware " + "tools, e.g. docstring-coverage scan roots.", + ), + ConfigKey( + ("tool", "forge", "test_dirs"), + ["tests"], + "Repo test roots (repo-wide layout).", + ), + ConfigKey( + ("tool", "forge", "cli_wiring", "enabled"), + default=False, + description="Opt into the cli_wiring pre-commit step (every [project.scripts] " + "reachable from a wiring source).", + ), + ConfigKey( + ("tool", "forge", "docstring_coverage", "badge"), + default=False, + description="Generate interrogate's coverage badge to " + ".badges/DocstringCoverage.svg for README embedding.", + ), + ConfigKey( + ("tool", "forge", "docstring_coverage", "paths"), + "source_dirs + test_dirs", + "Per-tool override of the coverage scan roots; otherwise inherits " + "the repo-wide [tool.forge].source_dirs + test_dirs.", + ), +) + +# Third-party tools forge reads from their OWN native section rather than +# wrapping under [tool.forge.*]. Named here so consumers see what forge +# reads without it being hidden behind a forge namespace. +# Path tuples match CONFIG_KEYS.path encoding (not dotted strings). +NATIVE_SECTIONS: tuple[tuple[tuple[str, ...], str], ...] = ( + ( + ("tool", "interrogate"), + "Docstring-coverage gate (fail-under, exclude, ignore-*) read by " + "verify-forge-docstring-coverage. interrogate's own section — " + "forge reads it directly, not a forge wrapper.", + ), +) + + +_UNSET = object() + + +def _lookup(data: dict, path: tuple[str, ...]) -> object: + """Return the value at *path* in nested *data*, or ``_UNSET`` if absent. + + Args: + data: Parsed ``pyproject.toml`` data. + path: Key path under the TOML root. + + Returns: + The configured value, or the ``_UNSET`` sentinel when any segment + of the path is missing. + """ + node: object = data + for segment in path: + if not isinstance(node, dict) or segment not in node: + return _UNSET + node = node[segment] + return node + + +def _section_of(key: ConfigKey) -> str: + """Return the section header (path without the leaf key) for *key*. + + Args: + key: The config key whose parent section is wanted. + + Returns: + Dotted section path, e.g. ``"tool.forge.docstring_coverage"``. + """ + return ".".join(key.path[:-1]) + + +def build_report(data: dict) -> list[str]: + """Build the ``forge-config`` report lines from parsed pyproject data. + + Args: + data: Parsed ``pyproject.toml`` data (``{}`` when absent). + + Returns: + Human-readable report lines: per-section key listing with current + values or defaults, the native-section pointers, and a suggested- + setup block for recommended keys that are unset. + """ + lines = ["forge config in this repo (pyproject.toml)", "=" * 42] + missing: list[ConfigKey] = [] + current_section = "" + for key in CONFIG_KEYS: + section = _section_of(key) + if section != current_section: + lines.append(f"[{section}]") + current_section = section + value = _lookup(data, key.path) + leaf = key.path[-1] + if value is _UNSET: + lines.append(f" {leaf:<14} = (not set)") + if key.recommended: + missing.append(key) + else: + lines.append(f" {leaf:<14} = {value!r}") + + lines.append("") + for path, desc in NATIVE_SECTIONS: + present = _lookup(data, path) is not _UNSET + flag = "set" if present else "not set" + section = ".".join(path) + lines.append(f"[{section}] ({flag} — native tool section, read by forge)") + lines.append(f" {desc}") + + if missing: + lines.append("") + lines.append("Suggested setup (forge reads these but you haven't set them):") + for key in missing: + lines.append(f" • [{_section_of(key)}].{key.path[-1]} — {key.description}") + lines.append(f" {key.path[-1]} = {key.default!r}") + return lines + + +def main() -> int: + """Entry point for ``forge-config``. + + Returns: + Always ``0`` — a read-only advisory report, never a gate. + """ + parser = argparse.ArgumentParser( + prog="forge-config", + description=( + "List the [tool.forge.*] config forge reads in this repo, with " + "current values / defaults, the native tool sections forge " + "reads, and advice on recommended-but-unset keys." + ), + ) + parser.add_argument( + "--list", + action="store_true", + help="List forge config + advice (the default action).", + ) + parser.parse_args() + + for line in build_report(read_pyproject_raw(Path.cwd())): + logger.info("%s", line) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/forge/git_utils.py b/src/forge/git_utils.py index ba00448..fd93308 100644 --- a/src/forge/git_utils.py +++ b/src/forge/git_utils.py @@ -137,6 +137,38 @@ def parse_semver(version: str) -> tuple[int, int, int] | None: return (int(match.group(1)), int(match.group(2)), int(match.group(3))) +def latest_v_tag(root: Path) -> str | None: + """Return the highest ``v*`` git tag by semver sort, or ``None`` if none. + + Resolves the latest release **globally** — ``git tag --list "v*" + --sort=-v:refname`` — independent of ``HEAD``'s ancestry. This is the + single source of truth for "latest release tag", shared by the + rolling-next pre-commit guard (``verify-forge-plugin-version``) and + the auto-tagger (``forge-next-prep``). A branch-independent resolution + is required in the dual-track (dev/main) model: a release tagged on + one branch is not in the other's history, so an ancestry-scoped + ``git describe`` would disagree with the auto-tagger and let a stale + manifest slip past the guard. + + Args: + root: Repo root (cwd for the git invocation). + + Returns: + Tag name like ``"v1.2.9"``, or ``None`` when no ``v*`` tags exist. + """ + proc = subprocess.run( + ["git", "tag", "--list", "v*", "--sort=-v:refname"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + out = proc.stdout.strip() + if not out: + return None + return out.splitlines()[0] + + def require_cli(name: str, *, caller: str | None = None) -> None: """Abort with a clear install hint if *name* isn't on PATH. diff --git a/src/forge/install_bootstrap.py b/src/forge/install_bootstrap.py index e2be8a3..e4010b2 100644 --- a/src/forge/install_bootstrap.py +++ b/src/forge/install_bootstrap.py @@ -124,6 +124,7 @@ def _gate_labels(_root: Path) -> str | None: gate=_gate_skip_in_ci, ), Step(slug="doctor", cli="forge-doctor", gate=_gate_skip_in_ci), + Step(slug="config", cli="forge-config", gate=_gate_skip_in_ci), ) diff --git a/src/forge/next_prep.py b/src/forge/next_prep.py index 9f20ebc..cf0e7ef 100644 --- a/src/forge/next_prep.py +++ b/src/forge/next_prep.py @@ -44,7 +44,7 @@ from pathlib import Path from forge.config import load_config -from forge.git_utils import configure_cli_logging, parse_semver +from forge.git_utils import configure_cli_logging, latest_v_tag, parse_semver configure_cli_logging() @@ -141,8 +141,10 @@ def _promotion_status_lines( """Build the read-only promotion-status report. Reports the base/dev plugin versions and, when dev is a MINOR/MAJOR - ahead, the ordered list of ``v*`` tags that ``base`` must be promoted - up to — one release per line, ascending. Shares version-read and + ahead, the ordered list of ``X.Y.0`` releases that ``base`` must be + promoted up to — one minor per line, ascending. ``base`` is + minor-only: interleaved patch tags are excluded because they fold + into the next minor's promotion. Shares version-read and pending-detection helpers with the ``/next`` advisory, giving the ``/promote`` skill a single authoritative source for the git/version comparison. @@ -174,11 +176,27 @@ def _promotion_status_lines( if base_tuple is None or dev_tuple is None or base_tuple[:2] >= dev_tuple[:2]: lines.append("Up to date — nothing to promote.") return lines + # Only MINOR/MAJOR releases (``X.Y.0``) are promotion targets — base + # is minor-only, and accumulated patches fold into the next minor's + # promotion (e.g. 1.5.1 / 1.5.2 ride along when 1.6.0 is promoted). + # Filtering on ``pv[2] == 0`` drops the interleaved patch tags the + # version range would otherwise list as separate promotions. staged = sorted( (pv, tag) for tag in _git("tag", "--list", "v*", cwd=repo_root, check=False).split() - if (pv := parse_semver(tag)) is not None and base_tuple < pv <= dev_tuple + if (pv := parse_semver(tag)) is not None + and pv[2] == 0 + and base_tuple < pv <= dev_tuple ) + if not staged: + # MINOR/MAJOR gap detected but no ``X.Y.0`` tag in range — the + # minor was never tagged (fresh or mid-flight repo). Don't print a + # misleading "promote these (0):" header with an empty list. + lines.append( + "Promotion pending, but no X.Y.0 release tag found in range " + "— check that the target minor was tagged." + ) + return lines lines.append(f"Promotion pending — promote these in order ({len(staged)}):") lines.extend(f" {tag}" for _, tag in staged) return lines @@ -232,21 +250,6 @@ def _read_plugin_version(repo_root: Path) -> str | None: return version -def _latest_v_tag(repo_root: Path) -> str | None: - """Return the highest ``v*`` git tag by sort-V, or ``None`` if none. - - Args: - repo_root: Repo root (cwd for git invocation). - - Returns: - Tag name like ``"v1.2.9"`` or ``None`` when no ``v*`` tags exist. - """ - out = _git("tag", "--list", "v*", "--sort=-v:refname", cwd=repo_root, check=False) - if not out: - return None - return out.splitlines()[0] - - def _is_newer(plugin_ver: str, latest_tag: str | None) -> bool: """Return True when ``v`` would sort *after* ``latest_tag``. @@ -289,7 +292,7 @@ def _maybe_tag_release(repo_root: Path) -> str | None: plugin_ver = _read_plugin_version(repo_root) if plugin_ver is None: return None - latest = _latest_v_tag(repo_root) + latest = latest_v_tag(repo_root) if not _is_newer(plugin_ver, latest): return None tag = f"v{plugin_ver}" diff --git a/src/forge/verify_docstring_coverage.py b/src/forge/verify_docstring_coverage.py index 4eaa04e..f4dff66 100644 --- a/src/forge/verify_docstring_coverage.py +++ b/src/forge/verify_docstring_coverage.py @@ -18,11 +18,14 @@ the dispatch contract: ``forge:precommit-fixer`` reads it and adds docstrings per entry. -Reads ``[tool.interrogate]`` for threshold + excludes -(``fail-under`` default 90). When -``[tool.forge.docstring_coverage].badge = true`` writes -``.badges/DocstringCoverage.svg`` for README embedding. Writes -``code_health/docstring_coverage.log``. +Reads ``[tool.interrogate]`` (the tool's own native section — forge +does not wrap it) for threshold + excludes (``fail-under`` default 90). +Scan roots default to the repo-wide layout +``[tool.forge].source_dirs + test_dirs`` (default ``src`` + ``tests``); +a per-tool ``[tool.forge.docstring_coverage].paths`` overrides them +(interrogate has no scan-root concept). ``[tool.forge.docstring_coverage] +.badge = true`` writes ``.badges/DocstringCoverage.svg`` for README +embedding. Writes ``code_health/docstring_coverage.log``. Exit codes: @@ -38,13 +41,17 @@ import argparse import logging import sys -import tomllib from pathlib import Path from interrogate.badge_gen import create as create_badge from interrogate.config import InterrogateConfig from interrogate.coverage import InterrogateCoverage +from forge.config import ( + DEFAULT_SOURCE_DIRS, + DEFAULT_TEST_DIRS, + read_pyproject_raw, +) from forge.git_utils import capturing_to_step_log, configure_cli_logging @@ -53,31 +60,10 @@ _DEFAULT_FAIL_UNDER = 90.0 -_DEFAULT_PATHS: tuple[str, ...] = ("src", "tests") _BADGE_DIR = ".badges" _BADGE_FILENAME = "DocstringCoverage.svg" -def _read_pyproject(repo_root: Path) -> dict: - """Load ``pyproject.toml`` from *repo_root*, or ``{}`` when absent. - - Args: - repo_root: Repository root containing ``pyproject.toml``. - - Returns: - Parsed TOML data, or an empty dict when the file is missing / - unreadable. Caller treats either as "consumer has not opted - in" and skips cleanly. - """ - pyproject = repo_root / "pyproject.toml" - if not pyproject.is_file(): - return {} - try: - return tomllib.loads(pyproject.read_text()) - except (OSError, tomllib.TOMLDecodeError): - return {} - - def _interrogate_config(data: dict) -> tuple[InterrogateConfig, float, list[str]]: """Build the interrogate config + threshold + excludes from TOML data. @@ -178,6 +164,55 @@ def _emit_missing_list(results: object) -> None: logger.info("MISSING: %s:%s:%s", path, lineno, name) +def _scan_paths(data: dict, repo_root: Path) -> list[str]: + """Resolve the docstring-coverage scan roots from config, safely. + + A per-tool ``[tool.forge.docstring_coverage].paths`` override wins + when set (interrogate has no scan-root concept of its own). Otherwise + the default is the **repo-wide layout** — + ``[tool.forge].source_dirs + test_dirs`` (default ``src`` + ``tests``) + — so the project's roots live in one place. Each root resolves + against *repo_root*; any that escapes the repo (absolute path or + ``..`` traversal) is rejected so the reporter never reads files + outside the repository (mirrors ``gen_api_digest.detect_roots``). + Non-existent roots are dropped. + + Args: + data: Parsed ``pyproject.toml`` data. + repo_root: Repository root the configured paths resolve against. + + Returns: + Existing in-repo directory paths to scan, as strings. Empty when + none of the configured roots exist. + """ + forge = data.get("tool", {}).get("forge", {}) + configured = forge.get("docstring_coverage", {}).get("paths") + if isinstance(configured, list): + # Per-tool override. + raw_paths = list(configured) + else: + # Default to the repo-wide layout ([tool.forge].source_dirs + + # test_dirs). Read from the already-parsed dict here (mirrors + # forge.config.load_config's read of the same keys — keep in sync) + # to avoid re-reading the file. + raw_paths = list(forge.get("source_dirs", DEFAULT_SOURCE_DIRS)) + list( + forge.get("test_dirs", DEFAULT_TEST_DIRS) + ) + root_resolved = repo_root.resolve() + scan: list[str] = [] + for raw in raw_paths: + resolved = (repo_root / raw).resolve() + if not resolved.is_relative_to(root_resolved): + logger.error( + "Ignoring docstring_coverage path %r — outside repo root.", + raw, + ) + continue + if resolved.is_dir(): + scan.append(str(resolved)) + return scan + + def main() -> int: """CLI entry point for ``verify-forge-docstring-coverage``. @@ -197,15 +232,17 @@ def main() -> int: repo_root = Path.cwd() with capturing_to_step_log(repo_root, "docstring_coverage"): - data = _read_pyproject(repo_root) + data = read_pyproject_raw(repo_root) if not data: logger.info("(no pyproject.toml — skipped)") return 0 config, fail_under, excludes = _interrogate_config(data) - paths = [str(repo_root / p) for p in _DEFAULT_PATHS if (repo_root / p).is_dir()] + paths = _scan_paths(data, repo_root) if not paths: - logger.info("(no src/ directory — skipped)") + logger.info( + "(none of the configured docstring_coverage paths exist — skipped)" + ) return 0 cov = InterrogateCoverage( diff --git a/src/forge/verify_plugin_version.py b/src/forge/verify_plugin_version.py index 625547e..f8c99ee 100644 --- a/src/forge/verify_plugin_version.py +++ b/src/forge/verify_plugin_version.py @@ -9,8 +9,9 @@ - ``.claude-plugin/plugin.json`` does not exist (consumer repo without a plugin manifest). - The repo has no git tags yet (pre-release repo). -- ``HEAD`` is the release commit (the commit pointed to by the latest - tag). On that single commit, ``plugin.json`` may equal the tag. +- ``HEAD``'s tree reproduces any published ``v*`` release tag — so a + staged ``release/vX.Y.Z`` branch promoting an older minor still passes + even when its ``plugin.json`` sits below the global-max tag. ``forge-precommit`` shells out to this CLI; agents may invoke it standalone to refresh just ``plugin_version.log``. @@ -25,7 +26,12 @@ import sys from pathlib import Path -from forge.git_utils import capturing_to_step_log, configure_cli_logging, parse_semver +from forge.git_utils import ( + capturing_to_step_log, + configure_cli_logging, + latest_v_tag, + parse_semver, +) configure_cli_logging() @@ -38,49 +44,68 @@ _parse_semver = parse_semver -def _is_release_commit(repo_root: Path, tag: str) -> bool: - """Return True when ``HEAD`` carries the same file content as *tag*. +def _is_release_commit(repo_root: Path) -> bool: + """Return True when ``HEAD``'s tree reproduces ANY published ``v*`` tag. - Compares the git **tree** SHA of ``HEAD`` against the tag's tree - SHA — not the commit SHA. Tree equality means the working - file-state is identical; commit identity is irrelevant. This is - the right semantic for the rolling-next guard: the rule "you must - bump plugin.json past the latest tag" only applies when the - commit actually changes file content. Three cases that should - skip and do: + Compares the git **tree** SHA of ``HEAD`` against the tree of every + ``v*`` tag — not commit SHAs. Tree equality means the working + file-state reproduces an already-tagged release, so the rolling-next + rule ("bump plugin.json past the latest tag") must NOT fire. - 1. The literal release commit (HEAD == tag commit). Same commit, - trivially same tree. - 2. A ``-s ours``-style merge that absorbs another branch with - no file diff (e.g. dev → main promotion back-merges). The - merge commit has a new SHA + new parents but its tree equals - the pre-merge tree, which equals the tag's tree. - 3. ``git commit --allow-empty`` or a revert that nets to zero - file change — same reasoning. + Checking **every** tag — not only the latest — is load-bearing for + the staged ``dev → main`` promotion (see the ``promote`` skill). + When ``main`` is two or more minors behind, a ``release/vX.Y.Z`` + branch carries an *older* minor's tree, so its ``plugin.json`` sits + legitimately **below** the global-max tag; it is still a real release + commit and must pass the guard. A prior version compared HEAD only + against the *latest* tag, which made promoting any minor below the + global-max impossible — the release branch's tree never equals the + latest tag's tree (regression from the #43 ancestry→global tag + switch). **Do not narrow this back to a single tag** — the + ``test_main_skips_when_head_reproduces_older_tag`` test locks it. + + Cases that correctly skip: + + 1. The literal release commit (HEAD == a tag commit) — same tree. + 2. A staged ``release/vX.Y.Z`` promotion branch reproducing an older + tag's tree (``plugin.json`` below the global-max tag). + 3. A ``-s ours`` merge / empty commit / net-zero revert — tree + unchanged from a tagged release. Args: repo_root: Git repo root. - tag: Tag name (e.g. ``"v1.1.2"``). Returns: - ``True`` when ``HEAD``'s tree SHA equals *tag*'s tree SHA; - ``False`` when either resolution fails or the trees differ. + ``True`` when ``HEAD``'s tree SHA equals the tree of some ``v*`` + tag; ``False`` when HEAD's tree resolves emptily or matches none. """ - tag_tree = subprocess.run( - ["git", "rev-parse", f"{tag}^{{tree}}"], + head_tree = subprocess.run( + ["git", "rev-parse", "HEAD^{tree}"], cwd=repo_root, capture_output=True, text=True, check=False, ).stdout.strip() - head_tree = subprocess.run( - ["git", "rev-parse", "HEAD^{tree}"], + if not head_tree: + return False + tags = subprocess.run( + ["git", "tag", "--list", "v*"], cwd=repo_root, capture_output=True, text=True, check=False, - ).stdout.strip() - return bool(tag_tree) and tag_tree == head_tree + ).stdout.split() + for tag in tags: + tag_tree = subprocess.run( + ["git", "rev-parse", f"{tag}^{{tree}}"], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ).stdout.strip() + if tag_tree and tag_tree == head_tree: + return True + return False def main() -> int: @@ -107,20 +132,18 @@ def main() -> int: logger.info("(no .claude-plugin/plugin.json — skipped)") return 0 - tag_proc = subprocess.run( - ["git", "describe", "--tags", "--abbrev=0"], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - if tag_proc.returncode != 0: + # Global semver-max ``v*`` tag, NOT ancestry-scoped ``git + # describe`` — the guard and the auto-tagger (forge-next-prep) + # must resolve "latest release" the same way, or they disagree in + # the dual-track case (a release tagged on main is absent from + # dev's history). See forge.git_utils.latest_v_tag. + latest_tag = latest_v_tag(repo_root) + if latest_tag is None: logger.info("(no git tags yet — skipped)") return 0 - latest_tag = tag_proc.stdout.strip() - if _is_release_commit(repo_root, latest_tag): - logger.info("(HEAD is the %s release commit — skipped)", latest_tag) + if _is_release_commit(repo_root): + logger.info("(HEAD reproduces a published v* release tag — skipped)") return 0 plugin_data = json.loads(plugin.read_text()) diff --git a/tests/test_config.py b/tests/test_config.py index 3e3d9c5..8ae8bd8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,7 @@ DEFAULT_DEV_BRANCH, ForgeConfig, load_config, + read_pyproject_raw, ) @@ -89,3 +90,37 @@ def test_load_config_partial_block_uses_defaults(tmp_path: Path) -> None: cfg = load_config(tmp_path) assert cfg.base_branch == DEFAULT_BASE_BRANCH assert cfg.dev_branch == "trunk" + + +def test_load_config_default_layout_dirs(tmp_path: Path) -> None: + """No ``[tool.forge]`` → source_dirs ``["src"]`` / test_dirs ``["tests"]``.""" + cfg = load_config(tmp_path) + assert cfg.source_dirs == ["src"] + assert cfg.test_dirs == ["tests"] + + +def test_load_config_reads_layout_dirs(tmp_path: Path) -> None: + """``source_dirs`` / ``test_dirs`` override the repo-layout defaults.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.forge]\nsource_dirs = ["src", "projects"]\ntest_dirs = ["t"]\n' + ) + cfg = load_config(tmp_path) + assert cfg.source_dirs == ["src", "projects"] + assert cfg.test_dirs == ["t"] + + +def test_read_pyproject_raw_returns_full_dict(tmp_path: Path) -> None: + """The shared raw reader returns the whole parsed TOML tree.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.forge]\nbase_branch = "main"\n\n[tool.interrogate]\nfail-under = 90\n' + ) + data = read_pyproject_raw(tmp_path) + assert data["tool"]["forge"]["base_branch"] == "main" + assert data["tool"]["interrogate"]["fail-under"] == 90 + + +def test_read_pyproject_raw_empty_on_missing_or_malformed(tmp_path: Path) -> None: + """Missing file and malformed TOML both degrade to ``{}`` (never raise).""" + assert read_pyproject_raw(tmp_path) == {} + (tmp_path / "pyproject.toml").write_text("not [ valid toml @@@") + assert read_pyproject_raw(tmp_path) == {} diff --git a/tests/test_forge_config.py b/tests/test_forge_config.py new file mode 100644 index 0000000..5b20282 --- /dev/null +++ b/tests/test_forge_config.py @@ -0,0 +1,102 @@ +"""Tests for ``forge.forge_config``.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from forge import forge_config + + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_lookup_returns_value_and_unset() -> None: + """`_lookup` returns nested values, or the `_UNSET` sentinel when absent.""" + data = {"tool": {"forge": {"base_branch": "trunk"}}} + assert forge_config._lookup(data, ("tool", "forge", "base_branch")) == "trunk" + assert ( + forge_config._lookup(data, ("tool", "forge", "dev_branch")) + is forge_config._UNSET + ) + + +def test_report_shows_defaults_when_unset() -> None: + """Unset keys render their default value flagged `(not set)`.""" + text = "\n".join(forge_config.build_report({})) + assert "[tool.forge]" in text + assert "base_branch" in text + assert "" in text + assert "(not set)" in text + + +def test_report_shows_configured_values() -> None: + """Set keys render their actual value, not the default.""" + data = {"tool": {"forge": {"base_branch": "main", "dev_branch": "dev"}}} + lines = forge_config.build_report(data) + # Set keys render their value (not a placeholder). + base_line = next(line for line in lines if "base_branch" in line) + dev_line = next(line for line in lines if "dev_branch" in line) + assert base_line.endswith("'main'") + assert dev_line.endswith("'dev'") + + +def test_report_lists_layout_dirs() -> None: + """The report enumerates the repo-wide source_dirs / test_dirs keys.""" + text = "\n".join(forge_config.build_report({})) + assert "source_dirs" in text + assert "test_dirs" in text + assert "" in text + assert "" in text + + +def test_report_omits_suggested_setup_when_nothing_recommended() -> None: + """No [tool.forge.*] key is recommended by default, so no nudge block. + + Override-only keys like `docstring_coverage.paths` are deliberately + NOT recommended — nudging them would have a consumer override the + repo-wide layout they already set. + """ + text = "\n".join(forge_config.build_report({})) + assert "Suggested setup" not in text + + +def test_report_suggests_recommended_unset_keys( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The advisor mechanism: a recommended-but-unset key is nudged.""" + key = forge_config.ConfigKey( + ("tool", "forge", "demo"), "x", "Demo key.", recommended=True + ) + monkeypatch.setattr(forge_config, "CONFIG_KEYS", (key,)) + text = "\n".join(forge_config.build_report({})) + assert "Suggested setup" in text + assert "tool.forge].demo" in text + + +def test_report_names_interrogate_as_native_section() -> None: + """The report points at `[tool.interrogate]` as forge-read native config.""" + text = "\n".join(forge_config.build_report({})) + assert "[tool.interrogate]" in text + assert "native tool section" in text + + +def test_report_marks_interrogate_set_when_present() -> None: + """When `[tool.interrogate]` exists, the native pointer flags it `set`.""" + data = {"tool": {"interrogate": {"fail-under": 100}}} + text = "\n".join(forge_config.build_report(data)) + assert "[tool.interrogate] (set" in text + + +def test_main_prints_report_and_exits_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """`forge-config` reads pyproject.toml and exits 0 (read-only advisory).""" + (tmp_path / "pyproject.toml").write_text( + '[tool.forge]\nbase_branch = "main"\ndev_branch = "dev"\n' + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.argv", ["forge-config", "--list"]) + assert forge_config.main() == 0 diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index faf3eaa..4be1bab 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -12,6 +12,7 @@ import pytest from forge import git_utils +from tests.conftest import make_fake_run if TYPE_CHECKING: @@ -133,11 +134,11 @@ def test_repo_root_returns_toplevel( fake_top = tmp_path / "repo" fake_top.mkdir() - def fake_run(cmd: list[str], **_kwargs: object) -> object: + def _fake_run(cmd: list[str], **_kwargs: object) -> object: assert cmd[:3] == ["git", "rev-parse", "--show-toplevel"] return type("P", (), {"returncode": 0, "stdout": f"{fake_top}\n"})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) assert git_utils.repo_root() == fake_top @@ -159,11 +160,11 @@ def test_repo_root_is_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> """Repeated calls hit subprocess only once (lru_cache).""" calls = {"count": 0} - def fake_run(*_args: object, **_kwargs: object) -> object: + def _fake_run(*_args: object, **_kwargs: object) -> object: calls["count"] += 1 return type("P", (), {"returncode": 0, "stdout": f"{tmp_path}\n"})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) git_utils.repo_root() git_utils.repo_root() git_utils.repo_root() @@ -180,12 +181,12 @@ def test_run_git_returns_stdout_on_success( ) -> None: """Success returns trimmed stdout.""" - def fake_run(cmd: list[str], **_kwargs: object) -> object: + def _fake_run(cmd: list[str], **_kwargs: object) -> object: if cmd[:3] == ["git", "rev-parse", "--show-toplevel"]: return type("P", (), {"returncode": 0, "stdout": f"{tmp_path}\n"})() return type("P", (), {"returncode": 0, "stdout": "main\n"})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) assert git_utils._run_git("branch", "--show-current") == "main" @@ -194,12 +195,12 @@ def test_run_git_returns_empty_on_failure( ) -> None: """Non-zero exit produces an empty string (not raise).""" - def fake_run(cmd: list[str], **_kwargs: object) -> object: + def _fake_run(cmd: list[str], **_kwargs: object) -> object: if cmd[:3] == ["git", "rev-parse", "--show-toplevel"]: return type("P", (), {"returncode": 0, "stdout": f"{tmp_path}\n"})() return type("P", (), {"returncode": 128, "stdout": "x\n", "stderr": "boom"})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) assert git_utils._run_git("nope") == "" @@ -225,7 +226,7 @@ def _stub_branch_path( stdout (e.g., ``{"main...HEAD": "src/foo.py\\n"}``). """ - def fake_run(cmd: list[str], **_kwargs: object) -> object: + def _fake_run(cmd: list[str], **_kwargs: object) -> object: if cmd[:3] == ["git", "rev-parse", "--show-toplevel"]: return type("P", (), {"returncode": 0, "stdout": f"{tmp_path}\n"})() if cmd[1:3] == ["branch", "--show-current"]: @@ -238,7 +239,7 @@ def fake_run(cmd: list[str], **_kwargs: object) -> object: return type("P", (), {"returncode": 0, "stdout": stdout})() return type("P", (), {"returncode": 0, "stdout": ""})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) def test_get_modified_files_feature_branch_aggregates_three_diffs( @@ -283,7 +284,7 @@ def test_get_modified_files_main_falls_back_to_head_prev( ) -> None: """On main, the previous-commit diff is used.""" - def fake_run(cmd: list[str], **_kwargs: object) -> object: + def _fake_run(cmd: list[str], **_kwargs: object) -> object: if cmd[:3] == ["git", "rev-parse", "--show-toplevel"]: return type("P", (), {"returncode": 0, "stdout": f"{tmp_path}\n"})() if cmd[1:3] == ["branch", "--show-current"]: @@ -292,7 +293,7 @@ def fake_run(cmd: list[str], **_kwargs: object) -> object: return type("P", (), {"returncode": 0, "stdout": "src/x.py\n"})() return type("P", (), {"returncode": 0, "stdout": ""})() - monkeypatch.setattr(git_utils.subprocess, "run", fake_run) + monkeypatch.setattr(git_utils.subprocess, "run", _fake_run) assert git_utils.get_modified_files() == ["src/x.py"] @@ -329,3 +330,23 @@ def test_configure_cli_logging_is_idempotent() -> None: """Calling configure_cli_logging twice is safe (handlers already attached).""" git_utils.configure_cli_logging() git_utils.configure_cli_logging() # second call must not raise + + +def test_latest_v_tag_returns_highest_sorted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The first line of the ``--sort=-v:refname`` output (highest) is returned.""" + monkeypatch.setattr( + git_utils.subprocess, + "run", + make_fake_run(stdout="v1.21.0\nv1.20.2\nv1.20.0\n"), + ) + assert git_utils.latest_v_tag(tmp_path) == "v1.21.0" + + +def test_latest_v_tag_none_when_no_tags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No ``v*`` tags → ``None``.""" + monkeypatch.setattr(git_utils.subprocess, "run", make_fake_run(stdout="")) + assert git_utils.latest_v_tag(tmp_path) is None diff --git a/tests/test_install_bootstrap.py b/tests/test_install_bootstrap.py index 7d40802..7baffc5 100644 --- a/tests/test_install_bootstrap.py +++ b/tests/test_install_bootstrap.py @@ -26,6 +26,7 @@ def test_steps_in_expected_order() -> None: "cli-reference", "audit-deps", "doctor", + "config", ] @@ -189,12 +190,12 @@ def test_main_continue_on_fail_runs_every_step( ) # Skip labels (its gate runs subprocess for `git remote`, which would # tangle with the always-returncode-1 fake) so we exercise the - # remaining six steps cleanly. + # remaining seven steps cleanly. argv = ["install-forge-bootstrap", "--skip", "labels"] with patch.object(install_bootstrap.sys, "argv", argv): rc = install_bootstrap.main() - # Six non-gated steps, each fails with rc=1. - assert rc == 6 + # Seven non-gated steps (incl. config), each fails with rc=1. + assert rc == 7 def test_doctor_and_audit_deps_skip_in_ci( diff --git a/tests/test_next_prep.py b/tests/test_next_prep.py index 0d1a4f2..659588e 100644 --- a/tests/test_next_prep.py +++ b/tests/test_next_prep.py @@ -148,12 +148,8 @@ def test_maybe_tag_release_skips_when_version_equals_latest_tag( json.dumps({"name": "x", "version": "1.0.0"}) ) - def _fake_git(*args: str, **_kw: object) -> str: - if args[:2] == ("tag", "--list"): - return "v1.0.0" - return "" - - monkeypatch.setattr(next_prep, "_git", _fake_git) + monkeypatch.setattr(next_prep, "latest_v_tag", lambda _root: "v1.0.0") + monkeypatch.setattr(next_prep, "_git", lambda *_a, **_kw: "") assert next_prep._maybe_tag_release(tmp_path) is None @@ -169,10 +165,9 @@ def test_maybe_tag_release_creates_and_pushes_new_tag( def _fake_git(*args: str, **_kw: object) -> str: invoked.append(list(args)) - if args[:2] == ("tag", "--list"): - return "v1.2.9" return "" + monkeypatch.setattr(next_prep, "latest_v_tag", lambda _root: "v1.2.9") monkeypatch.setattr(next_prep, "_git", _fake_git) result = next_prep._maybe_tag_release(tmp_path) assert result == "v1.2.10" @@ -342,6 +337,78 @@ def test_promotion_status_lists_pending_minors_in_order( assert pending == ["v1.18.0", "v1.19.0"] +def test_promotion_status_excludes_patch_tags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Patch tags fold into the next minor — never listed as separate promotions. + + base is minor-only; v1.19.1 / v1.20.1 / v1.20.2 ride along when their + minor is promoted, so only the ``X.Y.0`` targets appear. + """ + monkeypatch.setattr( + next_prep, + "_read_plugin_version_at_ref", + lambda _root, ref: "1.21.0" if "dev" in ref else "1.19.0", + ) + monkeypatch.setattr( + next_prep, + "_git", + lambda *args, **_kw: ( + "v1.19.0 v1.19.1 v1.20.0 v1.20.1 v1.20.2 v1.21.0" + if args[:1] == ("tag",) + else "" + ), + ) + lines = next_prep._promotion_status_lines(tmp_path, "dev", "main") + pending = [line.strip() for line in lines if line.startswith(" ")] + assert pending == ["v1.20.0", "v1.21.0"] + + +def test_promotion_status_includes_major_release( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A major release ``X.0.0`` is a promotion target (patch component is 0).""" + monkeypatch.setattr( + next_prep, + "_read_plugin_version_at_ref", + lambda _root, ref: "2.0.0" if "dev" in ref else "1.20.0", + ) + monkeypatch.setattr( + next_prep, + "_git", + lambda *args, **_kw: ( + "v1.20.0 v1.20.1 v1.21.0 v2.0.0" if args[:1] == ("tag",) else "" + ), + ) + lines = next_prep._promotion_status_lines(tmp_path, "dev", "main") + pending = [line.strip() for line in lines if line.startswith(" ")] + assert pending == ["v1.21.0", "v2.0.0"] + + +def test_promotion_status_when_dev_sits_on_patch_above_last_minor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Dev on a patch (1.21.3) still targets the minors up to its minor line.""" + monkeypatch.setattr( + next_prep, + "_read_plugin_version_at_ref", + lambda _root, ref: "1.21.3" if "dev" in ref else "1.19.0", + ) + monkeypatch.setattr( + next_prep, + "_git", + lambda *args, **_kw: ( + "v1.20.0 v1.20.1 v1.21.0 v1.21.3" if args[:1] == ("tag",) else "" + ), + ) + lines = next_prep._promotion_status_lines(tmp_path, "dev", "main") + pending = [line.strip() for line in lines if line.startswith(" ")] + assert pending == ["v1.20.0", "v1.21.0"] + + def test_main_promotion_status_early_exits( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_verify_docstring_coverage.py b/tests/test_verify_docstring_coverage.py index 05be168..7311367 100644 --- a/tests/test_verify_docstring_coverage.py +++ b/tests/test_verify_docstring_coverage.py @@ -129,10 +129,10 @@ def test_skip_when_no_pyproject( assert "skipped" in log -def test_skip_when_no_src_directory( +def test_skip_when_no_configured_paths_exist( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """``pyproject.toml`` present but no ``src/`` → skip.""" + """``pyproject.toml`` present but no configured scan root exists → skip.""" _write_pyproject(tmp_path, fail_under=90.0) monkeypatch.chdir(tmp_path) monkeypatch.setattr("sys.argv", ["verify-forge-docstring-coverage"]) @@ -203,3 +203,77 @@ def test_default_fail_under_matches_foundation( assert verify_docstring_coverage.main() == 0 log = (tmp_path / "code_health" / "docstring_coverage.log").read_text() assert "fail-under 90" in log + + +def test_scan_paths_defaults_to_src_and_tests(tmp_path: Path) -> None: + """No ``paths`` key → existing ``src`` / ``tests`` roots.""" + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + result = verify_docstring_coverage._scan_paths({}, tmp_path) + assert result == [ + str((tmp_path / "src").resolve()), + str((tmp_path / "tests").resolve()), + ] + + +def test_scan_paths_defaults_to_repo_layout(tmp_path: Path) -> None: + """No per-tool paths → ``[tool.forge].source_dirs + test_dirs``.""" + (tmp_path / "lib").mkdir() + (tmp_path / "t").mkdir() + data = {"tool": {"forge": {"source_dirs": ["lib"], "test_dirs": ["t"]}}} + result = verify_docstring_coverage._scan_paths(data, tmp_path) + assert result == [ + str((tmp_path / "lib").resolve()), + str((tmp_path / "t").resolve()), + ] + + +def test_scan_paths_per_tool_override_wins(tmp_path: Path) -> None: + """``[tool.forge.docstring_coverage].paths`` overrides the repo layout.""" + (tmp_path / "src").mkdir() + (tmp_path / "only").mkdir() + data = { + "tool": { + "forge": { + "source_dirs": ["src"], + "docstring_coverage": {"paths": ["only"]}, + } + } + } + result = verify_docstring_coverage._scan_paths(data, tmp_path) + assert result == [str((tmp_path / "only").resolve())] + + +def test_scan_paths_honors_configured_paths(tmp_path: Path) -> None: + """``[tool.forge.docstring_coverage].paths`` overrides the default roots.""" + (tmp_path / "projects").mkdir() + (tmp_path / "src").mkdir() + data = {"tool": {"forge": {"docstring_coverage": {"paths": ["projects", "src"]}}}} + result = verify_docstring_coverage._scan_paths(data, tmp_path) + assert result == [ + str((tmp_path / "projects").resolve()), + str((tmp_path / "src").resolve()), + ] + + +def test_scan_paths_rejects_traversal_outside_repo(tmp_path: Path) -> None: + """A ``..`` path escaping the repo is dropped (path-traversal guard).""" + (tmp_path / "src").mkdir() + (tmp_path.parent / "secret").mkdir(exist_ok=True) + data = {"tool": {"forge": {"docstring_coverage": {"paths": ["../secret", "src"]}}}} + result = verify_docstring_coverage._scan_paths(data, tmp_path) + assert result == [str((tmp_path / "src").resolve())] + + +def test_scan_paths_rejects_absolute_path_outside_repo(tmp_path: Path) -> None: + """An absolute path outside the repo is dropped (path-traversal guard).""" + (tmp_path / "src").mkdir() + data = {"tool": {"forge": {"docstring_coverage": {"paths": ["/etc", "src"]}}}} + result = verify_docstring_coverage._scan_paths(data, tmp_path) + assert result == [str((tmp_path / "src").resolve())] + + +def test_scan_paths_empty_when_none_exist(tmp_path: Path) -> None: + """Configured roots that don't exist → empty list (caller skips cleanly).""" + data = {"tool": {"forge": {"docstring_coverage": {"paths": ["nope"]}}}} + assert verify_docstring_coverage._scan_paths(data, tmp_path) == [] diff --git a/tests/test_verify_plugin_version.py b/tests/test_verify_plugin_version.py index 299703d..3e33fb8 100644 --- a/tests/test_verify_plugin_version.py +++ b/tests/test_verify_plugin_version.py @@ -179,7 +179,7 @@ def test_skipped_on_release_commit( monkeypatch.setattr("sys.argv", ["verify-forge-plugin-version"]) assert verify_plugin_version.main() == 0 log = (tmp_path / "code_health" / "plugin_version.log").read_text() - assert "release commit" in log + assert "release tag" in log def test_skipped_when_tree_matches_tag_via_ours_merge( @@ -226,4 +226,67 @@ def test_skipped_when_tree_matches_tag_via_ours_merge( monkeypatch.setattr("sys.argv", ["verify-forge-plugin-version"]) assert verify_plugin_version.main() == 0 log = (tmp_path / "code_health" / "plugin_version.log").read_text() - assert "release commit" in log + assert "release tag" in log + + +def test_main_skips_when_head_reproduces_older_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Staged dev→main promotion of a minor BELOW the global-max tag skips. + + Regression lock for the #43 ancestry→global tag switch: an earlier + guard compared HEAD only against the *latest* tag, which made + promoting any minor below the global-max impossible (the release + branch's tree never equals the latest tag's tree). Reproduces the + real ``v1.22.0`` (plugin.json 1.22.0) promoted while ``v1.23.0`` is + already tagged. Do not narrow ``_is_release_commit`` back to one tag. + """ + env = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": os.environ.get("PATH", ""), + } + _init_git_repo(tmp_path) + # Older release v1.0.0. + _write_plugin(tmp_path, "1.0.0") + subprocess.run(["git", "add", "."], cwd=tmp_path, env=env, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "v1.0.0"], cwd=tmp_path, env=env, check=True + ) + subprocess.run(["git", "tag", "v1.0.0"], cwd=tmp_path, env=env, check=True) + # Newer release v1.1.0 — becomes the global-max tag. + _write_plugin_overwrite(tmp_path, "1.1.0") + subprocess.run(["git", "add", "."], cwd=tmp_path, env=env, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "v1.1.0"], cwd=tmp_path, env=env, check=True + ) + subprocess.run(["git", "tag", "v1.1.0"], cwd=tmp_path, env=env, check=True) + # Release branch reproducing v1.0.0's tree (plugin.json 1.0.0, BELOW + # the global-max tag v1.1.0). + subprocess.run( + ["git", "checkout", "-q", "-b", "release/v1.0.0"], + cwd=tmp_path, + env=env, + check=True, + ) + subprocess.run( + ["git", "checkout", "-q", "v1.0.0", "--", "."], + cwd=tmp_path, + env=env, + check=True, + ) + subprocess.run( + ["git", "commit", "-q", "-m", "promote v1.0.0"], + cwd=tmp_path, + env=env, + check=True, + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sys.argv", ["verify-forge-plugin-version"]) + # HEAD's tree == v1.0.0's tree (an older tag) → guard skips, even + # though plugin.json 1.0.0 < latest tag v1.1.0. + assert verify_plugin_version.main() == 0 + log = (tmp_path / "code_health" / "plugin_version.log").read_text() + assert "release tag" in log