Skip to content

Commit a2f99c2

Browse files
fix(generator): render typed Parameters/Returns columns without polish (#30) (#31)
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 <noreply@anthropic.com>
1 parent aecdd4e commit a2f99c2

4 files changed

Lines changed: 112 additions & 8 deletions

File tree

src/attune_author/generator.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1040,11 +1040,15 @@ def _collect_function(
10401040
info: _SourceInfo,
10411041
) -> None:
10421042
first_line = _docstring_first_line(node)
1043+
signature = _format_function_signature(node)
1044+
params, returns = _split_signature(signature, node.name)
10431045
info.public_functions.append({"name": node.name, "doc": first_line, "file": rel_path})
10441046
info.function_signatures.append(
10451047
{
10461048
"name": node.name,
1047-
"signature": _format_function_signature(node),
1049+
"signature": signature,
1050+
"params": params,
1051+
"returns": returns,
10481052
"doc": first_line,
10491053
"file": rel_path,
10501054
"raises": _extract_raises(node),
@@ -1202,6 +1206,23 @@ def _format_function_signature(
12021206
return sig
12031207

12041208

1209+
def _split_signature(signature: str, name: str) -> tuple[str, str]:
1210+
# Splits ``name(params) -> R`` into ``(params, R)``. Returns
1211+
# ``("", "")`` if the signature doesn't match the expected
1212+
# shape — keeps rendering callers defensive.
1213+
prefix = f"{name}("
1214+
if not signature.startswith(prefix):
1215+
return ("", "")
1216+
rest = signature[len(prefix) :]
1217+
end = rest.rfind(")")
1218+
if end == -1:
1219+
return ("", "")
1220+
params = rest[:end]
1221+
tail = rest[end + 1 :].lstrip()
1222+
returns = tail[3:].strip() if tail.startswith("->") else ""
1223+
return (params, returns)
1224+
1225+
12051226
def _format_class_methods(node: ast.ClassDef) -> str:
12061227
"""Format a class's public method signatures.
12071228
@@ -1307,6 +1328,8 @@ def _render_template(
13071328
tags=feature.tags,
13081329
public_classes=source_info.public_classes,
13091330
public_functions=source_info.public_functions,
1331+
function_signatures=source_info.function_signatures,
1332+
class_signatures=source_info.class_signatures,
13101333
module_docstrings=source_info.module_docstrings,
13111334
config_keys=source_info.config_keys,
13121335
file_count=source_info.file_count,

src/attune_author/meta_templates/reference.md.j2

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# {{ title }} reference
2+
{% if description %}
23

4+
{{ description }}
5+
{% endif %}
36
{% if public_classes %}
7+
48
## Classes
59

610
| Class | Description | File |
@@ -9,8 +13,17 @@
913
| `{{ cls.name }}` | {{ cls.doc if cls.doc else "—" }} | `{{ cls.file }}` |
1014
{% endfor %}
1115
{% endif %}
16+
{% if function_signatures %}
17+
18+
## Functions
19+
20+
| Function | Parameters | Returns | Description | File |
21+
|----------|------------|---------|-------------|------|
22+
{% for fn in function_signatures %}
23+
| `{{ fn.name }}` | {{ ("`" ~ fn.params ~ "`") if fn.params else "—" }} | {{ ("`" ~ fn.returns ~ "`") if fn.returns else "—" }} | {{ fn.doc if fn.doc else "—" }} | `{{ fn.file }}` |
24+
{% endfor %}
25+
{% elif public_functions %}
1226

13-
{% if public_functions %}
1427
## Functions
1528

1629
| Function | Description | File |
@@ -19,8 +32,8 @@
1932
| `{{ fn.name }}()` | {{ fn.doc if fn.doc else "—" }} | `{{ fn.file }}` |
2033
{% endfor %}
2134
{% endif %}
22-
2335
{% if config_keys %}
36+
2437
## Configuration
2538

2639
| Key | Description |
@@ -39,8 +52,8 @@
3952
{% else %}
4053
No file patterns configured.
4154
{% endif %}
42-
4355
{% if tags %}
56+
4457
## Tags
4558

4659
{% for tag in tags %}`{{ tag }}`{{ ", " if not loop.last else "" }}{% endfor %}

tests/__snapshots__/test_generated_templates_golden.ambr

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@
5353

5454
# Auth reference
5555

56+
Authentication and authorization
5657

5758
## Functions
5859

59-
| Function | Description | File |
60-
|----------|-------------|------|
61-
| `authenticate()` | Authenticate a user. | `src/auth/login.py` |
62-
60+
| Function | Parameters | Returns | Description | File |
61+
|----------|------------|---------|-------------|------|
62+
| `authenticate` | `username: str, password: str` | `bool` | Authenticate a user. | `src/auth/login.py` |
6363

6464
## Source files
6565

tests/test_generator.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,79 @@
77

88
from attune_author.generator import (
99
GenerationResult,
10+
_split_signature,
1011
generate_feature_templates,
1112
)
1213
from attune_author.manifest import Feature
1314

1415

16+
class TestSplitSignature:
17+
"""Tests for ``_split_signature``."""
18+
19+
def test_typed_params_and_return(self) -> None:
20+
params, returns = _split_signature(
21+
"authenticate(username: str, password: str) -> bool", "authenticate"
22+
)
23+
assert params == "username: str, password: str"
24+
assert returns == "bool"
25+
26+
def test_no_return_annotation(self) -> None:
27+
params, returns = _split_signature("main()", "main")
28+
assert params == ""
29+
assert returns == ""
30+
31+
def test_complex_return_with_pipe(self) -> None:
32+
params, returns = _split_signature("main(argv: list[str] | None = None) -> int", "main")
33+
assert params == "argv: list[str] | None = None"
34+
assert returns == "int"
35+
36+
def test_unexpected_shape_returns_empty(self) -> None:
37+
# Defensive: anything not matching ``name(...)`` shouldn't crash.
38+
assert _split_signature("not a signature", "anything") == ("", "")
39+
40+
41+
class TestReferenceTemplateColumns:
42+
"""Reference template must surface Parameters/Returns columns
43+
from ``function_signatures`` without depending on the LLM polish
44+
pass — otherwise a polish bypass (no API key, lenient mode,
45+
cached miss) silently drops typed argument and return data that
46+
the AST already has.
47+
"""
48+
49+
def test_reference_table_includes_typed_params_and_return(
50+
self, help_dir: Path, project_root: Path
51+
) -> None:
52+
feature = Feature(
53+
name="auth",
54+
description="Authentication and authorization",
55+
files=["src/auth/**"],
56+
tags=["security"],
57+
)
58+
59+
with patch.dict("os.environ", {}, clear=False):
60+
result = generate_feature_templates(
61+
feature=feature,
62+
help_dir=help_dir,
63+
project_root=project_root,
64+
depths=["reference"],
65+
use_rag=False,
66+
)
67+
68+
ref = next(t for t in result.templates if t.depth == "reference")
69+
content = ref.path.read_text(encoding="utf-8")
70+
71+
# 4-column header is the structural fix — without polish,
72+
# the old template emitted only ``Function | Description | File``.
73+
assert "| Function | Parameters | Returns | Description | File |" in content
74+
# Typed parameters from the AST are surfaced verbatim.
75+
assert "`username: str, password: str`" in content
76+
# Return annotation is surfaced verbatim.
77+
assert "`bool`" in content
78+
# Feature description from features.yaml lands directly under
79+
# the title, not only via LLM-polish synthesis.
80+
assert "Authentication and authorization" in content
81+
82+
1583
class TestGenerateFeatureTemplates:
1684
"""Tests for generate_feature_templates()."""
1785

0 commit comments

Comments
 (0)