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
23 changes: 23 additions & 0 deletions .claude/hooks/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,26 @@ neither mypy nor ruff could have found the original bug *as they were
then configured*: both read the file at the project's own target
version, under which it was valid. Pinning the floor in `ruff.toml`
is what changes that, and the parse test is what proves it.

**A fourth standing rule, added 2026-09-08 (failure-mode audit):
`--extend-ignore F401` on this hook's own `ruff check --fix` call.**
F401 (unused import) is in `pyproject.toml`'s `[tool.ruff.lint]` select,
so left unrestricted this hook silently deleted an import the instant
after it was added, whenever its first usage landed in a later Edit/
Write call rather than the same one -- a routine sequence for an
agent's own edit-by-edit workflow, not a mistake. The deletion happened
between edits, so the failure it caused (`NameError`/F821 once the
usage landed) looked unrelated to this hook, and cost a re-add cycle
more than once before anyone connected the two -- see
`tests/integration/test_claude_hooks.py::
test_hook_does_not_strip_an_import_with_no_usage_yet`, the regression
test that pins this. **This only defers F401 from "every edit" to
"commit time", it does not disable it**: `.pre-commit-config.yaml`'s own
`ruff` hook (`make lint`, part of `make ci`) runs with no rule
restriction, so a genuinely-unused import is still caught and fixed
before it merges. Root `CLAUDE.md`'s Tooling Gotchas section used to ask
a session to remember to add an import and its usage in the same edit
to work around exactly this by hand; that section now says the hook
itself no longer strips a not-yet-used import, and keeps the "re-read
after an import edit" advice as the belt-and-braces second line of
defence, not the only one.
18 changes: 17 additions & 1 deletion .claude/hooks/post_edit_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,23 @@ def main() -> int:
if not target.exists():
return 0

