Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "1.23.2",
"version": "1.24.0",
"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",
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ change groups by conventional-commit type (**Features / Fixes / Refactor
Follows [Keep a Changelog](https://keepachangelog.com/) in spirit;
versions follow forge's rolling-next convention.

## v1.24.0 — 2026-06-17

All additive and opt-in — no consumer action required to upgrade.

### Features
- **Pluggable pre-commit step framework** — `[tool.forge.precommit]
enable` / `disable` (plus `forge-precommit --only` / `--skip`) turn any
step on or off uniformly, on top of each step's own self-skip (#6).
- **Opt-in `doctest` step** — `pytest --doctest-modules` over
`[tool.forge.doctest].paths` (default `["src"]`); non-blocking by
default (#5).
- **Opt-in `typecheck` step** — runs `pyrefly` over
`[tool.forge.typecheck].paths`; non-blocking by default (#48).
- **Opt-in `doc_consistency` step** + `verify-forge-doc-consistency` CLI —
checks that every `[project.scripts]` CLI is documented in
`docs/cli-reference.md`; non-blocking (#4).

### Tooling
- `forge-config --list` now enumerates the new
`[tool.forge.precommit/doctest/typecheck]` keys, and a drift test
couples `CONFIG_KEYS` to its readers so the registry can't silently go
stale (#46).

## v1.23.0 — 2026-06-17

### Features
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
## 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`.
- **PR & branch granularity — bundle cohesive work, don't reflex-split.** A PR is the smallest *cohesive* unit, not the smallest possible one. In forge every PR also costs a rolling-next `plugin.json` bump + a `dev` tag (and, at minor boundaries, a promotion), so splitting *related* changes across PRs multiplies that ceremony to buy isolation nobody needs. **Default to ONE PR** when the changes share a subsystem, must land together, or one half is meaningless without the other — e.g. a bug a type checker surfaces *while you build the feature that runs it* belongs in that feature's PR, not a parallel one; a follow-up cleanup to code this PR introduces belongs here too. **Split only for a concrete reason:** independent risk/rollback, different reviewers or release cadence, a hard blocking dependency, or a diff too large to review in one sitting. "It's a separate concern" does **not** justify a separate PR when the concerns ship together — separateness of *topic* ≠ separateness of *delivery*. When unsure, bundle. (File a separate *issue* freely for deferred work — that's cheap; it's separate *PRs/branches* that add the burden.)
- **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.
Expand Down
2 changes: 2 additions & 0 deletions REPO_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Code.
structure drift check
- verify_test_naming.py: `verify-forge-test-naming` — test naming check
- verify_manifest.py: `verify-forge-manifest` — `.claude-plugin/*.json` JSON validation
- verify_doc_consistency.py: `verify-forge-doc-consistency` — checks every `[project.scripts]` CLI is documented in `docs/cli-reference.md`; backs the opt-in `doc_consistency` pre-commit step (non-blocking)
- verify_plugin_version.py: `verify-forge-plugin-version` — rolling-next guard (plugin.json["version"] > latest git tag)
- gen_cli_reference.py: `forge-gen-cli-reference` — CLI reference
doc generator
Expand Down Expand Up @@ -159,6 +160,7 @@ Pytest suite mirroring the `src/forge/` layout:
- test_verify_docstrings.py: tests for verify_docstrings
- test_verify_docstring_coverage.py: tests for verify_docstring_coverage
- test_verify_manifest.py: tests for verify_manifest
- test_verify_doc_consistency.py: tests for verify_doc_consistency
- test_verify_plugin_version.py: tests for verify_plugin_version
- test_verify_repo_structure.py: tests for verify_repo_structure
- test_verify_test_naming.py: tests for verify_test_naming
Expand Down
9 changes: 6 additions & 3 deletions dev/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ if [ "${CONDA_DEFAULT_ENV:-}" != "$ENV_NAME" ]; then
exit 1
fi

# 4. Editable install with test deps.
echo -e "${YELLOW}→${NC} pip install -e .[test]"
pip install -e ".[test]"
# 4. Editable install with the full contributor umbrella (test + audit +
# typecheck). forge's own dev env should carry every tool it ships and
# dogfoods — pytest, the audit deps (vulture / jsonschema / PyYAML), and
# pyrefly for the opt-in typecheck step. `[dev]` is that umbrella.
echo -e "${YELLOW}→${NC} pip install -e .[dev]"
pip install -e ".[dev]"

# 5. Run the consumer-facing umbrella installer. Dogfood: forge is the
# source of install-forge-bootstrap, so running it here exercises the
Expand Down
23 changes: 19 additions & 4 deletions docs/api-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

_40 modules, 362 symbols._
_41 modules, 374 symbols._

## `forge._hook_helpers`

Expand Down Expand Up @@ -158,7 +158,7 @@ _40 modules, 362 symbols._

## `forge.config`

- `class ForgeConfig` — Branch-name configuration sourced from ``[tool.forge]``.
- `class ForgeConfig` — Repo 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``.
Expand Down Expand Up @@ -326,6 +326,7 @@ _40 modules, 362 symbols._

- `_read_plugin_version_at_ref(repo_root: Path, ref: str) -> str | None` _(internal)_ — Return ``plugin.json["version"]`` at the given git ref, or ``None`` when absent.
- `_check_promote_pending_message(repo_root: Path, dev_branch: str, base_branch: str) -> str | None` _(internal)_ — Return a one-line user-facing prompt when promotion is pending, else ``None``.
- `_changelog_lacks_entry(changelog_text: str, minor_tag: str) -> bool` _(internal)_ — Return True when *changelog_text* has no ``## <minor_tag>`` heading.
- `_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.
Expand Down Expand Up @@ -369,6 +370,8 @@ _40 modules, 362 symbols._

- `_color(code: str) -> str` _(internal)_ — Return *code* if stdout is a TTY, else an empty string.
- `class StepResult` — Outcome of a single pre-commit step.
- `class StepDef` — A registry entry: a step's name, its function, and whether it runs by default.
- `_forge_step_config(repo_root: Path, step: str) -> dict[str, object]` _(internal)_ — Return the ``[tool.forge.<step>]`` table, or ``{}`` when absent.
- `_run(cmd: list[str], cwd: Path) -> tuple[bool, str]` _(internal)_ — Run *cmd* and capture combined output.
- `step_ruff(repo_root: Path) -> StepResult` — Run ``fix-forge-ruff`` — owns the ruff phase end-to-end.
- `step_docstrings(repo_root: Path) -> StepResult` — Run ``verify-forge-docstrings`` over the current diff vs main.
Expand All @@ -382,9 +385,16 @@ _40 modules, 362 symbols._
- `step_cli_wiring(repo_root: Path) -> StepResult` — Run ``verify-forge-cli-wiring`` — assert every script has a real caller.
- `_cli_wiring_enabled(repo_root: Path) -> bool` _(internal)_ — Return True when the repo has opted into the cli_wiring check.
- `step_plugin_version(repo_root: Path) -> StepResult` — Run ``verify-forge-plugin-version`` — owns the rolling-next guard.
- `_bad_scan_paths(paths: list[object], repo_root: Path) -> list[str]` _(internal)_ — Return config scan-path values that are option-like or escape the repo.
- `step_doctest(repo_root: Path) -> StepResult` — Run ``pytest --doctest-modules`` over docstring examples (opt-in).
- `step_typecheck(repo_root: Path) -> StepResult` — Run pyrefly over the source tree (opt-in).
- `step_doc_consistency(repo_root: Path) -> StepResult` — Run ``verify-forge-doc-consistency`` — doc claims vs repo state (opt-in).
- `_write_log(repo_root: Path, result: StepResult) -> None` _(internal)_ — Persist *result*'s output to ``code_health/<name>.log``.
- `_print_step_line(result: StepResult) -> None` _(internal)_ — Print a one-line status for *result* (SKIP/PASS/WARN/FAIL).
- `run_all(repo_root: Path | None = None, *, print_progress: bool = True) -> list[StepResult]` — Run every step in order and return their results.
- `_validate_step_names(names: Sequence[str]) -> None` _(internal)_ — Raise ``ValueError`` listing any *names* that are not registered steps.
- `_resolve_steps(repo_root: Path, *, skip: Sequence[str] = (), only: Sequence[str] = ()) -> list[StepDef]` _(internal)_ — Resolve which steps to run, in registry order.
- `run_all(repo_root: Path | None = None, *, print_progress: bool = True, skip: Sequence[str] = (), only: Sequence[str] = ()) -> list[StepResult]` — Run the resolved step sequence in order and return their results.
- `_split_csv(values: Sequence[str]) -> list[str]` _(internal)_ — Flatten repeatable / comma-separated CLI values into a clean name list.
- `main() -> int` — CLI entry point.

## `forge.run_context`
Expand Down Expand Up @@ -432,6 +442,11 @@ _40 modules, 362 symbols._
- `_check_wiring(root: Path) -> int` _(internal)_ — Run the reachability check and report findings.
- `main() -> int` — Entry point for ``verify-forge-cli-wiring``.

## `forge.verify_doc_consistency`

- `_check_cli_coverage(repo_root: Path) -> list[str]` _(internal)_ — Return findings for ``[project.scripts]`` names missing from the CLI reference.
- `main() -> int` — CLI entry point.

## `forge.verify_docstring_coverage`

- `_interrogate_config(data: dict) -> tuple[InterrogateConfig, float, list[str]]` _(internal)_ — Build the interrogate config + threshold + excludes from TOML data.
Expand Down Expand Up @@ -464,7 +479,7 @@ _40 modules, 362 symbols._

## `forge.verify_plugin_version`

- `_is_release_commit(repo_root: Path, tag: str) -> bool` _(internal)_ — Return True when ``HEAD`` carries the same file content as *tag*.
- `_is_release_commit(repo_root: Path) -> bool` _(internal)_ — Return True when ``HEAD``'s tree reproduces ANY published ``v*`` tag.
- `main() -> int` — Enforce plugin.json version > latest git tag.

## `forge.verify_repo_structure`
Expand Down
26 changes: 23 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,8 @@ options:
## forge-precommit

```text
usage: forge-precommit [-h] [--json]
usage: forge-precommit [-h] [--json] [--skip STEP[,STEP...]]
[--only STEP[,STEP...]]

Run the forge pre-commit check sequence: ruff (format + check, self-healing
with --unsafe-fixes on failure) + docstring verification (diff vs main) +
Expand All @@ -387,8 +388,14 @@ default sequence — run it in CI or wire it into .githooks/pre-commit
explicitly. Used by any repo that adopts forge via install-forge-githooks.

options:
-h, --help show this help message and exit
--json Emit a JSON summary on stdout instead of human output.
-h, --help show this help message and exit
--json Emit a JSON summary on stdout instead of human output.
--skip STEP[,STEP...]
Force-skip these steps for this run (repeatable or
comma-separated).
--only STEP[,STEP...]
Run exactly these steps (repeatable or comma-
separated).
```

## forge-slow-tests-report
Expand Down Expand Up @@ -527,6 +534,19 @@ options:
-h, --help show this help message and exit
```

## verify-forge-doc-consistency

```text
usage: verify-forge-doc-consistency [-h]

Check machine-checkable documentation claims (CLI name-lists, agent counts)
against the actual repo state. Non-blocking reporter for the doc_consistency
pre-commit step.

options:
-h, --help show this help message and exit
```

## verify-forge-docstring-coverage

```text
Expand Down
40 changes: 40 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@ to see what, if anything, is worth adding for your repo.
|---|---|---|---|
| `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.precommit]` — turn steps on and off

The uniform lever over the pre-commit sequence. Applied on top of each
step's own self-skip (it never *weakens* a self-skip), and `disable` beats
`enable` when a name appears in both. The same effect for a single run is
available as `forge-precommit --only <names>` / `--skip <names>`.

| Key | Default | What it does | Set it when |
|---|---|---|---|
| `disable` | `[]` | Force-skip these default steps by name (e.g. `["pip_audit"]`). | You want a default step off repo-wide. |
| `enable` | `[]` | Opt into normally-off steps by name: `doctest`, `typecheck`, `doc_consistency`. | You want one of the opt-in steps below to run. |

## `[tool.forge.doctest]` — opt-in doctest step

Runs `pytest --doctest-modules` so docstring `>>>` examples are executed,
not just present. Enable via `[tool.forge.precommit] enable = ["doctest"]`.

| Key | Default | What it does | Set it when |
|---|---|---|---|
| `paths` | `["src"]` | Scan roots for `--doctest-modules`. | Your doctested code lives elsewhere. |
| `blocking` | `false` | Fail the commit on a broken example (else non-blocking WARN). | You want doctest drift to refuse a commit. |

## `[tool.forge.typecheck]` — opt-in type-check step

Runs [pyrefly](https://github.com/facebook/pyrefly) (Rust, stable,
pyproject-native, reads/migrates `[tool.mypy]`). Enable via
`[tool.forge.precommit] enable = ["typecheck"]`. When enabled but
`pyrefly` is absent, the step fails loudly (it does not silently pass).
Non-blocking by default — a type-checker false positive that refuses a
commit trains `--no-verify`.

| Key | Default | What it does | Set it when |
|---|---|---|---|
| `paths` | `["src"]` | Scan roots passed to pyrefly. | Your source lives elsewhere. |
| `blocking` | `false` | Fail the commit on a type error (else non-blocking WARN). | Your type baseline is clean and you want it enforced. |

The `doc_consistency` step (`verify-forge-doc-consistency`, enabled the
same way) has no config table — it checks that every `[project.scripts]`
CLI is documented in `docs/cli-reference.md`, and is always non-blocking.

## `[tool.forge.docstring_coverage]`

Forge-specific keys for the docstring-coverage reporter. (The coverage *gate*
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ verify-forge-repo-structure = "forge.verify_repo_structure:main"
install-forge-labels = "forge.install_labels:main"
verify-forge-test-naming = "forge.verify_test_naming:main"
verify-forge-cli-wiring = "forge.verify_cli_wiring:main"
verify-forge-doc-consistency = "forge.verify_doc_consistency:main"
forge-gen-cli-reference = "forge.gen_cli_reference:main"
forge-gen-api-digest = "forge.gen_api_digest:main"
forge-gen-commit-types = "forge.gen_commit_types:main"
Expand Down Expand Up @@ -60,12 +61,14 @@ forge-audit-all = "forge.audit.all:main"
test = ["pytest>=8.0"]
audit = ["vulture>=2.10", "jsonschema>=4.0", "PyYAML>=6.0"]
audit-tach = ["tach>=0.30"]
# Type checker for the opt-in `typecheck` pre-commit step (pyrefly only).
typecheck = ["pyrefly>=1,<2"]
# Contributor / dev-loop umbrella. Aggregates the targeted extras so
# every reference to `pip install -e ".[dev]"` in forge's own source,
# docs, hook error messages, and test assertions resolves to a real
# install target inside forge's own repo. Consumer repos typically
# carry their own `[dev]` extra; this one is for forge itself.
dev = ["forge-scripts[test,audit]"]
dev = ["forge-scripts[test,audit,typecheck]"]

[project.urls]
Homepage = "https://github.com/misnaej/forge"
Expand Down
2 changes: 1 addition & 1 deletion src/forge/audit/claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ def _comment_findings(
evidence=(comment_body[:COMMENT_PREVIEW],),
),
)
except tokenize.TokenizeError as exc:
except tokenize.TokenError as exc:
logger.debug("tokenize failed in %s: %s", rel, exc)
return findings

Expand Down
2 changes: 1 addition & 1 deletion src/forge/audit/dup.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def _tokenize_body(source: str) -> list[str]:
tokens.append(tok.string if keyword.iskeyword(tok.string) else "ID")
else:
tokens.append(tok.string)
except tokenize.TokenizeError:
except tokenize.TokenError:
logger.debug("tokenize failed on a snippet — skipping")
return []
return tokens
Expand Down
2 changes: 1 addition & 1 deletion src/forge/audit/suppressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def _iter_comments(text: str) -> list[tuple[int, str]]:
seen_lines.add(line_no)
if 1 <= line_no <= len(source_lines):
pairs.append((line_no, source_lines[line_no - 1]))
except tokenize.TokenizeError as exc:
except tokenize.TokenError as exc:
logger.debug("tokenize failed: %s", exc)
return pairs

Expand Down
34 changes: 34 additions & 0 deletions src/forge/forge_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,40 @@ class ConfigKey:
"Per-tool override of the coverage scan roots; otherwise inherits "
"the repo-wide [tool.forge].source_dirs + test_dirs.",
),
ConfigKey(
("tool", "forge", "precommit", "disable"),
default=[],
description="Force-skip these pre-commit steps by name (over each "
"step's own self-skip).",
),
ConfigKey(
("tool", "forge", "precommit", "enable"),
default=[],
description="Opt into normally-off pre-commit steps by name "
"(doctest, typecheck, doc_consistency).",
),
ConfigKey(
("tool", "forge", "doctest", "paths"),
["src"],
"Scan roots for the opt-in doctest step (pytest --doctest-modules).",
),
ConfigKey(
("tool", "forge", "doctest", "blocking"),
default=False,
description="Make the doctest step fail the commit on a broken "
"example (default: non-blocking WARN).",
),
ConfigKey(
("tool", "forge", "typecheck", "paths"),
["src"],
"Scan roots for the opt-in pyrefly typecheck step.",
),
ConfigKey(
("tool", "forge", "typecheck", "blocking"),
default=False,
description="Make the typecheck step fail the commit on a checker "
"error (default: non-blocking WARN).",
),
)

# Third-party tools forge reads from their OWN native section rather than
Expand Down
Loading
Loading