diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c3ea3cc409..c48a4dd5f8 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -101,14 +101,25 @@ def _inject_hook_command_note(content: str) -> str: Targets the line ``- For each executable hook, output the following`` and inserts the note on the line before it, matching its indentation. - Skips if the note is already present. + Skips individual instructions that already have the note immediately + above them. """ - if "replace dots" in content: - return content + note = _HOOK_COMMAND_NOTE.rstrip("\n") def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + # Check the line immediately above this instruction, mirroring the + # shared ``SkillsIntegration`` helper. The previous whole-document + # ``if "replace dots" in content`` scan meant any command or + # extension markdown whose prose merely contained that phrase lost + # the note entirely -- leaving the agent told to emit a dotted + # ``/speckit.git.commit``, which Cline never registers -- and a + # document with one already-noted section never got a note on a + # second, un-noted one. + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + note: + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -116,15 +127,20 @@ def repl(m: re.Match[str]) -> str: eol = m.group(3) or "\n" return ( indent - + _HOOK_COMMAND_NOTE.rstrip("\n") + + note + eol + indent + instruction + eol ) + # ``[ \t]*`` rather than ``\s*``: ``\s`` matches newlines, so the + # captured "indent" could swallow a preceding blank line and the note + # was then emitted with a spurious blank line between it and the + # instruction. This also matches the shared base helper, without which + # the per-instruction check above cannot line up. return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", repl, content, ) diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 3c813e8300..d7350a9633 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -106,6 +106,59 @@ def test_cline_hook_instruction_injection_no_trailing_newline(self): # Instruction stays on its own line rather than being mashed onto the note. assert "\n- For each executable hook, output the following:" in injected + def test_cline_hook_note_not_suppressed_by_unrelated_prose(self): + """Unrelated prose must not suppress the note for a real instruction. + + The idempotency guard used to be a whole-document substring scan + (``if "replace dots" in content``), so any command or extension + markdown whose prose merely contained that phrase lost the note + entirely -- leaving the agent told to emit ``/speckit.git.commit``, + a dotted command Cline never registers. + """ + cline = get_integration("cline") + content = ( + "# DB extension command\n\n" + "When normalizing table names, replace dots with underscores.\n\n" + "## Pre-Execution Hooks\n" + "- For each executable hook, output the following:\n" + ) + injected = cline._inject_hook_command_note(content) + assert "`/speckit-git-commit`" in injected, injected + # The user's own prose is untouched. + assert "replace dots with underscores" in injected + + def test_cline_hook_note_added_to_every_un_noted_instruction(self): + """A second, un-noted hook section must still get its own note.""" + cline = get_integration("cline") + instruction = "- For each executable hook, output the following:\n" + # Section 1 already carries the note; section 2 does not. + first = cline._inject_hook_command_note("## Hooks A\n" + instruction) + content = first + "\n## Hooks B\n" + instruction + + injected = cline._inject_hook_command_note(content) + assert injected.count("replace dots (`.`) with hyphens (`-`)") == 2, injected + # Still idempotent: re-running adds nothing. + assert cline._inject_hook_command_note(injected) == injected + + def test_cline_hook_note_sits_directly_above_indented_instruction(self): + """No blank line may be inserted between the note and the instruction. + + The regex captured indentation with ``\\s*``, which matches newlines, + so a preceding blank line was swallowed into the "indent" and re-emitted + between the note and the instruction. + """ + cline = get_integration("cline") + content = "## Hooks\n\n - For each executable hook, output the following:\n" + injected = cline._inject_hook_command_note(content) + lines = injected.splitlines() + instruction_idx = next( + i for i, line in enumerate(lines) if "For each executable hook" in line + ) + assert "replace dots" in lines[instruction_idx - 1], injected + # Indentation is preserved on both lines. + assert lines[instruction_idx].startswith(" - For each") + assert lines[instruction_idx - 1].startswith(" - When constructing") + # -- Overrides for MarkdownIntegrationTests --------------------------- def test_setup_creates_files(self, tmp_path):