for args in (["ruff", "check", "--fix", str(target)], ["ruff", "format", str(target)]):
for args in (
# --extend-ignore F401: this hook fires after every single Edit/
# Write, but Claude Code's own workflow routinely adds an import in
# one call and its first usage in the next -- the file genuinely
# has an "unused" import for the instant between the two. Left
# unrestricted, `ruff check --fix` (F401 is in `pyproject.toml`'s
# `[tool.ruff.lint]` select) silently deletes it before the second
# edit lands, which then fails with NameError/F821 for a reason
# that looks unrelated to this hook. `make lint`/CI still catch a
# genuinely unused import at commit time -- `.pre-commit-
# config.yaml`'s `ruff` hook runs unrestricted -- so this only
# defers that one rule from "every edit" to "commit time", it does
# not disable it. See tests/integration/test_claude_hooks.py::
# test_hook_does_not_strip_an_import_with_no_usage_yet.
["ruff", "check", "--fix", "--extend-ignore", "F401", str(target)],
["ruff", "format", str(target)],
):
result = subprocess.run(
["uv", "run", *args],
cwd=REPO_ROOT,
Expand Down
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ prevent (P-011, single authoritative source).
("targeting December 2026") -- the check matches only `YYYY-MM-DD`,
which this repository uses exclusively for things that have already
happened.
- `make check-duplicate-blocks` -- fail if a tracked Markdown file
contains the same large (12-line) block of prose twice, verbatim.
Added 2026-09-08 (failure-mode audit), for the structural shape a
botched `sed`/index-arithmetic edit leaves behind when it duplicates a
section instead of moving it -- see
`tools/validators/check_duplicate_blocks.py`'s own module docstring.
- `make check-claims` -- report documentation claiming some file or
directory is empty, unwritten, or a stub when it actually has content
(`docs/practices.md`). **Advisory and deliberately outside `make ci`**:
Expand Down Expand Up @@ -323,13 +329,16 @@ prevent (P-011, single authoritative source).
- `make ci` -- `lint typecheck test check-docs check-docs-index
check-graph check-dependency-tree check-inventory check-manifest
check-references check-scenarios check-stages check-documents
check-status check-config-template check-dates check-benchmark-report`
check-status check-config-template check-dates check-duplicate-blocks
check-benchmark-report`
together (this list
itself went stale by two targets, `check-references` and
`check-scenarios`, before one correction, and by a third,
`check-scenarios`, before one correction, by a third,
`check-benchmark-report`, added 2026-09-06 in the same change that
added the target -- restated facts drift even in the document that
warns about restated facts); this is what CI
added the target, and by a fourth, `check-duplicate-blocks`, added
2026-09-08 in the same change that added it too -- restated facts
drift even in the document that warns about restated facts, and
keeps proving it every time a target is added here); this is what CI
actually runs (`.github/workflows/ci.yml`), so it is also the one
command that verifies a change is ready before committing. **For
documentation it verifies structure, not content**
Expand Down
15 changes: 13 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: install lint format typecheck test check-docs check-docs-index check-graph \
dependency-tree check-dependency-tree inventory check-inventory \
check-manifest check-references check-scenarios check-stages check-documents \
check-claims check-dates status-report \
check-claims check-dates check-duplicate-blocks status-report \
check-status config-template check-config-template docs graph demo benchmark \
benchmark-report check-benchmark-report record-benchmarks ci clean

Expand Down Expand Up @@ -136,7 +136,7 @@ check-inventory:
check-manifest:
uv run python tools/validators/check_manifest.py

ci: lint typecheck test check-docs check-docs-index check-graph check-dependency-tree check-inventory check-manifest check-references check-scenarios check-stages check-documents check-status check-config-template check-dates check-benchmark-report
ci: lint typecheck test check-docs check-docs-index check-graph check-dependency-tree check-inventory check-manifest check-references check-scenarios check-stages check-documents check-status check-config-template check-dates check-duplicate-blocks check-benchmark-report

# Fails if prose names a repository path that does not exist. Gating:
# every rule is a definite structural fact (does this path resolve),
Expand Down Expand Up @@ -192,6 +192,17 @@ check-documents:
check-dates:
uv run python tools/validators/check_dates.py

# Fails if a tracked Markdown file contains the same large (12-line)
# block of prose twice, verbatim -- the structural shape a botched
# sed/index-based reorder leaves behind (a real ~3,900-line incident,
# never itself committed since it was caught and reverted within a
# session -- see tools/validators/check_duplicate_blocks.py's own module
# docstring). Gating, added 2026-09-08 (failure-mode audit): whether a
# specific run of lines repeats verbatim elsewhere in the same file is a
# structural fact, not a judgement call.
check-duplicate-blocks:
uv run python tools/validators/check_duplicate_blocks.py

check-claims:
uv run python tools/validators/check_claims.py

Expand Down
6 changes: 5 additions & 1 deletion docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,11 @@ This paragraph previously said `make install` and `make test` were still
expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale
since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock`
is committed (B2) and `make test` runs the suite with coverage
(C1a/C1b): **1163 tests as of 2026-09-08**, up from 1160 the day before
(C1a/C1b): **1171 tests as of 2026-09-08**, up from 1163 the same day (8
new tests from the failure-mode audit: `test_hook_does_not_strip_an_
import_with_no_usage_yet`, two `check_manifest.py` tests for the new
`claude-md-count-matches-live` rule, and five for the new
`check_duplicate_blocks.py`), 1163 itself up from 1160 the day before
(three new `check_manifest.py` tests, `ka-name-matches-manifest`), then
1154 slightly earlier that day (below), then 1153, 1143, 1137, 1131, and
1052 the day before that.
Expand Down
2 changes: 1 addition & 1 deletion docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pie showData
## Live repository facts

- **47** `CLAUDE.md` files
- **1163** tests collected
- **1171** tests collected
- **144** Gherkin scenarios (`tests/features/*.feature`)

## Stages
Expand Down
4 changes: 3 additions & 1 deletion docs/repository-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are
not here either -- those come from running the suite, not from
listing files.

**358 tracked files** across 47 directories;
**360 tracked files** across 47 directories;
2 are empty.

## (root)
Expand Down Expand Up @@ -414,6 +414,7 @@ listing files.
- `test_check_dates.py`
- `test_check_docs.py`
- `test_check_documents.py`
- `test_check_duplicate_blocks.py`
- `test_check_graph.py`
- `test_check_manifest.py`
- `test_check_references.py`
Expand Down Expand Up @@ -512,6 +513,7 @@ listing files.
- `check_dates.py`
- `check_docs.py`
- `check_documents.py`
- `check_duplicate_blocks.py`
- `check_graph.py`
- `check_manifest.py`
- `check_references.py`
Expand Down
67 changes: 33 additions & 34 deletions docs/repository-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -1154,12 +1154,15 @@ manifest or covered by one of its collective rules, gating, 2026-08-21),
and dropped, see `tools/validators/CLAUDE.md`) `check_scenarios.py`
(a Gherkin scenario nothing binds, gating, 2026-08-22), `check_stages.py`
(a Stage missing part of the shape `docs/planning/stage-shape.yaml`
declares, gating, 2026-09-03) and `check_documents.py` (a maintained
declares, gating, 2026-09-03), `check_documents.py` (a maintained
document not declaring what keeps it honest -- generated, gated, or
stage-boundary re-read -- gating, 2026-09-03; it also prints the list of
documents nothing checks mechanically, which is the reading list an exit
audit needs and which is therefore derived rather than restated
anywhere);
anywhere), and `check_duplicate_blocks.py` (the same large, mostly-
substantial block of Markdown prose appearing twice verbatim in one
file -- the shape a botched sed/index-based reorder leaves behind,
gating, 2026-09-08, failure-mode audit);
`generators/` holds `generate_dependency_tree.py`
(`docs/planning/dependency-tree.md` from the component graph,
2026-08-21), `generate_repository_inventory.py`
Expand Down Expand Up @@ -1267,38 +1270,34 @@ They are tracked collectively here, not as individual rows, because
per-directory agent guidance is a property of the directory rather than a
standalone artifact (KA-038).

