Skip to content

Commit b9a8f49

Browse files
committed
fix: JS/TS augmentor robustness (export default class, duplicate methods, generators, class-field arrows, default params) + Python grouped imports
1 parent 5d24769 commit b9a8f49

5 files changed

Lines changed: 251 additions & 12 deletions

File tree

osa_tool/operations/codebase/docstring_generation/adapters/python_adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ def _resolve_import_path(import_text, cwd):
9393
if not os.path.exists(module_path):
9494
return import_mapping
9595

96-
for entity in (e.strip() for e in import_part.split(",")):
96+
# a grouped import `from x import (a, b)` (possibly multi-line) keeps the
97+
# parentheses/newlines in the raw text; strip them so the names stay clean
98+
import_part = import_part.strip().strip("()").replace("\\", " ")
99+
100+
for entity in (e.strip().strip("()") for e in import_part.split(",")):
101+
if not entity or entity == "*":
102+
continue
97103
if " as " in entity:
98104
imported_name, alias_name = (e.strip() for e in entity.split(" as ", 1))
99105
else:

osa_tool/operations/codebase/docstring_generation/adapters/typescript_adapter.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@ def get_name(self, node, sv):
2828
if n:
2929
return sv.text(n)
3030

31-
# arrow functions / function expressions have no name field;
32-
# the name usually sits on the enclosing declarator (const foo = () => ...)
31+
# arrow functions / function expressions have no name field; the name usually
32+
# sits on the enclosing declarator (const foo = () => ...) or, for a class field
33+
# arrow (foo = () => ...), on the surrounding field definition.
3334
parent = node.parent
34-
if parent and parent.type == "variable_declarator":
35+
if parent and parent.type in (
36+
"variable_declarator",
37+
"public_field_definition",
38+
"field_definition",
39+
):
3540
declared = parent.child_by_field_name("name")
3641
if declared:
3742
return sv.text(declared)

