Skip to content

Commit fa70449

Browse files
committed
Suppress autogenerated module docstrings from rationale extraction (#882)
Alembic/Flask-Migrate revisions, Django migrations, and protobuf/OpenAPI generated files produce hundreds of degree-1 rationale nodes labeled as 'possible documentation gaps'. Their module docstrings are revision annotations or boilerplate, not architectural rationale. - Add _is_autogenerated_python() in extract.py detecting Alembic, Django migrations, and generic DO-NOT-EDIT markers; skip module docstring only - Function/class docstrings inside those files still extracted as normal - report.py: exclude file_type=rationale nodes from isolated-node gaps section — rationale nodes are degree-1 by construction; flagging them as missing edges was always wrong - 5 new tests covering Alembic, Django, protobuf, false-positive guard, and function-docstring passthrough
1 parent 63926c5 commit fa70449

3 files changed

Lines changed: 117 additions & 5 deletions

File tree

‎graphify/extract.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,27 @@ def walk_calls(node, caller_nid: str) -> None:
18661866
_RATIONALE_PREFIXES = ("# NOTE:", "# IMPORTANT:", "# HACK:", "# WHY:", "# RATIONALE:", "# TODO:", "# FIXME:")
18671867

18681868

1869+
def _is_autogenerated_python(source: bytes) -> bool:
1870+
"""Return True if this Python file is auto-generated and its module docstring is noise.
1871+
1872+
Covers: Alembic/Flask-Migrate revisions, Django migrations, protobuf/gRPC/OpenAPI stubs.
1873+
Module docstrings in these files are change annotations or boilerplate, not rationale.
1874+
"""
1875+
head = source[:2048].decode("utf-8", errors="replace")
1876+
# Generic generated-file markers (protobuf, gRPC, OpenAPI codegen, etc.)
1877+
if any(m in head for m in ("DO NOT EDIT", "@generated", "Generated by the protocol buffer")):
1878+
return True
1879+
# Alembic / Flask-Migrate revision files
1880+
if (re.search(r"^revision\s*[:=]", head, re.MULTILINE)
1881+
and "def upgrade(" in head
1882+
and "down_revision" in head):
1883+
return True
1884+
# Django migrations
1885+
if "class Migration(migrations.Migration)" in head and "operations" in head:
1886+
return True
1887+
return False
1888+
1889+
18691890
def _extract_python_rationale(path: Path, result: dict) -> None:
18701891
"""Post-pass: extract docstrings and rationale comments from Python source.
18711892
Mutates result in-place by appending to result['nodes'] and result['edges'].
@@ -1924,10 +1945,13 @@ def _add_rationale(text: str, line: int, parent_nid: str) -> None:
19241945
"weight": 1.0,
19251946
})
19261947

1927-
# Module-level docstring
1928-
ds = _get_docstring(root)
1929-
if ds:
1930-
_add_rationale(ds[0], ds[1], file_nid)
1948+
# Module-level docstring — skip for auto-generated files (Alembic, Django
1949+
# migrations, protobuf stubs, etc.) whose module docstrings are revision
1950+
# annotations, not architectural rationale.
1951+
if not _is_autogenerated_python(source):
1952+
ds = _get_docstring(root)
1953+
if ds:
1954+
_add_rationale(ds[0], ds[1], file_nid)
19311955

19321956
# Class and function docstrings
19331957
def walk_docstrings(node, parent_nid: str) -> None:

‎graphify/report.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ def generate(
164164

165165
isolated = [
166166
n for n in G.nodes()
167-
if G.degree(n) <= 1 and not _is_file_node(G, n) and not _is_concept_node(G, n)
167+
if G.degree(n) <= 1
168+
and not _is_file_node(G, n)
169+
and not _is_concept_node(G, n)
170+
and G.nodes[n].get("file_type") != "rationale"
168171
]
169172
thin_communities = {
170173
cid: nodes for cid, nodes in communities.items()

‎tests/test_rationale.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,88 @@ def parse(): pass
8787
result = extract_python(path)
8888
rationale_edges = [e for e in result["edges"] if e.get("relation") == "rationale_for"]
8989
assert all(e.get("confidence") == "EXTRACTED" for e in rationale_edges)
90+
91+
92+
def test_alembic_module_docstring_suppressed(tmp_path):
93+
path = _write_py(tmp_path, '''
94+
"""initial schema
95+
96+
Revision ID: 0001abcd
97+
Revises:
98+
Create Date: 2023-01-01 00:00:00
99+
"""
100+
revision = "0001abcd"
101+
down_revision = None
102+
branch_labels = None
103+
104+
def upgrade():
105+
pass
106+
107+
def downgrade():
108+
pass
109+
''')
110+
result = extract_python(path)
111+
rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"]
112+
assert not any("Revision ID" in n["label"] for n in rationale)
113+
114+
115+
def test_alembic_function_docstrings_still_extracted(tmp_path):
116+
"""Function docstrings inside upgrade/downgrade should still be captured."""
117+
path = _write_py(tmp_path, '''
118+
"""Revision ID: 0002 Revises: 0001"""
119+
revision = "0002"
120+
down_revision = "0001"
121+
122+
def upgrade():
123+
"""Add users table because auth was added in this release."""
124+
pass
125+
126+
def downgrade():
127+
pass
128+
''')
129+
result = extract_python(path)
130+
rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"]
131+
# module docstring suppressed
132+
assert not any("Revision ID" in n["label"] for n in rationale)
133+
# function docstring still captured
134+
assert any("auth" in n["label"] for n in rationale)
135+
136+
137+
def test_non_migration_revision_var_not_suppressed(tmp_path):
138+
"""A file with a `revision` variable but no Alembic markers keeps its docstring."""
139+
path = _write_py(tmp_path, '''
140+
"""This module tracks document revisions because we need audit history."""
141+
revision = 42
142+
143+
def get_revision(): pass
144+
''')
145+
result = extract_python(path)
146+
rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"]
147+
assert any("audit history" in n["label"] for n in rationale)
148+
149+
150+
def test_django_migration_module_docstring_suppressed(tmp_path):
151+
path = _write_py(tmp_path, '''
152+
"""Add post_priority_config table."""
153+
from django.db import migrations
154+
155+
class Migration(migrations.Migration):
156+
dependencies = [("myapp", "0001_initial")]
157+
operations = []
158+
''')
159+
result = extract_python(path)
160+
rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"]
161+
assert not any("post_priority" in n["label"] for n in rationale)
162+
163+
164+
def test_generated_file_module_docstring_suppressed(tmp_path):
165+
path = _write_py(tmp_path, '''
166+
"""Generated by the protocol buffer compiler. DO NOT EDIT!"""
167+
from google.protobuf import descriptor as _descriptor
168+
169+
class UserMessage:
170+
pass
171+
''')
172+
result = extract_python(path)
173+
rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"]
174+
assert not any("protocol buffer" in n["label"].lower() for n in rationale)

0 commit comments

Comments
 (0)