As of 2026-08-23: **45 files exist; 4 are still the generic placeholder**
and 41 carry real local content. (Read "42 ... and 38", as of
2026-08-22, until 2026-08-23 -- three files joined in between and this
count was not updated for any of them: `tests/features/CLAUDE.md`
(added by the same change as ADR-007, 2026-08-22, real content, missed
by the very consistency sweep that landed hours earlier) and
`src/pyflow/engine/numerics/CLAUDE.md`/`tests/unit/numerics/CLAUDE.md`
(TASK-018, 2026-08-23, both real content). Found while drafting this
same TASK-018 change, the same "count restated in three places, one
file added, count not touched" failure this row already exists to warn
about.) (42 rather than 40 because F2
(`docs/planning/backlog.md`) found `.claude/` and `.claude/hooks/`
untracked by this manifest and by `docs/planning/knowledge-architecture.md`,
with no `CLAUDE.md` at all -- both written in the same change, both real
content, not placeholders. 40 itself down from 43 because `assets/icons/`,
`assets/shaders/`, `assets/textures/` were retired 2026-08-19, E9, taking
their placeholder files with them, on the same "nothing states what this
is for" test that retired `tools/planner/`/`tools/scripts/`, E10; 43
itself down from 45 for that same E10 retirement.) E9's *Done when* was
revised the same day it closed: no placeholder may remain in a directory
that has content, not no placeholder anywhere -- inventing
directory-specific guidance for a directory that is still genuinely
empty produces speculation, not knowledge. **3** remaining placeholders
as of 2026-09-04 (`docs/tutorials/`, `examples/tutorials/`,
`tests/performance/`) sit in directories with no real content yet --
down from 4, `examples/experiments/` having gained real content that
day (`smoke_transport_high_res.yaml`, a higher-resolution variant of
`examples/golden-demos/smoke_transport.yaml`) -- so E9 is closed under
the revised criterion. `docs/planning/backlog.md` E9
holds the file-by-file breakdown and is the authoritative count; this
row and `docs/planning/roadmap.md`'s TASK-009 status both restate it, so
update all three together. `examples/experiments/` gained a second file
As of 2026-09-08: **47 files exist; 3 are still the generic placeholder**
and 44 carry real local content. **This row had drifted to 2026-08-23's
count of 45 while `docs/planning/roadmap.md`'s TASK-009 status kept
being updated to 46 and then 47** -- found and corrected in this same
change by the new `claude-md-count-matches-live` rule
(`tools/validators/check_manifest.py`, `make check-manifest`, added in
this same change), which now cross-checks this exact claim against the
live count on every run rather than leaving it to the next person who
happens to compare the two by hand. `docs/planning/roadmap.md`'s
TASK-009 row carries the full incremental history (47 from
`tools/benchmarks/CLAUDE.md`; 46 from `tests/fixtures/CLAUDE.md`,
TASK-034; 45 from `tests/features/CLAUDE.md`, ADR-007, plus
`src/pyflow/engine/numerics/CLAUDE.md`/`tests/unit/numerics/CLAUDE.md`,
TASK-018; 42 from F2 finding `.claude/` and `.claude/hooks/` untracked;
40 from retiring `assets/icons/`/`assets/shaders/`/`assets/textures/`,
E9/E10) -- this row restates only the current total rather than
re-deriving that whole chain a second time, since restating it fully in
both places is exactly the duplication that let this row go stale for
two updates running. `docs/planning/backlog.md` E9 holds the
file-by-file breakdown as of its own 2026-08-19 closure and is not kept
current past that point -- it is a record of when E9 closed, not a
running total; this row and `docs/planning/roadmap.md`'s TASK-009 status
are the two that track the live count, so update both together. **3**
files sit in directories with no real content yet (`docs/tutorials/`,
`examples/tutorials/`, `tests/performance/`), unchanged since 2026-09-04
(`docs/planning/backlog.md` E9's revised *Done when*: no placeholder may
remain in a directory that has content, not no placeholder anywhere).
`examples/experiments/` gained a second file
2026-09-06, `smoke_transport_re1000.yaml` -- a 64x64, Re = 1000 trial of
the same smoke-transport shape, checking whether more mesh and a higher
Reynolds number make secondary corner vortices visible where Re = 100
Expand Down
50 changes: 50 additions & 0 deletions tests/integration/test_claude_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,56 @@ def test_settings_json_wires_up_at_least_one_hook() -> None:
assert _configured_hook_commands()


