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..047be2c --- /dev/null +++ b/tests/test_empty_source_guard.py @@ -0,0 +1,209 @@ +"""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 + + 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.""" + + 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