diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 3e39db717e..ad016a4dd4 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,35 +5,19 @@ from pathlib import Path from typing import Any -import re - import yaml +from .skill_postprocess import ( + ARGUMENT_HINTS, + apply_claude_skill_postprocess, + inject_argument_hint, + inject_frontmatter_flag, + inject_hook_command_note, + set_frontmatter_key, +) from ..base import SkillsIntegration from ..manifest import IntegrationManifest -# Note injected into hook sections so Claude maps dot-notation command -# names (from extensions.yml) to the hyphenated skill names it uses. -_HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " - "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" -) - -# Mapping of command template stem → argument-hint text shown inline -# when a user invokes the slash command in Claude Code. -ARGUMENT_HINTS: dict[str, str] = { - "specify": "Describe the feature you want to specify", - "plan": "Optional guidance for the planning phase", - "tasks": "Optional task generation constraints", - "implement": "Optional implementation guidance or task filter", - "analyze": "Optional focus areas for analysis", - "clarify": "Optional areas to clarify in the spec", - "constitution": "Principles or values for the project constitution", - "checklist": "Domain or focus area for the checklist", - "taskstoissues": "Optional filter or label for GitHub issues", -} - class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -56,51 +40,18 @@ class ClaudeIntegration(SkillsIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Delegate to shared Claude skill transform implementation.""" + return inject_argument_hint(content, hint) - Skips injection if ``argument-hint:`` already exists in the - frontmatter to avoid duplicate keys. - """ - lines = content.splitlines(keepends=True) - - # Pre-scan: bail out if argument-hint already present in frontmatter - dash_count = 0 - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - if dash_count == 2: - break - continue - if dash_count == 1 and stripped.startswith("argument-hint:"): - return content # already present - - out: list[str] = [] - in_fm = False - dash_count = 0 - injected = False - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - in_fm = dash_count == 1 - out.append(line) - continue - if in_fm and not injected and stripped.startswith("description:"): - out.append(line) - # Preserve the exact line-ending style (\r\n vs \n) - if line.endswith("\r\n"): - eol = "\r\n" - elif line.endswith("\n"): - eol = "\n" - else: - eol = "" - escaped = hint.replace("\\", "\\\\").replace('"', '\\"') - out.append(f'argument-hint: "{escaped}"{eol}') - injected = True - continue - out.append(line) - return "".join(out) + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Delegate to shared Claude skill transform implementation.""" + return inject_frontmatter_flag(content, key, value) + + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Delegate to shared Claude skill transform implementation.""" + return inject_hook_command_note(content) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: """Render a processed command template as a Claude skill.""" @@ -121,80 +72,22 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: self.key, name, description, source ) - @staticmethod - def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: - """Insert ``key: value`` before the closing ``---`` if not already present.""" - lines = content.splitlines(keepends=True) - - # Pre-scan: bail out if already present in frontmatter - dash_count = 0 - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - if dash_count == 2: - break - continue - if dash_count == 1 and stripped.startswith(f"{key}:"): - return content - - # Inject before the closing --- of frontmatter - out: list[str] = [] - dash_count = 0 - injected = False - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - if dash_count == 2 and not injected: - if line.endswith("\r\n"): - eol = "\r\n" - elif line.endswith("\n"): - eol = "\n" - else: - eol = "" - out.append(f"{key}: {value}{eol}") - injected = True - out.append(line) - return "".join(out) - - @staticmethod - def _inject_hook_command_note(content: str) -> str: - """Insert a dot-to-hyphen note before each hook output instruction. + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags and hook notes (no argument-hint). - 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. + Used by preset/extension skill generators; matches flags applied during + ``setup()`` except for fenced question rendering and argument-hint lines. """ - if "replace dots" in content: - return content - - def repl(m: re.Match[str]) -> str: - indent = m.group(1) - instruction = m.group(2) - eol = m.group(3) - return ( - indent - + _HOOK_COMMAND_NOTE.rstrip("\n") - + eol - + indent - + instruction - + eol - ) - - return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", - repl, - content, - ) - - def post_process_skill_content(self, content: str) -> str: - """Inject Claude-specific frontmatter flags and hook notes.""" - updated = self._inject_frontmatter_flag(content, "user-invocable") - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") - updated = self._inject_hook_command_note(updated) + updated = inject_frontmatter_flag(content, "user-invocable") + updated = set_frontmatter_key(updated, "disable-model-invocation", "false") + updated = inject_hook_command_note(updated) return updated + @classmethod + def render_skill_postprocess(cls, content: str, skill_path: Path) -> str: + """Run Claude-specific skill post-processing pipeline.""" + return apply_claude_skill_postprocess(content, skill_path) + def setup( self, project_root: Path, @@ -202,14 +95,12 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject Claude-specific flags and argument-hints.""" + """Install Claude skills, then run the skill post-process extension chain.""" created = super().setup(project_root, manifest, parsed_options, **opts) - # Post-process generated skill files skills_dir = self.skills_dest(project_root).resolve() for path in created: - # Only touch SKILL.md files under the skills directory try: path.resolve().relative_to(skills_dir) except ValueError: @@ -220,16 +111,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - updated = self.post_process_skill_content(content) - - # Inject argument-hint if available for this skill - skill_dir_name = path.parent.name # e.g. "speckit-plan" - stem = skill_dir_name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - hint = ARGUMENT_HINTS.get(stem, "") - if hint: - updated = self.inject_argument_hint(updated, hint) + updated = self.render_skill_postprocess(content, path) if updated != content: path.write_bytes(updated.encode("utf-8")) diff --git a/src/specify_cli/integrations/claude/question_transformer.py b/src/specify_cli/integrations/claude/question_transformer.py new file mode 100644 index 0000000000..8c34708d20 --- /dev/null +++ b/src/specify_cli/integrations/claude/question_transformer.py @@ -0,0 +1,117 @@ +"""Question block transformer for Claude Code integration.""" + +from __future__ import annotations + +import json +import re + +_FENCE_RE = re.compile( + r"\s*\n(.*?)\n\s*", + re.DOTALL, +) +_SEPARATOR_RE = re.compile(r"^\|[-| :]+\|$") + +# Markers that promote an option to the top of the list. +_RECOMMENDED_RE = re.compile(r"\bRecommended\b\s*[\u2014\-]", re.IGNORECASE) + + +def _parse_table_rows(block: str) -> list[list[str]]: + """Return data rows from a Markdown table, skipping header and separator.""" + rows: list[list[str]] = [] + header_seen = False + separator_seen = False + + for line in block.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + if not header_seen: + header_seen = True + continue + if not separator_seen: + if _SEPARATOR_RE.match(stripped): + separator_seen = True + continue + cells = [c.strip() for c in stripped.split("|")[1:-1]] + if cells: + rows.append(cells) + + return rows + + +def parse_clarify(block: str) -> list[dict]: + """Parse clarify.md schema: | Option | Description |.""" + options: list[dict] = [] + recommended: dict | None = None + seen_labels: set[str] = set() + + for cells in _parse_table_rows(block): + if len(cells) < 2: + continue + label = cells[0] + description = cells[1] + if label in seen_labels: + continue + seen_labels.add(label) + entry = {"label": label, "description": description} + if _RECOMMENDED_RE.search(description): + if recommended is None: + recommended = entry + else: + options.append(entry) + + if recommended: + options.insert(0, recommended) + + return options + + +def parse_checklist(block: str) -> list[dict]: + """Parse checklist.md schema: | Option | Candidate | Why It Matters |.""" + options: list[dict] = [] + seen_labels: set[str] = set() + + for cells in _parse_table_rows(block): + if len(cells) < 3: + continue + label = cells[1] + description = cells[2] + if label in seen_labels: + continue + seen_labels.add(label) + options.append({"label": label, "description": description}) + + return options + + +def _build_payload(options: list[dict]) -> str: + """Serialise options into a validated AskUserQuestion JSON code block.""" + if not any(o["label"].lower() == "other" for o in options): + options = options + [ + { + "label": "Other", + "description": "Provide my own short answer (\u226410 words)", + } + ] + + payload: dict = { + "question": "Please select an option:", + "multiSelect": False, + "options": options, + } + + raw = json.dumps(payload, ensure_ascii=False, indent=2) + json.loads(raw) + return f"```json\n{raw}\n```" + + +def transform_question_block(content: str) -> str: + """Replace fenced question blocks with AskUserQuestion JSON payloads.""" + + def _replace(match: re.Match) -> str: + block = match.group(1) + is_checklist = "| Candidate |" in block or "|Candidate|" in block + options = parse_checklist(block) if is_checklist else parse_clarify(block) + return _build_payload(options) + + return _FENCE_RE.sub(_replace, content) diff --git a/src/specify_cli/integrations/claude/skill_postprocess.py b/src/specify_cli/integrations/claude/skill_postprocess.py new file mode 100644 index 0000000000..dec0d22e89 --- /dev/null +++ b/src/specify_cli/integrations/claude/skill_postprocess.py @@ -0,0 +1,170 @@ +"""Claude-specific skill post-processing helpers.""" + +from __future__ import annotations + +import re +from pathlib import Path + +# Note injected into hook sections so Claude maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` -> `/speckit-git-commit`.\n" +) + +# Mapping of command template stem -> argument-hint text shown inline +# when a user invokes the slash command in Claude Code. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + + +def inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction.""" + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + +def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.""" + lines = content.splitlines(keepends=True) + + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + return "".join(out) + + +def inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Insert ``key: value`` before the closing ``---`` if not already present.""" + lines = content.splitlines(keepends=True) + + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + return content + + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") + injected = True + out.append(line) + return "".join(out) + + +def set_frontmatter_key(content: str, key: str, value: str) -> str: + """Ensure ``key: value`` in the first frontmatter block; replace if key exists.""" + lines = content.splitlines(keepends=True) + dash_count = 0 + for i, line in enumerate(lines): + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + lines[i] = f"{key}: {value}{eol}" + return "".join(lines) + return inject_frontmatter_flag(content, key, value) + + +def apply_claude_skill_postprocess(content: str, skill_path: Path) -> str: + """Apply Claude-specific transforms in sequence for generated SKILL.md.""" + from .question_transformer import transform_question_block + + updated = transform_question_block(content) + updated = inject_frontmatter_flag(updated, "user-invocable") + updated = set_frontmatter_key(updated, "disable-model-invocation", "false") + updated = inject_hook_command_note(updated) + + skill_dir_name = skill_path.parent.name + stem = skill_dir_name[len("speckit-") :] if skill_dir_name.startswith("speckit-") else skill_dir_name + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = inject_argument_hint(updated, hint) + return updated diff --git a/tests/test_claude_question_transformer.py b/tests/test_claude_question_transformer.py new file mode 100644 index 0000000000..107e784582 --- /dev/null +++ b/tests/test_claude_question_transformer.py @@ -0,0 +1,318 @@ +"""Tests for src/specify_cli/integrations/claude/question_transformer.py""" + +import pytest +from specify_cli.integrations.claude.question_transformer import ( + parse_clarify, + parse_checklist, + transform_question_block, +) + +# --------------------------------------------------------------------------- +# _parse_table_rows (tested indirectly via parse_clarify / parse_checklist) +# --------------------------------------------------------------------------- + + +class TestParseClarify: + def test_basic_options(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | First option |\n" + "| B | Second option |\n" + ) + result = parse_clarify(block) + assert len(result) == 2 + assert result[0] == {"label": "A", "description": "First option"} + assert result[1] == {"label": "B", "description": "Second option"} + + def test_recommended_em_dash_placed_first(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Plain option |\n" + "| B | Recommended — best practice |\n" + "| C | Another option |\n" + ) + result = parse_clarify(block) + assert result[0]["label"] == "B" + assert "Recommended" in result[0]["description"] + + def test_recommended_hyphen_placed_first(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Plain option |\n" + "| B | Recommended - use this |\n" + ) + result = parse_clarify(block) + assert result[0]["label"] == "B" + + def test_no_recommended_preserves_order(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Alpha |\n" + "| B | Beta |\n" + "| C | Gamma |\n" + ) + result = parse_clarify(block) + assert [r["label"] for r in result] == ["A", "B", "C"] + + def test_indented_table_rows(self): + """Rows with leading spaces (as in clarify.md template) must still parse.""" + block = ( + " | Option | Description |\n" + " |--------|-------------|\n" + " | A | Option A desc |\n" + " | B | Option B desc |\n" + ) + result = parse_clarify(block) + assert len(result) == 2 + assert result[0]["label"] == "A" + + def test_empty_block_returns_empty(self): + assert parse_clarify("") == [] + + def test_header_only_returns_empty(self): + block = "| Option | Description |\n|--------|-------------|\n" + assert parse_clarify(block) == [] + + def test_rows_with_extra_whitespace_stripped(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Spaced out |\n" + ) + result = parse_clarify(block) + assert result[0] == {"label": "A", "description": "Spaced out"} + + +class TestParseChecklist: + def test_basic_rows(self): + block = ( + "| Option | Candidate | Why It Matters |\n" + "|--------|-----------|----------------|\n" + "| A | Unit tests | Catches regressions |\n" + "| B | Integration tests | Validates contracts |\n" + ) + result = parse_checklist(block) + assert len(result) == 2 + assert result[0] == {"label": "Unit tests", "description": "Catches regressions"} + assert result[1] == {"label": "Integration tests", "description": "Validates contracts"} + + def test_indented_rows(self): + block = ( + " | Option | Candidate | Why It Matters |\n" + " |--------|-----------|----------------|\n" + " | A | Scope refinement | Focuses the checklist |\n" + ) + result = parse_checklist(block) + assert result[0]["label"] == "Scope refinement" + + def test_skips_rows_with_fewer_than_3_cells(self): + block = ( + "| Option | Candidate | Why It Matters |\n" + "|--------|-----------|----------------|\n" + "| A | Only two cells |\n" + "| B | Good | Has three |\n" + ) + result = parse_checklist(block) + assert len(result) == 1 + assert result[0]["label"] == "Good" + + def test_empty_block_returns_empty(self): + assert parse_checklist("") == [] + + +class TestTransformQuestionBlock: + def _clarify_fenced(self, rows: str) -> str: + return ( + "\n" + "| Option | Description |\n" + "|--------|-------------|\n" + + rows + + "" + ) + + def _checklist_fenced(self, rows: str) -> str: + return ( + "\n" + "| Option | Candidate | Why It Matters |\n" + "|--------|-----------|----------------|\n" + + rows + + "" + ) + + # --- no-op passthrough --- + + def test_no_markers_returns_identical(self): + text = "hello world, no markers here" + assert transform_question_block(text) is not text or transform_question_block(text) == text + + def test_no_markers_byte_identical(self): + text = "some content\nwith multiple lines\n" + assert transform_question_block(text) == text + + # --- clarify schema --- + + def test_clarify_markers_replaced(self): + content = self._clarify_fenced("| A | Option A |\n| B | Option B |\n") + out = transform_question_block(content) + assert "speckit:question-render" not in out + + def test_clarify_options_present(self): + content = self._clarify_fenced("| A | Option A |\n| B | Option B |\n") + out = transform_question_block(content) + assert '"label": "A"' in out + assert '"label": "B"' in out + + def test_clarify_other_appended(self): + content = self._clarify_fenced("| A | Option A |\n") + out = transform_question_block(content) + assert '"label": "Other"' in out + assert "Provide my own short answer" in out + + def test_clarify_recommended_first(self): + content = self._clarify_fenced( + "| A | Plain |\n" + "| B | Recommended — best choice |\n" + ) + out = transform_question_block(content) + b_pos = out.index('"label": "B"') + a_pos = out.index('"label": "A"') + assert b_pos < a_pos + + def test_clarify_json_structure_valid(self): + import json + content = self._clarify_fenced("| A | Option A |\n") + out = transform_question_block(content) + # Extract JSON block + json_str = out.split("```json\n")[1].split("\n```")[0] + parsed = json.loads(json_str) + assert parsed["multiSelect"] is False + assert isinstance(parsed["options"], list) + assert parsed["options"][-1]["label"] == "Other" + + # --- checklist schema --- + + def test_checklist_detected_by_candidate_column(self): + content = self._checklist_fenced("| A | Unit tests | Catches bugs |\n") + out = transform_question_block(content) + assert '"label": "Unit tests"' in out + assert '"description": "Catches bugs"' in out + + def test_checklist_other_appended(self): + content = self._checklist_fenced("| A | Unit tests | Catches bugs |\n") + out = transform_question_block(content) + assert '"label": "Other"' in out + + # --- surrounding content preserved --- + + def test_content_before_markers_preserved(self): + content = "Before text\n" + self._clarify_fenced("| A | Opt |\n") + "\nAfter text" + out = transform_question_block(content) + assert out.startswith("Before text") + assert out.endswith("After text") + + def test_multiselect_false(self): + import json + content = self._clarify_fenced("| A | Option A |\n") + out = transform_question_block(content) + json_str = out.split("```json\n")[1].split("\n```")[0] + assert json.loads(json_str)["multiSelect"] is False + + # --- special characters --- + + def test_quotes_in_description_escaped(self): + content = self._clarify_fenced('| A | Say "hello" |\n') + out = transform_question_block(content) + # Should not produce invalid JSON + import json + json_str = out.split("```json\n")[1].split("\n```")[0] + json.loads(json_str) # must not raise + + +class TestEdgeCases: + """Edge cases and robustness checks.""" + + def test_duplicate_labels_deduplicated(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | First |\n" + "| A | Duplicate |\n" + "| B | Second |\n" + ) + result = parse_clarify(block) + labels = [r["label"] for r in result] + assert labels.count("A") == 1 + assert result[0]["description"] == "First" # first occurrence wins + + def test_empty_description_included(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | |\n" + ) + result = parse_clarify(block) + assert len(result) == 1 + assert result[0]["description"] == "" + + def test_recommended_case_insensitive(self): + block = ( + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Plain |\n" + "| B | recommended — lowercase |\n" + ) + result = parse_clarify(block) + assert result[0]["label"] == "B" + + def test_other_not_duplicated_if_already_present(self): + """If table already has an 'Other' row, transformer must not add a second one.""" + fenced = ( + "\n" + "| Option | Description |\n" + "|--------|-------------|\n" + "| A | Option A |\n" + "| Other | My own answer |\n" + "" + ) + out = transform_question_block(fenced) + import json + json_str = out.split("```json\n")[1].split("\n```")[0] + parsed = json.loads(json_str) + other_count = sum(1 for o in parsed["options"] if o["label"] == "Other") + assert other_count == 1 + + def test_output_is_valid_json(self): + """Generated payload must always be parseable JSON.""" + import json + fenced = ( + "\n" + "| Option | Description |\n" + "|--------|-------------|\n" + '| A | Has "quotes" and \\backslash |\n' + "" + ) + out = transform_question_block(fenced) + json_str = out.split("```json\n")[1].split("\n```")[0] + json.loads(json_str) # must not raise + + def test_crlf_content_passthrough(self): + """Content with CRLF line endings and no markers must be returned unchanged.""" + text = "line one\r\nline two\r\nno markers\r\n" + assert transform_question_block(text) == text + + def test_checklist_duplicate_candidates_deduplicated(self): + block = ( + "| Option | Candidate | Why It Matters |\n" + "|--------|-----------|----------------|\n" + "| A | Unit tests | First |\n" + "| B | Unit tests | Duplicate |\n" + "| C | Integration | Unique |\n" + ) + result = parse_checklist(block) + labels = [r["label"] for r in result] + assert labels.count("Unit tests") == 1 + assert "Integration" in labels