diff --git a/src/attune_author/generator.py b/src/attune_author/generator.py index 226c0be..788e266 100644 --- a/src/attune_author/generator.py +++ b/src/attune_author/generator.py @@ -1040,11 +1040,15 @@ def _collect_function( info: _SourceInfo, ) -> None: first_line = _docstring_first_line(node) + signature = _format_function_signature(node) + params, returns = _split_signature(signature, node.name) info.public_functions.append({"name": node.name, "doc": first_line, "file": rel_path}) info.function_signatures.append( { "name": node.name, - "signature": _format_function_signature(node), + "signature": signature, + "params": params, + "returns": returns, "doc": first_line, "file": rel_path, "raises": _extract_raises(node), @@ -1202,6 +1206,23 @@ def _format_function_signature( return sig +def _split_signature(signature: str, name: str) -> tuple[str, str]: + # Splits ``name(params) -> R`` into ``(params, R)``. Returns + # ``("", "")`` if the signature doesn't match the expected + # shape — keeps rendering callers defensive. + prefix = f"{name}(" + if not signature.startswith(prefix): + return ("", "") + rest = signature[len(prefix) :] + end = rest.rfind(")") + if end == -1: + return ("", "") + params = rest[:end] + tail = rest[end + 1 :].lstrip() + returns = tail[3:].strip() if tail.startswith("->") else "" + return (params, returns) + + def _format_class_methods(node: ast.ClassDef) -> str: """Format a class's public method signatures. @@ -1307,6 +1328,8 @@ def _render_template( tags=feature.tags, public_classes=source_info.public_classes, public_functions=source_info.public_functions, + function_signatures=source_info.function_signatures, + class_signatures=source_info.class_signatures, module_docstrings=source_info.module_docstrings, config_keys=source_info.config_keys, file_count=source_info.file_count, diff --git a/src/attune_author/meta_templates/reference.md.j2 b/src/attune_author/meta_templates/reference.md.j2 index 199c376..e213989 100644 --- a/src/attune_author/meta_templates/reference.md.j2 +++ b/src/attune_author/meta_templates/reference.md.j2 @@ -1,6 +1,10 @@ # {{ title }} reference +{% if description %} +{{ description }} +{% endif %} {% if public_classes %} + ## Classes | Class | Description | File | @@ -9,8 +13,17 @@ | `{{ cls.name }}` | {{ cls.doc if cls.doc else "—" }} | `{{ cls.file }}` | {% endfor %} {% endif %} +{% if function_signatures %} + +## Functions + +| Function | Parameters | Returns | Description | File | +|----------|------------|---------|-------------|------| +{% for fn in function_signatures %} +| `{{ fn.name }}` | {{ ("`" ~ fn.params ~ "`") if fn.params else "—" }} | {{ ("`" ~ fn.returns ~ "`") if fn.returns else "—" }} | {{ fn.doc if fn.doc else "—" }} | `{{ fn.file }}` | +{% endfor %} +{% elif public_functions %} -{% if public_functions %} ## Functions | Function | Description | File | @@ -19,8 +32,8 @@ | `{{ fn.name }}()` | {{ fn.doc if fn.doc else "—" }} | `{{ fn.file }}` | {% endfor %} {% endif %} - {% if config_keys %} + ## Configuration | Key | Description | @@ -39,8 +52,8 @@ {% else %} No file patterns configured. {% endif %} - {% if tags %} + ## Tags {% for tag in tags %}`{{ tag }}`{{ ", " if not loop.last else "" }}{% endfor %} diff --git a/tests/__snapshots__/test_generated_templates_golden.ambr b/tests/__snapshots__/test_generated_templates_golden.ambr index 2aef7e4..bfe0687 100644 --- a/tests/__snapshots__/test_generated_templates_golden.ambr +++ b/tests/__snapshots__/test_generated_templates_golden.ambr @@ -53,13 +53,13 @@ # Auth reference + Authentication and authorization ## Functions - | Function | Description | File | - |----------|-------------|------| - | `authenticate()` | Authenticate a user. | `src/auth/login.py` | - + | Function | Parameters | Returns | Description | File | + |----------|------------|---------|-------------|------| + | `authenticate` | `username: str, password: str` | `bool` | Authenticate a user. | `src/auth/login.py` | ## Source files diff --git a/tests/test_generator.py b/tests/test_generator.py index 257b1c4..e0fcd9e 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -7,11 +7,79 @@ from attune_author.generator import ( GenerationResult, + _split_signature, generate_feature_templates, ) from attune_author.manifest import Feature +class TestSplitSignature: + """Tests for ``_split_signature``.""" + + def test_typed_params_and_return(self) -> None: + params, returns = _split_signature( + "authenticate(username: str, password: str) -> bool", "authenticate" + ) + assert params == "username: str, password: str" + assert returns == "bool" + + def test_no_return_annotation(self) -> None: + params, returns = _split_signature("main()", "main") + assert params == "" + assert returns == "" + + def test_complex_return_with_pipe(self) -> None: + params, returns = _split_signature("main(argv: list[str] | None = None) -> int", "main") + assert params == "argv: list[str] | None = None" + assert returns == "int" + + def test_unexpected_shape_returns_empty(self) -> None: + # Defensive: anything not matching ``name(...)`` shouldn't crash. + assert _split_signature("not a signature", "anything") == ("", "") + + +class TestReferenceTemplateColumns: + """Reference template must surface Parameters/Returns columns + from ``function_signatures`` without depending on the LLM polish + pass — otherwise a polish bypass (no API key, lenient mode, + cached miss) silently drops typed argument and return data that + the AST already has. + """ + + def test_reference_table_includes_typed_params_and_return( + self, help_dir: Path, project_root: Path + ) -> None: + feature = Feature( + name="auth", + description="Authentication and authorization", + files=["src/auth/**"], + tags=["security"], + ) + + with patch.dict("os.environ", {}, clear=False): + result = generate_feature_templates( + feature=feature, + help_dir=help_dir, + project_root=project_root, + depths=["reference"], + use_rag=False, + ) + + ref = next(t for t in result.templates if t.depth == "reference") + content = ref.path.read_text(encoding="utf-8") + + # 4-column header is the structural fix — without polish, + # the old template emitted only ``Function | Description | File``. + assert "| Function | Parameters | Returns | Description | File |" in content + # Typed parameters from the AST are surfaced verbatim. + assert "`username: str, password: str`" in content + # Return annotation is surfaced verbatim. + assert "`bool`" in content + # Feature description from features.yaml lands directly under + # the title, not only via LLM-polish synthesis. + assert "Authentication and authorization" in content + + class TestGenerateFeatureTemplates: """Tests for generate_feature_templates()."""