Skip to content

Commit a1caa78

Browse files
committed
fix: correct duplicate-method doc mapping + comment/string-aware injection + bare-call false positive
1 parent b9a8f49 commit a1caa78

2 files changed

Lines changed: 250 additions & 2 deletions

File tree

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

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,17 @@ def _inject_methods(self, lines, methods):
6464
used = set()
6565
actions = []
6666

67-
for doc, meta in methods:
67+
# lines inside an existing comment must never be matched as a declaration
68+
# (e.g. a JSDoc body line ` * save(entity) ...` would otherwise look like a
69+
# generator method `* save(`), which would corrupt the file on regeneration.
70+
comment_idx = self._comment_lines(lines)
71+
72+
# methods may arrive out of source order (they are generated in dependency
73+
# order). Map them to declarations by ascending source position so two methods
74+
# sharing a name each get their OWN doc instead of a swapped one.
75+
ordered = sorted(methods, key=lambda dm: dm[1].get("start_line", 0))
76+
77+
for doc, meta in ordered:
6878
name = meta["method_name"]
6979
patterns = [
7080
# public/private/protected async static method<T>(
@@ -137,7 +147,7 @@ def _inject_methods(self, lines, methods):
137147
)
138148

139149
for i, line in enumerate(lines):
140-
if i in used:
150+
if i in used or i in comment_idx:
141151
continue
142152

143153
stripped = line.strip()
@@ -180,6 +190,68 @@ def _inject_methods(self, lines, methods):
180190

181191
return lines
182192

