Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ All notable changes to this project will be documented in this file.

---

## [0.43.2] - 2026-03-29

### Changed

- Pre-commit `specfact-code-review-gate` now runs `specfact code review run --json --out .specfact/code-review.json` (instead of `--score-only`) so the governed `ReviewReport` JSON is written under gitignored `.specfact/` for IDE and Copilot workflows.
Comment thread
djm81 marked this conversation as resolved.

Comment thread
djm81 marked this conversation as resolved.
---

## [0.43.1] - 2026-03-28

### Changed
Expand Down
6 changes: 4 additions & 2 deletions docs/modules/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ repos:
The helper script scopes the gate to staged Python files only and then runs:

```bash
specfact code review run --score-only <staged-python-files>
specfact code review run --json --out .specfact/code-review.json <staged-python-files>
```

The JSON report is written under ``.specfact/`` (ignored by git via ``.specfact/`` in ``.gitignore``) so local tools and Copilot can read structured findings. The CLI still echoes the output path on success.

Commit behavior:

- `PASS` keeps the commit green
Expand All @@ -148,7 +150,7 @@ repos:
hooks:
- id: specfact-code-review
name: specfact code review gate
entry: specfact code review run --score-only
entry: specfact code review run --json --out .specfact/code-review.json
language: system
files: \.pyi?$
```
Expand Down
18 changes: 18 additions & 0 deletions openspec/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ context: |
`openspec/changes/<change-id>/TDD_EVIDENCE.md` for every behavior change.
- If the pattern does not work in practice, adjust the process until it does.

Code review JSON (dogfood): Treat `.specfact/code-review.json` as mandatory evidence before an OpenSpec
change is complete. If the file is missing or stale (see tasks), run
`specfact code review run --json --out .specfact/code-review.json` and resolve **every** finding at any
severity (warning or error) unless the proposal documents a rare, explicit exception.

# Per-artifact rules (only injected into matching artifacts)
rules:
proposal:
Expand Down Expand Up @@ -158,3 +163,16 @@ rules:
- body following `.github/ISSUE_TEMPLATE/change_proposal.md` template (Why, What Changes sections from proposal)
- footer `*OpenSpec Change Proposal: <change-id>*`
- After creation, update proposal.md Source Tracking section with issue number, URL, repository, and status.
- |-
SpecFact code review JSON (dogfood, required before PR): Include tasks to
- (1) Ensure `.specfact/code-review.json` is present and **fresh**: if the file is missing, or its
last-modified time is older than any file you changed in this change under `src/`, `scripts/`,
`tools/`, `tests/`, or `openspec/changes/<change-id>/`, run a new review, e.g.
Comment thread
djm81 marked this conversation as resolved.
`hatch run specfact code review run --json --out .specfact/code-review.json` with `--scope changed`
during iteration and `--scope full` (or equivalent coverage) before the final PR.
- (2) Read the JSON report and remediate **all** findings regardless of severity (warning, advisory,
error, or equivalent in the schema): treat them as blocking until fixed or the proposal explicitly
documents a justified exception approved in the change.
- (3) Re-run the review after substantive edits until the report shows a passing outcome per the
review module (e.g. overall verdict PASS / CI exit 0); record the review command(s) and timestamp
in `TDD_EVIDENCE.md` or the PR description when the change touches behavior or quality gates.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "specfact-cli"
version = "0.43.1"
version = "0.43.2"
description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
43 changes: 36 additions & 7 deletions scripts/pre_commit_code_review.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""Run specfact code review as a staged-file pre-commit gate."""
"""Run specfact code review as a staged-file pre-commit gate.

Writes a machine-readable JSON report to ``.specfact/code-review.json`` (gitignored)
so IDEs and Copilot can read findings; exit code still reflects the governed CI verdict.
"""

# CrossHair: ignore
# This helper shells out to the CLI and is intentionally side-effecting.
Expand All @@ -16,6 +20,9 @@

PYTHON_SUFFIXES = {".py", ".pyi"}

# Default matches dogfood / OpenSpec: machine-readable report under ignored ``.specfact/``.
REVIEW_JSON_OUT = ".specfact/code-review.json"


@require(lambda paths: paths is not None)
@ensure(lambda result: len(result) == len(set(result)))
Expand All @@ -36,13 +43,29 @@ def filter_review_files(paths: Sequence[str]) -> list[str]:

@require(lambda files: files is not None)
@ensure(lambda result: result[:5] == [sys.executable, "-m", "specfact_cli.cli", "code", "review"])
@ensure(lambda result: "--score-only" in result)
@ensure(lambda result: "--json" in result and "--out" in result)
@ensure(lambda result: REVIEW_JSON_OUT in result)
def build_review_command(files: Sequence[str]) -> list[str]:
"""Build the score-only review command used by the pre-commit gate."""
return [sys.executable, "-m", "specfact_cli.cli", "code", "review", "run", "--score-only", *files]
"""Build ``code review run --json --out …`` so findings are written for tooling."""
return [
sys.executable,
"-m",
"specfact_cli.cli",
"code",
"review",
"run",
"--json",
"--out",
REVIEW_JSON_OUT,
*files,
]


def _repo_root() -> Path:
"""Repository root (parent of ``scripts/``)."""
return Path(__file__).resolve().parents[1]


@ensure(lambda result: isinstance(result[0], bool))
def ensure_runtime_available() -> tuple[bool, str | None]:
"""Verify the current Python environment can import SpecFact CLI."""
try:
Expand All @@ -54,7 +77,7 @@ def ensure_runtime_available() -> tuple[bool, str | None]:

@ensure(lambda result: isinstance(result, int))
def main(argv: Sequence[str] | None = None) -> int:
"""Run the score-only review gate over staged Python files."""
"""Run the code review gate; write JSON under ``.specfact/`` and return CLI exit code."""
files = filter_review_files(list(argv or []))
if not files:
sys.stdout.write("No staged Python files to review; skipping code review gate.\n")
Expand All @@ -65,7 +88,13 @@ def main(argv: Sequence[str] | None = None) -> int:
sys.stdout.write(f"Unable to run the code review gate. {guidance}\n")
return 1

result = subprocess.run(build_review_command(files), check=False, text=True, capture_output=True)
result = subprocess.run(
build_review_command(files),
check=False,
text=True,
capture_output=True,
cwd=str(_repo_root()),
)
Comment thread
djm81 marked this conversation as resolved.
if result.stdout:
sys.stdout.write(result.stdout)
if result.stderr:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
if __name__ == "__main__":
_setup = setup(
name="specfact-cli",
version="0.43.1",
version="0.43.2",
description=(
"The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with "
"validation and contract enforcement for new projects and long-lived codebases."
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"""

# Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py
__version__ = "0.43.1"
__version__ = "0.43.2"
2 changes: 1 addition & 1 deletion src/specfact_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ def _bootstrap_bundle_paths() -> None:

_bootstrap_bundle_paths()

__version__ = "0.43.1"
__version__ = "0.43.2"

__all__ = ["__version__"]
3 changes: 2 additions & 1 deletion tests/unit/scripts/test_code_review_module_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def test_code_review_docs_cover_pre_commit_gate_and_portable_adoption() -> None:
docs = _docs_text()
assert "## Pre-Commit Review Gate" in docs
assert ".pre-commit-config.yaml" in docs
assert "specfact code review run --score-only" in docs
assert "specfact code review run" in docs
assert ".specfact/code-review.json" in docs
assert "## Add to Any Project" in docs


Expand Down
16 changes: 10 additions & 6 deletions tests/unit/scripts/test_pre_commit_code_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@ def test_filter_review_files_keeps_only_python_sources() -> None:
]


def test_build_review_command_uses_score_only_mode() -> None:
"""Pre-commit gate should rely on score-only exit-code semantics."""
def test_build_review_command_writes_json_report() -> None:
"""Pre-commit gate should write ReviewReport JSON for IDE/Copilot and use exit verdict."""
module = _load_script_module()

command = module.build_review_command(["src/app.py", "tests/test_app.py"])

assert command[:5] == [sys.executable, "-m", "specfact_cli.cli", "code", "review"]
assert "--score-only" in command
assert "--json" in command
assert "--out" in command
assert module.REVIEW_JSON_OUT in command
assert command[-2:] == ["src/app.py", "tests/test_app.py"]


Expand All @@ -62,9 +64,11 @@ def test_main_propagates_review_gate_exit_code(monkeypatch: pytest.MonkeyPatch)
def _fake_ensure() -> tuple[bool, str | None]:
return True, None

def _fake_run(cmd: list[str], **_: object) -> subprocess.CompletedProcess[str]:
assert "--score-only" in cmd
return subprocess.CompletedProcess(cmd, 1, stdout="-7\n", stderr="")
def _fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert "--json" in cmd
assert module.REVIEW_JSON_OUT in cmd
assert kwargs.get("cwd") is not None
return subprocess.CompletedProcess(cmd, 1, stdout=".specfact/code-review.json\n", stderr="")
Comment thread
djm81 marked this conversation as resolved.

monkeypatch.setattr(module, "ensure_runtime_available", _fake_ensure)
monkeypatch.setattr(module.subprocess, "run", _fake_run)
Expand Down
Loading