diff --git a/src/brigade/handoff_cmd/drafts.py b/src/brigade/handoff_cmd/drafts.py index 748ca2b9..fbcb7dfe 100644 --- a/src/brigade/handoff_cmd/drafts.py +++ b/src/brigade/handoff_cmd/drafts.py @@ -379,7 +379,7 @@ def _draft_summary( except OSError: text = "" raw_sections = _parse_markdown_sections(text) - sections, _collision_errors = _canonicalize_sections(raw_sections) + sections, _collision_errors, _ = _canonicalize_sections(raw_sections) lint_result = lint_file(path) action = lint_result.action target_card = ( diff --git a/src/brigade/handoff_cmd/issue_ops.py b/src/brigade/handoff_cmd/issue_ops.py index 89469185..995446d7 100644 --- a/src/brigade/handoff_cmd/issue_ops.py +++ b/src/brigade/handoff_cmd/issue_ops.py @@ -335,16 +335,33 @@ def _normalize_section_heading(name: str) -> str: return re.sub(r"\s+", " ", name.strip().casefold()).rstrip(":").strip() +CANONICAL_HANDOFF_SECTION_HEADINGS: tuple[str, ...] = ( + "Type", + "Title", + "Summary", + "Durable facts", + "Evidence", + "Recommended memory action", + "Target card", + "Suggested card content", + "Target document", + "Suggested document content", +) + + _SECTION_SYNONYMS: dict[str, str] = { "type": "Type", "handoff type": "Type", "kind": "Type", + "category": "Type", "title": "Title", "name": "Title", + "subject": "Title", "summary": "Summary", "tldr": "Summary", "tl;dr": "Summary", "overview": "Summary", + "what changed": "Summary", "durable facts": "Durable facts", "facts": "Durable facts", "evidence": "Evidence", @@ -364,15 +381,44 @@ def _normalize_section_heading(name: str) -> str: } -def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], list[str]]: +def _exact_canonical_heading(raw_name: str) -> str | None: + normalized = _normalize_section_heading(raw_name) + for canonical in CANONICAL_HANDOFF_SECTION_HEADINGS: + if _normalize_section_heading(canonical) == normalized: + return canonical + return None + + +def _resolve_section_canonical(raw_name: str) -> str | None: + key = _normalize_section_heading(raw_name) + canonical = _SECTION_SYNONYMS.get(key) + if canonical is not None: + return canonical + return _exact_canonical_heading(raw_name) + + +def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], list[str], list[tuple[str, str]]]: resolved: dict[str, str] = {} owners: dict[str, str] = {} errors: list[str] = [] + noncanonical: list[tuple[str, str]] = [] for raw_name, body in sections.items(): key = _normalize_section_heading(raw_name) canonical = _SECTION_SYNONYMS.get(key) if canonical is None: - resolved[raw_name] = body + exact = _exact_canonical_heading(raw_name) + if exact is not None: + prior = owners.get(exact) + if prior is not None and prior != raw_name: + errors.append(f"ambiguous section headings for {exact}: {prior!r}, {raw_name!r}") + continue + owners[exact] = raw_name + if exact not in resolved or (body and not resolved[exact]): + resolved[exact] = body + if raw_name != exact: + noncanonical.append((raw_name, exact)) + else: + resolved[raw_name] = body continue prior = owners.get(canonical) if prior is not None and prior != raw_name: @@ -381,7 +427,46 @@ def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], li owners[canonical] = raw_name if canonical not in resolved or (body and not resolved[canonical]): resolved[canonical] = body - return resolved, errors + if raw_name != canonical: + noncanonical.append((raw_name, canonical)) + return resolved, errors, noncanonical + + +def _canonical_sections_in_file_order(sections: dict[str, str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for raw_name in sections: + canonical = _resolve_section_canonical(raw_name) + if canonical is None or canonical not in CANONICAL_HANDOFF_SECTION_HEADINGS: + continue + if canonical in seen: + continue + seen.add(canonical) + ordered.append(canonical) + return ordered + + +def _lint_noncanonical_heading_messages( + noncanonical: list[tuple[str, str]], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + if not noncanonical: + return (), () + warnings = tuple(f"noncanonical section heading {raw!r}; use ## {canonical}" for raw, canonical in noncanonical) + hints = tuple(f"use ## {canonical} instead of ## {raw}" for raw, canonical in noncanonical) + return warnings, hints + + +def _lint_section_order_messages( + sections: dict[str, str], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + ordered = _canonical_sections_in_file_order(sections) + if len(ordered) < 2: + return (), () + indices = [CANONICAL_HANDOFF_SECTION_HEADINGS.index(name) for name in ordered] + if indices == sorted(indices): + return (), () + expected = ", ".join(f"## {name}" for name in CANONICAL_HANDOFF_SECTION_HEADINGS) + return ("sections are out of canonical order",), (f"Expected section order: {expected}",) def _section_value(sections: dict[str, str], name: str) -> str: diff --git a/src/brigade/handoff_cmd/linting.py b/src/brigade/handoff_cmd/linting.py index 6c1c2d01..5b5efec3 100644 --- a/src/brigade/handoff_cmd/linting.py +++ b/src/brigade/handoff_cmd/linting.py @@ -192,8 +192,14 @@ def lint_file(path: Path) -> HandoffLintResult: ) raw_sections = _parse_markdown_sections(text) - sections, collision_errors = _canonicalize_sections(raw_sections) + sections, collision_errors, noncanonical_headings = _canonicalize_sections(raw_sections) errors.extend(collision_errors) + heading_warnings, heading_hints = _lint_noncanonical_heading_messages(noncanonical_headings) + warnings.extend(heading_warnings) + hints.extend(heading_hints) + order_warnings, order_hints = _lint_section_order_messages(raw_sections) + warnings.extend(order_warnings) + hints.extend(order_hints) for required in ("Type", "Title", "Summary", "Recommended memory action"): if required not in sections or not _section_value(sections, required): errors.append(f"missing required section: {required}") @@ -236,7 +242,7 @@ def _loose_field(text: str, name: str) -> str | None: def _migrate_extract(text: str) -> tuple[dict[str, str], list[str]]: """Merge proper `## Section` values with loose bullet metadata; report gaps.""" raw_sections = _parse_markdown_sections(text) - sections, _collision_errors = _canonicalize_sections(raw_sections) + sections, _collision_errors, _ = _canonicalize_sections(raw_sections) def field(section_name: str) -> str: return _section_value(sections, section_name) or _loose_field(text, section_name) or "" diff --git a/tests/test_handoff_cmd.py b/tests/test_handoff_cmd.py index 4c9eb9cd..ad4356c4 100644 --- a/tests/test_handoff_cmd.py +++ b/tests/test_handoff_cmd.py @@ -87,6 +87,30 @@ """ +HOMEGROWN_NO_CARD_HANDOFF = """# Memory Handoff + +## Category +workflow + +## Subject +Keep release checks attached to Brigade receipts + +## What changed +Verification commands now run through Brigade so the outcome ledger receives the result. + +## Recommended memory action +no-card + +## Target document +.learnings/LEARNINGS.md + +## Suggested document content +### Receipt-backed verification + +Run completion checks through `brigade work verify run` and capture the outcome. +""" + + PROMOTED_IMPORT_HANDOFF = """# Memory Handoff ## Type @@ -2090,11 +2114,90 @@ def test_handoff_lint_surfaces_injection_signals(tmp_path, capsys): assert "handoff migrate" not in out +def test_handoff_lint_accepts_homegrown_section_headings_with_warnings(tmp_path): + from brigade import ingest as ingest_mod + + note = tmp_path / "homegrown.md" + note.write_text(HOMEGROWN_NO_CARD_HANDOFF) + result = handoff_cmd.lint_file(note) + assert result.valid, result.errors + assert result.warnings + assert any("noncanonical" in warning for warning in result.warnings) + assert any("Category" in warning for warning in result.warnings) + assert any("Subject" in warning for warning in result.warnings) + assert any("What changed" in warning for warning in result.warnings) + assert result.hints + assert any("use ## Type instead of ## Category" in hint for hint in result.hints) + assert any("use ## Title instead of ## Subject" in hint for hint in result.hints) + assert any("use ## Summary instead of ## What changed" in hint for hint in result.hints) + + sections = ingest_mod.parse(note) + assert sections.get("recommended memory action", "").strip().casefold() == "no-card" + + +def test_handoff_lint_accepts_case_and_punctuation_heading_variants_with_warnings(tmp_path): + note = tmp_path / "variants.md" + note.write_text( + NO_CARD_HANDOFF.replace("## Type", "## TYPE") + .replace("## Title", "## title") + .replace("## Summary", "## Summary:") + ) + result = handoff_cmd.lint_file(note) + assert result.valid, result.errors + assert result.warnings + assert any("TYPE" in warning for warning in result.warnings) + assert any("title" in warning for warning in result.warnings) + assert any("Summary:" in warning for warning in result.warnings) + assert result.hints + assert any("use ## Type instead of ## TYPE" in hint for hint in result.hints) + assert any("use ## Title instead of ## title" in hint for hint in result.hints) + assert any("use ## Summary instead of ## Summary:" in hint for hint in result.hints) + + +def test_handoff_lint_accepts_reordered_canonical_sections_with_warnings(tmp_path): + note = tmp_path / "reordered.md" + note.write_text( + """# Memory Handoff + +## Summary +Document handoffs should be routable. + +## Type +learning + +## Title +Handoff lint documents + +## Recommended memory action +no-card + +## Target document +.learnings/LEARNINGS.md + +## Suggested document content +### Handoff lint documents + +Document handoffs only include document fields. +""" + ) + result = handoff_cmd.lint_file(note) + assert result.valid, result.errors + assert result.warnings + assert any("out of canonical order" in warning for warning in result.warnings) + assert result.hints + assert any("Expected section order:" in hint for hint in result.hints) + assert any("## Type" in hint for hint in result.hints) + assert any("## Summary" in hint for hint in result.hints) + + def test_handoff_lint_accepts_synonym_section_headings(tmp_path): note = tmp_path / "synonym.md" note.write_text(SYNONYM_NO_CARD_HANDOFF) result = handoff_cmd.lint_file(note) assert result.valid, result.errors + assert result.warnings + assert any("noncanonical" in warning for warning in result.warnings) + assert result.hints def test_handoff_lint_rejects_ambiguous_synonym_collision(tmp_path): @@ -2137,6 +2240,8 @@ def test_handoff_lint_still_accepts_canonical_headings(tmp_path): note.write_text(NO_CARD_HANDOFF) result = handoff_cmd.lint_file(note) assert result.valid, result.errors + assert not result.warnings + assert not result.hints def test_handoff_draft_summary_resolves_synonym_target_card(tmp_path):