osa_tool/operations/codebase/docstring_generation/insert/ts_js_augmentor.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def augment(self, file: str, source_code: str, docstrings: dict) -> dict[str, st
2323
def _inject_classes(self, lines, classes):
2424
for doc, class_name in classes:
2525

26-
class_pattern = re.compile(rf"^\s*(export\s+)?(abstract\s+)?class\s+{re.escape(class_name)}\b")
26+
class_pattern = re.compile(rf"^\s*(export\s+)?(default\s+)?(abstract\s+)?class\s+{re.escape(class_name)}\b")
2727

2828
for i, line in enumerate(lines):
2929
if class_pattern.search(line):
@@ -59,6 +59,11 @@ def _inject_functions(self, lines, functions):
5959
return lines
6060

6161
def _inject_methods(self, lines, methods):
62+
# Decide targets first (pure scan, no mutation) so `used` line indices stay
63+
# stable, then apply bottom-up so earlier indices remain valid while we edit.
64+
used = set()
65+
actions = []
66+
6267
for doc, meta in methods:
6368
name = meta["method_name"]
6469
patterns = [
@@ -89,6 +94,19 @@ def _inject_methods(self, lines, methods):
8994
""",
9095
re.VERBOSE,
9196
),
97+
# generator method: * method<T>( / async * method( / static * method(
98+
re.compile(
99+
rf"""^\s*
100+
(?:public|private|protected|static|readonly|async|\s)*
101+
\*\s*
102+
{re.escape(name)}
103+
\s*
104+
(?:<[^>]*>)?
105+
\s*
106+
\(
107+
""",
108+
re.VERBOSE,
109+
),
92110
# method<T>(
93111
re.compile(
94112
rf"""^\s*
@@ -111,10 +129,21 @@ def _inject_methods(self, lines, methods):
111129
re.VERBOSE,
112130
),
113131
]
132+
# class field arrow: name = (...) => / name = async (...) => / name = x =>
133+
field_arrow = re.compile(
134+
rf"^\s*(?:(?:public|private|protected|static|readonly)\s+)*"
135+
rf"{re.escape(name)}\s*=\s*(?:async\s+)?"
136+
rf"(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::\s*[^=]+?)?\s*=>"
137+
)
114138

115139
for i, line in enumerate(lines):
140+
if i in used:
141+
continue
142+
116143
stripped = line.strip()
117-
# skip obvious calls/usages
144+
is_field_arrow = bool(field_arrow.search(line))
145+
146+
# skip obvious calls / control-flow / usages
118147
if (
119148
stripped.startswith("return ")
120149
or stripped.startswith("if ")
@@ -124,17 +153,31 @@ def _inject_methods(self, lines, methods):
124153
or stripped.startswith("catch ")
125154
or stripped.startswith("new ")
126155
or re.search(rf"\.\s*{re.escape(name)}\s*\(", stripped)
127-
or "=" in stripped
128156
):
129157
continue
130158

131-
if any(p.search(line) for p in patterns):
132-
if self._has_doc(lines, i):
133-
lines = self._replace_doc(lines, i, doc)
134-
else:
135-
lines.insert(i, self._format(doc, line))
159+
# a statement (ends with ';') or an assignment ('=' before the call
160+
# parens) is a call/usage, not a declaration -- skip it. A legitimate
161+
# class-field arrow is allowed through (it also ends with ';').
162+
if not is_field_arrow:
163+
if stripped.endswith(";"):
164+
continue
165+
paren = stripped.find("(")
166+
before_paren = stripped if paren == -1 else stripped[:paren]
167+
if "=" in before_paren:
168+
continue
169+
170+
if is_field_arrow or any(p.search(line) for p in patterns):
171+
used.add(i)
172+
actions.append((i, doc))
136173
break
137174

175+
for i, doc in sorted(actions, key=lambda a: a[0], reverse=True):
176+
if self._has_doc(lines, i):
177+
lines = self._replace_doc(lines, i, doc)
178+
else:
179+
lines.insert(i, self._format(doc, lines[i]))
180+
138181
return lines
139182

140183
def _has_doc(self, lines, i):
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from osa_tool.operations.codebase.docstring_generation.adapters.python_adapter import PythonAdapter
2+
from osa_tool.operations.codebase.docstring_generation.core.osa_parser import OSA_TreeSitter
3+
4+
# --- #5: class-field arrow name resolution (TypeScript adapter) --------------
5+
6+
7+
def _method_names(res):
8+
names = []
9+
for item in res["structure"]:
10+
if item.get("type") == "class":
11+
names += [m["method_name"] for m in item["methods"]]
12+
elif item.get("type") == "function":
13+
names.append(item["details"]["method_name"])
14+
return names
15+
16+
17+
def test_class_field_arrow_gets_name(tmp_path):
18+
f = tmp_path / "c.ts"
19+
f.write_text(
20+
"class C {\n greet = (name: string): string => 'hi ' + name;\n}\n",
21+
encoding="utf-8",
22+
)
23+
res = OSA_TreeSitter(str(tmp_path)).extract_structure(str(f))
24+
names = _method_names(res)
25+
assert "greet" in names
26+
assert "anonymous" not in names
27+
28+
29+
def test_regular_arrow_const_still_named(tmp_path):
30+
f = tmp_path / "u.ts"
31+
f.write_text("export const mul = (a, b) => a * b;\n", encoding="utf-8")
32+
res = OSA_TreeSitter(str(tmp_path)).extract_structure(str(f))
33+
assert "mul" in _method_names(res)
34+
assert "anonymous" not in _method_names(res)
35+
36+
37+
# --- #6: parenthesized / multi-line Python from-imports ----------------------
38+
39+
40+
def test_grouped_import_clean_keys(tmp_path):
41+
(tmp_path / "mod.py").write_text("a = 1\nb = 2\n", encoding="utf-8")
42+
mapping = PythonAdapter._resolve_import_path("from mod import (a, b)", str(tmp_path))
43+
assert set(mapping.keys()) == {"a", "b"}
44+
assert not any("(" in k or ")" in k for k in mapping)
45+
46+
47+
def test_multiline_grouped_import_clean_keys(tmp_path):
48+
(tmp_path / "mod.py").write_text("a = 1\nb = 2\nc = 3\n", encoding="utf-8")
49+
text = "from mod import (\n a,\n b,\n c,\n)"
50+
mapping = PythonAdapter._resolve_import_path(text, str(tmp_path))
51+
assert set(mapping.keys()) == {"a", "b", "c"}
52+
53+
54+
def test_grouped_import_with_alias(tmp_path):
55+
(tmp_path / "mod.py").write_text("a = 1\nb = 2\n", encoding="utf-8")
56+
mapping = PythonAdapter._resolve_import_path("from mod import (a as x, b)", str(tmp_path))
57+
assert set(mapping.keys()) == {"x", "b"}
58+
assert mapping["x"]["class"] == "a"
59+
60+
61+
def test_plain_import_still_works(tmp_path):
62+
(tmp_path / "mod.py").write_text("a = 1\n", encoding="utf-8")
63+
mapping = PythonAdapter._resolve_import_path("from mod import a", str(tmp_path))
64+
assert set(mapping.keys()) == {"a"}

tests/unit/operations/codebase/docstring_generation/test_ts_js_augmentor.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,124 @@ def test_format_plain_text_unchanged_behaviour():
112112
assert "```" not in out
113113
assert out.count("/**") == 1
114114
assert out.count("*/") == 1
115+
116+
117+
# --- robustness fixes: class/method injection edge cases ---------------------
118+
119+
120+
def _line_above(out, decl_substring):
121+
"""Return the stripped line directly above the first line containing decl_substring."""
122+
lines = out.splitlines()
123+
for i, l in enumerate(lines):
124+
if decl_substring in l:
125+
return lines[i - 1].strip() if i > 0 else ""
126+
return None
127+
128+
129+
def test_inject_export_default_class():
130+
"""`export default class X` must receive a class-level JSDoc (regex #1)."""
131+
aug = TSJSAugmentor()
132+
src = "export default class Queue {\n\tclear() {}\n}\n"
133+
out = aug.augment("f.ts", src, {"classes": [("A FIFO queue.", "Queue")]})["f.ts"]
134+
135+
assert "A FIFO queue." in out
136+
assert _line_above(out, "export default class Queue") == "*/"
137+
138+
139+
def test_inject_generator_method():
140+
"""Generator methods (`* gen()`) must be matched and documented (#4)."""
141+
aug = TSJSAugmentor()
142+
src = "class Q {\n\t* drain() {\n\t\tyield 1;\n\t}\n}\n"
143+
out = aug.augment("f.ts", src, {"methods": [("Drains the queue.", {"method_name": "drain"})]})["f.ts"]
144+
145+
assert "Drains the queue." in out
146+
assert _line_above(out, "* drain()") == "*/"
147+
148+
149+
def test_inject_two_same_named_methods_both_documented():
150+
"""Two methods with the same name must each get their own doc, in order (#2)."""
151+
aug = TSJSAugmentor()
152+
src = (
153+
"class Node {\n\tconstructor(value) {\n\t\tthis.value = value;\n\t}\n}\n"
154+
"class Queue {\n\tconstructor() {\n\t\tthis.clear();\n\t}\n}\n"
155+
)
156+
docs = {
157+
"methods": [
158+
("Node constructor.", {"method_name": "constructor"}),
159+
("Queue constructor.", {"method_name": "constructor"}),
160+
]
161+
}
162+
out = aug.augment("f.ts", src, docs)["f.ts"]
163+
164+
assert out.count("constructor(") == 2
165+
assert "Node constructor." in out
166+
assert "Queue constructor." in out
167+
# both declarations carry a doc block directly above them
168+
lines = out.splitlines()
169+
ctor_idxs = [i for i, l in enumerate(lines) if "constructor(" in l]
170+
assert len(ctor_idxs) == 2
171+
for idx in ctor_idxs:
172+
assert lines[idx - 1].strip() == "*/"
173+
# order preserved: first constructor -> first doc
174+
assert out.index("Node constructor.") < out.index("Queue constructor.")
175+
176+
177+
def test_inject_method_with_default_param_value():
178+
"""A method whose signature line contains `=` (default value) must be documented (#3)."""
179+
aug = TSJSAugmentor()
180+
src = "class C {\n\tscale(x, factor = 2) {\n\t\treturn x * factor;\n\t}\n}\n"
181+
out = aug.augment("f.ts", src, {"methods": [("Scales a value.", {"method_name": "scale"})]})["f.ts"]
182+
183+
assert "Scales a value." in out
184+
assert _line_above(out, "scale(x, factor = 2)") == "*/"
185+
186+
187+
def test_assignment_is_not_mistaken_for_method():
188+
"""A local assignment must NOT be documented as a method (guard still holds)."""
189+
aug = TSJSAugmentor()
190+
src = "class C {\n\trun() {\n\t\tconst compute = helper(1);\n\t\treturn compute;\n\t}\n}\n"
191+
out = aug.augment("f.ts", src, {"methods": [("Computes.", {"method_name": "compute"})]})["f.ts"]
192+
193+
assert "Computes." not in out
194+
195+
196+
def test_inject_class_field_arrow_method():
197+
"""A class-field arrow (`name = (...) => ...`) must be documented (#5, injection)."""
198+
aug = TSJSAugmentor()
199+
src = "class C {\n\tgreet = (name) => 'hi ' + name;\n}\n"
200+
out = aug.augment("f.ts", src, {"methods": [("Greets by name.", {"method_name": "greet"})]})["f.ts"]
201+
202+
assert "Greets by name." in out
203+
assert _line_above(out, "greet = (name) =>") == "*/"
204+
205+
206+
def test_bare_call_with_operator_not_documented():
207+
"""A bare call statement whose args contain `===`/`>=`/etc must NOT be mistaken
208+
for a method declaration; the real same-named method gets the doc instead (#3 guard)."""
209+
aug = TSJSAugmentor()
210+
src = (
211+
"class S {\n"
212+
"\tboot() {\n"
213+
"\t\tassert(ready === true);\n"
214+
"\t}\n"
215+
"\tassert(cond) {\n"
216+
"\t\treturn cond;\n"
217+
"\t}\n"
218+
"}\n"
219+
)
220+
docs = {
221+
"methods": [
222+
("Boots the service.", {"method_name": "boot"}),
223+
("Asserts a condition.", {"method_name": "assert"}),
224+
]
225+
}
226+
out = aug.augment("f.ts", src, docs)["f.ts"]
227+
lines = out.splitlines()
228+
229+
# the call statement must NOT receive a doc
230+
call_idx = next(i for i, l in enumerate(lines) if "assert(ready === true)" in l)
231+
assert lines[call_idx - 1].strip() != "*/"
232+
# the real declaration does
233+
decl_idx = next(i for i, l in enumerate(lines) if "assert(cond)" in l)
234+
assert lines[decl_idx - 1].strip() == "*/"
235+
assert "Asserts a condition." in out

0 commit comments

Comments
 (0)