From 5e457b7635f37a34ebd91e7093fd22d241ae2930 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Thu, 7 May 2026 22:39:29 -0400 Subject: [PATCH] refactor(help): shim manifest/staleness/freshness; add rag adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B2 of the architecture-realignment spec (specs/architecture-realignment/{requirements,design,tasks}.md). The authoring modules manifest.py, staleness.py, freshness/ moved to attune-author in B1 (#14). This change replaces the help-side files with thin re-export shims that emit DeprecationWarning, preserves existing import paths for one minor release, and adds the HelpCorpusAdapter implementation that pairs with attune-rag's typed protocol from Phase A (attune-rag #10). Net change: ~914 lines removed, ~176 added. Three internal modules (~1063 lines total) collapse to four ~30-line shims that re-export their replacements. Closes finding #1 (ADR-002 violation) of the 2026-05-07 architecture review. Finding #3 (ADR-001 leak in discovery.py) is intentionally deferred in this PR — see PR body. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 40 ++ pyproject.toml | 9 +- src/attune_help/__init__.py | 47 +-- src/attune_help/adapters/__init__.py | 7 + src/attune_help/adapters/rag.py | 39 ++ src/attune_help/freshness/__init__.py | 34 +- src/attune_help/freshness/symbols.py | 251 +------------ src/attune_help/manifest.py | 374 +++---------------- src/attune_help/staleness.py | 511 ++------------------------ tests/test_authoring_shims.py | 78 ++++ 10 files changed, 300 insertions(+), 1090 deletions(-) create mode 100644 src/attune_help/adapters/__init__.py create mode 100644 src/attune_help/adapters/rag.py create mode 100644 tests/test_authoring_shims.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e35ad6..62c6647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to `attune-help` are documented here. +## 0.11.0 — 2026-05-08 + +### Changed (deprecation) + +- **`attune_help.manifest`, `attune_help.staleness`, and + `attune_help.freshness` (+ `attune_help.freshness.symbols`) moved to + `attune_author.*`.** The original modules now ship as thin re-export + shims that emit `DeprecationWarning` on import. Update your imports: + + # before + from attune_help.manifest import Feature, FeatureManifest + + # after + from attune_author.manifest import Feature, FeatureManifest + + The shims will be **removed in the next minor release of + attune-help (target: 2026-07-07)**. After that release, importing + from the old paths raises `ImportError`. + +- The package no longer re-exports manifest / staleness symbols from + its top-level (`attune_help.Feature`, `attune_help.StalenessReport`, + …). Import them from `attune_author` directly. A plain + `import attune_help` is now warning-free during the deprecation + window. + +### Added + +- **`attune_help.adapters.rag.AttuneHelpAdapter`** — implements + `attune_rag.corpus.help_adapter.HelpCorpusAdapter` so attune-rag's + `AttuneHelpCorpus` can be built without a dynamic import of + attune-help. Pairs with `attune-rag` 0.1.10's typed adapter + protocol. Closes finding **#1** (ADR-002 violation) of the + 2026-05-07 architecture review. + +### Dependencies + +- New required: `attune-author>=0.7.0`. Transitional — required only + while the deprecated shims live. Removed together with the shims + on 2026-07-07. + ## 0.10.0 — 2026-04-30 ### Added diff --git a/pyproject.toml b/pyproject.toml index 80c78aa..72a817b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "attune-help" -version = "0.10.2" +version = "0.11.0" description = "Lightweight help runtime with progressive depth and audience adaptation." readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.10" @@ -27,6 +27,13 @@ classifiers = [ ] dependencies = [ "python-frontmatter>=1.0.0", + # Transitional: manifest/staleness/freshness moved to attune-author + # in 0.11.0. ``attune_help.manifest``/``staleness``/``freshness`` + # are deprecated shims that re-export from attune-author. Required + # so existing imports keep working through the deprecation window. + # Drop this dep when the shims are removed (target 2026-07-07) and + # restore tech.md's zero-required-deps constraint. + "attune-author>=0.7.0", ] [project.optional-dependencies] diff --git a/src/attune_help/__init__.py b/src/attune_help/__init__.py index 51abff4..e836099 100644 --- a/src/attune_help/__init__.py +++ b/src/attune_help/__init__.py @@ -14,28 +14,7 @@ PopulatedTemplate, TemplateContext, ) -from attune_help.manifest import ( - Feature, - FeatureManifest, - Manifest, - is_safe_feature_name, - load_manifest, - match_files_to_features, - resolve_topic, - save_manifest, - slugify, -) from attune_help.preamble import get_preamble # noqa: F401 -from attune_help.staleness import ( - DocStaleness, - FeatureStaleness, - StalenessReport, - build_doc_footer, - check_staleness, - compute_semantic_hash, - compute_source_hash, - parse_doc_footer, -) from attune_help.storage import LocalFileStorage, SessionStorage __all__ = [ @@ -48,27 +27,15 @@ "TemplateContext", "get_demo_path", "get_preamble", - # Manifest - "Feature", - "FeatureManifest", - "Manifest", - "is_safe_feature_name", - "load_manifest", - "match_files_to_features", - "resolve_topic", - "save_manifest", - "slugify", - # Staleness - "DocStaleness", - "FeatureStaleness", - "StalenessReport", - "build_doc_footer", - "check_staleness", - "compute_semantic_hash", - "compute_source_hash", - "parse_doc_footer", ] +# Manifest, staleness, and freshness moved to attune-author. Importing +# them through ``attune_help.*`` still works via the deprecated shims +# in this package (they emit ``DeprecationWarning`` on first import). +# The shims are not eagerly imported here so that a plain +# ``import attune_help`` stays warning-free during the deprecation +# window. + try: from importlib.metadata import version diff --git a/src/attune_help/adapters/__init__.py b/src/attune_help/adapters/__init__.py new file mode 100644 index 0000000..348a708 --- /dev/null +++ b/src/attune_help/adapters/__init__.py @@ -0,0 +1,7 @@ +"""Adapters that expose attune-help's bundled corpus to other packages. + +The :mod:`attune_help.adapters.rag` module implements +:class:`attune_rag.corpus.help_adapter.HelpCorpusAdapter` so attune-rag +can build an :class:`attune_rag.corpus.attune_help.AttuneHelpCorpus` +without ever importing attune-help directly. +""" diff --git a/src/attune_help/adapters/rag.py b/src/attune_help/adapters/rag.py new file mode 100644 index 0000000..a7194a5 --- /dev/null +++ b/src/attune_help/adapters/rag.py @@ -0,0 +1,39 @@ +"""Adapter exposing attune-help's bundled corpus to attune-rag. + +attune-rag declares a small structural ``HelpCorpusAdapter`` protocol +(``templates_root: Path``, ``version: str``). This module ships an +implementation so attune-rag's corpus factory can build the +``AttuneHelpCorpus`` without dynamically importing attune-help. The +direction of the dependency edge inverts: attune-rag knows nothing of +attune-help; attune-help imports rag's protocol and conforms to it. + +Usage:: + + from attune_help.adapters.rag import AttuneHelpAdapter + from attune_rag.corpus.attune_help import AttuneHelpCorpus + + corpus = AttuneHelpCorpus(adapter=AttuneHelpAdapter()) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from attune_help import __version__ as _help_version + +_BUNDLED_TEMPLATES = Path(__file__).resolve().parent.parent / "templates" + + +@dataclass(frozen=True) +class AttuneHelpAdapter: + """attune-help's default :class:`HelpCorpusAdapter` implementation. + + By default points at the bundled templates directory and reports + the installed package version. Both fields are overridable so + tests and downstream callers can supply their own corpus root or + version string without subclassing. + """ + + templates_root: Path = field(default=_BUNDLED_TEMPLATES) + version: str = field(default=_help_version) diff --git a/src/attune_help/freshness/__init__.py b/src/attune_help/freshness/__init__.py index 0f66f43..6007092 100644 --- a/src/attune_help/freshness/__init__.py +++ b/src/attune_help/freshness/__init__.py @@ -1,14 +1,28 @@ -"""Symbol extraction for semantic-freshness staleness detection (Phase 1). - -Known limitation — re-export shims: - Pure re-export modules (``from pkg import *`` with no definitions) produce - zero symbols. If a feature's ``files:`` list includes only shim files, - ``compute_semantic_hash`` will return SHA-256("") for that feature. - Mitigation: list the upstream implementation file alongside the shim in - ``features.yaml``, or use Option B (per-symbol frontmatter) in a future - release. Tracked for Phase 2 evaluation. +"""DEPRECATED: moved to :mod:`attune_author.freshness`. + +Re-exported here for one minor release of attune-help so existing +imports keep working while consumers migrate. The shim emits +:class:`DeprecationWarning` on import. + +Update your imports:: + + from attune_author.freshness import SymbolExtractor, SymbolRecord + +This shim will be removed in the next minor release of attune-help +(target: 2026-07-07 — see CHANGELOG). """ -from attune_help.freshness.symbols import SymbolExtractor, SymbolRecord +from __future__ import annotations + +import warnings + +from attune_author.freshness import SymbolExtractor, SymbolRecord __all__ = ["SymbolExtractor", "SymbolRecord"] + +warnings.warn( + "attune_help.freshness is deprecated; import from attune_author.freshness. " + "This shim will be removed in the next minor release of attune-help.", + DeprecationWarning, + stacklevel=2, +) diff --git a/src/attune_help/freshness/symbols.py b/src/attune_help/freshness/symbols.py index 321014b..7c2b212 100644 --- a/src/attune_help/freshness/symbols.py +++ b/src/attune_help/freshness/symbols.py @@ -1,240 +1,29 @@ -""" -Symbol extraction and hashing for semantic-freshness staleness detection. - -Phase 1 of the Content Quality Flow optimization. See -docs/specs/phase-1-semantic-freshness-spec.md for the full design. - -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 - +"""DEPRECATED: moved to :mod:`attune_author.freshness.symbols`. -def _format_arg(arg: ast.arg) -> str: - if arg.annotation is not None: - return f"{arg.arg}: {ast.unparse(arg.annotation)}" - return arg.arg +Re-exported here for one minor release of attune-help so existing +imports keep working while consumers migrate. The shim emits +:class:`DeprecationWarning` on import. +Update your imports:: -def _normalize_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: - """Return a canonical signature string. Stable across formatter changes.""" - args = node.args - parts: list[str] = [] + from attune_author.freshness.symbols import SymbolExtractor, SymbolRecord - # 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): - 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 +This shim will be removed in the next minor release of attune-help +(target: 2026-07-07 — see CHANGELOG). +""" - # ---------- private builders ---------- +from __future__ import annotations - 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), - ) +import warnings - 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) +from attune_author.freshness.symbols import SymbolExtractor, SymbolRecord - 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), - ) +__all__ = ["SymbolExtractor", "SymbolRecord"] - 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 +warnings.warn( + "attune_help.freshness.symbols is deprecated; import from " + "attune_author.freshness.symbols. This shim will be removed in the " + "next minor release of attune-help.", + DeprecationWarning, + stacklevel=2, +) diff --git a/src/attune_help/manifest.py b/src/attune_help/manifest.py index 01da7a3..de949f0 100644 --- a/src/attune_help/manifest.py +++ b/src/attune_help/manifest.py @@ -1,340 +1,48 @@ -"""Feature manifest parser with project-doc field support. +"""DEPRECATED: moved to :mod:`attune_author.manifest`. -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.). +Re-exported here for one minor release of attune-help so existing +imports keep working while consumers migrate. The shim emits +:class:`DeprecationWarning` on import. -Backward compatibility: legacy ``doc_path`` (scalar) is accepted -on load and migrated into ``doc_paths`` (list). Saved manifests -always emit ``doc_paths``. +Update your imports:: -This module is intentionally self-contained so attune-author can -import from here rather than carrying its own copy. + from attune_author.manifest import Feature, FeatureManifest, load_manifest + +This shim will be removed in the next minor release of attune-help +(target: 2026-07-07 — see CHANGELOG). """ 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. - """ - - 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 - - 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"), - ) - - # 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 - 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("-") +import warnings + +from attune_author.manifest import ( + Feature, + FeatureManifest, + Manifest, + is_safe_feature_name, + load_manifest, + match_files_to_features, + resolve_topic, + save_manifest, + slugify, +) + +__all__ = [ + "Feature", + "FeatureManifest", + "Manifest", + "is_safe_feature_name", + "load_manifest", + "match_files_to_features", + "resolve_topic", + "save_manifest", + "slugify", +] + +warnings.warn( + "attune_help.manifest is deprecated; import from attune_author.manifest. " + "This shim will be removed in the next minor release of attune-help.", + DeprecationWarning, + stacklevel=2, +) diff --git a/src/attune_help/staleness.py b/src/attune_help/staleness.py index bad9ba2..99d788d 100644 --- a/src/attune_help/staleness.py +++ b/src/attune_help/staleness.py @@ -1,485 +1,46 @@ -"""Staleness detection for help templates and project docs. +"""DEPRECATED: moved to :mod:`attune_author.staleness`. -Two tracking formats are supported: +Re-exported here for one minor release of attune-help so existing +imports keep working while consumers migrate. The shim emits +:class:`DeprecationWarning` on import. -1. **Help templates** (``.help/templates//concept.md``): - Source hash stored in YAML frontmatter: - ``source_hash: abc123`` +Update your imports:: -2. **Project docs** (``docs/how-to/foo.md``, etc.): - Source hash stored in an HTML comment footer (mkdocs-invisible): - ```` + from attune_author.staleness import check_staleness, StalenessReport -Both formats use the same SHA-256 of the feature's source files so -that staleness comparisons are consistent across tracking locations. +This shim will be removed in the next minor release of attune-help +(target: 2026-07-07 — see CHANGELOG). """ from __future__ import annotations -import hashlib -import logging -import re -from dataclasses import dataclass, field -from pathlib import Path - -from attune_help.manifest import Feature, FeatureManifest, is_safe_feature_name - -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, +import warnings + +from attune_author.staleness import ( + DocStaleness, + FeatureStaleness, + StalenessReport, + build_doc_footer, + check_staleness, + compute_semantic_hash, + compute_source_hash, + parse_doc_footer, ) -_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_help.freshness.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 _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" +__all__ = [ + "DocStaleness", + "FeatureStaleness", + "StalenessReport", + "build_doc_footer", + "check_staleness", + "compute_semantic_hash", + "compute_source_hash", + "parse_doc_footer", +] + +warnings.warn( + "attune_help.staleness is deprecated; import from attune_author.staleness. " + "This shim will be removed in the next minor release of attune-help.", + DeprecationWarning, + stacklevel=2, +) diff --git a/tests/test_authoring_shims.py b/tests/test_authoring_shims.py new file mode 100644 index 0000000..26aece6 --- /dev/null +++ b/tests/test_authoring_shims.py @@ -0,0 +1,78 @@ +"""Verify the deprecated authoring re-export shims. + +attune_help.manifest, attune_help.staleness, and +attune_help.freshness (+ .symbols) are kept as thin re-exports of the +relocated attune_author modules for one minor release. These tests +assert: (a) symbols still resolve through the old paths, and (b) +each shim emits :class:`DeprecationWarning` on import. +""" + +from __future__ import annotations + +import importlib +import sys +import warnings + +import pytest + +SHIMS = [ + "attune_help.manifest", + "attune_help.staleness", + "attune_help.freshness", + "attune_help.freshness.symbols", +] + + +@pytest.mark.parametrize("module_name", SHIMS) +def test_shim_emits_deprecation_warning(module_name: str) -> None: + sys.modules.pop(module_name, None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + importlib.import_module(module_name) + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations, f"{module_name} did not emit DeprecationWarning" + assert any( + module_name in str(w.message) for w in deprecations + ), f"{module_name} DeprecationWarning did not name the module" + + +def test_manifest_symbols_resolve_through_shim() -> None: + from attune_help.manifest import ( + Feature, + FeatureManifest, + is_safe_feature_name, + load_manifest, + save_manifest, + slugify, + ) + from attune_author.manifest import Feature as AuthorFeature + + assert Feature is AuthorFeature + assert callable(is_safe_feature_name) + assert callable(load_manifest) + assert callable(save_manifest) + assert callable(slugify) + assert FeatureManifest.__module__.startswith("attune_author.") + + +def test_staleness_symbols_resolve_through_shim() -> None: + from attune_help.staleness import ( + StalenessReport, + check_staleness, + compute_source_hash, + ) + from attune_author.staleness import StalenessReport as AuthorReport + + assert StalenessReport is AuthorReport + assert callable(check_staleness) + assert callable(compute_source_hash) + + +def test_freshness_symbols_resolve_through_shim() -> None: + from attune_help.freshness import SymbolExtractor, SymbolRecord + from attune_help.freshness.symbols import SymbolExtractor as DeepExtractor + from attune_author.freshness import SymbolExtractor as AuthorExtractor + + assert SymbolExtractor is AuthorExtractor + assert DeepExtractor is AuthorExtractor + assert SymbolRecord.__module__.startswith("attune_author.")