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
4 changes: 4 additions & 0 deletions src/attune_author/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +23,8 @@
"Manifest",
"load_manifest",
# Staleness
"EMPTY_SOURCE_SHA256",
"EmptySourceError",
"StalenessReport",
"check_staleness",
"check_workspace_staleness",
Expand Down
7 changes: 7 additions & 0 deletions src/attune_author/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down
62 changes: 57 additions & 5 deletions src/attune_author/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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

Expand Down
31 changes: 30 additions & 1 deletion src/attune_author/staleness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
# <!-- attune-generated: source_hash=abc123 feature=foo kind=how-to generated_at=2026-04-23 -->
_DOC_FOOTER_RE = re.compile(
Expand Down
209 changes: 209 additions & 0 deletions tests/test_empty_source_guard.py
Original file line number Diff line number Diff line change
@@ -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
Loading