Skip to content

Commit 3de9323

Browse files
feat(fact-check): Phase 1 — AST-based post-polish fact-check (#28)
* feat(fact-check): Phase 1 — AST-based post-polish fact-check Phase 1 of the polish-fact-check umbrella spec (docs/specs/polish-fact-check/), shipped as its own PR per the "four phases, four PRs" plan in the spec. Adds `src/attune_author/fact_check/`, a stdlib-only post-polish verification layer that runs against every polished template emitted by `apply_polish_results`. Four checks, zero LLM cost: - `check_python_refs` — parses Python code fences with `ast`, resolves each import + prose dotted path via `importlib.import_module` in the active venv. Catches the `attune.ops._readers` class of hallucination (the path parses fine but doesn't exist) — the most damaging failure mode in the attune-ai #351 regression fixture. - `check_cli_refs` — parses references of the form `attune <subcommand> --flag` and verifies the flag appears in the cached `--help` output for that subcommand chain. Every finding carries a version-coupling messaging block (installed attune-ai version + per-file override snippet) so the operator can resolve false positives across version drift without spelunking. - `check_md_links` — verifies relative `[label](target.md)` link targets exist. External URLs and pure anchors are skipped. - `check_numeric_refs` — verifies counts like `N templates`, `N features`, `N kinds` against the project filesystem and manifest. Unverifiable nouns (workflows, skills, agents) surface as warnings asking for human review. Wired into the polish pipeline at `generator.apply_polish_results`. Default mode is soft-fail: findings append an `## Unresolved references` table to the polished file. Strict mode raises `FactCheckError`. Control via `ATTUNE_AUTHOR_FACT_CHECK` env var (`off | soft | strict`, default `soft`) and the `[tool.attune-author.fact-check]` table in `pyproject.toml` (per-check toggles + per-file skip list). Regression fixture: `tests/fixtures/fact_check_ops_dashboard/` ships pre-fix and post-fix versions of the four attune-ai #351 docs. The fixture-based test suite asserts each check fires on the pre-fix files and is silent on the post-fix files, exercising the spec's "5/6 ops-dashboard errors caught" exit gate. Coverage: 55 new tests (`tests/unit/fact_check/`). One integration test verifies multi-check aggregation; per-check tests cover the happy path, the regression-fixture cases, de-duplication, and the version-coupling block. Spec tasks completed: 1.1–1.8, 1.10, 1.11, 1.11.1, 1.12, 1.13, 1.14, 1.15, 1.16. Deferred: 1.9 (CLI flags — env var ships in this PR; the named flags can land as a small follow-up). Phase 2 (ground-truth context injection), Phase 3 (faithfulness judge integration), and Phase 4 (tutorial static check) remain in spec; each ships as its own PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(fact-check): pin shutil.which in CI fixture tests The cli_refs check at src/attune_author/fact_check/cli_refs.py early-returns ``[]`` when ``shutil.which(cli)`` returns None. In CI, ``attune`` isn't installed (attune-ai is not in attune-author's dev deps), so the check silently produces no findings — and the four CLI-ref tests fail with "expected --turbo to surface" / "assert []". Locally the tests passed because the dev venv resolves ``attune`` via the uv workspace setup. CI is a clean checkout without that — hence the divergence across 8 platforms (ubuntu × 3.10–3.13 and windows × 3.10–3.13 in PR #28's matrix). Fix is one-line per affected test: monkey-patch ``cli_refs.shutil.which`` to return a non-None path so the guard passes and the rest of the test's monkey-patches (over _resolve_cli_name, _help_text, _installed_version) actually take effect. Verified: - 65/65 fact_check tests pass locally (with attune on PATH). - Same 11 fixture-based tests pass with PATH stripped of attune (the CI scenario): ``PATH=/usr/bin:/bin pytest …``. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(fact-check): --fact-check / --no-fact-check CLI flags Closes task 1.9 in docs/specs/polish-fact-check/tasks.md. The fact-check pass is controlled by ATTUNE_AUTHOR_FACT_CHECK (``off | soft | strict``, default ``soft``). Phase 1 of the spec landed this env-var path in e11feb5. This commit adds a matching CLI surface on the two commands that invoke the polish pipeline: attune-author generate <feat> --fact-check strict attune-author regenerate --no-fact-check Argparse adds the flags as a mutually exclusive group on both ``generate`` and ``regenerate`` subparsers. ``--no-fact-check`` is shorthand for ``--fact-check off``. ``_apply_fact_check_args`` translates either flag into the env var before the dispatch function imports the generator. Precedence (matches existing --rag pattern): 1. ATTUNE_AUTHOR_FACT_CHECK env var if set — shell-level intent wins over per-invocation flags so the operator can enforce a policy across an entire session. 2. ``--fact-check`` / ``--no-fact-check`` CLI flags — per-invocation override of the project default. 3. ``[tool.attune-author.fact-check]`` in pyproject.toml — project-level defaults loaded by load_config(). Tests added at tests/unit/fact_check/test_cli_flags.py (10 cases): each precedence rule, mutual-exclusivity enforcement, argparse choice validation, and the four-pass argparse shape across generate + regenerate. All 65 fact_check tests still pass. CHANGELOG/README updated to describe the three-layer control surface (the existing entries only documented the env var). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f84dce6 commit 3de9323

31 files changed

Lines changed: 2538 additions & 22 deletions

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,53 @@ and this project adheres to
1313
Work in progress for the next release. Add entries here as
1414
changes land, not at tag time.
1515

16+
### Added
17+
18+
- **Polish fact-check (Phase 1 of [polish-fact-check
19+
spec](docs/specs/polish-fact-check/)).** AST-based
20+
post-generation verification of every polished template.
21+
Four checks, no LLM cost:
22+
- `check_python_refs` — imports + dotted attune-paths
23+
resolved against the active venv via
24+
`importlib.import_module`. Catches the
25+
`attune.ops._readers` class of hallucination.
26+
- `check_cli_refs``attune <subcommand> --flag`
27+
references compared against cached `--help` output.
28+
Findings include version-coupling messaging so the
29+
operator knows which attune-ai version was probed.
30+
- `check_md_links` — relative `[label](target.md)` link
31+
targets verified for existence.
32+
- `check_numeric_refs` — counts (`N templates`,
33+
`N features`, `N kinds`) verified against the project
34+
filesystem / manifest.
35+
36+
Wired into the polish pipeline at
37+
[`generator.apply_polish_results`](src/attune_author/generator.py).
38+
Defaults to **soft-fail**: findings are appended to the
39+
polished file as an `## Unresolved references` block.
40+
Strict mode raises `FactCheckError`. Control via three
41+
layers (each overriding the next):
42+
1. `ATTUNE_AUTHOR_FACT_CHECK` env var
43+
(`off | soft | strict`, default `soft`) — shell-level
44+
intent, wins over per-invocation flags.
45+
2. `--fact-check` / `--no-fact-check` flags on
46+
`generate` and `regenerate` — per-invocation
47+
override.
48+
3. `[tool.attune-author.fact-check]` table in
49+
`pyproject.toml` — project-level defaults, per-check
50+
toggles, per-file skip list.
51+
52+
Regression fixture frozen at
53+
`tests/fixtures/fact_check_ops_dashboard/` (pre-fix
54+
and post-fix versions of the four ops-dashboard docs
55+
from attune-ai PR #351). The Phase 1 exit gate is
56+
"5/6 errors caught" — Python refs ×2, MD links ×4+,
57+
numeric ×1; the 6th (insecure-example detection) is
58+
Phase 3 scope. Motivated by attune-ai PR #351, where
59+
one feature regen produced six factual errors that
60+
needed a manual editorial pass — five of six are now
61+
caught automatically.
62+
1663
## [0.11.1] - 2026-05-08
1764

1865
### Changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,55 @@ attune-author generate security-audit
6868
attune-author regenerate
6969
```
7070

71+
## Fact-check (post-polish)
72+
73+
Every polished template runs through an AST-based fact-check
74+
pass that verifies four classes of LLM-fabricable detail without
75+
calling an LLM:
76+
77+
- Python imports and `attune.foo.bar` dotted paths resolve in
78+
the active venv
79+
- `attune <cmd> --flag` references appear in the cached
80+
`--help` output (findings include version-coupling
81+
context so the operator knows which version was probed)
82+
- Relative `[label](target.md)` link targets exist
83+
- Counts (`N templates`, `N features`, `N kinds`) match the
84+
project filesystem / manifest
85+
86+
Defaults to **soft-fail** — findings are appended to the
87+
polished file as an `## Unresolved references` table. Control
88+
via `--fact-check` / `--no-fact-check` on `generate` and
89+
`regenerate`:
90+
91+
```bash
92+
attune-author generate ops-dashboard --fact-check strict
93+
attune-author regenerate --no-fact-check
94+
```
95+
96+
Or via `ATTUNE_AUTHOR_FACT_CHECK` (`off | soft | strict`,
97+
default `soft`) — the env var takes precedence over the CLI
98+
flag so shell-level intent overrides one-off invocations.
99+
Persistent project-level config lives in
100+
`[tool.attune-author.fact-check]` in `pyproject.toml`:
101+
102+
```toml
103+
[tool.attune-author.fact-check]
104+
enabled = true
105+
soft_fail = true
106+
check_python_refs = true
107+
check_cli_refs = true
108+
check_md_links = true
109+
check_numeric_refs = true
110+
111+
[tool.attune-author.fact-check.skip]
112+
"docs/architecture/some-feature.md" = ["check_md_links"]
113+
```
114+
115+
This is Phase 1 of the [polish-fact-check
116+
spec](docs/specs/polish-fact-check/). Phase 2 (ground-truth
117+
context injection), Phase 3 (faithfulness judge), and Phase 4
118+
(tutorial static check) are tracked in `tasks.md`.
119+
71120
## Polish cache
72121

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

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

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,24 @@
1616

1717
| # | Task | Layer | Status | Notes |
1818
|---|------|-------|--------|-------|
19-
| 1.1 | Decide config-file location (`pyproject.toml` vs new `.attune-author.toml`) | attune-author | todo | Match regen-pipeline convention; document decision in PR |
20-
| 1.2 | Create `src/attune_author/fact_check/` package skeleton with `__init__.py`, `python_refs.py`, `cli_refs.py`, `md_links.py`, `numeric_refs.py`, `report.py` | attune-author | todo | One module per check + shared `FactCheckReport` dataclass |
21-
| 1.3 | Implement `python_refs.check(polished_path, source_paths, project_root)` | attune-author | todo | AST parse → resolve via `importlib.import_module` in active venv |
22-
| 1.4 | Implement `cli_refs.check(polished_path, project_root)` | attune-author | todo | Per-file cache of `attune <cmd> --help` output; regex extract flag names. **Findings must include version-coupling messaging block** (installed attune-ai version + override snippet) — see design.md |
23-
| 1.5 | Implement `md_links.check(polished_path, project_root)` | attune-author | todo | Resolve relative links; confirm target file exists |
24-
| 1.5.1 | Implement `numeric_refs.check(polished_path, project_root)` | attune-author | todo | Noun-to-resolver mapping (`templates` → filesystem count, `features``features.yaml` key count, etc.). Severity: `error` on mismatch, `warning` on unverifiable nouns |
25-
| 1.6 | Implement `report.format_unresolved_block(findings)` | attune-author | todo | Markdown table; severity column; appended above `<!-- attune-generated ... -->` |
26-
| 1.7 | Wire into `attune_author/polish.py` after the polish write | attune-author | todo | Soft-fail: append to file. Strict mode: raise `FactCheckError` |
27-
| 1.8 | Add `[tool.attune-author.fact-check]` config schema + parser | attune-author | todo | `enabled`, `soft_fail`, per-check toggles, skip-list |
28-
| 1.9 | Add `--fact-check=strict` / `--no-fact-check` CLI flags to `generate` and `regenerate` | attune-author | todo | Match existing CLI style |
29-
| 1.10 | Build regression fixture: copy the 6 pre-fix ops-dashboard errors as test inputs | attune-author | todo | `tests/fixtures/ops_dashboard_pre_fix/{how-to,tutorials,reference,architecture}.md` |
30-
| 1.11 | Test: each check fires on the matching fixture error | attune-author | todo | `test_python_refs_catches_underscore_module`, `test_cli_refs_catches_invented_flag`, `test_md_links_catches_missing_target`, `test_numeric_refs_catches_invented_count` |
31-
| 1.11.1 | Test: CLI-ref finding contains version-coupling messaging | attune-author | todo | Assert installed version + override snippet appear in finding text |
32-
| 1.12 | Test: zero findings on post-fix ops-dashboard versions | attune-author | todo | Pull from attune-ai PR #351 head |
33-
| 1.13 | Test: soft-fail writes the block; strict mode raises | attune-author | todo | Two test cases |
34-
| 1.14 | Test: config opt-outs work per-check and per-file | attune-author | todo | Toggle each in `pyproject.toml` test fixture |
35-
| 1.15 | Update CHANGELOG with the four checks and the soft-fail default | attune-author | todo | Reference attune-ai PR #351 as motivation |
36-
| 1.16 | Update README with a short "Fact-check" section + one example output | attune-author | todo | Keep it scannable; full docs go in attune-author's own help corpus later |
19+
| 1.1 | Decide config-file location (`pyproject.toml` vs new `.attune-author.toml`) | attune-author | **done** | Match regen-pipeline convention; document decision in PR |
20+
| 1.2 | Create `src/attune_author/fact_check/` package skeleton with `__init__.py`, `python_refs.py`, `cli_refs.py`, `md_links.py`, `numeric_refs.py`, `report.py` | attune-author | **done** | One module per check + shared `FactCheckReport` dataclass |
21+
| 1.3 | Implement `python_refs.check(polished_path, source_paths, project_root)` | attune-author | **done** | AST parse → resolve via `importlib.import_module` in active venv |
22+
| 1.4 | Implement `cli_refs.check(polished_path, project_root)` | attune-author | **done** | Per-file cache of `attune <cmd> --help` output; regex extract flag names. **Findings must include version-coupling messaging block** (installed attune-ai version + override snippet) — see design.md |
23+
| 1.5 | Implement `md_links.check(polished_path, project_root)` | attune-author | **done** | Resolve relative links; confirm target file exists |
24+
| 1.5.1 | Implement `numeric_refs.check(polished_path, project_root)` | attune-author | **done** | Noun-to-resolver mapping (`templates` → filesystem count, `features``features.yaml` key count, etc.). Severity: `error` on mismatch, `warning` on unverifiable nouns |
25+
| 1.6 | Implement `report.format_unresolved_block(findings)` | attune-author | **done** | Markdown table; severity column; appended above `<!-- attune-generated ... -->` |
26+
| 1.7 | Wire into `attune_author/polish.py` after the polish write | attune-author | **done** | Soft-fail: append to file. Strict mode: raise `FactCheckError` |
27+
| 1.8 | Add `[tool.attune-author.fact-check]` config schema + parser | attune-author | **done** | `enabled`, `soft_fail`, per-check toggles, skip-list |
28+
| 1.9 | Add `--fact-check=strict` / `--no-fact-check` CLI flags to `generate` and `regenerate` | attune-author | deferred | Match existing CLI style |
29+
| 1.10 | Build regression fixture: copy the 6 pre-fix ops-dashboard errors as test inputs | attune-author | **done** | `tests/fixtures/fact_check_ops_dashboard/{pre_fix,post_fix}/{architecture,how-to,reference,tutorial}.md` |
30+
| 1.11 | Test: each check fires on the matching fixture error | attune-author | **done** | `test_python_refs_catches_underscore_module`, `test_cli_refs_catches_invented_flag`, `test_md_links_catches_missing_target`, `test_numeric_refs_catches_invented_count` |
31+
| 1.11.1 | Test: CLI-ref finding contains version-coupling messaging | attune-author | **done** | Assert installed version + override snippet appear in finding text |
32+
| 1.12 | Test: zero findings on post-fix ops-dashboard versions | attune-author | **done** | `test_clean_on_post_fix` in `test_checks_against_fixtures.py` per check class |
33+
| 1.13 | Test: soft-fail writes the block; strict mode raises | attune-author | **done** | Two test cases |
34+
| 1.14 | Test: config opt-outs work per-check and per-file | attune-author | **done** | Toggle each in `pyproject.toml` test fixture |
35+
| 1.15 | Update CHANGELOG with the four checks and the soft-fail default | attune-author | **done** | Reference attune-ai PR #351 as motivation |
36+
| 1.16 | Update README with a short "Fact-check" section + one example output | attune-author | **done** | Keep it scannable; full docs go in attune-author's own help corpus later |
3737

3838
### Phase 1 testing strategy
3939

@@ -51,13 +51,18 @@
5151

5252
### Phase 1 exit checklist
5353

54-
- [ ] All tasks 1.1–1.16 done
55-
- [ ] CI green
56-
- [ ] Regression fixture: **5/6 ops-dashboard errors caught** (Python
54+
- [x] Core implementation (tasks 1.1–1.8)
55+
- [x] Test coverage (tasks 1.11, 1.11.1, 1.13, 1.14): 55 new tests
56+
- [x] CHANGELOG + README (tasks 1.15, 1.16)
57+
- [x] Regression fixture from attune-ai PR #351 (tasks 1.10, 1.12)
58+
- [x] Regression fixture: **5/6 ops-dashboard errors caught** (Python
5759
refs ×2 + CLI refs ×1 + Markdown links ×1 + numeric claims ×1).
5860
The 6th error (missing-security-callout for `0.0.0.0`) is
5961
explicitly Phase 3 scope.
60-
- [ ] Zero findings on post-fix ops-dashboard versions
62+
- [x] Zero findings on post-fix ops-dashboard versions
63+
- [ ] CLI flags `--fact-check=strict` / `--no-fact-check` (task 1.9) —
64+
deferred to a follow-up; env var `ATTUNE_AUTHOR_FACT_CHECK`
65+
ships with Phase 1.
6166
- [ ] CLI-ref findings include version-coupling messaging (verified by
6267
test 1.11.1)
6368
- [ ] CHANGELOG + README updated

src/attune_author/cli.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import argparse
1717
import json
1818
import logging
19+
import os
1920
import sys
2021
import urllib.error
2122
import urllib.parse
@@ -150,6 +151,29 @@ def _build_parser() -> argparse.ArgumentParser:
150151
"architecture). Use this for full help and docs coverage."
151152
),
152153
)
154+
# Fact-check mode per spec docs/specs/polish-fact-check/. Default
155+
# is "soft" (append findings to the polished file as an
156+
# ``## Unresolved references`` block). "strict" raises
157+
# FactCheckError on any error finding; "off" disables.
158+
# Internally sets ATTUNE_AUTHOR_FACT_CHECK; the env var path is
159+
# the single source of truth read by generator._run_fact_check.
160+
fact_check_group = p_gen.add_mutually_exclusive_group()
161+
fact_check_group.add_argument(
162+
"--fact-check",
163+
choices=["off", "soft", "strict"],
164+
default=None,
165+
help=(
166+
"Fact-check mode after polish. ``soft`` (default) appends "
167+
"an ``## Unresolved references`` block to the polished "
168+
"file; ``strict`` raises on findings; ``off`` disables. "
169+
"Overridden by ATTUNE_AUTHOR_FACT_CHECK if set."
170+
),
171+
)
172+
fact_check_group.add_argument(
173+
"--no-fact-check",
174+
action="store_true",
175+
help="Shortcut for ``--fact-check=off``.",
176+
)
153177

154178
p_regen = sub.add_parser(
155179
"regenerate",
@@ -215,6 +239,24 @@ def _build_parser() -> argparse.ArgumentParser:
215239
action="store_true",
216240
help="With --status: emit JSON instead of human-readable output.",
217241
)
242+
# Same fact-check controls as p_gen; see that block for design notes.
243+
regen_fact_check_group = p_regen.add_mutually_exclusive_group()
244+
regen_fact_check_group.add_argument(
245+
"--fact-check",
246+
choices=["off", "soft", "strict"],
247+
default=None,
248+
help=(
249+
"Fact-check mode after polish. ``soft`` (default) appends "
250+
"an ``## Unresolved references`` block to each polished "
251+
"file; ``strict`` raises on findings; ``off`` disables. "
252+
"Overridden by ATTUNE_AUTHOR_FACT_CHECK if set."
253+
),
254+
)
255+
regen_fact_check_group.add_argument(
256+
"--no-fact-check",
257+
action="store_true",
258+
help="Shortcut for ``--fact-check=off``.",
259+
)
218260

219261
p_cache = sub.add_parser(
220262
"cache",
@@ -454,11 +496,28 @@ def _cmd_status(args: argparse.Namespace) -> int:
454496
return 0
455497

456498

499+
def _apply_fact_check_args(args: argparse.Namespace) -> None:
500+
"""Translate ``--fact-check`` / ``--no-fact-check`` to the env var
501+
read by :func:`attune_author.generator._run_fact_check`.
502+
503+
The env var is the single source of truth; the CLI flags are a
504+
convenience that maps onto it. An already-set env var wins (the
505+
operator's shell-level intent overrides the per-invocation flag).
506+
"""
507+
if os.environ.get("ATTUNE_AUTHOR_FACT_CHECK"):
508+
return
509+
if getattr(args, "no_fact_check", False):
510+
os.environ["ATTUNE_AUTHOR_FACT_CHECK"] = "off"
511+
elif getattr(args, "fact_check", None):
512+
os.environ["ATTUNE_AUTHOR_FACT_CHECK"] = args.fact_check
513+
514+
457515
def _cmd_generate(args: argparse.Namespace) -> int:
458516
"""Handle the generate command."""
459517
from attune_author.generator import generate_feature_templates
460518
from attune_author.manifest import load_manifest
461519

520+
_apply_fact_check_args(args)
462521
root = validate_file_path(args.project_root)
463522
help_dir = validate_file_path(args.help_dir)
464523

@@ -505,6 +564,7 @@ def _cmd_generate(args: argparse.Namespace) -> int:
505564

506565
def _cmd_regenerate(args: argparse.Namespace) -> int:
507566
"""Handle the regenerate command."""
567+
_apply_fact_check_args(args)
508568
root = validate_file_path(args.project_root)
509569
help_dir = validate_file_path(args.help_dir)
510570

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""AST-based post-generation fact-check for polished docs.
2+
3+
Phase 1 of the polish-fact-check spec
4+
(``docs/specs/polish-fact-check``). Each check module surfaces
5+
findings into a shared :class:`FactCheckReport`; the caller
6+
decides whether to soft-fail (append an ``## Unresolved
7+
references`` block to the polished file) or strict-fail (raise
8+
:class:`FactCheckError`).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from pathlib import Path
14+
15+
from . import cli_refs, md_links, numeric_refs, python_refs
16+
from .config import load_config
17+
from .report import (
18+
CHECK_CLI_REFS,
19+
CHECK_MD_LINKS,
20+
CHECK_NUMERIC_REFS,
21+
CHECK_PYTHON_REFS,
22+
FactCheckConfig,
23+
FactCheckError,
24+
FactCheckReport,
25+
Finding,
26+
Severity,
27+
format_unresolved_block,
28+
)
29+
30+
31+
def check_polished_file(
32+
polished_path: Path,
33+
*,
34+
project_root: Path,
35+
config: FactCheckConfig | None = None,
36+
) -> FactCheckReport:
37+
"""Run all enabled fact-check passes against ``polished_path``.
38+
39+
Args:
40+
polished_path: Markdown file produced by the polish pass.
41+
project_root: Consumer project root. Used to resolve CLI
42+
``--help`` output, ``.help/features.yaml``, and to
43+
match per-file skip entries from configuration.
44+
config: Optional explicit config; ``None`` means load
45+
from the project's ``pyproject.toml``.
46+
47+
Returns:
48+
A :class:`FactCheckReport` with zero or more findings.
49+
Callers gate on ``report.has_errors()`` for strict mode
50+
and feed ``report.findings`` to
51+
:func:`format_unresolved_block` for soft-fail.
52+
"""
53+
cfg = config if config is not None else load_config(project_root)
54+
report = FactCheckReport()
55+
if not cfg.enabled:
56+
return report
57+
58+
try:
59+
rel_path = polished_path.relative_to(project_root).as_posix()
60+
except ValueError:
61+
rel_path = polished_path.name
62+
63+
if cfg.is_check_enabled(CHECK_PYTHON_REFS, rel_path):
64+
report.extend(python_refs.check(polished_path))
65+
if cfg.is_check_enabled(CHECK_CLI_REFS, rel_path):
66+
report.extend(cli_refs.check(polished_path, project_root))
67+
if cfg.is_check_enabled(CHECK_MD_LINKS, rel_path):
68+
report.extend(md_links.check(polished_path))
69+
if cfg.is_check_enabled(CHECK_NUMERIC_REFS, rel_path):
70+
report.extend(numeric_refs.check(polished_path, project_root))
71+
72+
return report
73+
74+
75+
def apply_soft_fail(polished_path: Path, report: FactCheckReport) -> bool:
76+
"""Append the unresolved-references block to ``polished_path``.
77+
78+
Returns True if the block was appended, False if there was
79+
nothing to append (empty report).
80+
"""
81+
block = format_unresolved_block(report.findings)
82+
if not block:
83+
return False
84+
existing = polished_path.read_text(encoding="utf-8")
85+
if not existing.endswith("\n"):
86+
existing += "\n"
87+
polished_path.write_text(existing + block + "\n", encoding="utf-8")
88+
return True
89+
90+
91+
__all__ = [
92+
"CHECK_CLI_REFS",
93+
"CHECK_MD_LINKS",
94+
"CHECK_NUMERIC_REFS",
95+
"CHECK_PYTHON_REFS",
96+
"FactCheckConfig",
97+
"FactCheckError",
98+
"FactCheckReport",
99+
"Finding",
100+
"Severity",
101+
"apply_soft_fail",
102+
"check_polished_file",
103+
"format_unresolved_block",
104+
"load_config",
105+
]

0 commit comments

Comments
 (0)