From e22d778e80f4fac6de59d93723c258aa0ca82a6c Mon Sep 17 00:00:00 2001 From: Patrick Roebuck Date: Wed, 1 Jul 2026 11:19:02 -0400 Subject: [PATCH 1/2] feat(authoring): absorb staleness modules; repoint help_data in-process (T2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2a of the attune-author consolidation. Absorbs the staleness trio into attune.authoring and severs help_data's subprocess dependency on the attune-author CLI. - Absorb `symbols.py`, `manifest.py`, `staleness.py` into attune.authoring (verbatim from upstream attune-author; imports repointed, provenance headers). Byte-compatible hashing preserved. - Bring the upstream tests into tests/unit/authoring/ (83 tests; manifest 96% / staleness 85% / symbols 88% coverage). - Repoint `help_data._stale_features` (was `_attune_author_stale_features`) to the in-process `attune.authoring.staleness.check_staleness`, on top of #1203. Removes the `attune-author status` subprocess, `shutil` / `subprocess` imports, and the `_parse_status_output` markdown parser — eliminating the masked-crash bug class at the root (D9a). - Manual→N/A filter (D9b): features with no `files:` globs are reported untracked, not stale. Guards the all-manual single-sourced repo from a wall of 27 false positives that a faithful hash check produces. - decisions.md: port D8 forward + append D9 (both blockers resolved) and D10 (LLM polish moves upstream onto the master; executed by T3). Resolver fold-in (spec T2 / #1191) split out to keep this focused — it touches disjoint files with its own regression test; tracked in Open. Co-Authored-By: Claude Opus 4.8 --- .../attune-author-consolidation/decisions.md | 83 ++- src/attune/authoring/manifest.py | 349 ++++++++++++ src/attune/authoring/staleness.py | 525 ++++++++++++++++++ src/attune/authoring/symbols.py | 236 ++++++++ src/attune/ops/help_data.py | 201 +++---- tests/unit/authoring/test_manifest.py | 473 ++++++++++++++++ tests/unit/authoring/test_staleness.py | 445 +++++++++++++++ tests/unit/authoring/test_symbols.py | 387 +++++++++++++ tests/unit/ops/test_help_data.py | 298 +++------- 9 files changed, 2661 insertions(+), 336 deletions(-) create mode 100644 src/attune/authoring/manifest.py create mode 100644 src/attune/authoring/staleness.py create mode 100644 src/attune/authoring/symbols.py create mode 100644 tests/unit/authoring/test_manifest.py create mode 100644 tests/unit/authoring/test_staleness.py create mode 100644 tests/unit/authoring/test_symbols.py diff --git a/docs/specs/attune-author-consolidation/decisions.md b/docs/specs/attune-author-consolidation/decisions.md index 1d95282b1..60012000e 100644 --- a/docs/specs/attune-author-consolidation/decisions.md +++ b/docs/specs/attune-author-consolidation/decisions.md @@ -64,10 +64,87 @@ and then rewriting in one move would balloon the change and couple a mechanical migration to a behavioral redesign. Absorb first; the staleness hardening is a tracked follow-on. +## D8 — Dogfooding the absorb reframes T2: it is a bug-fix + design call, not a mechanical repoint + +**Decided (2026-07-01).** Refines D7. Copied `staleness.py` + +`manifest.py` + `freshness/symbols.py` verbatim into `attune.authoring` +(imports repointed, provenance headers, imports clean). Then dogfooded the +absorbed `check_staleness` against this repo's real `.help/` — and against +the *current* live behavior of the `help_data` consumer — and found two +facts that make a "behavior-preserving repoint" of `help_data` impossible: + +1. **The current dashboard staleness signal is a masked crash.** The + `attune-author` CLI shim on this machine points at a Python without + `attune_author` installed → `ModuleNotFoundError`. `help_data`'s + `_attune_author_stale_features` runs it via `subprocess.run(check=False)`, + the subprocess exits non-zero with an empty stdout, and + `_parse_status_output("")` returns `frozenset()`. So a **crash reads as + "nothing stale"**, and because the return is an empty set (not `None`), + callers never reach the age-based fallback. This is a latent bug the + absorb surfaced — a broken CLI silently reports every feature fresh. +2. **Hash-staleness is N/A for this repo.** Post single-source rollout all + 27 features are `status: manual` with **no `files:` globs** + (projector-owned from `content/features/*.md`). `manifest.py` doesn't + even parse the `status` field — manual-skip lives in the generator layer, + not in `check_staleness`. So a faithful `check_staleness` computes the + empty-input hash for every feature and flags **all 27 stale** (leftover + stored hash ≠ empty). Faithful behavior is pure noise here. + +So T2 is not "absorb → repoint, behavior-preserving." The current behavior +is a bug; the faithful behavior is noise. Doing it right means teaching +staleness to treat glob-less/manual features as **untracked (N/A)** — a +product-semantics decision. **Parked** (Patrick, ~5am) to make that call +rested. The three absorbed modules live on branch `feat/authoring-staleness` +(WIP, no PR) for the next session. Pairs with the "registered ≠ working / +dogfood the real path" and "should-this-exist" (removing-dead-code) lessons. + +## D9 — Both D8 blockers resolved + +**Decided (2026-07-01).** The two facts D8 parked on are now settled, and +T2a executes the absorb on top of both. + +- **(a) Masked-crash: FIXED + SHIPPED standalone in #1203** (merged, + `383a252c1`). `help_data`'s subprocess wrapper now treats an + `attune-author status` non-zero exit as "unknown" (→ `None` → age + fallback), not "clean" (→ `frozenset()`). T2a then removes that + subprocess path entirely (the CLI is severed), so the class of bug is + gone at the root, not just guarded. +- **(b) Glob-less/manual staleness semantics: DECIDED — filter in the + `help_data` consumer.** Manual features with no `files:` globs report as + *untracked (N/A)*, not *stale*: `_stale_features` excludes glob-less + features from the drift check (and from the result), so the all-manual + repo reports zero drift instead of a wall of 27 false positives. The + absorbed `check_staleness` / `manifest.py` stay **byte-identical to + upstream** (preserves golden verification). **Rejected:** teaching the + module itself to skip glob-less features — the semantics belong to the + consumer, not the shared hashing primitive. + +## D10 — LLM polish is preserved but moves UPSTREAM onto the master + +**Decided (2026-07-01).** Reverses D6; refines the D3 / Step-4 "retire" +scope. The pipeline is `content/features/.md` (reviewed prose master += single source of truth) → deterministic golden projector → +`.help/templates/...` (served). LLM polish runs at **authoring time on the +master** (reviewed, committed), NOT on the projected output — serve-time +polish would break single-source (served ≠ reviewed master) and golden +reproducibility (the −207/+149 regen churn). Concretely: **absorb** the +generator/polish machinery into `attune.authoring` (do NOT delete it), +repoint its LLM calls to **`attune.models`** (tier routing + +subscription-first auth + telemetry), and wire it into the +master-authoring flow (`author-feature` skill / a polish-master action +producing a reviewable diff). Keep `anthropic` / `claude-agent-sdk` +(workflows already carry the SDK). Splits cleanly on the thesis seam: +*authoring* = LLM polish on the master; *mechanics* = deterministic +projection. This supersedes the earlier Stop-hook-stashed "retire the LLM +regen backend" finding. **Executed by T3**, not T2a. + ## Open -- **Where exactly the absorbed module lives** — `attune.authoring` vs. - `attune.single_source` vs. folding into an existing package. Naming - decision for the design review; doesn't change the substance. - **Shim vs. archive** for the package (T4) — decide with the (near-zero) download numbers in hand at execution time. +- **Resolver fold-in (spec T2 / #1191)** — extract `audit_doc_imports.py`'s + `src`-on-`sys.path` resolver into a shared `fact_check/imports.py` and + repoint the absorbed `python_refs.py` at it (kills the line-115 false + positive). Split OUT of T2a to keep the staleness absorb focused; it + touches disjoint files (`fact_check/`, `scripts/`) with its own + regression test. Tracked as its own follow-up. diff --git a/src/attune/authoring/manifest.py b/src/attune/authoring/manifest.py new file mode 100644 index 000000000..7283e362d --- /dev/null +++ b/src/attune/authoring/manifest.py @@ -0,0 +1,349 @@ +"""Feature manifest parser with project-doc field support. + +Loads .help/features.yaml, validates structure, and exposes the +Feature model extended with doc_kinds/doc_paths/arch_path fields +for project-level documentation tracking. Also parses the +top-level ``_docs:`` bucket for hand-written narrative docs that +don't belong to any single feature (FAQ, glossary, installation, +etc.). + +Backward compatibility: legacy ``doc_path`` (scalar) is accepted +on load and migrated into ``doc_paths`` (list). Saved manifests +always emit ``doc_paths``. + +Extracted verbatim from attune-author's ``manifest.py`` during the +attune-author consolidation (``docs/specs/attune-author-consolidation/``). +Self-contained: stdlib (``fnmatch``/``re``) plus a lazy ``yaml`` import. +""" + +from __future__ import annotations + +import fnmatch +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_MANIFEST_VERSION = 1 +_MANIFEST_FILENAME = "features.yaml" + +#: Substrings that turn a feature name into a path-traversal vector +#: when the name is used as a directory component. +_UNSAFE_NAME_TOKENS = ("/", "\\", "..", "\x00") + + +def is_safe_feature_name(name: object) -> bool: + """Check whether a feature name is safe to use as a path component. + + Feature names appear as directory components under + ``.help/templates//``. A name containing path separators, + parent-directory tokens, or null bytes can escape that directory. + + Args: + name: Candidate feature name. Non-string values are rejected. + + Returns: + True if ``name`` is a non-empty string with no traversal + characters. + """ + if not isinstance(name, str) or not name: + return False + return not any(token in name for token in _UNSAFE_NAME_TOKENS) + + +@dataclass +class Feature: + """A project feature mapped to source files and optional doc outputs. + + Attributes: + name: Feature identifier (e.g., "authentication"). + description: One-line summary for topic resolution. + files: Glob patterns matching source files. + tags: Keywords for cross-referencing and discovery. + doc_kinds: Doc kinds to generate (e.g., ["how-to", "architecture"]). + doc_paths: Output paths under docs/ for non-architecture kinds. + A feature may have multiple docs (e.g., memory has 4 how-to + files); the first entry is the primary doc. Prefer this over + the scalar ``doc_path`` going forward. + doc_path: Deprecated scalar form of ``doc_paths``. On load, a + legacy ``doc_path`` scalar is migrated into ``doc_paths``; + this attribute remains populated as ``doc_paths[0]`` for + readers that have not yet moved to the list form. + arch_path: Output path for the architecture doc under docs/. + doc_nav_section: mkdocs.yml nav section to insert under. + cli_command: Consumer-CLI subcommand that exposes this + feature (e.g., ``"ops"`` for ``attune ops``). Optional; + absence skips the CLI-help block during ground-truth + context injection. See polish-fact-check Phase 2. + """ + + name: str + description: str + files: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + doc_kinds: list[str] = field(default_factory=list) + doc_paths: list[str] = field(default_factory=list) + doc_path: str | None = None + arch_path: str | None = None + doc_nav_section: str | None = None + cli_command: str | None = None + + def __post_init__(self) -> None: + """Keep ``doc_paths`` and ``doc_path`` in sync. + + Callers may set either form; whichever is populated drives the + other so downstream code can read either without a surprise + None/empty mismatch. + """ + if self.doc_paths and not self.doc_path: + self.doc_path = self.doc_paths[0] + elif self.doc_path and not self.doc_paths: + self.doc_paths = [self.doc_path] + + +@dataclass +class FeatureManifest: + """Parsed features.yaml manifest. + + Attributes: + version: Schema version (currently 1). + features: Map of feature name to Feature object. + docs: Top-level narrative docs not owned by any single feature + (FAQ, glossary, installation, etc.). These are tracked for + discovery and mkdocs nav but are hand-written and never + regenerated from source. + path: Filesystem path the manifest was loaded from. + """ + + version: int + features: dict[str, Feature] + docs: list[str] = field(default_factory=list) + path: Path | None = None + + +#: Public alias kept for backward compatibility. +Manifest = FeatureManifest + + +def load_manifest(help_dir: str | Path) -> FeatureManifest: + """Load and validate features.yaml from a .help/ directory. + + Parses all standard fields (name, description, files, tags) plus + the new project-doc fields (doc_kinds, doc_path, arch_path, + doc_nav_section). Unknown fields are silently ignored so older + manifests remain loadable. + + Args: + help_dir: Path to the .help/ directory. + + Returns: + Parsed FeatureManifest. + + Raises: + FileNotFoundError: If features.yaml doesn't exist. + ValueError: If the manifest is malformed. + """ + import yaml # optional dep; must be available (python-frontmatter installs it) + + manifest_path = Path(help_dir) / _MANIFEST_FILENAME + if not manifest_path.exists(): + raise FileNotFoundError(f"No {_MANIFEST_FILENAME} in {help_dir}") + + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError( + f"Invalid manifest at {manifest_path}: expected mapping, " f"got {type(raw).__name__}" + ) + + version = raw.get("version", 1) + if version != _MANIFEST_VERSION: + logger.warning( + "Manifest version %s differs from expected %s", + version, + _MANIFEST_VERSION, + ) + + raw_features = raw.get("features", {}) + if not isinstance(raw_features, dict): + raise ValueError(f"Invalid manifest at {manifest_path}: 'features' must be a mapping") + + features: dict[str, Feature] = {} + for name, spec in raw_features.items(): + if not is_safe_feature_name(name): + raise ValueError(f"Invalid feature name: {name!r}") + if not isinstance(spec, dict): + raise ValueError( + f"Invalid manifest at {manifest_path}: " f"feature '{name}' must be a mapping" + ) + + # Coalesce doc_path (legacy scalar) and doc_paths (list). + # doc_paths wins when both are present. + doc_paths_raw = spec.get("doc_paths") + doc_path_raw = spec.get("doc_path") + if isinstance(doc_paths_raw, list): + doc_paths = [str(p) for p in doc_paths_raw] + elif doc_path_raw: + doc_paths = [str(doc_path_raw)] + else: + doc_paths = [] + + features[name] = Feature( + name=name, + description=spec.get("description", ""), + files=spec.get("files", []), + tags=spec.get("tags", []), + doc_kinds=spec.get("doc_kinds", []), + doc_paths=doc_paths, + doc_path=doc_paths[0] if doc_paths else None, + arch_path=spec.get("arch_path"), + doc_nav_section=spec.get("doc_nav_section"), + cli_command=spec.get("cli_command"), + ) + + # Top-level _docs bucket (hand-written narrative docs). + raw_docs = raw.get("_docs", []) + if not isinstance(raw_docs, list): + raise ValueError(f"Invalid manifest at {manifest_path}: '_docs' must be a list") + docs = [str(p) for p in raw_docs] + + return FeatureManifest( + version=version, + features=features, + docs=docs, + path=manifest_path, + ) + + +def save_manifest(manifest: FeatureManifest, help_dir: str | Path) -> Path: + """Write a FeatureManifest to features.yaml. + + Omits optional fields that are empty/None to keep YAML clean. + + Args: + manifest: The manifest to save. + help_dir: Path to the .help/ directory. + + Returns: + Path to the written file. + """ + import yaml + + help_path = Path(help_dir) + help_path.mkdir(parents=True, exist_ok=True) + out = help_path / _MANIFEST_FILENAME + + data: dict[str, Any] = { + "version": manifest.version, + } + if manifest.docs: + data["_docs"] = manifest.docs + data["features"] = {} + for name, feat in sorted(manifest.features.items()): + entry: dict[str, Any] = {"description": feat.description} + if feat.files: + entry["files"] = feat.files + if feat.tags: + entry["tags"] = feat.tags + if feat.doc_kinds: + entry["doc_kinds"] = feat.doc_kinds + if feat.doc_paths: + entry["doc_paths"] = feat.doc_paths + if feat.arch_path: + entry["arch_path"] = feat.arch_path + if feat.doc_nav_section: + entry["doc_nav_section"] = feat.doc_nav_section + if feat.cli_command: + entry["cli_command"] = feat.cli_command + data["features"][name] = entry + + out.write_text( + yaml.dump(data, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) + return out + + +def match_files_to_features( + changed_files: list[str], + manifest: FeatureManifest, +) -> dict[str, list[str]]: + """Match changed files against feature glob patterns. + + Args: + changed_files: Relative paths of changed files. + manifest: The feature manifest. + + Returns: + Dict mapping feature name to the changed files that matched + its globs. + """ + matches: dict[str, list[str]] = {} + for name, feat in manifest.features.items(): + matched = [] + for filepath in changed_files: + for pattern in feat.files: + flat = pattern.replace("**", "*") + if fnmatch.fnmatch(filepath, flat): + matched.append(filepath) + break + if matched: + matches[name] = matched + return matches + + +def resolve_topic( + query: str, + manifest: FeatureManifest, +) -> str | None: + """Resolve a user query to a feature name. + + Tries exact match first, then fuzzy match against descriptions + and tags. Returns None if ambiguous or no match. + + Args: + query: User's topic query string. + manifest: The feature manifest. + + Returns: + Feature name or None. + """ + q = query.lower().strip() + + if q in manifest.features: + return q + + name_hits = [n for n in manifest.features if q in n] + if len(name_hits) == 1: + return name_hits[0] + + desc_hits = [n for n, f in manifest.features.items() if q in f.description.lower()] + if len(desc_hits) == 1: + return desc_hits[0] + + tag_hits = [n for n, f in manifest.features.items() if q in [t.lower() for t in f.tags]] + if len(tag_hits) == 1: + return tag_hits[0] + + return None + + +# --------------------------------------------------------------------------- +# Helpers for slugging feature names to tags in the slug-normalized resolver +# --------------------------------------------------------------------------- + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def slugify(text: str) -> str: + """Convert text to a lowercase slug for tag comparison. + + Args: + text: Input string. + + Returns: + Lowercase hyphenated slug. + """ + return _SLUG_RE.sub("-", text.lower()).strip("-") diff --git a/src/attune/authoring/staleness.py b/src/attune/authoring/staleness.py new file mode 100644 index 000000000..e0378f952 --- /dev/null +++ b/src/attune/authoring/staleness.py @@ -0,0 +1,525 @@ +"""Staleness detection for help templates and project docs. + +Two tracking formats are supported: + +1. **Help templates** (``.help/templates//concept.md``): + Source hash stored in YAML frontmatter: + ``source_hash: abc123`` + +2. **Project docs** (``docs/how-to/foo.md``, etc.): + Source hash stored in an HTML comment footer (mkdocs-invisible): + ```` + +Both formats use the same SHA-256 of the feature's source files so +that staleness comparisons are consistent across tracking locations. + +Extracted verbatim from attune-author's ``staleness.py`` during the +attune-author consolidation (``docs/specs/attune-author-consolidation/``). +The hashing here MUST stay byte-compatible with the hashes stored in +existing ``.help/`` files, so the algorithm (semantic hash via +:class:`~attune.authoring.symbols.SymbolExtractor`) is preserved exactly. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path + +from attune.authoring.manifest import ( + Feature, + FeatureManifest, + is_safe_feature_name, + load_manifest, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_EXCLUDED_DIRS = { + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", + ".git", +} + +# Regex to parse the HTML comment footer written by attune-author's doc generator: +# +_DOC_FOOTER_RE = re.compile( + r"", + re.DOTALL, +) +_DOC_ATTR_RE = re.compile(r"(\w+)=(\S+)") + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class FeatureStaleness: + """Staleness status for one feature's ``.help/`` templates. + + Attributes: + feature: Feature name. + is_stale: True if source hash differs from stored. + current_hash: SHA-256 of current source files. + stored_hash: Hash from concept.md frontmatter (or None if absent). + matched_files: Source files that matched the globs. + """ + + feature: str + is_stale: bool + current_hash: str + stored_hash: str | None + matched_files: list[str] = field(default_factory=list) + + +@dataclass +class DocStaleness: + """Staleness status for one project doc file in ``docs/``. + + Attributes: + feature: Feature name that owns this doc. + doc_path: Relative path to the doc file (e.g., ``docs/how-to/foo.md``). + kind: Doc kind (``how-to``, ``architecture``, etc.). + is_stale: True if source hash differs from stored, or file is absent. + missing: True if the doc file doesn't exist yet. + current_hash: SHA-256 of current source files. + stored_hash: Hash from the HTML comment footer (or None if absent). + """ + + feature: str + doc_path: str + kind: str + is_stale: bool + missing: bool + current_hash: str + stored_hash: str | None = None + + +@dataclass +class StalenessReport: + """Combined staleness report across help templates and project docs. + + Attributes: + help_entries: Per-feature staleness for ``.help/`` templates. + doc_entries: Per-doc staleness for ``docs/`` generated files. + """ + + help_entries: list[FeatureStaleness] = field(default_factory=list) + doc_entries: list[DocStaleness] = field(default_factory=list) + + @property + def stale_count(self) -> int: + """Total stale items across both sections.""" + return sum(1 for e in self.help_entries if e.is_stale) + sum( + 1 for e in self.doc_entries if e.is_stale + ) + + @property + def current_count(self) -> int: + """Total up-to-date items across both sections.""" + return sum(1 for e in self.help_entries if not e.is_stale) + sum( + 1 for e in self.doc_entries if not e.is_stale + ) + + @property + def stale_features(self) -> list[str]: + """Names of features with stale help templates.""" + return [e.feature for e in self.help_entries if e.is_stale] + + @property + def stale_docs(self) -> list[DocStaleness]: + """Doc entries that are stale or missing.""" + return [e for e in self.doc_entries if e.is_stale] + + +# --------------------------------------------------------------------------- +# Source hashing +# --------------------------------------------------------------------------- + + +def _is_excluded(path: Path) -> bool: + """Return True if any path component is an excluded directory.""" + return any(part in _EXCLUDED_DIRS for part in path.parts) + + +def _collect_matched_files(feature: Feature, root: Path) -> list[str]: + """Resolve a feature's glob patterns to a sorted list of relative paths.""" + matched: list[str] = [] + for pattern in feature.files: + glob_pattern = pattern + if glob_pattern.endswith("**"): + glob_pattern += "/*" + for path in sorted(root.glob(glob_pattern)): + if path.is_file() and not _is_excluded(path): + rel = path.relative_to(root).as_posix() + if rel not in matched: + matched.append(rel) + return sorted(matched) + + +def compute_semantic_hash( + feature: Feature, + project_root: str | Path, + extractor: object | None = None, +) -> tuple[str, list[str]]: + """Compute a semantic SHA-256 hash of a feature's Python source files. + + For each matched ``.py`` file, hashes the normalized *signature* of + every public symbol (function, class, method). Docstring edits, + body-only changes, and formatter passes do not change the hash; + parameter, return-type, decorator, or base-class changes do. + + Non-``.py`` files fall back to a byte-level SHA-256 per file. + + Args: + feature: The feature to hash. + project_root: Project root for resolving globs. + extractor: Optional ``SymbolExtractor`` instance (for testing/reuse). + + Returns: + Tuple of (hex digest, sorted list of matched relative paths). + """ + from attune.authoring.symbols import SymbolExtractor + + if extractor is None: + extractor = SymbolExtractor() + + root = Path(project_root) + matched = _collect_matched_files(feature, root) + + hash_parts: list[str] = [] + for rel_path in matched: + abs_path = root / rel_path + try: + if abs_path.suffix == ".py": + try: + for record in extractor.extract(abs_path): + hash_parts.append(f"{rel_path}::{record.qualname}::{record.signature_hash}") + except SyntaxError: + content = abs_path.read_bytes() + hash_parts.append(f"{rel_path}::{hashlib.sha256(content).hexdigest()}") + else: + content = abs_path.read_bytes() + hash_parts.append(f"{rel_path}::{hashlib.sha256(content).hexdigest()}") + except OSError as e: + logger.warning("Cannot read %s: %s", rel_path, e) + + final = hashlib.sha256("\n".join(sorted(hash_parts)).encode("utf-8")).hexdigest() + return final, matched + + +def compute_source_hash( + feature: Feature, + project_root: str | Path, +) -> tuple[str, list[str]]: + """Compute SHA-256 hash of a feature's source files. + + For pure-Python features (all matched files are ``.py``), delegates to + ``compute_semantic_hash`` so that docstring edits and formatter passes + do not trigger spurious staleness. Mixed-content and non-Python features + use legacy byte-concatenation. + + Args: + feature: The feature to hash. + project_root: Project root for resolving globs. + + Returns: + Tuple of (hex digest, sorted list of matched relative paths). + """ + root = Path(project_root) + matched = _collect_matched_files(feature, root) + + if matched and all((root / p).suffix == ".py" for p in matched): + return compute_semantic_hash(feature, root) + + hasher = hashlib.sha256() + for rel_path in matched: + try: + content = (root / rel_path).read_bytes() + hasher.update(content) + except OSError as e: + logger.warning("Cannot read %s: %s", rel_path, e) + + return hasher.hexdigest(), matched + + +# --------------------------------------------------------------------------- +# Help template staleness (YAML frontmatter format) +# --------------------------------------------------------------------------- + + +def _read_frontmatter_value(text: str, key: str) -> str | None: + """Extract a value from YAML frontmatter. + + Args: + text: Full file content. + key: Frontmatter key (e.g. ``"source_hash"``). + + Returns: + Stripped value string, or None if not found. + """ + if not text.startswith("---"): + return None + end = text.find("---", 3) + if end == -1: + return None + for line in text[3:end].splitlines(): + stripped = line.strip() + if stripped.startswith(f"{key}:"): + return stripped.split(":", 1)[1].strip() + return None + + +def _read_stored_hash_from_template( + feature_name: str, + help_dir: Path, +) -> str | None: + """Read source_hash from a feature's concept.md frontmatter. + + Args: + feature_name: Feature name (directory under .help/templates/). + help_dir: Path to the .help/ directory. + + Returns: + The stored source_hash or None if absent. + """ + if not is_safe_feature_name(feature_name): + return None + concept = help_dir / "templates" / feature_name / "concept.md" + if not concept.exists(): + return None + try: + text = concept.read_text(encoding="utf-8") + except OSError: + return None + return _read_frontmatter_value(text, "source_hash") + + +# --------------------------------------------------------------------------- +# Project doc staleness (HTML comment footer format) +# --------------------------------------------------------------------------- + + +def parse_doc_footer(text: str) -> dict[str, str]: + """Parse an attune-generated HTML comment footer. + + The footer format is:: + + + + The comment may appear anywhere in the file but is conventionally + the last line. + + Args: + text: Full file content. + + Returns: + Dict of key→value pairs extracted from the comment. + Empty dict if no attune-generated comment is found. + """ + match = _DOC_FOOTER_RE.search(text) + if not match: + return {} + return dict(_DOC_ATTR_RE.findall(match.group(1))) + + +def build_doc_footer( + source_hash: str, + feature: str, + kind: str, + generated_at: str, +) -> str: + """Build an attune-generated HTML comment footer line. + + Args: + source_hash: SHA-256 digest of the feature's source files. + feature: Feature name. + kind: Doc kind (e.g., ``"how-to"``). + generated_at: ISO date string (e.g., ``"2026-04-23"``). + + Returns: + Single-line HTML comment string (no trailing newline). + """ + return ( + f"" + ) + + +def _read_stored_hash_from_doc( + doc_path: str, + project_root: Path, +) -> str | None: + """Read source_hash from an HTML comment footer in a doc file. + + Args: + doc_path: Path relative to project_root. + project_root: Absolute project root. + + Returns: + source_hash value or None if absent. + """ + full_path = project_root / doc_path + if not full_path.exists(): + return None + try: + text = full_path.read_text(encoding="utf-8") + except OSError: + return None + attrs = parse_doc_footer(text) + return attrs.get("source_hash") + + +# --------------------------------------------------------------------------- +# Unified staleness check +# --------------------------------------------------------------------------- + + +def check_staleness( + manifest: FeatureManifest, + help_dir: str | Path, + project_root: str | Path, + features: list[str] | None = None, +) -> StalenessReport: + """Check staleness across help templates and project docs. + + For each feature in the manifest: + + - **Help templates**: reads ``source_hash`` from + ``.help/templates//concept.md`` frontmatter. + - **Project docs**: for each path in ``doc_paths`` / + ``arch_path``, reads ``source_hash`` from the HTML comment + footer at the bottom of the generated file. Reports a doc as + stale if the hash mismatches or the file is absent. + + Args: + manifest: The feature manifest. + help_dir: Path to the .help/ directory. + project_root: Project root for resolving source globs and doc paths. + features: Optional list of feature names to check. + Defaults to all features in the manifest. + + Returns: + StalenessReport with separate help_entries and doc_entries. + """ + help_path = Path(help_dir) + root = Path(project_root) + help_entries: list[FeatureStaleness] = [] + doc_entries: list[DocStaleness] = [] + + names = features if features is not None else list(manifest.features.keys()) + + for name in names: + feat = manifest.features.get(name) + if not feat: + logger.warning("Feature '%s' not in manifest", name) + continue + + current_hash, matched = compute_source_hash(feat, root) + + # --- Help template staleness --- + stored_hash = _read_stored_hash_from_template(name, help_path) + help_entries.append( + FeatureStaleness( + feature=name, + is_stale=stored_hash != current_hash, + current_hash=current_hash, + stored_hash=stored_hash, + matched_files=matched, + ) + ) + + # --- Project doc staleness --- + doc_paths: list[tuple[str, str]] = [] # (path, kind) + for p in feat.doc_paths: + doc_paths.append((p, _infer_kind(feat, "doc_path"))) + if feat.arch_path: + doc_paths.append((feat.arch_path, "architecture")) + + for doc_path, kind in doc_paths: + full = root / doc_path + missing = not full.exists() + stored = _read_stored_hash_from_doc(doc_path, root) + doc_entries.append( + DocStaleness( + feature=name, + doc_path=doc_path, + kind=kind, + is_stale=missing or stored != current_hash, + missing=missing, + current_hash=current_hash, + stored_hash=stored, + ) + ) + + return StalenessReport(help_entries=help_entries, doc_entries=doc_entries) + + +def check_workspace_staleness( + workspace: str | Path, + features: list[str] | None = None, +) -> StalenessReport: + """Check staleness for a workspace using the conventional ``.help/`` layout. + + Convenience wrapper for callers that just have a project root and + want the answer to "are any templates/docs out of date?" without + knowing the manifest loader or where ``.help/`` lives. The workspace + serves as both the project root (for resolving source globs) and + the parent of ``.help/`` (for the manifest and template hashes). + + If ``/.help/features.yaml`` is absent, returns an empty + report rather than raising — a workspace without a manifest has no + templates to be stale about. + + Args: + workspace: Project root containing ``.help/`` (typically a git + repo root). + features: Optional list of feature names to check. Defaults to + all features in the manifest. + + Returns: + StalenessReport (empty if no manifest is present). + """ + root = Path(workspace) + help_dir = root / ".help" + try: + manifest = load_manifest(help_dir) + except FileNotFoundError: + return StalenessReport() + return check_staleness(manifest, help_dir, root, features=features) + + +def _infer_kind(feat: Feature, path_field: str) -> str: + """Infer the doc kind for a path field. + + Uses ``doc_kinds`` from the manifest when available; falls back to + ``"how-to"`` for ``doc_path`` and ``"architecture"`` for + ``arch_path``. + + Args: + feat: The Feature entry. + path_field: Which path attribute is being resolved + (``"doc_path"`` or ``"arch_path"``). + + Returns: + Kind string. + """ + if path_field == "arch_path": + return "architecture" + # First non-architecture kind in doc_kinds, or "how-to" + for kind in feat.doc_kinds: + if kind != "architecture": + return kind + return "how-to" diff --git a/src/attune/authoring/symbols.py b/src/attune/authoring/symbols.py new file mode 100644 index 000000000..55f5980dc --- /dev/null +++ b/src/attune/authoring/symbols.py @@ -0,0 +1,236 @@ +""" +Symbol extraction and hashing for semantic-freshness staleness detection. + +Extracted verbatim from attune-author's ``freshness/symbols.py`` during +the attune-author consolidation (``docs/specs/attune-author-consolidation/``). +Pure ``ast`` + ``hashlib``: no LLM, no jinja, no network. The +``signature_hash`` here is load-bearing for staleness detection and MUST +stay byte-compatible with the hashes stored in existing ``.help/`` files. + +Public API: + SymbolRecord — frozen dataclass; one per public symbol + SymbolExtractor — parses Python source, emits SymbolRecord lists +""" + +from __future__ import annotations + +import ast +import hashlib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +SymbolKind = Literal["function", "class", "method"] + + +@dataclass(frozen=True) +class SymbolRecord: + """A normalized record of a public symbol's contract surface. + + `signature_hash` is the load-bearing field for staleness detection: + it is stable across docstring edits, body edits, formatter passes, and + import reorders, but changes when the *contract* changes (parameters, + return type, decorators, bases, public attributes). + + `body_hash` is recorded for downstream phases (Phase 2 quality scoring) + but does NOT participate in Phase 1 staleness decisions. + """ + + file: str + qualname: str + kind: SymbolKind + signature: str + decorators: tuple[str, ...] = () + bases: tuple[str, ...] = () + public_attrs: tuple[str, ...] = () + body_ast_repr: str = "" + + @property + def signature_hash(self) -> str: + payload = "\n".join( + [ + self.kind, + self.qualname, + self.signature, + "|".join(self.decorators), + "|".join(self.bases), + "|".join(self.public_attrs), + ] + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + @property + def body_hash(self) -> str: + return hashlib.sha256(self.body_ast_repr.encode("utf-8")).hexdigest() + + +# ---------- Internal helpers ---------- + + +def _strip_docstring(body: list[ast.stmt]) -> list[ast.stmt]: + """Drop a leading string-literal expression statement (the docstring).""" + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + return body[1:] + return body + + +def _format_arg(arg: ast.arg) -> str: + if arg.annotation is not None: + return f"{arg.arg}: {ast.unparse(arg.annotation)}" + return arg.arg + + +def _normalize_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + """Return a canonical signature string. Stable across formatter changes.""" + args = node.args + parts: list[str] = [] + + # positional-only + for arg in args.posonlyargs: + parts.append(_format_arg(arg)) + if args.posonlyargs: + parts.append("/") + + # regular positional + defaults (defaults align to the *end* of args.args) + n_pos = len(args.args) + n_defaults = len(args.defaults) + default_start = n_pos - n_defaults + for i, arg in enumerate(args.args): + formatted = _format_arg(arg) + if i >= default_start: + formatted += f"={ast.unparse(args.defaults[i - default_start])}" + parts.append(formatted) + + # *args (or bare * marker if there are kwonly args without *args) + if args.vararg is not None: + parts.append(f"*{_format_arg(args.vararg)}") + elif args.kwonlyargs: + parts.append("*") + + # keyword-only + for arg, default in zip(args.kwonlyargs, args.kw_defaults, strict=False): + formatted = _format_arg(arg) + if default is not None: + formatted += f"={ast.unparse(default)}" + parts.append(formatted) + + # **kwargs + if args.kwarg is not None: + parts.append(f"**{_format_arg(args.kwarg)}") + + args_str = ", ".join(parts) + returns = f" -> {ast.unparse(node.returns)}" if node.returns else "" + prefix = "async def " if isinstance(node, ast.AsyncFunctionDef) else "def " + return f"{prefix}{node.name}({args_str}){returns}" + + +def _normalize_body(body: list[ast.stmt]) -> str: + """Stable representation of a function/method body. Strips docstring.""" + stripped = _strip_docstring(body) + if not stripped: + return "" + return "\n".join(ast.unparse(s) for s in stripped) + + +def _is_public(name: str) -> bool: + """Public if not underscore-prefixed; `__init__` is the documented exception.""" + return not name.startswith("_") or name == "__init__" + + +# ---------- Public extractor ---------- + + +class SymbolExtractor: + """Parses Python source and emits normalized `SymbolRecord` lists. + + Only top-level public symbols are extracted. Nested classes and inner + functions are out of scope for Phase 1 — templates that reference them + can declare deps explicitly via features.yaml. + """ + + def extract(self, file: Path) -> list[SymbolRecord]: + source = file.read_text(encoding="utf-8") + tree = ast.parse(source) + records: list[SymbolRecord] = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if _is_public(node.name): + records.append( + self._function_record(file, node, qualname=node.name, kind="function") + ) + elif isinstance(node, ast.ClassDef): + if _is_public(node.name): + records.append(self._class_record(file, node)) + records.extend(self._class_method_records(file, node)) + return records + + def extract_one(self, file: Path, qualname: str) -> SymbolRecord | None: + """Return the first record matching `qualname`, or None. + + Note: properties with a setter share a qualname. `extract_one` returns + the first match; staleness checks that need both should use `extract()` + and filter. Tracked as an open question in the Phase 1 spec. + """ + for r in self.extract(file): + if r.qualname == qualname: + return r + return None + + # ---------- private builders ---------- + + def _function_record( + self, + file: Path, + node: ast.FunctionDef | ast.AsyncFunctionDef, + *, + qualname: str, + kind: SymbolKind, + ) -> SymbolRecord: + return SymbolRecord( + file=str(file), + qualname=qualname, + kind=kind, + signature=_normalize_signature(node), + decorators=tuple(ast.unparse(d) for d in node.decorator_list), + body_ast_repr=_normalize_body(node.body), + ) + + def _class_record(self, file: Path, node: ast.ClassDef) -> SymbolRecord: + public_attrs: list[str] = [] + for stmt in node.body: + if isinstance(stmt, ast.Assign): + for target in stmt.targets: + if isinstance(target, ast.Name) and _is_public(target.id): + public_attrs.append(target.id) + elif isinstance(stmt, ast.AnnAssign): + if isinstance(stmt.target, ast.Name) and _is_public(stmt.target.id): + public_attrs.append(stmt.target.id) + + return SymbolRecord( + file=str(file), + qualname=node.name, + kind="class", + signature=f"class {node.name}", + decorators=tuple(ast.unparse(d) for d in node.decorator_list), + bases=tuple(ast.unparse(b) for b in node.bases), + public_attrs=tuple(public_attrs), + ) + + def _class_method_records(self, file: Path, class_node: ast.ClassDef) -> list[SymbolRecord]: + records: list[SymbolRecord] = [] + for stmt in class_node.body: + if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef) and _is_public(stmt.name): + records.append( + self._function_record( + file, + stmt, + qualname=f"{class_node.name}.{stmt.name}", + kind="method", + ) + ) + return records diff --git a/src/attune/ops/help_data.py b/src/attune/ops/help_data.py index 358f2540b..6713f78a2 100644 --- a/src/attune/ops/help_data.py +++ b/src/attune/ops/help_data.py @@ -15,8 +15,6 @@ import logging import re -import shutil -import subprocess import time from collections.abc import Iterable from dataclasses import asdict, dataclass, field @@ -26,13 +24,13 @@ logger = logging.getLogger(__name__) -# Authority cache TTL for attune-author status. Short enough that -# regen jobs that change staleness state surface promptly; long -# enough that a page-refresh burst doesn't re-spawn the subprocess -# per request. +# Authority cache TTL for the in-process source-hash drift check. +# Short enough that regen jobs that change staleness state surface +# promptly; long enough that a page-refresh burst doesn't re-hash +# every feature's source per request. _STALENESS_CACHE_TTL_SECONDS = 5.0 -# Process-lifetime cache for attune-author status. Keyed on the +# Process-lifetime cache for the drift check. Keyed on the # (project_root, help_dir) pair so multiple dashboards in one # process don't cross-contaminate. _staleness_cache: dict[tuple[str, str], tuple[float, frozenset[str]]] = {} @@ -187,14 +185,14 @@ def corpus_root(config: Config) -> Path: def list_features(config: Config) -> list[FeatureSummary]: """All features in the corpus, alphabetical. - Staleness comes from ``attune-author status`` when available - (the authoritative source-hash drift signal). Falls back to - per-template age-based check when attune-author isn't on PATH. + Staleness comes from an in-process source-hash drift check + (the authoritative signal). Falls back to per-template + age-based check when the manifest can't be loaded. """ root = corpus_root(config) if not root.exists(): return [] - stale = _attune_author_stale_features(config.project_root, config.project_root / ".help") + stale = _stale_features(config.project_root, config.project_root / ".help") out: list[FeatureSummary] = [] for feat_dir in sorted(p for p in root.iterdir() if p.is_dir()): out.append(_summarize_feature(feat_dir, stale_features=stale)) @@ -204,8 +202,8 @@ def list_features(config: Config) -> list[FeatureSummary]: def get_template(config: Config, feature: str, kind: str) -> TemplateRecord | None: """Load one template by feature + kind. None if missing. - Staleness uses the attune-author authority when available; - falls back to age-based when the CLI isn't on PATH. + Staleness uses the in-process source-hash drift check; falls + back to age-based when the manifest can't be loaded. """ if not _safe_slug(feature) or not _safe_slug(kind): return None @@ -217,7 +215,7 @@ def get_template(config: Config, feature: str, kind: str) -> TemplateRecord | No except OSError as exc: logger.warning("help_data: cannot read %s: %s", path, exc) return None - stale = _attune_author_stale_features(config.project_root, config.project_root / ".help") + stale = _stale_features(config.project_root, config.project_root / ".help") return _parse_template(feature, kind, path, raw, stale_features=stale) @@ -414,9 +412,9 @@ def _summarize_feature( """Scan a feature directory for its kinds + freshness. ``stale_features`` is the authoritative set from - :func:`_attune_author_stale_features` (drift-based). When - ``None`` the function falls back to per-template age-based - staleness — used in tests and when attune-author isn't on PATH. + :func:`_stale_features` (source-hash drift). When ``None`` the + function falls back to per-template age-based staleness — used + in tests and when the manifest can't be loaded. """ name = feat_dir.name kinds: list[str] = [] @@ -431,8 +429,8 @@ def _summarize_feature( if _is_template_stale(path): stale_count += 1 else: - # Authority path: if the feature is in attune-author's - # stale set, every kind it has is treated as stale (the + # Authority path: if the feature is in the source-hash + # drift set, every kind it has is treated as stale (the # source files drifted; all derived templates need # regen). If not in the set, nothing is stale. for kind in EXPECTED_KINDS: @@ -454,9 +452,9 @@ def _is_template_stale(path: Path, *, stale_features: frozenset[str] | None = No """Per-template freshness check (hash-based only). A template is stale iff its parent feature is in - ``stale_features`` — attune-author's source-hash drift set. - When ``stale_features`` is ``None`` (attune-author unavailable), - the dashboard cannot determine drift, so it reports + ``stale_features`` — the source-hash drift set. When + ``stale_features`` is ``None`` (drift undeterminable), the + dashboard cannot determine drift, so it reports ``False`` (assume fresh). The previous age-based fallback was dropped: a template's age in days is not a proxy for whether its content matches the current source. @@ -469,32 +467,36 @@ def _is_template_stale(path: Path, *, stale_features: frozenset[str] | None = No # --------------------------------------------------------------------------- -# attune-author authority — source-hash drift, the real staleness signal +# Source-hash drift — the real staleness signal (in-process) # --------------------------------------------------------------------------- -# Markdown table-row pattern from attune-author's ``status`` -# output. Format: -# | | | | -# The header + divider rows are filtered by the surrounding -# parser context (only rows AFTER "### Stale" + the divider row). -_TABLE_ROW_RE = re.compile(r"^\s*\|\s*([a-z][a-z0-9-]*)\s*\|") +def _stale_features(project_root: Path, help_dir: Path) -> frozenset[str] | None: + """Return features whose ``.help/`` templates have drifted from source. + Computes source-hash drift **in-process** via + :func:`attune.authoring.staleness.check_staleness` — the same + semantic-hash algorithm the retired ``attune-author status`` CLI + used, now absorbed into :mod:`attune.authoring`. Result cached + for :data:`_STALENESS_CACHE_TTL_SECONDS` per + ``(project_root, help_dir)`` key. -def _attune_author_stale_features(project_root: Path, help_dir: Path) -> frozenset[str] | None: - """Return the set of features attune-author reports as stale. - - Shells out to ``attune-author status`` and parses the markdown - output. Result cached for :data:`_STALENESS_CACHE_TTL_SECONDS` - per ``(project_root, help_dir)`` key. + Manual / single-sourced features with no ``files:`` globs are + reported as *untracked (N/A)*, not stale: hash-drift is + undefined for a feature with no source to hash, so evaluating + them would flag every projector-owned feature as permanently + stale (leftover stored hash ≠ the empty-input hash — see + ``docs/specs/attune-author-consolidation/decisions.md`` D9). + They are excluded from the drift check and from the result. Returns: - - ``frozenset[str]``: feature names with source-hash drift, - including the empty set when everything is in sync. - - ``None`` when attune-author isn't on PATH, its subprocess - raises, or the CLI exits non-zero — callers fall back to - age-based staleness in that case. A non-zero exit is treated - as "cannot determine drift", never as "nothing stale". + - ``frozenset[str]``: feature names with source-hash drift + in their ``.help/`` templates (empty set = all in sync, + which now includes the all-manual repo). + - ``None`` when the manifest can't be loaded (no ``.help/`` + manifest, parse error) or the drift check raises — + callers fall back to age-based staleness, the same signal + the old code returned when the CLI was unavailable. """ key = (str(project_root), str(help_dir)) now = time.monotonic() @@ -502,92 +504,43 @@ def _attune_author_stale_features(project_root: Path, help_dir: Path) -> frozens if cached is not None and (now - cached[0]) < _STALENESS_CACHE_TTL_SECONDS: return cached[1] - binary = shutil.which("attune-author") - if binary is None: - logger.info("help_data: attune-author not on PATH; using age fallback") - return None - cmd = [ - binary, - "status", - "--project-root", - str(project_root), - "--help-dir", - str(help_dir), - ] try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=15, - check=False, - ) - except (OSError, subprocess.SubprocessError) as exc: - logger.warning("help_data: attune-author status failed: %s", exc) + from attune.authoring.manifest import load_manifest + from attune.authoring.staleness import check_staleness + except ImportError as exc: + logger.warning("help_data: attune.authoring unavailable: %s", exc) return None - if result.returncode != 0: - # A non-zero exit means the CLI itself failed (e.g. a broken - # shim raising ModuleNotFoundError), NOT "everything is in - # sync". Parsing the (typically empty) stdout here would return - # frozenset(), masking the crash as "nothing stale" and denying - # callers the age-based fallback. Return None so callers fall - # back — same signal as a missing binary or a raised exception. - logger.warning( - "help_data: attune-author status exited %s; using age fallback (stderr: %s)", - result.returncode, - (result.stderr or "").strip()[:200], + + try: + manifest = load_manifest(help_dir) + except (OSError, ValueError) as exc: + logger.info( + "help_data: cannot load manifest from %s: %s; using age fallback", + help_dir, + exc, ) return None - stale = _parse_status_output(result.stdout) - _staleness_cache[key] = (now, stale) - return stale - - -def _parse_status_output(text: str) -> frozenset[str]: - """Extract stale feature names from ``attune-author status`` output. - The status command emits two top-level sections: + # Manual/single-sourced features (no ``files:`` globs) have no + # source to hash — report as untracked (N/A), not stale (D9). + tracked = [name for name, feat in manifest.features.items() if feat.files] + if not tracked: + stale = frozenset() + _staleness_cache[key] = (now, stale) + return stale - - ``## Help Templates`` — drift in ``.help/templates/`` files, - which ``attune-author regenerate`` can fix. - - ``## Project Docs`` — drift in ``docs/`` files (``docs/how-to/``, - ``docs/reference/``, etc.) which is a SEPARATE corpus that - ``regenerate`` does NOT touch. - - The dashboard only manages ``.help/templates/``, so this parser - collects feature names ONLY from the Stale section under - ``## Help Templates``. Including Project Docs stale features - here caused the dashboard to flag all templates of those - features as stale, but "Regenerate all stale" couldn't fix - them (the regenerate command leaves ``docs/`` alone) — so the - count never went down. Default behavior when no ``## `` headers - appear: assume Help Templates context (backward compat with - older attune-author versions that omitted the header). + try: + report = check_staleness(manifest, help_dir, project_root, features=tracked) + except Exception as exc: # noqa: BLE001 + # INTENTIONAL: staleness is advisory. Any failure (unreadable + # source, malformed glob) falls back to age-based staleness + # rather than 500-ing the dashboard. + logger.warning("help_data: check_staleness failed: %s; using age fallback", exc) + return None - Skips the table header and divider rows automatically because - they don't match :data:`_TABLE_ROW_RE` (they begin with - ``Feature`` or ``-----``). - """ - out: set[str] = set() - in_project_docs = False - in_stale = False - for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("## "): - in_project_docs = "Project Docs" in stripped - in_stale = False # h2 boundary resets h3 state - continue - if stripped.startswith("###"): - in_stale = stripped == "### Stale" - continue - if not in_stale or in_project_docs: - continue - m = _TABLE_ROW_RE.match(line) - if m: - out.add(m.group(1)) - return frozenset(out) + stale = frozenset(report.stale_features) + _staleness_cache[key] = (now, stale) + return stale def _clear_staleness_cache() -> None: @@ -606,10 +559,10 @@ def _parse_template( """Split frontmatter + body, build a TemplateRecord. Staleness is hash-based only: a template is stale iff its - parent feature is in ``stale_features`` (attune-author's - source-hash drift set). When ``stale_features`` is ``None`` - (attune-author unavailable), the dashboard cannot determine - drift and reports ``is_stale=False`` — see ``_is_template_stale``. + parent feature is in ``stale_features`` (the source-hash drift + set). When ``stale_features`` is ``None`` (drift undeterminable), + the dashboard cannot determine drift and reports + ``is_stale=False`` — see ``_is_template_stale``. """ m = _FRONTMATTER_RE.match(raw) if m: diff --git a/tests/unit/authoring/test_manifest.py b/tests/unit/authoring/test_manifest.py new file mode 100644 index 000000000..66536d4b7 --- /dev/null +++ b/tests/unit/authoring/test_manifest.py @@ -0,0 +1,473 @@ +"""Tests for attune_help.manifest.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from attune.authoring.manifest import ( + Feature, + FeatureManifest, + is_safe_feature_name, + load_manifest, + match_files_to_features, + resolve_topic, + save_manifest, + slugify, +) + +# --------------------------------------------------------------------------- +# is_safe_feature_name +# --------------------------------------------------------------------------- + + +def test_safe_name_accepts_simple(): + assert is_safe_feature_name("authentication") is True + + +def test_safe_name_accepts_hyphenated(): + assert is_safe_feature_name("memory-storage") is True + + +def test_safe_name_rejects_slash(): + assert is_safe_feature_name("a/b") is False + + +def test_safe_name_rejects_backslash(): + assert is_safe_feature_name("a\\b") is False + + +def test_safe_name_rejects_dotdot(): + assert is_safe_feature_name("../etc") is False + + +def test_safe_name_rejects_null_byte(): + assert is_safe_feature_name("foo\x00bar") is False + + +def test_safe_name_rejects_empty(): + assert is_safe_feature_name("") is False + + +def test_safe_name_rejects_non_string(): + assert is_safe_feature_name(42) is False # type: ignore[arg-type] + assert is_safe_feature_name(None) is False # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# load_manifest +# --------------------------------------------------------------------------- + + +def _write_yaml(tmp_path: Path, content: str) -> Path: + p = tmp_path / ".help" / "features.yaml" + p.parent.mkdir(parents=True) + p.write_text(content, encoding="utf-8") + return tmp_path / ".help" + + +def test_load_manifest_basic(tmp_path: Path): + help_dir = _write_yaml( + tmp_path, + """\ +version: 1 +features: + auth: + description: Authentication module + files: + - src/auth/** + tags: + - login +""", + ) + manifest = load_manifest(help_dir) + assert manifest.version == 1 + assert "auth" in manifest.features + feat = manifest.features["auth"] + assert feat.description == "Authentication module" + assert feat.files == ["src/auth/**"] + assert feat.tags == ["login"] + + +def test_load_manifest_doc_fields(tmp_path: Path): + help_dir = _write_yaml( + tmp_path, + """\ +version: 1 +features: + foo: + description: Foo module + files: + - src/attune/foo/** + doc_kinds: + - how-to + - architecture + doc_path: docs/how-to/foo.md + arch_path: docs/architecture/foo-architecture.md + doc_nav_section: "How-to > Advanced" +""", + ) + manifest = load_manifest(help_dir) + feat = manifest.features["foo"] + assert feat.doc_kinds == ["how-to", "architecture"] + assert feat.doc_path == "docs/how-to/foo.md" + assert feat.arch_path == "docs/architecture/foo-architecture.md" + assert feat.doc_nav_section == "How-to > Advanced" + + +def test_load_manifest_missing_doc_fields_defaults_none(tmp_path: Path): + help_dir = _write_yaml( + tmp_path, + """\ +version: 1 +features: + bar: + description: Bar module +""", + ) + manifest = load_manifest(help_dir) + feat = manifest.features["bar"] + assert feat.doc_kinds == [] + assert feat.doc_path is None + assert feat.arch_path is None + assert feat.doc_nav_section is None + + +def test_load_manifest_missing_file_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_manifest(tmp_path / ".help") + + +def test_load_manifest_invalid_yaml_raises(tmp_path: Path): + help_dir = _write_yaml(tmp_path, "not a mapping at all") + with pytest.raises(ValueError, match="expected mapping"): + load_manifest(help_dir) + + +def test_load_manifest_invalid_feature_name_raises(tmp_path: Path): + help_dir = _write_yaml( + tmp_path, + """\ +version: 1 +features: + ../evil: + description: bad +""", + ) + with pytest.raises(ValueError, match="Invalid feature name"): + load_manifest(help_dir) + + +# --------------------------------------------------------------------------- +# save_manifest / round-trip +# --------------------------------------------------------------------------- + + +def test_save_and_reload_manifest(tmp_path: Path): + help_dir = tmp_path / ".help" + manifest = FeatureManifest( + version=1, + features={ + "auth": Feature( + name="auth", + description="Auth module", + files=["src/auth/**"], + tags=["login"], + doc_kinds=["how-to"], + doc_path="docs/how-to/auth.md", + arch_path=None, + doc_nav_section="How-to", + ) + }, + ) + save_manifest(manifest, help_dir) + reloaded = load_manifest(help_dir) + feat = reloaded.features["auth"] + assert feat.doc_kinds == ["how-to"] + assert feat.doc_path == "docs/how-to/auth.md" + assert feat.doc_nav_section == "How-to" + + +def test_save_omits_empty_doc_fields(tmp_path: Path): + help_dir = tmp_path / ".help" + manifest = FeatureManifest( + version=1, + features={"plain": Feature(name="plain", description="No docs")}, + ) + save_manifest(manifest, help_dir) + yaml_text = (help_dir / "features.yaml").read_text(encoding="utf-8") + assert "doc_kinds" not in yaml_text + assert "doc_path" not in yaml_text + + +# --------------------------------------------------------------------------- +# match_files_to_features +# --------------------------------------------------------------------------- + + +def _manifest_with(*features: tuple[str, list[str]]) -> FeatureManifest: + feats = { + name: Feature(name=name, description=name, files=patterns) for name, patterns in features + } + return FeatureManifest(version=1, features=feats) + + +def test_match_files_single_feature(): + manifest = _manifest_with(("auth", ["src/auth/*.py"])) + result = match_files_to_features(["src/auth/login.py"], manifest) + assert result == {"auth": ["src/auth/login.py"]} + + +def test_match_files_no_match(): + manifest = _manifest_with(("auth", ["src/auth/*.py"])) + result = match_files_to_features(["src/other/foo.py"], manifest) + assert result == {} + + +def test_match_files_double_star(): + manifest = _manifest_with(("mem", ["src/mem/**"])) + result = match_files_to_features(["src/mem/store/redis.py"], manifest) + assert result == {"mem": ["src/mem/store/redis.py"]} + + +# --------------------------------------------------------------------------- +# resolve_topic +# --------------------------------------------------------------------------- + + +def _simple_manifest() -> FeatureManifest: + return FeatureManifest( + version=1, + features={ + "auth": Feature( + name="auth", + description="Authentication and login", + tags=["login", "session"], + ), + "memory": Feature( + name="memory", + description="Persistent memory storage", + tags=["redis", "cache"], + ), + }, + ) + + +def test_resolve_exact(): + assert resolve_topic("auth", _simple_manifest()) == "auth" + + +def test_resolve_substring_name(): + assert resolve_topic("mem", _simple_manifest()) == "memory" + + +def test_resolve_description(): + assert resolve_topic("login", _simple_manifest()) == "auth" + + +def test_resolve_tag(): + assert resolve_topic("redis", _simple_manifest()) == "memory" + + +def test_resolve_ambiguous_returns_none(): + manifest = FeatureManifest( + version=1, + features={ + "auth": Feature(name="auth", description="auth things", tags=["shared"]), + "memory": Feature(name="memory", description="mem things", tags=["shared"]), + }, + ) + assert resolve_topic("shared", manifest) is None + + +def test_resolve_no_match_returns_none(): + assert resolve_topic("zzz", _simple_manifest()) is None + + +# --------------------------------------------------------------------------- +# slugify +# --------------------------------------------------------------------------- + + +def test_slugify_basic(): + assert slugify("Memory Storage") == "memory-storage" + + +def test_slugify_already_slug(): + assert slugify("auth") == "auth" + + +def test_slugify_special_chars(): + assert slugify("foo & bar!") == "foo-bar" + + +# --------------------------------------------------------------------------- +# doc_paths / _docs schema (attune-help 0.9 extensions) +# --------------------------------------------------------------------------- + + +def test_feature_post_init_coalesces_doc_path_to_doc_paths(): + """Legacy-style Feature with only ``doc_path`` populates ``doc_paths``.""" + feat = Feature(name="x", description="", doc_path="docs/how-to/x.md") + assert feat.doc_paths == ["docs/how-to/x.md"] + assert feat.doc_path == "docs/how-to/x.md" + + +def test_feature_post_init_coalesces_doc_paths_to_doc_path(): + """New-style Feature with only ``doc_paths`` populates ``doc_path``.""" + feat = Feature( + name="x", + description="", + doc_paths=["docs/how-to/a.md", "docs/how-to/b.md"], + ) + assert feat.doc_path == "docs/how-to/a.md" + + +def test_feature_multiple_doc_paths_preserved(): + """A feature may carry multiple doc paths.""" + feat = Feature( + name="memory", + description="", + doc_paths=[ + "docs/how-to/memory-graph.md", + "docs/how-to/short-term-memory-implementation.md", + "docs/how-to/unified-memory-system.md", + ], + ) + assert len(feat.doc_paths) == 3 + assert feat.doc_path == "docs/how-to/memory-graph.md" + + +def test_load_manifest_accepts_legacy_doc_path_scalar(tmp_path): + """Legacy YAML using ``doc_path:`` (scalar) loads into ``doc_paths``.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + "version: 1\n" + "features:\n" + " auth:\n" + " description: Authentication\n" + " doc_path: docs/how-to/auth.md\n", + encoding="utf-8", + ) + manifest = load_manifest(help_dir) + feat = manifest.features["auth"] + assert feat.doc_paths == ["docs/how-to/auth.md"] + assert feat.doc_path == "docs/how-to/auth.md" + + +def test_load_manifest_doc_paths_wins_over_legacy_doc_path(tmp_path): + """When both forms are present, ``doc_paths`` (new) takes precedence.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + "version: 1\n" + "features:\n" + " auth:\n" + " description: Authentication\n" + " doc_path: docs/how-to/legacy.md\n" + " doc_paths:\n" + " - docs/how-to/new-primary.md\n" + " - docs/how-to/new-secondary.md\n", + encoding="utf-8", + ) + manifest = load_manifest(help_dir) + feat = manifest.features["auth"] + assert feat.doc_paths == [ + "docs/how-to/new-primary.md", + "docs/how-to/new-secondary.md", + ] + assert feat.doc_path == "docs/how-to/new-primary.md" + + +def test_load_manifest_parses_top_level_docs_bucket(tmp_path): + """The ``_docs:`` bucket loads into ``FeatureManifest.docs``.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + "version: 1\n" + "_docs:\n" + " - docs/reference/FAQ.md\n" + " - docs/reference/glossary.md\n" + "features:\n" + " auth:\n" + " description: Authentication\n", + encoding="utf-8", + ) + manifest = load_manifest(help_dir) + assert manifest.docs == [ + "docs/reference/FAQ.md", + "docs/reference/glossary.md", + ] + + +def test_load_manifest_missing_docs_bucket_defaults_to_empty(tmp_path): + """Omitting ``_docs:`` entirely yields an empty list.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + "version: 1\nfeatures: {}\n", + encoding="utf-8", + ) + assert load_manifest(help_dir).docs == [] + + +def test_load_manifest_rejects_non_list_docs_bucket(tmp_path): + """``_docs:`` must be a list, not a mapping or scalar.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + "version: 1\n_docs:\n FAQ: docs/reference/FAQ.md\nfeatures: {}\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="'_docs' must be a list"): + load_manifest(help_dir) + + +def test_save_manifest_emits_doc_paths_not_doc_path(tmp_path): + """Saving a Feature with doc_paths writes the list form, not the scalar.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + manifest = FeatureManifest( + version=1, + features={ + "memory": Feature( + name="memory", + description="Memory graph", + doc_paths=["docs/how-to/memory-graph.md", "docs/how-to/unified.md"], + ) + }, + ) + save_manifest(manifest, help_dir) + raw = (help_dir / "features.yaml").read_text(encoding="utf-8") + assert "doc_paths:" in raw + assert "doc_path:" not in raw + + +def test_save_manifest_emits_top_level_docs_bucket(tmp_path): + """FeatureManifest.docs round-trips through save/load.""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + original = FeatureManifest( + version=1, + features={}, + docs=["docs/reference/FAQ.md", "docs/reference/glossary.md"], + ) + save_manifest(original, help_dir) + reloaded = load_manifest(help_dir) + assert reloaded.docs == original.docs + + +def test_save_manifest_omits_empty_docs_bucket(tmp_path): + """Empty ``docs`` list is not written to YAML (clean output).""" + help_dir = tmp_path / ".help" + help_dir.mkdir() + manifest = FeatureManifest( + version=1, + features={"x": Feature(name="x", description="")}, + docs=[], + ) + save_manifest(manifest, help_dir) + raw = (help_dir / "features.yaml").read_text(encoding="utf-8") + assert "_docs" not in raw diff --git a/tests/unit/authoring/test_staleness.py b/tests/unit/authoring/test_staleness.py new file mode 100644 index 000000000..c4b29120b --- /dev/null +++ b/tests/unit/authoring/test_staleness.py @@ -0,0 +1,445 @@ +"""Tests for attune_help.staleness.""" + +from __future__ import annotations + +from pathlib import Path + +from attune.authoring.manifest import Feature, FeatureManifest +from attune.authoring.staleness import ( + DocStaleness, + FeatureStaleness, + StalenessReport, + build_doc_footer, + check_staleness, + compute_source_hash, + parse_doc_footer, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_manifest(*features: Feature) -> FeatureManifest: + return FeatureManifest(version=1, features={f.name: f for f in features}) + + +def _write_src(root: Path, rel: str, content: str = "# code") -> Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return p + + +def _write_concept(help_dir: Path, feature: str, source_hash: str) -> Path: + p = help_dir / "templates" / feature / "concept.md" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + f"---\nsource_hash: {source_hash}\n---\n\n# Concept\n", + encoding="utf-8", + ) + return p + + +def _write_doc(root: Path, rel: str, source_hash: str, feature: str, kind: str) -> Path: + footer = build_doc_footer( + source_hash=source_hash, + feature=feature, + kind=kind, + generated_at="2026-04-23", + ) + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"# Doc\n\nContent here.\n\n{footer}\n", encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# parse_doc_footer +# --------------------------------------------------------------------------- + + +def test_parse_doc_footer_present(): + text = ( + "# Title\n\nSome content.\n\n" + "" + ) + attrs = parse_doc_footer(text) + assert attrs["source_hash"] == "abc123" + assert attrs["feature"] == "foo" + assert attrs["kind"] == "how-to" + assert attrs["generated_at"] == "2026-04-23" + + +def test_parse_doc_footer_absent(): + assert parse_doc_footer("# No footer here\n") == {} + + +def test_parse_doc_footer_partial_comment(): + text = "" + assert parse_doc_footer(text) == {} + + +# --------------------------------------------------------------------------- +# build_doc_footer +# --------------------------------------------------------------------------- + + +def test_build_doc_footer_round_trips(): + footer = build_doc_footer("deadbeef", "auth", "how-to", "2026-04-23") + attrs = parse_doc_footer(footer) + assert attrs["source_hash"] == "deadbeef" + assert attrs["feature"] == "auth" + assert attrs["kind"] == "how-to" + assert attrs["generated_at"] == "2026-04-23" + + +def test_build_doc_footer_is_single_line(): + footer = build_doc_footer("h", "f", "k", "d") + assert "\n" not in footer + + +# --------------------------------------------------------------------------- +# compute_source_hash +# --------------------------------------------------------------------------- + + +def test_compute_source_hash_basic(tmp_path: Path): + _write_src(tmp_path, "src/auth/login.py", "def login(): pass") + feat = Feature(name="auth", description="Auth", files=["src/auth/**"]) + digest, matched = compute_source_hash(feat, tmp_path) + assert len(digest) == 64 # sha256 hex + assert "src/auth/login.py" in matched + + +def test_compute_source_hash_empty_globs(tmp_path: Path): + feat = Feature(name="nothing", description="", files=[]) + digest, matched = compute_source_hash(feat, tmp_path) + assert digest == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assert matched == [] + + +def test_compute_source_hash_excludes_pycache(tmp_path: Path): + _write_src(tmp_path, "src/auth/login.py", "code") + _write_src(tmp_path, "src/auth/__pycache__/login.cpython-311.pyc", "bytecode") + feat = Feature(name="auth", description="", files=["src/auth/**"]) + _, matched = compute_source_hash(feat, tmp_path) + assert all("__pycache__" not in p for p in matched) + + +def test_compute_source_hash_deterministic(tmp_path: Path): + _write_src(tmp_path, "src/a.py", "x") + _write_src(tmp_path, "src/b.py", "y") + feat = Feature(name="f", description="", files=["src/**"]) + h1, _ = compute_source_hash(feat, tmp_path) + h2, _ = compute_source_hash(feat, tmp_path) + assert h1 == h2 + + +def test_compute_source_hash_changes_on_edit(tmp_path: Path): + f = _write_src(tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n") + feat = Feature(name="auth", description="", files=["src/auth/**"]) + h1, _ = compute_source_hash(feat, tmp_path) + f.write_text( + "def login(name: str, password: str) -> bool:\n return True\n", encoding="utf-8" + ) + h2, _ = compute_source_hash(feat, tmp_path) + assert h1 != h2 + + +def test_compute_source_hash_stable_across_docstring_edit(tmp_path: Path): + """Semantic hashing: docstring-only edits do not trigger staleness.""" + f = _write_src( + tmp_path, + "src/auth/login.py", + 'def login(name: str) -> str:\n """Log in."""\n return name\n', + ) + feat = Feature(name="auth", description="", files=["src/auth/**"]) + h1, _ = compute_source_hash(feat, tmp_path) + f.write_text( + "def login(name: str) -> str:\n" + ' """Very detailed login documentation."""\n' + " return name\n", + encoding="utf-8", + ) + h2, _ = compute_source_hash(feat, tmp_path) + assert h1 == h2 + + +def test_compute_source_hash_changes_on_signature_edit(tmp_path: Path): + """Semantic hashing: adding a parameter changes the hash.""" + f = _write_src(tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n") + feat = Feature(name="auth", description="", files=["src/auth/**"]) + h1, _ = compute_source_hash(feat, tmp_path) + f.write_text("def login(name: str, password: str) -> str:\n return name\n", encoding="utf-8") + h2, _ = compute_source_hash(feat, tmp_path) + assert h1 != h2 + + +# --------------------------------------------------------------------------- +# check_staleness — help templates +# --------------------------------------------------------------------------- + + +def test_check_staleness_help_fresh(tmp_path: Path): + _write_src(tmp_path, "src/auth/login.py", "def login(): ...") + help_dir = tmp_path / ".help" + feat = Feature(name="auth", description="Auth", files=["src/auth/**"]) + manifest = _make_manifest(feat) + current_hash, _ = compute_source_hash(feat, tmp_path) + _write_concept(help_dir, "auth", current_hash) + + report = check_staleness(manifest, help_dir, tmp_path) + assert len(report.help_entries) == 1 + entry = report.help_entries[0] + assert entry.feature == "auth" + assert not entry.is_stale + assert entry.stored_hash == current_hash + + +def test_check_staleness_help_stale(tmp_path: Path): + _write_src(tmp_path, "src/auth/login.py", "updated code") + help_dir = tmp_path / ".help" + _write_concept(help_dir, "auth", "oldhashabc123") + feat = Feature(name="auth", description="", files=["src/auth/**"]) + manifest = _make_manifest(feat) + + report = check_staleness(manifest, help_dir, tmp_path) + entry = report.help_entries[0] + assert entry.is_stale + assert entry.stored_hash == "oldhashabc123" + + +def test_check_staleness_help_missing_concept(tmp_path: Path): + _write_src(tmp_path, "src/auth/login.py", "code") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature(name="auth", description="", files=["src/auth/**"]) + manifest = _make_manifest(feat) + + report = check_staleness(manifest, help_dir, tmp_path) + entry = report.help_entries[0] + assert entry.is_stale + assert entry.stored_hash is None + + +# --------------------------------------------------------------------------- +# check_staleness — project docs +# --------------------------------------------------------------------------- + + +def test_check_staleness_doc_fresh(tmp_path: Path): + _write_src(tmp_path, "src/foo/main.py", "# foo") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="foo", + description="Foo", + files=["src/foo/**"], + doc_kinds=["how-to"], + doc_path="docs/how-to/foo.md", + ) + manifest = _make_manifest(feat) + current_hash, _ = compute_source_hash(feat, tmp_path) + _write_concept(help_dir, "foo", current_hash) + _write_doc(tmp_path, "docs/how-to/foo.md", current_hash, "foo", "how-to") + + report = check_staleness(manifest, help_dir, tmp_path) + assert len(report.doc_entries) == 1 + doc = report.doc_entries[0] + assert doc.feature == "foo" + assert doc.kind == "how-to" + assert not doc.is_stale + assert not doc.missing + + +def test_check_staleness_doc_stale_hash(tmp_path: Path): + _write_src(tmp_path, "src/foo/main.py", "# foo updated") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="foo", + description="Foo", + files=["src/foo/**"], + doc_path="docs/how-to/foo.md", + ) + manifest = _make_manifest(feat) + _write_concept(help_dir, "foo", "stale_hash") + _write_doc(tmp_path, "docs/how-to/foo.md", "stale_hash", "foo", "how-to") + + report = check_staleness(manifest, help_dir, tmp_path) + doc = report.doc_entries[0] + assert doc.is_stale + assert not doc.missing + assert doc.stored_hash == "stale_hash" + + +def test_check_staleness_doc_missing(tmp_path: Path): + _write_src(tmp_path, "src/foo/main.py", "# foo") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="foo", + description="Foo", + files=["src/foo/**"], + doc_path="docs/how-to/foo.md", + ) + manifest = _make_manifest(feat) + + report = check_staleness(manifest, help_dir, tmp_path) + doc = report.doc_entries[0] + assert doc.is_stale + assert doc.missing + assert doc.stored_hash is None + + +def test_check_staleness_arch_path(tmp_path: Path): + _write_src(tmp_path, "src/foo/main.py", "code") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="foo", + description="Foo", + files=["src/foo/**"], + doc_path="docs/how-to/foo.md", + arch_path="docs/architecture/foo.md", + ) + manifest = _make_manifest(feat) + current_hash, _ = compute_source_hash(feat, tmp_path) + _write_doc(tmp_path, "docs/how-to/foo.md", current_hash, "foo", "how-to") + _write_doc(tmp_path, "docs/architecture/foo.md", current_hash, "foo", "architecture") + + report = check_staleness(manifest, help_dir, tmp_path) + assert len(report.doc_entries) == 2 + kinds = {d.kind for d in report.doc_entries} + assert "how-to" in kinds + assert "architecture" in kinds + + +def test_check_staleness_no_doc_fields_no_doc_entries(tmp_path: Path): + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature(name="plain", description="", files=[]) + manifest = _make_manifest(feat) + + report = check_staleness(manifest, help_dir, tmp_path) + assert report.doc_entries == [] + + +# --------------------------------------------------------------------------- +# StalenessReport aggregates +# --------------------------------------------------------------------------- + + +def test_staleness_report_counts(): + report = StalenessReport( + help_entries=[ + FeatureStaleness("a", is_stale=True, current_hash="x", stored_hash=None), + FeatureStaleness("b", is_stale=False, current_hash="y", stored_hash="y"), + ], + doc_entries=[ + DocStaleness( + "a", + "docs/a.md", + "how-to", + is_stale=True, + missing=False, + current_hash="x", + ), + ], + ) + assert report.stale_count == 2 + assert report.current_count == 1 + assert report.stale_features == ["a"] + assert len(report.stale_docs) == 1 + + +def test_staleness_report_filter_features(tmp_path: Path): + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat_a = Feature(name="a", description="", files=[]) + feat_b = Feature(name="b", description="", files=[]) + manifest = _make_manifest(feat_a, feat_b) + + report = check_staleness(manifest, help_dir, tmp_path, features=["a"]) + assert len(report.help_entries) == 1 + assert report.help_entries[0].feature == "a" + + +def test_check_staleness_unknown_feature_skipped(tmp_path: Path): + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature(name="known", description="", files=[]) + manifest = _make_manifest(feat) + + report = check_staleness(manifest, help_dir, tmp_path, features=["unknown"]) + assert report.help_entries == [] + assert report.doc_entries == [] + + +def test_check_staleness_iterates_multiple_doc_paths(tmp_path: Path): + """A feature with ``doc_paths=[a, b, c]`` produces three doc_entries. + + Locks in the staleness change that iterates the full list rather + than reading only the primary ``doc_path``. + """ + _write_src(tmp_path, "src/memory/main.py", "# memory") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="memory", + description="Memory", + files=["src/memory/**"], + doc_kinds=["how-to"], + doc_paths=[ + "docs/how-to/memory-graph.md", + "docs/how-to/unified-memory-system.md", + "docs/how-to/short-term-memory-implementation.md", + ], + ) + manifest = _make_manifest(feat) + current_hash, _ = compute_source_hash(feat, tmp_path) + _write_concept(help_dir, "memory", current_hash) + # First two docs fresh; third missing on disk. + _write_doc(tmp_path, "docs/how-to/memory-graph.md", current_hash, "memory", "how-to") + _write_doc( + tmp_path, + "docs/how-to/unified-memory-system.md", + current_hash, + "memory", + "how-to", + ) + + report = check_staleness(manifest, help_dir, tmp_path) + assert len(report.doc_entries) == 3 + paths = {d.doc_path: d for d in report.doc_entries} + assert paths["docs/how-to/memory-graph.md"].is_stale is False + assert paths["docs/how-to/unified-memory-system.md"].is_stale is False + assert paths["docs/how-to/short-term-memory-implementation.md"].missing is True + + +def test_check_staleness_one_stale_doc_among_many(tmp_path: Path): + """When N doc_paths exist and only one's hash drifts, only that one is stale.""" + _write_src(tmp_path, "src/memory/main.py", "# memory") + help_dir = tmp_path / ".help" + help_dir.mkdir() + feat = Feature( + name="memory", + description="Memory", + files=["src/memory/**"], + doc_paths=["docs/a.md", "docs/b.md"], + ) + manifest = _make_manifest(feat) + current_hash, _ = compute_source_hash(feat, tmp_path) + _write_concept(help_dir, "memory", current_hash) + _write_doc(tmp_path, "docs/a.md", current_hash, "memory", "how-to") + _write_doc(tmp_path, "docs/b.md", "stale_hash", "memory", "how-to") + + report = check_staleness(manifest, help_dir, tmp_path) + stale = [d for d in report.doc_entries if d.is_stale] + current = [d for d in report.doc_entries if not d.is_stale] + assert len(stale) == 1 + assert stale[0].doc_path == "docs/b.md" + assert len(current) == 1 + assert current[0].doc_path == "docs/a.md" diff --git a/tests/unit/authoring/test_symbols.py b/tests/unit/authoring/test_symbols.py new file mode 100644 index 000000000..37f03a91d --- /dev/null +++ b/tests/unit/authoring/test_symbols.py @@ -0,0 +1,387 @@ +""" +Tests for SymbolExtractor / SymbolRecord. + +Each test maps to a numbered acceptance criterion in +docs/specs/phase-1-semantic-freshness-spec.md. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from attune.authoring.symbols import SymbolExtractor + + +@pytest.fixture +def extractor() -> SymbolExtractor: + return SymbolExtractor() + + +def write(tmp_path: Path, name: str, source: str) -> Path: + p = tmp_path / name + p.write_text(textwrap.dedent(source), encoding="utf-8") + return p + + +# ---------- Criterion 1: docstring invariance ---------- + + +def test_docstring_only_edit_does_not_change_signature_hash(tmp_path, extractor): + a = write( + tmp_path, + "a.py", + ''' + def greet(name: str) -> str: + """Say hello.""" + return f"Hello, {name}" + ''', + ) + b = write( + tmp_path, + "b.py", + ''' + def greet(name: str) -> str: + """Say a warm and friendly hello to the user.""" + return f"Hello, {name}" + ''', + ) + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa is not None and sb is not None + assert sa.signature_hash == sb.signature_hash + + +def test_class_docstring_edit_does_not_change_signature_hash(tmp_path, extractor): + a = write( + tmp_path, + "a.py", + ''' + class Greeter: + """A polite greeter.""" + def greet(self, name: str) -> str: + return f"Hello, {name}" + ''', + ) + b = write( + tmp_path, + "b.py", + ''' + class Greeter: + """A warm, friendly greeter that welcomes users.""" + def greet(self, name: str) -> str: + return f"Hello, {name}" + ''', + ) + sa = extractor.extract_one(a, "Greeter") + sb = extractor.extract_one(b, "Greeter") + assert sa is not None and sb is not None + assert sa.signature_hash == sb.signature_hash + + +# ---------- Criterion 2: signature sensitivity ---------- + + +def test_added_parameter_changes_signature_hash(tmp_path, extractor): + a = write(tmp_path, "a.py", "def greet(name: str) -> str:\n return name\n") + b = write( + tmp_path, + "b.py", + "def greet(name: str, formal: bool = False) -> str:\n return name\n", + ) + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash != sb.signature_hash + + +def test_changed_return_type_changes_signature_hash(tmp_path, extractor): + a = write(tmp_path, "a.py", "def count() -> int:\n return 1\n") + b = write(tmp_path, "b.py", "def count() -> str:\n return '1'\n") + sa = extractor.extract_one(a, "count") + sb = extractor.extract_one(b, "count") + assert sa.signature_hash != sb.signature_hash + + +def test_changed_default_value_changes_signature_hash(tmp_path, extractor): + a = write(tmp_path, "a.py", "def greet(name: str = 'world') -> str:\n return name\n") + b = write(tmp_path, "b.py", "def greet(name: str = 'friend') -> str:\n return name\n") + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash != sb.signature_hash + + +def test_signature_change_does_not_affect_unrelated_template(tmp_path, extractor): + """Criterion 2: signature change flags exactly the dependent — no others.""" + file = write( + tmp_path, + "x.py", + """ + def greet(name: str) -> str: + return name + def farewell(name: str) -> str: + return name + """, + ) + greet_v1 = extractor.extract_one(file, "greet") + farewell_v1 = extractor.extract_one(file, "farewell") + + # Now change `greet` only + file.write_text( + textwrap.dedent( + """ + def greet(name: str, formal: bool = False) -> str: + return name + def farewell(name: str) -> str: + return name + """ + ), + encoding="utf-8", + ) + greet_v2 = extractor.extract_one(file, "greet") + farewell_v2 = extractor.extract_one(file, "farewell") + + assert greet_v1.signature_hash != greet_v2.signature_hash + assert farewell_v1.signature_hash == farewell_v2.signature_hash + + +# ---------- Criterion 3: rename detection ---------- + + +def test_renamed_function_disappears_under_old_qualname(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + "def greet_warmly(name: str) -> str:\n return name\n", + ) + assert extractor.extract_one(file, "greet") is None + assert extractor.extract_one(file, "greet_warmly") is not None + + +def test_renamed_method_disappears_under_old_qualname(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + """ + class Greeter: + def hello(self, name: str) -> str: + return name + """, + ) + assert extractor.extract_one(file, "Greeter.greet") is None + assert extractor.extract_one(file, "Greeter.hello") is not None + + +# ---------- Criterion 4: removal ---------- + + +def test_removed_function_returns_none(tmp_path, extractor): + file = write(tmp_path, "x.py", "def survivor() -> None:\n pass\n") + assert extractor.extract_one(file, "removed") is None + + +# ---------- Criterion 5: formatter invariance ---------- + + +def test_whitespace_and_blank_lines_dont_change_signature_hash(tmp_path, extractor): + a = write( + tmp_path, + "a.py", + """ + def greet(name: str) -> str: + return f"Hello, {name}" + """, + ) + b_source = ( + "\n\n" + "def greet( name : str ) -> str :\n" + "\n\n" + ' return f"Hello, {name}"\n' + "\n\n" + ) + b = tmp_path / "b.py" + b.write_text(b_source, encoding="utf-8") + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash == sb.signature_hash + + +def test_import_reorder_does_not_affect_function_hashes(tmp_path, extractor): + a = write( + tmp_path, + "a.py", + """ + import os + import sys + + def greet(name: str) -> str: + return name + """, + ) + b = write( + tmp_path, + "b.py", + """ + import sys + import os + + def greet(name: str) -> str: + return name + """, + ) + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash == sb.signature_hash + assert sa.body_hash == sb.body_hash + + +# ---------- Sanity coverage ---------- + + +def test_method_qualname_is_class_dot_method(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + """ + class Greeter: + def greet(self, name: str) -> str: + return name + """, + ) + methods = [r for r in extractor.extract(file) if r.kind == "method"] + assert len(methods) == 1 + assert methods[0].qualname == "Greeter.greet" + + +def test_class_record_captures_bases_decorators_and_attrs(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + """ + from dataclasses import dataclass + + @dataclass + class Config(BaseConfig, Mixin): + name: str + value: int = 0 + _private: int = 0 + """, + ) + cls = extractor.extract_one(file, "Config") + assert cls is not None + assert cls.kind == "class" + assert "BaseConfig" in cls.bases + assert "Mixin" in cls.bases + assert "dataclass" in cls.decorators + assert "name" in cls.public_attrs + assert "value" in cls.public_attrs + assert "_private" not in cls.public_attrs + + +def test_async_function_signature_includes_async(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + "async def fetch(url: str) -> bytes:\n return b''\n", + ) + rec = extractor.extract_one(file, "fetch") + assert rec is not None + assert "async def" in rec.signature + + +def test_private_top_level_functions_are_skipped(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + """ + def public_one() -> None: pass + def _private_one() -> None: pass + """, + ) + qualnames = [r.qualname for r in extractor.extract(file)] + assert "public_one" in qualnames + assert "_private_one" not in qualnames + + +def test_init_method_is_extracted(tmp_path, extractor): + file = write( + tmp_path, + "x.py", + """ + class Foo: + def __init__(self, x: int) -> None: + self.x = x + def _helper(self) -> None: pass + """, + ) + qualnames = [r.qualname for r in extractor.extract(file)] + assert "Foo.__init__" in qualnames + assert "Foo._helper" not in qualnames + + +def test_body_changes_alter_body_hash_but_not_signature_hash(tmp_path, extractor): + """body_hash is informational in Phase 1; spec says it must not affect staleness.""" + a = write( + tmp_path, + "a.py", + """ + def greet(name: str) -> str: + return f"Hello, {name}" + """, + ) + b = write( + tmp_path, + "b.py", + """ + def greet(name: str) -> str: + return f"Hi, {name}!" + """, + ) + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash == sb.signature_hash + assert sa.body_hash != sb.body_hash + + +def test_property_getter_and_setter_both_extracted(tmp_path, extractor): + """Spec open question: properties share qualname; both records appear.""" + file = write( + tmp_path, + "x.py", + """ + class Box: + @property + def size(self) -> int: + return self._size + + @size.setter + def size(self, v: int) -> None: + self._size = v + """, + ) + methods = [r for r in extractor.extract(file) if r.qualname == "Box.size"] + assert len(methods) == 2 + decorator_sets = {r.decorators for r in methods} + assert ("property",) in decorator_sets + assert ("size.setter",) in decorator_sets + # The two records must have distinct signature hashes + assert methods[0].signature_hash != methods[1].signature_hash + + +def test_added_decorator_changes_signature_hash(tmp_path, extractor): + a = write(tmp_path, "a.py", "def greet() -> str:\n return 'hi'\n") + b = write( + tmp_path, + "b.py", + """ + from functools import lru_cache + + @lru_cache + def greet() -> str: + return 'hi' + """, + ) + sa = extractor.extract_one(a, "greet") + sb = extractor.extract_one(b, "greet") + assert sa.signature_hash != sb.signature_hash diff --git a/tests/unit/ops/test_help_data.py b/tests/unit/ops/test_help_data.py index ca746a108..2a819dc61 100644 --- a/tests/unit/ops/test_help_data.py +++ b/tests/unit/ops/test_help_data.py @@ -130,26 +130,25 @@ def corpus(tmp_path: Path) -> Path: @pytest.fixture(autouse=True) def _inject_stale_features(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: - """Inject the attune-author authority set used by the corpus. + """Inject the source-hash drift authority set used by the corpus. Staleness is hash-based only (no date fallback). The synthetic corpus marks ``stale-feature`` and ``mixed-feature`` as the features whose source has drifted; this fixture stubs - ``_attune_author_stale_features`` to return that exact set so - tests run deterministically without needing the real - attune-author CLI on PATH. - - Skipped for ``TestAttuneAuthorStaleFeatures`` — those tests - exercise the real ``_attune_author_stale_features`` body, so - they need to see the un-stubbed function. Tests in other - classes that need a different authority set re-monkeypatch - ``_attune_author_stale_features`` themselves. + ``_stale_features`` to return that exact set so tests run + deterministically without needing a real manifest on disk. + + Skipped for ``TestStaleFeatures`` — those tests exercise the + real ``_stale_features`` body, so they need to see the + un-stubbed function. Tests in other classes that need a + different authority set re-monkeypatch ``_stale_features`` + themselves. """ - if request.cls is not None and request.cls.__name__ == "TestAttuneAuthorStaleFeatures": + if request.cls is not None and request.cls.__name__ == "TestStaleFeatures": return monkeypatch.setattr( help_data_mod, - "_attune_author_stale_features", + "_stale_features", lambda *_args, **_kwargs: frozenset({"stale-feature", "mixed-feature"}), ) help_data_mod._clear_staleness_cache() @@ -644,249 +643,130 @@ def test_unreadable_file_returns_false(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# attune-author authority path +# Source-hash drift authority path (in-process) # --------------------------------------------------------------------------- -_REAL_STATUS_OUTPUT = """## Help Templates +def _write_features_yaml(help_dir: Path, features: dict) -> None: + """Write a minimal ``.help/features.yaml`` for the given feature specs.""" + import yaml -**24** current, **2** stale - -### Stale - -| Feature | Description | Files Changed | -|---------|-------------|---------------| -| spec-engine | The spec engine | 8 source files | -| smart-test | Find test gaps | 3 source files | - - -### Current - -- **security-audit** — desc -- **code-quality** — desc -""" - - -class TestParseStatusOutput: - def test_extracts_stale_features(self) -> None: - from attune.ops.help_data import _parse_status_output - - stale = _parse_status_output(_REAL_STATUS_OUTPUT) - assert stale == frozenset({"spec-engine", "smart-test"}) - - def test_no_stale_section_returns_empty(self) -> None: - from attune.ops.help_data import _parse_status_output - - text = "## Help Templates\n\n**25** current, **0** stale\n" - assert _parse_status_output(text) == frozenset() - - def test_empty_string(self) -> None: - from attune.ops.help_data import _parse_status_output - - assert _parse_status_output("") == frozenset() - - def test_ignores_table_header_and_divider(self) -> None: - """Header row (Feature/Description/Files) and the |---|---| divider - don't match the slug regex, so they're correctly skipped.""" - from attune.ops.help_data import _parse_status_output - - text = ( - "### Stale\n\n" - "| Feature | Description | Files Changed |\n" - "|---------|-------------|---------------|\n" - "| valid-slug | desc | 1 file |\n" - ) - assert _parse_status_output(text) == frozenset({"valid-slug"}) - - def test_current_section_ignored(self) -> None: - """Bullets in the ### Current section don't pollute the stale set.""" - from attune.ops.help_data import _parse_status_output - - text = ( - "### Stale\n\n" - "| a-feature | x | 1 |\n\n" - "### Current\n\n" - "- **other-feature** — desc\n" - ) - assert _parse_status_output(text) == frozenset({"a-feature"}) - - def test_project_docs_stale_section_ignored(self) -> None: - """``## Project Docs`` is a SEPARATE corpus from - ``.help/templates/``. Its stale section must NOT contribute - to the dashboard's help-templates stale set — otherwise - clicking "Regenerate all stale" can never bring the count - down (regenerate only touches .help/, not docs/). - - This is the bug PR #494 fixed: pre-fix the parser walked - every "### Stale" section regardless of its parent h2, - rolling project-docs drift into help-templates staleness. - """ - from attune.ops.help_data import _parse_status_output - - text = ( - "## Help Templates\n\n" - "**1** current, **1** stale\n\n" - "### Stale\n\n" - "| help-only-feat | foo | 1 |\n\n" - "## Project Docs\n\n" - "**0** current, **2** stale\n\n" - "### Stale\n\n" - "| docs-only-feat | bar | 2 |\n" - "| another-docs-feat | baz | 1 |\n" - ) - # Only the help-templates feature appears; the project-docs - # features are silently dropped. - assert _parse_status_output(text) == frozenset({"help-only-feat"}) + help_dir.mkdir(parents=True, exist_ok=True) + (help_dir / "features.yaml").write_text( + yaml.safe_dump({"version": 1, "features": features}), encoding="utf-8" + ) - def test_help_templates_implicit_when_no_h2(self) -> None: - """If no ``## `` h2 appears, default to Help Templates context. - Backward compat with older attune-author versions whose - status output didn't include section headers. - """ - from attune.ops.help_data import _parse_status_output +def _write_hash_template(help_dir: Path, feature: str, source_hash: str | None) -> None: + """Write ``.help/templates//concept.md`` with an optional stored hash.""" + tdir = help_dir / "templates" / feature + tdir.mkdir(parents=True, exist_ok=True) + fm = "---\ntype: concept\n" + if source_hash is not None: + fm += f"source_hash: {source_hash}\n" + fm += "---\n\n# Concept\n" + (tdir / "concept.md").write_text(fm, encoding="utf-8") - text = "### Stale\n\n| legacy-feat | foo | 1 |\n" - assert _parse_status_output(text) == frozenset({"legacy-feat"}) +class TestStaleFeatures: + """Tests for the in-process source-hash drift check (``_stale_features``). -class TestAttuneAuthorStaleFeatures: - """Tests for the subprocess wrapper. We mock subprocess.run to - keep tests fast and deterministic — no dependency on the real - attune-author CLI being installed in the venv.""" + Exercises the real function body (no subprocess) — the autouse + ``_inject_stale_features`` fixture skips this class so the + un-stubbed function runs. + """ - def test_returns_none_when_binary_missing( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """When ``shutil.which`` reports the binary missing, - ``_attune_author_stale_features`` returns ``None`` (the - signal callers use to mean 'cannot determine drift').""" + def test_returns_none_when_manifest_missing(self, tmp_path: Path) -> None: + """No ``features.yaml`` -> ``None`` (callers fall back to age-based).""" from attune.ops import help_data - monkeypatch.setattr(help_data.shutil, "which", lambda _: None) help_data._clear_staleness_cache() - result = help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - assert result is None + assert help_data._stale_features(tmp_path, tmp_path / ".help") is None - def test_parses_subprocess_output( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: + def test_manual_features_reported_untracked_not_stale(self, tmp_path: Path) -> None: + """Regression (decisions D9): features with no ``files:`` globs are + untracked (N/A), never stale -- even though a faithful hash check + would flag every one (stored hash != empty-input hash). Guards the + all-manual single-sourced repo from a wall of false 'stale'.""" from attune.ops import help_data - monkeypatch.setattr(help_data.shutil, "which", lambda _: "/fake/attune-author") - - class _FakeResult: - stdout = _REAL_STATUS_OUTPUT - stderr = "" - returncode = 0 - - monkeypatch.setattr(help_data.subprocess, "run", lambda *a, **kw: _FakeResult()) + help_dir = tmp_path / ".help" + _write_features_yaml(help_dir, {"manual-feat": {"description": "no globs"}}) + _write_hash_template(help_dir, "manual-feat", "deadbeef") # stale-looking hash help_data._clear_staleness_cache() - result = help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - assert result == frozenset({"spec-engine", "smart-test"}) + assert help_data._stale_features(tmp_path, help_dir) == frozenset() - def test_nonzero_exit_returns_none_not_empty_set( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - """Regression: a CLI that runs but exits non-zero (e.g. a broken - shim raising ModuleNotFoundError) must return ``None`` (→ age - fallback), NOT ``frozenset()``. Previously ``check=False`` let the - crash's empty stdout parse to an empty set, masking the failure as - 'nothing stale' and denying callers the fallback.""" + def test_tracked_feature_in_sync_not_stale(self, tmp_path: Path) -> None: + """A tracked feature whose stored hash matches current source is not stale.""" + from attune.authoring.manifest import Feature + from attune.authoring.staleness import compute_source_hash from attune.ops import help_data - monkeypatch.setattr(help_data.shutil, "which", lambda _: "/fake/attune-author") - - class _CrashResult: - stdout = "" - stderr = "ModuleNotFoundError: No module named 'attune_author'" - returncode = 1 - - monkeypatch.setattr(help_data.subprocess, "run", lambda *a, **kw: _CrashResult()) + (tmp_path / "mod.py").write_text("def f(x: int) -> int:\n return x\n", encoding="utf-8") + help_dir = tmp_path / ".help" + _write_features_yaml(help_dir, {"tracked-feat": {"files": ["mod.py"]}}) + current, _ = compute_source_hash( + Feature(name="tracked-feat", description="", files=["mod.py"]), tmp_path + ) + _write_hash_template(help_dir, "tracked-feat", current) help_data._clear_staleness_cache() - result = help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - assert result is None - - def test_subprocess_failure_returns_none( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path - ) -> None: - import subprocess as _sp + assert help_data._stale_features(tmp_path, help_dir) == frozenset() + def test_tracked_feature_drift_detected(self, tmp_path: Path) -> None: + """A tracked feature whose stored hash != current source hash is stale.""" from attune.ops import help_data - monkeypatch.setattr(help_data.shutil, "which", lambda _: "/fake/attune-author") - - def _raise(*_a, **_kw): - raise _sp.SubprocessError("boom") - - monkeypatch.setattr(help_data.subprocess, "run", _raise) + (tmp_path / "mod.py").write_text("def f(x: int) -> int:\n return x\n", encoding="utf-8") + help_dir = tmp_path / ".help" + _write_features_yaml(help_dir, {"tracked-feat": {"files": ["mod.py"]}}) + _write_hash_template(help_dir, "tracked-feat", "0" * 64) # wrong hash help_data._clear_staleness_cache() - result = help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - assert result is None + assert help_data._stale_features(tmp_path, help_dir) == frozenset({"tracked-feat"}) - def test_cache_hits_skip_subprocess( + def test_cache_hits_skip_recompute( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + """A second call within the TTL returns the cached set without + re-running ``check_staleness``.""" + import attune.authoring.staleness as staleness_mod from attune.ops import help_data - monkeypatch.setattr(help_data.shutil, "which", lambda _: "/fake/attune-author") - - call_count = {"n": 0} + help_dir = tmp_path / ".help" + _write_features_yaml(help_dir, {"tracked-feat": {"files": ["mod.py"]}}) + (tmp_path / "mod.py").write_text("x = 1\n", encoding="utf-8") + _write_hash_template(help_dir, "tracked-feat", "0" * 64) - class _FakeResult: - stdout = _REAL_STATUS_OUTPUT - returncode = 0 - stderr = "" + calls = {"n": 0} + real = staleness_mod.check_staleness - def _counting_run(*a, **kw): - call_count["n"] += 1 - return _FakeResult() + def _counting(*a, **kw): + calls["n"] += 1 + return real(*a, **kw) - monkeypatch.setattr(help_data.subprocess, "run", _counting_run) + monkeypatch.setattr(staleness_mod, "check_staleness", _counting) help_data._clear_staleness_cache() - # First call: subprocess fires - help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - # Second call within TTL: cache hit, no subprocess - help_data._attune_author_stale_features(tmp_path, tmp_path / ".help") - assert call_count["n"] == 1 + help_data._stale_features(tmp_path, help_dir) + help_data._stale_features(tmp_path, help_dir) + assert calls["n"] == 1 def test_drift_set_used_for_feature_staleness( self, monkeypatch: pytest.MonkeyPatch, cfg: Config, corpus: Path ) -> None: - """End-to-end: with attune-author saying 'complete-feature' is - stale, list_features() should mark it stale (and no other).""" + """End-to-end: with the drift set saying 'complete-feature' is + stale, ``list_features()`` marks it stale (and no other).""" from attune.ops import help_data - # Override the autouse fallback to return a known stale set. + # Override the autouse fallback to return a known drift set. monkeypatch.setattr( help_data, - "_attune_author_stale_features", + "_stale_features", lambda *_a, **_kw: frozenset({"complete-feature"}), ) feats = {f.name: f for f in help_data.list_features(cfg)} - # Authority says complete-feature is stale → every kind it has - # is treated as stale. stale-feature (which has only old - # generated_at timestamps) is NOT in the authority set → - # zero stale. + # Authority says complete-feature is stale -> every kind it has + # is treated as stale. stale-feature (only old generated_at + # timestamps) is NOT in the authority set -> zero stale. assert feats["complete-feature"].has_any_stale is True assert feats["complete-feature"].stale_count == 11 assert feats["stale-feature"].has_any_stale is False assert feats["stale-feature"].stale_count == 0 - - -class TestIsTemplateStaleAuthorityMode: - def test_in_authority_set_returns_true(self, tmp_path: Path) -> None: - from attune.ops.help_data import _is_template_stale - - p = tmp_path / "my-feat" / "concept.md" - p.parent.mkdir() - p.write_text("# x\n", encoding="utf-8") - assert _is_template_stale(p, stale_features=frozenset({"my-feat"})) is True - - def test_not_in_authority_set_returns_false(self, tmp_path: Path) -> None: - from attune.ops.help_data import _is_template_stale - - p = tmp_path / "my-feat" / "concept.md" - p.parent.mkdir() - p.write_text("# x\n", encoding="utf-8") - assert _is_template_stale(p, stale_features=frozenset({"other-feat"})) is False From 219d0a92920d7eede096fd41185929b5a1b6c3f9 Mon Sep 17 00:00:00 2001 From: Patrick Roebuck Date: Wed, 1 Jul 2026 11:48:01 -0400 Subject: [PATCH 2/2] test(authoring): cover staleness/symbols/manifest edge branches (T2a) Raises patch coverage on the absorbed modules from ~79-85% to 90-100% (Codecov flagged 72 missing lines / partials on #1205). The brought upstream suites cover mainline paths; this adds the branch edges they miss: - symbols.py 83% -> 98%: posonly / *args / bare-* kwonly / **kwargs / async signature variants + class public-attr and method extraction. - staleness.py 85% -> 93%: mixed-content byte hash, SyntaxError->byte fallback, frontmatter parse edges, unsafe-name / missing-template guards, check_workspace_staleness (with + without manifest), _infer_kind. - manifest.py 95% -> 100%: load-validation raises (non-mapping, bad features, unsafe name, non-mapping spec), version-mismatch warn, save_manifest full-field round-trip. - help_data.py: _stale_features import-unavailable and check_staleness- raises fallback branches (both -> None -> age fallback). Remaining staleness gaps are the project-doc footer paths that help_data does not use (help templates only). Co-Authored-By: Claude Opus 4.8 --- tests/unit/authoring/test_authoring_edges.py | 212 +++++++++++++++++++ tests/unit/ops/test_help_data.py | 32 +++ 2 files changed, 244 insertions(+) create mode 100644 tests/unit/authoring/test_authoring_edges.py diff --git a/tests/unit/authoring/test_authoring_edges.py b/tests/unit/authoring/test_authoring_edges.py new file mode 100644 index 000000000..87fee72f1 --- /dev/null +++ b/tests/unit/authoring/test_authoring_edges.py @@ -0,0 +1,212 @@ +"""Edge-case coverage for the absorbed authoring staleness trio. + +The upstream ``_relocated`` suites (``test_manifest.py`` / +``test_staleness.py`` / ``test_symbols.py``) cover the mainline paths; +this module targets the signature-formatting, byte-hash-fallback, +frontmatter-parsing, manifest-validation, and convenience-wrapper +branches they leave uncovered — the T2a absorb (see +``docs/specs/attune-author-consolidation/``) added these modules whole, +so the edges matter for the drift signal ``help_data`` now depends on. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from attune.authoring.manifest import ( + Feature, + FeatureManifest, + load_manifest, + save_manifest, +) +from attune.authoring.staleness import ( + _infer_kind, + _read_frontmatter_value, + _read_stored_hash_from_template, + check_workspace_staleness, + compute_semantic_hash, + compute_source_hash, +) +from attune.authoring.symbols import SymbolExtractor + +# --------------------------------------------------------------------------- +# symbols.py — signature formatting variants +# --------------------------------------------------------------------------- + + +def _extract(tmp_path: Path, src: str) -> dict: + f = tmp_path / "m.py" + f.write_text(src, encoding="utf-8") + return {r.qualname: r for r in SymbolExtractor().extract(f)} + + +def test_signature_covers_posonly_varargs_kwonly_kwargs_async(tmp_path: Path) -> None: + src = ( + "def posonly(a, /, b):\n return a\n\n" + "def variadic(*args, **kwargs):\n return args\n\n" + "def kwonly(*, j, k=1):\n return j\n\n" + "async def afunc(x: int) -> int:\n return x\n" + ) + recs = _extract(tmp_path, src) + assert "/" in recs["posonly"].signature + assert "*args" in recs["variadic"].signature + assert "**kwargs" in recs["variadic"].signature + # bare ``*`` marker (kwonly args with no ``*args``) + assert "*, " in recs["kwonly"].signature or recs["kwonly"].signature.count("*") >= 1 + assert "k=1" in recs["kwonly"].signature + assert recs["afunc"].signature.startswith("async def") + assert "-> int" in recs["afunc"].signature + + +def test_class_public_attrs_and_methods_extracted(tmp_path: Path) -> None: + src = ( + "class C:\n" + " x = 1\n" + " y: int = 2\n" + " _private = 3\n" + " def method(self):\n return self.x\n" + " def _hidden(self):\n return 0\n" + ) + recs = _extract(tmp_path, src) + assert "x" in recs["C"].public_attrs + assert "y" in recs["C"].public_attrs + assert "_private" not in recs["C"].public_attrs + assert "C.method" in recs + assert "C._hidden" not in recs + + +# --------------------------------------------------------------------------- +# staleness.py — hashing fallbacks + frontmatter/kind helpers +# --------------------------------------------------------------------------- + + +def test_mixed_content_feature_uses_byte_hash(tmp_path: Path) -> None: + """A feature mixing .py and non-.py files uses byte-concatenation.""" + (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "b.txt").write_text("plain text\n", encoding="utf-8") + feat = Feature(name="mixed", description="", files=["a.py", "b.txt"]) + digest, matched = compute_source_hash(feat, tmp_path) + assert matched == ["a.py", "b.txt"] + assert len(digest) == 64 + + +def test_semantic_hash_syntax_error_falls_back_to_bytes(tmp_path: Path) -> None: + """A .py file that won't parse falls back to a byte hash, not a crash.""" + (tmp_path / "bad.py").write_text("def f(:\n", encoding="utf-8") + feat = Feature(name="bad", description="", files=["bad.py"]) + digest, matched = compute_semantic_hash(feat, tmp_path) + assert matched == ["bad.py"] + assert len(digest) == 64 + + +def test_read_frontmatter_value_edges() -> None: + assert _read_frontmatter_value("no frontmatter here", "source_hash") is None + assert _read_frontmatter_value("---\nunclosed block\n", "source_hash") is None + assert _read_frontmatter_value("---\nsource_hash: abc123\n---\n", "source_hash") == "abc123" + + +def test_read_stored_hash_unsafe_name_and_missing(tmp_path: Path) -> None: + assert _read_stored_hash_from_template("../evil", tmp_path) is None + assert _read_stored_hash_from_template("does-not-exist", tmp_path) is None + + +def test_check_workspace_staleness_no_manifest_is_empty(tmp_path: Path) -> None: + report = check_workspace_staleness(tmp_path) + assert report.help_entries == [] + assert report.stale_count == 0 + + +def test_check_workspace_staleness_with_manifest(tmp_path: Path) -> None: + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text( + yaml.safe_dump({"version": 1, "features": {"f": {"description": "d"}}}), + encoding="utf-8", + ) + report = check_workspace_staleness(tmp_path) + assert isinstance(report.help_entries, list) + assert len(report.help_entries) == 1 + + +def test_infer_kind_variants() -> None: + arch = Feature(name="f", description="", arch_path="docs/a.md") + assert _infer_kind(arch, "arch_path") == "architecture" + # first non-architecture kind wins + ff = Feature(name="f", description="", doc_kinds=["architecture", "reference"]) + assert _infer_kind(ff, "doc_path") == "reference" + # default when no doc_kinds + plain = Feature(name="f", description="") + assert _infer_kind(plain, "doc_path") == "how-to" + + +# --------------------------------------------------------------------------- +# manifest.py — load validation + save round-trip +# --------------------------------------------------------------------------- + + +def _write_manifest_yaml(help_dir: Path, data: object) -> None: + help_dir.mkdir(parents=True, exist_ok=True) + (help_dir / "features.yaml").write_text(yaml.safe_dump(data), encoding="utf-8") + + +def test_load_manifest_non_mapping_raises(tmp_path: Path) -> None: + help_dir = tmp_path / ".help" + help_dir.mkdir() + (help_dir / "features.yaml").write_text("- a\n- b\n", encoding="utf-8") + with pytest.raises(ValueError, match="expected mapping"): + load_manifest(help_dir) + + +def test_load_manifest_version_mismatch_still_loads( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + _write_manifest_yaml(tmp_path / ".help", {"version": 999, "features": {}}) + manifest = load_manifest(tmp_path / ".help") # warns, does not raise + assert manifest.version == 999 + + +def test_load_manifest_features_not_mapping_raises(tmp_path: Path) -> None: + _write_manifest_yaml(tmp_path / ".help", {"features": ["a", "b"]}) + with pytest.raises(ValueError, match="'features' must be a mapping"): + load_manifest(tmp_path / ".help") + + +def test_load_manifest_unsafe_feature_name_raises(tmp_path: Path) -> None: + _write_manifest_yaml(tmp_path / ".help", {"features": {"../evil": {"description": "x"}}}) + with pytest.raises(ValueError, match="Invalid feature name"): + load_manifest(tmp_path / ".help") + + +def test_load_manifest_feature_spec_not_mapping_raises(tmp_path: Path) -> None: + _write_manifest_yaml(tmp_path / ".help", {"features": {"f": "not-a-dict"}}) + with pytest.raises(ValueError, match="must be a mapping"): + load_manifest(tmp_path / ".help") + + +def test_save_manifest_round_trips_all_fields(tmp_path: Path) -> None: + manifest = FeatureManifest( + version=1, + features={ + "f": Feature( + name="f", + description="d", + files=["x.py"], + tags=["t"], + doc_kinds=["how-to"], + doc_paths=["docs/f.md"], + arch_path="docs/arch.md", + doc_nav_section="Guides", + cli_command="ops", + ) + }, + ) + out = save_manifest(manifest, tmp_path / ".help") + assert out.exists() + reloaded = load_manifest(tmp_path / ".help") + feat = reloaded.features["f"] + assert feat.cli_command == "ops" + assert feat.arch_path == "docs/arch.md" + assert feat.doc_nav_section == "Guides" diff --git a/tests/unit/ops/test_help_data.py b/tests/unit/ops/test_help_data.py index 2a819dc61..cb28c994f 100644 --- a/tests/unit/ops/test_help_data.py +++ b/tests/unit/ops/test_help_data.py @@ -749,6 +749,38 @@ def _counting(*a, **kw): help_data._stale_features(tmp_path, help_dir) assert calls["n"] == 1 + def test_authoring_unavailable_returns_none( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """If ``attune.authoring`` can't be imported, fall back to age-based.""" + import sys + + from attune.ops import help_data + + monkeypatch.setitem(sys.modules, "attune.authoring.staleness", None) + help_data._clear_staleness_cache() + assert help_data._stale_features(tmp_path, tmp_path / ".help") is None + + def test_check_staleness_raising_returns_none( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """A raising ``check_staleness`` falls back to age-based (advisory, + never 500-ing the dashboard).""" + import attune.authoring.staleness as staleness_mod + from attune.ops import help_data + + help_dir = tmp_path / ".help" + _write_features_yaml(help_dir, {"tracked-feat": {"files": ["mod.py"]}}) + (tmp_path / "mod.py").write_text("x = 1\n", encoding="utf-8") + _write_hash_template(help_dir, "tracked-feat", "0" * 64) + + def _boom(*_a, **_kw): + raise RuntimeError("boom") + + monkeypatch.setattr(staleness_mod, "check_staleness", _boom) + help_data._clear_staleness_cache() + assert help_data._stale_features(tmp_path, help_dir) is None + def test_drift_set_used_for_feature_staleness( self, monkeypatch: pytest.MonkeyPatch, cfg: Config, corpus: Path ) -> None: