Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/brigade/handoff_cmd/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
91 changes: 88 additions & 3 deletions src/brigade/handoff_cmd/issue_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions src/brigade/handoff_cmd/linting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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 ""
Expand Down
105 changes: 105 additions & 0 deletions tests/test_handoff_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading