diff --git a/src/specify_cli/integrations/alquimia/__init__.py b/src/specify_cli/integrations/alquimia/__init__.py index 507ce879..13261520 100644 --- a/src/specify_cli/integrations/alquimia/__init__.py +++ b/src/specify_cli/integrations/alquimia/__init__.py @@ -65,7 +65,16 @@ class AlquimiaAIIntegration(SkillsIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. + + A long ``description`` gets folded by the YAML dumper across + indented continuation lines (plain or quoted), and an embedded + paragraph break can add unindented blank lines inside a quoted + scalar. Inserting the new line right after the *first* line of + that scalar — instead of after the whole scalar — either produces + invalid YAML or gets silently absorbed into the description + string (#4044), so every continuation line (indented, or blank) + is skipped first. Skips injection if ``argument-hint:`` already exists in the frontmatter to avoid duplicate keys. @@ -88,15 +97,29 @@ class AlquimiaAIIntegration(SkillsIntegration): in_fm = False dash_count = 0 injected = False - for line in lines: + i = 0 + n = len(lines) + while i < n: + line = lines[i] stripped = line.rstrip("\n\r") if stripped == "---": dash_count += 1 in_fm = dash_count == 1 out.append(line) + i += 1 continue if in_fm and not injected and stripped.startswith("description:"): out.append(line) + i += 1 + # Skip folded/quoted continuation lines before inserting + # so the new key lands after the description scalar ends. + # Blank lines count too: PyYAML emits unindented blank + # lines for embedded "\n\n" inside a quoted scalar. + while i < n and ( + lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" + ): + out.append(lines[i]) + i += 1 # Preserve the exact line-ending style (\r\n vs \n) if line.endswith("\r\n"): eol = "\r\n" @@ -109,6 +132,7 @@ class AlquimiaAIIntegration(SkillsIntegration): injected = True continue out.append(line) + i += 1 return "".join(out) @staticmethod diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index bdf4fa32..e8eab828 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -471,6 +471,101 @@ class TestAlquimiaArgumentHints: hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + def test_inject_argument_hint_survives_folded_description(self): + """A long description folded across lines must not corrupt the YAML (#4044). + + A description long enough for the YAML dumper to fold it into a + multi-line plain scalar previously had ``argument-hint:`` spliced + into the *middle* of that scalar, producing invalid YAML. + """ + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts an issue URL " + "resolved via gh CLI (demo customization)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_quoted_folded_description(self): + """A folded description forced into quotes must not absorb the hint (#4044).""" + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts a GitHub " + "issue/PR URL or #N reference resolved via gh CLI (demo)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_multi_paragraph_description(self): + """A description with an embedded blank line must not absorb the hint. + + PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as + unindented blank lines, not indented ones, so a fix that only skips + indented continuation lines still fails on this case. + """ + from specify_cli.integrations.alquimia import AlquimiaAIIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "First paragraph of a fairly long description that will " + "need to wrap across multiple lines when dumped by PyYAML." + "\n\n" + "Second paragraph continues the description after a blank " + "line separator to force embedded newlines in the scalar." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n\n" in frontmatter_text, ( + "fixture must produce a blank continuation line" + ) + + result = AlquimiaAIIntegration.inject_argument_hint( + content, "Describe the feature" + ) + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + class TestAlquimiaDisableModelInvocation: """Verify disable-model-invocation is false for Alquimia skills."""