def _post_edit_format_command() -> str:
"""The one configured hook command whose target is `post_edit_format.py`.

Read from `.claude/settings.json` rather than hardcoded, same as
`_configured_hook_commands` above, so a change to how it is invoked is
picked up automatically rather than silently going untested.
"""
for command in _configured_hook_commands():
if "post_edit_format.py" in command:
return command
raise AssertionError("no configured hook command targets post_edit_format.py")


def test_hook_does_not_strip_an_import_with_no_usage_yet(tmp_path: Path) -> None:
"""A newly-added import with no usage yet must survive the hook.

Regression test (2026-09-08, failure-mode audit). `ruff check --fix`'s
configured rule set (`pyproject.toml`'s `[tool.ruff.lint]`, `"F"`)
includes F401 (unused import), and an Edit/Write-by-edit workflow
routinely adds an import in one call and its first usage in the next --
the file genuinely has an unused import for the instant between the
two. Without a carve-out this hook fires after the first edit and
silently deletes the import before the second edit lands, which then
fails with `NameError`/F821 for a reason that looks unrelated to the
hook that caused it. Confirmed as a real, repeated cost before this
test existed (Claude Code Insights, 2026-09-08 usage report): a
formatter hook silently dropping a just-added `import math` cost a
re-add cycle across multiple sessions, never itself visible in `git
log` because the workaround happened inside a session, before anything
was committed.
"""
target = tmp_path / "not_yet_used.py"
target.write_text("import math\n", encoding="utf-8")
payload = json.dumps({"tool_input": {"file_path": str(target)}})

result = subprocess.run(
shlex.split(_post_edit_format_command()),
input=payload,
capture_output=True,
text=True,
cwd=REPO_ROOT,
check=False,
)

assert result.returncode == 0, f"hook failed: {result.stderr}"
assert "import math" in target.read_text(encoding="utf-8"), (
"the hook stripped an import that simply has no usage yet"
)


@pytest.mark.parametrize("command", _configured_hook_commands())
def test_configured_hook_runs_and_formats_the_file_it_is_given(
command: str, tmp_path: Path
Expand Down
Loading
Loading