193+
@staticmethod
194+
def _scan_comment_state(line, in_block, quote):
195+
"""Advance the (in_block, quote) lexer state across one line.
196+
Honours block comments `/* */`, `//` line comments, and string/template literals
197+
(with backslash escapes). `quote` is threaded so a multi-line template literal
198+
keeps its state and a `/*` inside it never opens a comment."""
199+
i, n = 0, len(line)
200+
while i < n:
201+
pair = line[i : i + 2]
202+
if in_block:
203+
if pair == "*/":
204+
in_block = False
205+
i += 2
206+
continue
207+
i += 1
208+
continue
209+
if quote:
210+
if line[i] == "\\":
211+
i += 2
212+
continue
213+
if line[i] == quote:
214+
quote = None
215+
i += 1
216+
continue
217+
if line[i] in "\"'`":
218+
quote = line[i]
219+
i += 1
220+
continue
221+
if pair == "//":
222+
break
223+
if pair == "/*":
224+
in_block = True
225+
i += 2
226+
continue
227+
i += 1
228+
229+
# only a template literal (backtick) may legally span lines; a dangling ' or "
230+
# at end of line is a mis-lexed regex/division, not a real multi-line string, so
231+
# drop it instead of leaking the quote state onto the next line.
232+
if quote is not None and quote != "`":
233+
quote = None
234+
return in_block, quote
235+
236+
@staticmethod
237+
def _comment_lines(lines):
238+
"""Indices of lines whose start sits inside a block comment or a (multi-line)
239+
string/template literal, or that are whole-line `//` comments, so declaration
240+
patterns are never matched against comment or string text. A line that merely
241+
opens a comment/string after real code is NOT marked (its code may be a
242+
declaration); only lines that begin inside one are skipped."""
243+
marked = set()
244+
in_block = False
245+
quote = None
246+
for i, line in enumerate(lines):
247+
if in_block or quote is not None:
248+
marked.add(i)
249+
elif line.strip().startswith("//"):
250+
marked.add(i)
251+
in_block, quote = TSJSAugmentor._scan_comment_state(line, in_block, quote)
252+
253+
return marked
254+
183255
def _has_doc(self, lines, i):
184256
j = i - 1
185257
while j >= 0:

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,179 @@ def test_bare_call_with_operator_not_documented():
233233
decl_idx = next(i for i, l in enumerate(lines) if "assert(cond)" in l)
234234
assert lines[decl_idx - 1].strip() == "*/"
235235
assert "Asserts a condition." in out
236+
237+
238+
def test_same_named_methods_mapped_by_source_order():
239+
"""When entries arrive out of source order (dependency order), each doc must land
240+
on the method at its own start_line, not get swapped (regression for the swap bug)."""
241+
aug = TSJSAugmentor()
242+
src = (
243+
"class A {\n\tadd(item, priority = 0) {\n\t\treturn item;\n\t}\n}\n"
244+
"class B {\n\tadd(key, value) {\n\t\treturn key;\n\t}\n}\n"
245+
)
246+
# entries deliberately reversed (B.add first) to mimic dependency-order output
247+
docs = {
248+
"methods": [
249+
("Doc for B add.", {"method_name": "add", "start_line": 7}),
250+
("Doc for A add.", {"method_name": "add", "start_line": 2}),
251+
]
252+
}
253+
out = aug.augment("f.ts", src, docs)["f.ts"]
254+
lines = out.splitlines()
255+
256+
a_idx = next(i for i, l in enumerate(lines) if "add(item, priority = 0)" in l)
257+
b_idx = next(i for i, l in enumerate(lines) if "add(key, value)" in l)
258+
a_block = "\n".join(lines[max(0, a_idx - 4) : a_idx])
259+
b_block = "\n".join(lines[max(0, b_idx - 4) : b_idx])
260+
261+
assert "Doc for A add." in a_block
262+
assert "Doc for B add." in b_block
263+
264+
265+
def test_comment_body_not_matched_as_declaration():
266+
"""A JSDoc body line like ` * save(entity) ...` must not be matched as the method
267+
declaration (the generator `*` pattern must not hit comment lines and corrupt the
268+
file). Regenerating updates the real method's doc instead."""
269+
aug = TSJSAugmentor()
270+
src = (
271+
"class Repo {\n"
272+
"\t/**\n"
273+
"\t * save(entity) persists the record.\n"
274+
"\t */\n"
275+
"\tsave(entity) {\n"
276+
"\t\treturn entity;\n"
277+
"\t}\n"
278+
"}\n"
279+
)
280+
out = aug.augment(
281+
"f.ts",
282+
src,
283+
{"methods": [("Persists the entity.", {"method_name": "save", "start_line": 5})]},
284+
)["f.ts"]
285+
286+
assert "Persists the entity." in out
287+
# exactly one JSDoc block (the method's, replaced) -> no nested/corrupted /**
288+
assert out.count("/**") == 1
289+
lines = out.splitlines()
290+
decl = next(i for i, l in enumerate(lines) if "save(entity) {" in l)
291+
assert lines[decl - 1].strip() == "*/"
292+
293+
294+
def test_block_comment_line_not_matched_as_declaration():
295+
"""A plain block-comment line starting with a method name must not be matched."""
296+
aug = TSJSAugmentor()
297+
src = (
298+
"class Repo {\n"
299+
"\t/*\n"
300+
"\tload(id) is deprecated, use fetch instead.\n"
301+
"\t*/\n"
302+
"\tload(id) {\n"
303+
"\t\treturn id;\n"
304+
"\t}\n"
305+
"}\n"
306+
)
307+
out = aug.augment(
308+
"f.ts",
309+
src,
310+
{"methods": [("Loads by id.", {"method_name": "load", "start_line": 5})]},
311+
)["f.ts"]
312+
313+
assert "Loads by id." in out
314+
# the doc must land above the real declaration, not inside the block comment
315+
lines = out.splitlines()
316+
decl = next(i for i, l in enumerate(lines) if "load(id) {" in l)
317+
assert lines[decl - 1].strip() == "*/"
318+
# the deprecated block-comment text is untouched (still present, not wrapped in /**)
319+
assert "load(id) is deprecated" in out
320+
321+
322+
def test_method_with_inline_block_comment_still_documented():
323+
"""A method whose declaration line has an inline `/* */` comment must still be
324+
documented (single-line block comments are not treated as comment lines)."""
325+
aug = TSJSAugmentor()
326+
src = "class C {\n\tparse(url) /* TODO */ {\n\t\treturn url;\n\t}\n}\n"
327+
out = aug.augment(
328+
"f.ts",
329+
src,
330+
{"methods": [("Parses a URL.", {"method_name": "parse", "start_line": 2})]},
331+
)["f.ts"]
332+
333+
assert "Parses a URL." in out
334+
lines = out.splitlines()
335+
decl = next(i for i, l in enumerate(lines) if "parse(url)" in l)
336+
assert lines[decl - 1].strip() == "*/"
337+
338+
339+
def test_string_literal_slash_star_does_not_suppress_methods():
340+
"""A `/*` inside a string literal must NOT open a phantom comment block that
341+
suppresses documentation of later methods (literal-aware comment scan)."""
342+
aug = TSJSAugmentor()
343+
src = "class Repo {\n" '\tglob = "src/*";\n' "\tsave(entity) {\n\t\treturn entity;\n\t}\n" "}\n"
344+
out = aug.augment(
345+
"f.ts",
346+
src,
347+
{"methods": [("Saves the entity.", {"method_name": "save", "start_line": 3})]},
348+
)["f.ts"]
349+
350+
assert "Saves the entity." in out
351+
lines = out.splitlines()
352+
decl = next(i for i, l in enumerate(lines) if "save(entity) {" in l)
353+
assert lines[decl - 1].strip() == "*/"
354+
355+
356+
def test_declaration_opening_trailing_block_comment_still_documented():
357+
"""A declaration line that also opens a multi-line comment after the code must still
358+
be documented (the code part is a real declaration)."""
359+
aug = TSJSAugmentor()
360+
src = "class I {\n" "\tsave(entity) { /* note:\n" "\t multi-line */\n" "\t\treturn entity;\n" "\t}\n" "}\n"
361+
out = aug.augment(
362+
"f.ts",
363+
src,
364+
{"methods": [("Saves it.", {"method_name": "save", "start_line": 2})]},
365+
)["f.ts"]
366+
367+
assert "Saves it." in out
368+
lines = out.splitlines()
369+
decl = next(i for i, l in enumerate(lines) if "save(entity)" in l and "*" != l.strip()[:1])
370+
assert lines[decl - 1].strip() == "*/"
371+
372+
373+
def test_multiline_template_literal_does_not_suppress_methods():
374+
"""A multi-line template literal containing '/*' must not open a phantom comment
375+
block (quote state is threaded across lines), so later methods stay documented."""
376+
aug = TSJSAugmentor()
377+
src = (
378+
"class Q {\n"
379+
"\tsql = `\n"
380+
"\t\tSELECT /* unclosed marker\n"
381+
"\t`;\n"
382+
"\tsave(entity) {\n\t\treturn entity;\n\t}\n"
383+
"}\n"
384+
)
385+
out = aug.augment(
386+
"f.ts",
387+
src,
388+
{"methods": [("Saves it.", {"method_name": "save", "start_line": 5})]},
389+
)["f.ts"]
390+
391+
assert "Saves it." in out
392+
lines = out.splitlines()
393+
decl = next(i for i, l in enumerate(lines) if "save(entity) {" in l)
394+
assert lines[decl - 1].strip() == "*/"
395+
396+
397+
def test_regex_with_apostrophe_does_not_suppress_methods():
398+
"""A regex literal containing a lone quote char (e.g. /O'Brien/) must not leave a
399+
dangling string state that suppresses documentation of later methods."""
400+
aug = TSJSAugmentor()
401+
src = "class M {\n" "\tre = /O'Brien/;\n" "\tsave(entity) {\n\t\treturn entity;\n\t}\n" "}\n"
402+
out = aug.augment(
403+
"f.ts",
404+
src,
405+
{"methods": [("Saves it.", {"method_name": "save", "start_line": 3})]},
406+
)["f.ts"]
407+
408+
assert "Saves it." in out
409+
lines = out.splitlines()
410+
decl = next(i for i, l in enumerate(lines) if "save(entity) {" in l)
411+
assert lines[decl - 1].strip() == "*/"

0 commit comments

Comments
 (0)