From 7c9107ac3404bfc8983896eeb8510162f41923e8 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Fri, 15 May 2026 13:44:51 -0400 Subject: [PATCH] fix(generator): render typed Parameters/Returns columns without polish (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Jinja meta-template for `reference.md` only emitted a 3-column table (`Function | Description | File`). The richer 4-column table seen in committed help templates was synthesized by the LLM polish pass. When polish was bypassed (no API key, lenient mode, or a silently-swallowed failure), regeneration produced a structurally lossier file that looked normal in a diff and slipped through PR review. See attune-author#30 and attune-rag commit d39e39d. This commit makes the structural data flow without depending on the LLM: - Capture `params` and `returns` on each entry in `_SourceInfo.function_signatures` via a small `_split_signature` helper that parses the already-formatted signature string. No new AST traversal — reuses `_format_function_signature` output. - Thread `function_signatures` (and `class_signatures`) into the Jinja render call in `_render_template` so help templates can render typed data. - Update `meta_templates/reference.md.j2`: - Render `feature.description` directly under the title so the human-written intro from `features.yaml` survives without polish. - Render a 4-column `Function | Parameters | Returns | Description | File` table from `function_signatures` when available, falling back to the 3-column shape from `public_functions` for callers that pass the legacy data only. Polish still has a job (rewriting prose, smoothing tone). It is no longer the only source of structural columns, so a polish bypass degrades prose quality rather than silently deleting typed data the AST already has. Tests: - New `TestSplitSignature` unit tests cover the parser (typed params + return, no-return, complex pipe-union returns, defensive no-match shape). - New `TestReferenceTemplateColumns` asserts the rendered reference template contains the 4-column header, typed parameters from the AST, the return annotation, and the feature description — all *without* polish. - Updated reference golden snapshot to reflect the new shape. Closes #30. Co-Authored-By: Claude Opus 4.7 --- src/attune_author/generator.py | 25 ++++++- .../meta_templates/reference.md.j2 | 19 +++++- .../test_generated_templates_golden.ambr | 8 +-- tests/test_generator.py | 68 +++++++++++++++++++ 4 files changed, 112 insertions(+), 8 deletions(-) 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()."""