Skip to content

Commit ce31103

Browse files
feat(polish): tutorial code-fence static check (Phase 4 of polish-fact-check) (#37)
Phase 4 adds a static analysis pass over Python code fences in polished tutorials. Every ```python fence is extracted, parsed with ast.parse for syntax errors, and type-checked with `mypy --strict` as a subprocess per fence. Findings land in the same `## Unresolved references` block as Phase 1 fact-check findings. Scope is limited to `docs/tutorials/*.md`. Other doc kinds may carry code samples but the reader-follow-along expectation is highest for tutorials, so they're the highest-value target. New module: src/attune_author/fact_check/tutorial_static_check.py - _FENCE_PATTERN regex extracts ```python ... ``` bodies plus line offsets so findings map back to absolute tutorial lines - _run_mypy: subprocess wrapper with 10s timeout; handles TimeoutExpired, FileNotFoundError, and unexpected exit codes by returning an empty findings list. The static check never blocks the polish pipeline. - _parse_mypy_output: regex parse of `<path>:<line>: <sev>: <msg>` lines, rewriting mypy line numbers to absolute file position via `base_line + mypy_line - 1` - _strip_skip_directive: honors `# attune-author: skip-mypy` as the first non-blank line of a fence (only first-line is recognized; trailing directives are intentionally preserved) - strip_skip_directives_in_file: removes the directive from the written tutorial so readers don't see it Wiring: - check_tutorial_static toggle on FactCheckConfig (default True) - check_polished_file routes to tutorial_static_check.check only when is_tutorial_path(polished_path) returns True - apply_polish_results calls strip_skip_directives_in_file on the final content of tutorial-kind templates before writing Phase 4.2 (sample EXECUTION — running the code, not just type-checking it) is explicitly OUT OF SCOPE for this PR. Static analysis only. Execution-tier design tracked as a follow-up. Tests: 16 new tests covering syntax-error path, mypy happy path with mocked subprocess, skip directive (in-fence + file-level strip), tutorial-path routing in check_polished_file, and every subprocess failure mode. Full suite: 942 passed (was 926), 37 pre-existing skips. Real-fixture acceptance tests (4.8, 4.9, 4.11) require the ops-dashboard tutorial fixture + a real mypy install. Unit-level coverage with mocked subprocess + syntax-error path is in place; the integration test lands alongside Phase 2/3's live-LLM run. Spec: docs/specs/polish-fact-check/ Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d3c5f3e commit ce31103

10 files changed

Lines changed: 672 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,31 @@ changes land, not at tag time.
1515

1616
### Added
1717

18+
- **Polish fact-check Phase 4 — tutorial code-fence static
19+
check.** Polished tutorials (`docs/tutorials/*.md`) now have
20+
their ```python code fences extracted, syntax-checked with
21+
`ast.parse`, and type-checked with `mypy --strict` as a
22+
subprocess per fence. Failures land in the same
23+
`## Unresolved references` block as Phase 1 findings. The
24+
static check only runs on the tutorial doc kind — other kinds
25+
(how-to, reference, architecture) are skipped.
26+
- New module: `src/attune_author/fact_check/tutorial_static_check.py`.
27+
- `# attune-author: skip-mypy` as the first line of a fence
28+
opts that fence out of the mypy pass and is stripped from
29+
the published tutorial. Trailing directives are preserved
30+
(only first-line is recognized).
31+
- Subprocess robustness: missing `mypy`, 10-second timeout,
32+
and unexpected exit codes all degrade silently. The check
33+
never blocks the polish pipeline.
34+
- Config: `check_tutorial_static` toggle on
35+
`FactCheckConfig` (defaults `True`); routes only when the
36+
polished path lives under `tutorials/`.
37+
- 16 new tests under `tests/unit/fact_check/test_tutorial_static_check.py`.
38+
- Phase 4.2 (sample *execution* — running the code, not just
39+
type-checking it) is explicitly **out of scope** for this
40+
PR. Static check ships now; execution-tier design is
41+
deferred per the spec.
42+
1843
- **Polish fact-check Phase 3 — faithfulness judge.** Wraps
1944
`attune_rag.eval.faithfulness.FaithfulnessJudge` as a
2045
post-polish step: scores each polished file's claims against

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,43 @@ End-of-run telemetry (call count, skip count, total estimated
193193
USD) logs at INFO level after `attune-author regenerate`. Set
194194
`ATTUNE_AUTHOR_FAITHFULNESS=off` to disable for a single run.
195195

196+
## Tutorial static check (Phase 4)
197+
198+
Polished tutorials get an additional pass that targets their
199+
embedded code samples. Every ```python fence is extracted, parsed
200+
with `ast.parse` for syntax errors, and type-checked with `mypy
201+
--strict` as a subprocess. Findings land in the same
202+
`## Unresolved references` block as the Phase 1 fact-check
203+
findings.
204+
205+
The check only runs on tutorials (`docs/tutorials/<feature>.md`).
206+
Other doc kinds (how-to, reference, architecture) may carry code
207+
samples, but the reader-follow-along expectation is highest for
208+
tutorials, so they're the highest-value target.
209+
210+
For samples that intentionally use unresolved types
211+
(illustrative pseudocode, packages that don't exist yet), add a
212+
directive as the **first line** of the fence:
213+
214+
````markdown
215+
```python
216+
# attune-author: skip-mypy
217+
some_function_we_havent_built_yet()
218+
```
219+
````
220+
221+
The directive is stripped from the published tutorial before it
222+
reaches readers. Trailing directives (later lines) are
223+
intentionally preserved — only first-line directives opt the
224+
fence out.
225+
226+
Subprocess robustness: a missing `mypy`, a 10-second timeout, or
227+
any unexpected exit code degrades silently. The check never
228+
blocks the polish pipeline.
229+
230+
Phase 4.2 — actual *execution* of code samples — is explicitly
231+
out of scope for this release. Static analysis only.
232+
196233
## Polish cache
197234

198235
`attune-author` caches LLM polish responses on disk so re-generating an

docs/specs/polish-fact-check/decisions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,44 @@ To be filled in during Phase 3 implementation:
108108
default and the calibration is scheduled to land alongside
109109
the live-LLM Phase 2 acceptance run so a single real-API
110110
cycle covers both phases' open work.
111+
- 2026-05-16 — Phase 4 shipped. New decisions captured during
112+
implementation:
113+
- **Scope limited to tutorials**: `is_tutorial_path` gates the
114+
routing in `check_polished_file` so only `docs/tutorials/`
115+
files invoke the static check. Other doc kinds may carry
116+
code samples but the reader-follow-along expectation is
117+
highest for tutorials.
118+
- **Subprocess robustness over feature richness**: a missing
119+
`mypy`, a TimeoutExpired, or any unexpected exit code returns
120+
an empty findings list rather than raising. The static check
121+
must never block the polish pipeline — matches the Phase 1
122+
fact-check and Phase 3 judge contracts.
123+
- **First-line skip directive only**: `# attune-author:
124+
skip-mypy` is recognized as the first non-blank line of a
125+
fence and ignored elsewhere. Trailing directives are
126+
intentionally preserved so authors can document the policy
127+
without changing it.
128+
- **Strip directives at write time, not read time**: the
129+
polish writer calls `strip_skip_directives_in_file` on the
130+
final content before writing tutorials only. The directive
131+
therefore appears in the source corpus (visible in PR
132+
review) but not in the published tutorial (readers don't
133+
see it).
134+
- **Sub-table config deferred**: the spec called for
135+
`[tool.attune-author.fact-check.tutorial_static]` with
136+
`enabled`, `mypy_args`, `timeout_seconds`. Only the top-level
137+
`check_tutorial_static` toggle landed for v1; the
138+
`mypy_args`/`timeout_seconds` knobs ship when a real consumer
139+
asks for them. The default 10s timeout + `--strict
140+
--no-error-summary --no-color-output` works for the test
141+
suite and the ops-dashboard fixture.
142+
- **No execution (Phase 4.2 deferred)**: this PR ships static
143+
analysis only — `ast.parse` + `mypy --strict`. Executing
144+
LLM-generated code samples is a separate design question
145+
(security + perf) tracked for a follow-up. Tracked in
146+
tasks.md as task 4.13.
147+
- **Real-fixture acceptance deferred**: tasks 4.8, 4.9, 4.11
148+
require the ops-dashboard tutorial fixture + a real `mypy`
149+
installation. Unit-level coverage with mocked subprocess +
150+
syntax-error path is in place; the integration test lands
151+
alongside Phase 2/3's live-LLM run.

docs/specs/polish-fact-check/tasks.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,26 +136,28 @@ plumbing.
136136

137137
| # | Task | Layer | Status | Notes |
138138
|---|------|-------|--------|-------|
139-
| 4.1 | Add `tutorial_static_check.check(polished_path, project_root)` to `fact_check/` package | attune-author | todo | Operates only on `docs/tutorials/*.md` |
140-
| 4.2 | Code-fence extractor: pull all ```python fences with line numbers | attune-author | todo | Skip fences with `# attune-author: skip-mypy` first line |
141-
| 4.3 | `ast.parse` each fence; collect syntax errors as findings | attune-author | todo | Cheap pre-check before invoking mypy |
142-
| 4.4 | Run `mypy --strict --no-error-summary` per fence | attune-author | todo | Subprocess; timeout 10s; capture stderr |
143-
| 4.5 | Parse mypy output into findings | attune-author | todo | Map line numbers back to original fence position |
144-
| 4.6 | Strip `# attune-author: skip-mypy` directives before publication | attune-author | todo | Apply only to the file written; preserve in source if any |
145-
| 4.7 | Add `[tool.attune-author.fact-check.tutorial_static]` config | attune-author | todo | `enabled`, `mypy_args`, `timeout_seconds` |
146-
| 4.8 | Test: pre-fix `tutorials/ops-dashboard.md` flags `_readers` + `_models` imports | attune-author | todo | The headline acceptance gate |
147-
| 4.9 | Test: post-fix version produces zero errors | attune-author | todo | |
148-
| 4.10 | Test: `skip-mypy` directive is honored and stripped from output | attune-author | todo | |
149-
| 4.11 | Test: total static-check time per tutorial < 10s | attune-author | todo | Bench against the ops-dashboard tutorial |
150-
| 4.12 | Update CHANGELOG + README | attune-author | todo | Note Phase 4.2 (execution) explicitly deferred |
151-
| 4.13 | Add design.md follow-up section on Phase 4.2 execution tiers | attune-author | todo | Reference the security + perf walkthrough from spec discussion |
139+
| 4.1 | Add `tutorial_static_check.check(polished_path, project_root)` to `fact_check/` package | attune-author | **done** | `is_tutorial_path(...)` heuristic gates routing in `check_polished_file` so only `docs/tutorials/` files run the static check |
140+
| 4.2 | Code-fence extractor: pull all ```python fences with line numbers | attune-author | **done** | `_FENCE_PATTERN` regex; line numbers derived from `_line_of_offset` |
141+
| 4.3 | `ast.parse` each fence; collect syntax errors as findings | attune-author | **done** | SyntaxError caught with the `exc.lineno` mapped back to absolute file line |
142+
| 4.4 | Run `mypy --strict --no-error-summary` per fence | attune-author | **done** | `_run_mypy` via temp file + subprocess; 10s timeout; handles TimeoutExpired + FileNotFoundError + unexpected exit codes by returning `[]` |
143+
| 4.5 | Parse mypy output into findings | attune-author | **done** | `_parse_mypy_output` regex; line numbers rewritten to absolute file position via `base_line + mypy_line - 1` |
144+
| 4.6 | Strip `# attune-author: skip-mypy` directives before publication | attune-author | **done** | `strip_skip_directives_in_file` invoked in `apply_polish_results` for `tutorial` depth only; first-line directive only, trailing directives intentionally preserved |
145+
| 4.7 | Add `[tool.attune-author.fact-check.tutorial_static]` config | attune-author | **partial** | Top-level `check_tutorial_static` toggle on `FactCheckConfig` (defaults True). Sub-table for `mypy_args` / `timeout_seconds` deferred — current constants match the spec; expose only when a real consumer needs the knob. |
146+
| 4.8 | Test: pre-fix `tutorials/ops-dashboard.md` flags `_readers` + `_models` imports | attune-author | deferred | Requires the ops-dashboard regression fixture + real mypy run. Unit-level coverage via mocked mypy + syntax-error path is in place; the integration test lands alongside the live-LLM acceptance run. |
147+
| 4.9 | Test: post-fix version produces zero errors | attune-author | deferred | Same gate as 4.8 |
148+
| 4.10 | Test: `skip-mypy` directive is honored and stripped from output | attune-author | **done** | `test_check_skips_fence_with_directive` + `test_strip_skip_directive_first_line` |
149+
| 4.11 | Test: total static-check time per tutorial < 10s | attune-author | deferred | Enforced indirectly via the 10s mypy subprocess timeout; a real bench lands with 4.8 |
150+
| 4.12 | Update CHANGELOG + README | attune-author | **done** | CHANGELOG under Unreleased; README adds "Tutorial static check (Phase 4)" subsection. 4.2 execution explicitly noted as out of scope. |
151+
| 4.13 | Add design.md follow-up section on Phase 4.2 execution tiers | attune-author | deferred | Tracked separately; not gating Phase 4.1 ship |
152152

153153
### Phase 4 exit checklist
154154

155-
- [ ] Tasks 4.1–4.13 done
156-
- [ ] Pre-fix fixture flagged correctly; post-fix clean
157-
- [ ] Per-tutorial check time < 10s
158-
- [ ] Spec status updated; full umbrella spec marked `complete`
155+
- [x] Tasks 4.1–4.7, 4.10, 4.12 done (16 new tests)
156+
- [x] Spec status updated
157+
- [ ] Tasks 4.8, 4.9, 4.11 (real-fixture mypy runs) — deferred to the
158+
same live-LLM cycle that closes Phase 2/3's open items
159+
- [ ] Task 4.13 (Phase 4.2 execution-tier design follow-up) — tracked
160+
separately; Phase 4.1 ships without it
159161

160162
---
161163

src/attune_author/fact_check/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from pathlib import Path
1414

15-
from . import cli_refs, md_links, numeric_refs, python_refs
15+
from . import cli_refs, md_links, numeric_refs, python_refs, tutorial_static_check
1616
from .config import load_config
1717
from .report import (
1818
CHECK_CLI_REFS,
@@ -26,6 +26,7 @@
2626
Severity,
2727
format_unresolved_block,
2828
)
29+
from .tutorial_static_check import CHECK_TUTORIAL_STATIC
2930

3031

3132
def check_polished_file(
@@ -68,6 +69,10 @@ def check_polished_file(
6869
report.extend(md_links.check(polished_path))
6970
if cfg.is_check_enabled(CHECK_NUMERIC_REFS, rel_path):
7071
report.extend(numeric_refs.check(polished_path, project_root))
72+
if cfg.is_check_enabled(
73+
CHECK_TUTORIAL_STATIC, rel_path
74+
) and tutorial_static_check.is_tutorial_path(polished_path):
75+
report.extend(tutorial_static_check.check(polished_path))
7176

7277
return report
7378

@@ -93,6 +98,7 @@ def apply_soft_fail(polished_path: Path, report: FactCheckReport) -> bool:
9398
"CHECK_MD_LINKS",
9499
"CHECK_NUMERIC_REFS",
95100
"CHECK_PYTHON_REFS",
101+
"CHECK_TUTORIAL_STATIC",
96102
"FactCheckConfig",
97103
"FactCheckError",
98104
"FactCheckReport",

src/attune_author/fact_check/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def _bool(key: str, default: bool) -> bool:
4848
check_cli_refs=_bool("check_cli_refs", True),
4949
check_md_links=_bool("check_md_links", True),
5050
check_numeric_refs=_bool("check_numeric_refs", True),
51+
check_tutorial_static=_bool("check_tutorial_static", True),
5152
)
5253
if isinstance(skip, dict):
5354
cfg.skip = {

src/attune_author/fact_check/report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CHECK_CLI_REFS = "check_cli_refs"
1919
CHECK_MD_LINKS = "check_md_links"
2020
CHECK_NUMERIC_REFS = "check_numeric_refs"
21+
CHECK_TUTORIAL_STATIC = "check_tutorial_static"
2122

2223

2324
class FactCheckError(Exception):
@@ -82,6 +83,7 @@ class FactCheckConfig:
8283
check_cli_refs: bool = True
8384
check_md_links: bool = True
8485
check_numeric_refs: bool = True
86+
check_tutorial_static: bool = True
8587
#: Mapping of file path (relative to project root, posix) to
8688
#: a list of check-id strings that should be skipped for
8789
#: that file.

0 commit comments

Comments
 (0)