From 97fc62259c78e08ffb8218b685a5aa447f039774 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Mon, 6 Jul 2026 04:32:30 -0400 Subject: [PATCH 1/2] fix(generator): refuse to regenerate templates from empty resolved source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-07-05 incident: a bulk regen in attune-ai ran against a wrong project root, resolved every feature's source globs to zero files, computed source_hash of the empty string (e3b0c442...), and rewrote 36 existing .help/templates files with drastically degraded content (1044 insertions vs 2609 deletions). The pipeline generated happily from empty input instead of failing. Guard (prepare_polish_phase, before any mkdir/write): when globs match nothing — or every matched file is empty/unreadable — and generated output already exists on disk, raise EmptySourceError instead of overwriting. Scaffolding a brand-new feature from empty source (nothing on disk yet) stays allowed, per the documented zero-match scaffold contract in test_generator.py. - staleness.py: EMPTY_SOURCE_SHA256 constant + EmptySourceError - generator.py: overwrite guard covering help templates and project-doc kinds; both hash paths (byte and semantic) produce the empty-string SHA on empty input, so one sentinel covers both - cli.py: catch EmptySourceError at the run_command boundary and exit 1 with the message (RuntimeError subclass on purpose — the batch-submit path swallows ValueError as "nothing to do" exit 0) - run_maintenance/batch submit propagate it, so `regenerate` aborts loudly rather than recording a silent per-feature failure Tests: tests/test_empty_source_guard.py — refusal preserves on-disk content, zero-byte source refused, scaffold-from-empty still allowed, healthy source unaffected, CLI exits 1 without traceback. Co-Authored-By: Claude Fable 5 --- src/attune_author/__init__.py | 4 + src/attune_author/cli.py | 7 ++ src/attune_author/generator.py | 62 ++++++++++- src/attune_author/staleness.py | 31 +++++- tests/test_empty_source_guard.py | 170 +++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 tests/test_empty_source_guard.py diff --git a/src/attune_author/__init__.py b/src/attune_author/__init__.py index d24a684..9f6a084 100644 --- a/src/attune_author/__init__.py +++ b/src/attune_author/__init__.py @@ -9,6 +9,8 @@ from attune_author.manifest import Feature, Manifest, load_manifest from attune_author.staleness import ( + EMPTY_SOURCE_SHA256, + EmptySourceError, StalenessReport, check_staleness, check_workspace_staleness, @@ -21,6 +23,8 @@ "Manifest", "load_manifest", # Staleness + "EMPTY_SOURCE_SHA256", + "EmptySourceError", "StalenessReport", "check_staleness", "check_workspace_staleness", diff --git a/src/attune_author/cli.py b/src/attune_author/cli.py index 9454a87..f6b7c01 100644 --- a/src/attune_author/cli.py +++ b/src/attune_author/cli.py @@ -30,6 +30,7 @@ ensure_sidecar, ) from attune_author.mcp.path_validation import validate_file_path +from attune_author.staleness import EmptySourceError logger = logging.getLogger(__name__) @@ -485,6 +486,12 @@ def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: try: return handler(args) + except EmptySourceError as e: + # Deliberate hard stop: source resolved empty — nothing was + # written. Exit non-zero so wrappers (pre-commit, CI, release + # prep) see the failure instead of a silently degraded regen. + print(f"Error: {e}", file=sys.stderr) + return 1 except (FileNotFoundError, ValueError) as e: print(f"Error: {e}", file=sys.stderr) return 1 diff --git a/src/attune_author/generator.py b/src/attune_author/generator.py index 002289b..4c81b2f 100644 --- a/src/attune_author/generator.py +++ b/src/attune_author/generator.py @@ -29,7 +29,11 @@ resolve_write_content, ) from attune_author.manifest import Feature, is_safe_feature_name -from attune_author.staleness import compute_source_hash +from attune_author.staleness import ( + EMPTY_SOURCE_SHA256, + EmptySourceError, + compute_source_hash, +) logger = logging.getLogger(__name__) @@ -461,9 +465,46 @@ def prepare_polish_phase( raise ValueError(f"Invalid feature name: {feature.name!r}") source_hash, matched_files = compute_source_hash(feature, root) + template_dir = help_path / "templates" / feature.name + + if not matched_files or source_hash == EMPTY_SOURCE_SHA256: + # Empty resolved source is fine for scaffolding a brand-new + # feature (no templates on disk yet), but must never OVERWRITE + # existing content: the 2026-07-05 incident regen ran against a + # wrong project root, resolved every glob to nothing, and + # rewrote 36 good templates with degraded empty-source output. + at_risk = [] + for depth in target_depths: + if depth not in _ALL_TEMPLATE_NAMES: + continue + if depth in _PROJECT_DOC_NAMES: + out = _project_doc_output_path(depth, feature, root) + else: + out = template_dir / f"{depth}.md" + if out.exists(): + at_risk.append(str(out)) + detail = ( + "no files matched its source globs" + if not matched_files + else f"all {len(matched_files)} matched file(s) resolved to empty/unreadable content" + ) + if at_risk: + raise EmptySourceError( + f"Refusing to regenerate templates for '{feature.name}': {detail} " + f"under project root {root} (globs: {list(feature.files)!r}), " + f"but generated output already exists ({', '.join(at_risk)}). " + "Overwriting it from empty source would replace good templates " + "with degraded content stamped with the empty-string source_hash " + "— check that --project-root points at the repo checkout." + ) + logger.warning( + "Scaffolding '%s' from empty source (%s under %s); " "no existing templates at risk", + feature.name, + detail, + root, + ) source_info = _extract_source_info(matched_files, root) - template_dir = help_path / "templates" / feature.name template_dir.mkdir(parents=True, exist_ok=True) env = _build_jinja_env(help_path) @@ -1315,7 +1356,9 @@ def _string_collection_values(node: ast.expr) -> list[str] | None: return out -def _extract_module_constant(node: ast.Assign | ast.AnnAssign) -> dict[str, object] | None: +def _extract_module_constant( + node: ast.Assign | ast.AnnAssign, +) -> dict[str, object] | None: """Return a structured record for a module-level string constant. Recognizes four shapes that users routinely ask about: @@ -1420,7 +1463,13 @@ def _extract_class_properties(node: ast.ClassDef) -> list[dict[str, str]]: if not _has_property_decorator(child): continue ret = _unparse_annotation(child.returns) if child.returns else "" - props.append({"name": child.name, "return_type": ret, "doc": _docstring_first_line(child)}) + props.append( + { + "name": child.name, + "return_type": ret, + "doc": _docstring_first_line(child), + } + ) return props @@ -1429,7 +1478,10 @@ def _has_property_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> boo for dec in node.decorator_list: if isinstance(dec, ast.Name) and dec.id == "property": return True - if isinstance(dec, ast.Attribute) and dec.attr in ("property", "abstractproperty"): + if isinstance(dec, ast.Attribute) and dec.attr in ( + "property", + "abstractproperty", + ): return True return False diff --git a/src/attune_author/staleness.py b/src/attune_author/staleness.py index 94db3f1..6006cc0 100644 --- a/src/attune_author/staleness.py +++ b/src/attune_author/staleness.py @@ -22,7 +22,12 @@ from dataclasses import dataclass, field from pathlib import Path -from attune_author.manifest import Feature, FeatureManifest, is_safe_feature_name, load_manifest +from attune_author.manifest import ( + Feature, + FeatureManifest, + is_safe_feature_name, + load_manifest, +) logger = logging.getLogger(__name__) @@ -39,6 +44,30 @@ ".git", } +#: SHA-256 of zero bytes. Both hash paths (byte-concatenation and +#: semantic) produce this digest when a feature's globs match nothing, +#: or when every matched file is unreadable or empty. A generation run +#: must never stamp this hash into frontmatter — it means the resolved +#: source content was empty and the polished output would be degraded +#: (2026-07-05 incident: a bulk regen against a wrong project root +#: rewrote 36 templates with this hash). +EMPTY_SOURCE_SHA256 = hashlib.sha256(b"").hexdigest() + + +class EmptySourceError(RuntimeError): + """Empty/unreadable resolved source would overwrite existing output. + + Raised by the generation pipeline (before any file is written) + when a feature's source globs resolve to no readable content + under the given project root AND generated output already exists + on disk. Regenerating from empty source silently replaces good + templates with degraded content stamped with the empty-string + ``source_hash``, so the pipeline refuses instead. The usual cause + is a wrong ``--project-root``. Scaffolding a brand-new feature + (no templates on disk yet) from empty source stays allowed. + """ + + # Regex to parse the HTML comment footer written by attune-author's doc generator: # _DOC_FOOTER_RE = re.compile( diff --git a/tests/test_empty_source_guard.py b/tests/test_empty_source_guard.py new file mode 100644 index 0000000..e9cea9c --- /dev/null +++ b/tests/test_empty_source_guard.py @@ -0,0 +1,170 @@ +"""Regression tests: refuse to overwrite templates from empty source. + +2026-07-05 incident: a bulk regen run against a wrong project root +resolved every feature's globs to zero files, computed +``source_hash`` of the empty string +(e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855), +and rewrote 36 existing templates with drastically degraded content. +The generation pipeline must fail loudly (before any write) when +empty-resolved source would overwrite existing output. Scaffolding a +brand-new feature (nothing on disk yet) from empty source remains +allowed — see ``test_feature_glob_with_zero_matches`` in +``test_generator.py``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from attune_author import EMPTY_SOURCE_SHA256, EmptySourceError +from attune_author.generator import generate_feature_templates, prepare_polish_phase +from attune_author.manifest import Feature + + +@pytest.fixture +def feature() -> Feature: + return Feature( + name="auth", + description="Authentication and authorization", + files=["src/auth/**"], + tags=["security"], + ) + + +GOOD_TEMPLATE = ( + "---\n" + "type: concept\n" + "name: auth-concept\n" + "feature: auth\n" + "depth: concept\n" + "generated_at: 2026-06-23T00:00:00+00:00\n" + "source_hash: 544951b28662066a703ef7be552af08e83ef52a5186e5ad71ad216119352938b\n" + "status: generated\n" + "---\n" + "\n" + "# Real polished content that must not be clobbered\n" +) + + +def _seed_existing_template(help_dir: Path) -> Path: + """Put a healthy generated template on disk for feature 'auth'.""" + tdir = help_dir / "templates" / "auth" + tdir.mkdir(parents=True) + concept = tdir / "concept.md" + concept.write_text(GOOD_TEMPLATE, encoding="utf-8") + return concept + + +class TestEmptySourceOverwriteGuard: + """Empty resolved source + existing output on disk → hard refuse.""" + + def test_zero_matched_files_refuses_and_preserves_content( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + concept = _seed_existing_template(help_dir) + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + + with pytest.raises(EmptySourceError, match="no files matched"): + prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=empty_root, + overwrite=True, + ) + + # The guard fires before any write — existing content intact. + assert concept.read_text(encoding="utf-8") == GOOD_TEMPLATE + + def test_all_empty_matched_files_refuses( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + # Non-.py so the legacy byte-concatenation path is exercised; + # zero bytes total → the empty-string SHA. + _seed_existing_template(help_dir) + root = tmp_path / "zerobyte" + (root / "src" / "auth").mkdir(parents=True) + (root / "src" / "auth" / "data.txt").write_bytes(b"") + + with pytest.raises(EmptySourceError, match="empty/unreadable"): + prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=root, + overwrite=True, + ) + + def test_generate_feature_templates_propagates( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + _seed_existing_template(help_dir) + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + + with pytest.raises(EmptySourceError): + generate_feature_templates( + feature=feature, + help_dir=help_dir, + project_root=empty_root, + overwrite=True, + ) + + def test_scaffolding_new_feature_from_empty_source_allowed( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + # No templates on disk → bootstrap scaffold stays permitted. + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + + prep = prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=empty_root, + overwrite=True, + ) + assert prep.matched_files == [] + assert prep.pending + + def test_healthy_source_still_generates( + self, feature: Feature, help_dir: Path, project_root: Path + ) -> None: + _seed_existing_template(help_dir) + prep = prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=project_root, + overwrite=True, + ) + assert prep.matched_files + assert prep.source_hash != EMPTY_SOURCE_SHA256 + assert prep.pending + + +class TestEmptySourceCLI: + """The CLI exits non-zero with a clear message — no traceback.""" + + def test_generate_against_empty_root_exits_1( + self, help_dir: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + from attune_author.cli import main + + _seed_existing_template(help_dir) + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + + exit_code = main( + [ + "generate", + "auth", + "--help-dir", + str(help_dir), + "--project-root", + str(empty_root), + "--overwrite", + ] + ) + assert exit_code == 1 + err = capsys.readouterr().err + assert "Refusing to regenerate" in err From 9e352bfd810f8e04eaec9cb12be4edd6a64697ca Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 11 Jul 2026 22:00:11 -0400 Subject: [PATCH 2/2] test(generator): cover empty-source guard's depth-filtering branches Codecov flagged the guard at 80.76% patch coverage (target 86.93%). Covers: an unrecognized depth name outside _ALL_TEMPLATE_NAMES is skipped rather than crashing the at-risk scan, and a project-doc kind (how-to/tutorial/cli-reference/architecture) resolves its at-risk path via _project_doc_output_path, not just .help/ template_dir. --- tests/test_empty_source_guard.py | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_empty_source_guard.py b/tests/test_empty_source_guard.py index e9cea9c..047be2c 100644 --- a/tests/test_empty_source_guard.py +++ b/tests/test_empty_source_guard.py @@ -141,6 +141,45 @@ def test_healthy_source_still_generates( assert prep.source_hash != EMPTY_SOURCE_SHA256 assert prep.pending + def test_unrecognized_depth_name_is_skipped_not_raised( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + # A depth string outside _ALL_TEMPLATE_NAMES must not crash the + # at-risk scan — it's silently skipped, not treated as a file + # to check for existing output. + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + + prep = prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=empty_root, + depths=["not-a-real-depth"], + overwrite=True, + ) + assert prep.matched_files == [] + + def test_project_doc_depth_at_risk_raises( + self, feature: Feature, help_dir: Path, tmp_path: Path + ) -> None: + # Project-doc kinds (how-to/tutorial/cli-reference/architecture) + # resolve their at-risk path via _project_doc_output_path, not + # template_dir — must be checked too, not just .help/ templates. + empty_root = tmp_path / "emptyroot" + empty_root.mkdir() + how_to = empty_root / "docs" / "how-to" / f"{feature.name}.md" + how_to.parent.mkdir(parents=True) + how_to.write_text("# Existing how-to doc\n", encoding="utf-8") + + with pytest.raises(EmptySourceError, match="how-to"): + prepare_polish_phase( + feature=feature, + help_dir=help_dir, + project_root=empty_root, + depths=["how-to"], + overwrite=True, + ) + class TestEmptySourceCLI: """The CLI exits non-zero with a clear message — no traceback."""