Skip to content

Commit 984a9fb

Browse files
authored
Merge pull request #558 from escoffier-labs/fix/handoff-lint-heading-leniency
fix(handoff): warn on near-miss section headings
2 parents fe61227 + 2af8a81 commit 984a9fb

4 files changed

Lines changed: 202 additions & 6 deletions

File tree

src/brigade/handoff_cmd/drafts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ def _draft_summary(
379379
except OSError:
380380
text = ""
381381
raw_sections = _parse_markdown_sections(text)
382-
sections, _collision_errors = _canonicalize_sections(raw_sections)
382+
sections, _collision_errors, _ = _canonicalize_sections(raw_sections)
383383
lint_result = lint_file(path)
384384
action = lint_result.action
385385
target_card = (

src/brigade/handoff_cmd/issue_ops.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,16 +335,33 @@ def _normalize_section_heading(name: str) -> str:
335335
return re.sub(r"\s+", " ", name.strip().casefold()).rstrip(":").strip()
336336

337337

338+
CANONICAL_HANDOFF_SECTION_HEADINGS: tuple[str, ...] = (
339+
"Type",
340+
"Title",
341+
"Summary",
342+
"Durable facts",
343+
"Evidence",
344+
"Recommended memory action",
345+
"Target card",
346+
"Suggested card content",
347+
"Target document",
348+
"Suggested document content",
349+
)
350+
351+
338352
_SECTION_SYNONYMS: dict[str, str] = {
339353
"type": "Type",
340354
"handoff type": "Type",
341355
"kind": "Type",
356+
"category": "Type",
342357
"title": "Title",
343358
"name": "Title",
359+
"subject": "Title",
344360
"summary": "Summary",
345361
"tldr": "Summary",
346362
"tl;dr": "Summary",
347363
"overview": "Summary",
364+
"what changed": "Summary",
348365
"durable facts": "Durable facts",
349366
"facts": "Durable facts",
350367
"evidence": "Evidence",
@@ -364,15 +381,44 @@ def _normalize_section_heading(name: str) -> str:
364381
}
365382

366383

367-
def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], list[str]]:
384+
def _exact_canonical_heading(raw_name: str) -> str | None:
385+
normalized = _normalize_section_heading(raw_name)
386+
for canonical in CANONICAL_HANDOFF_SECTION_HEADINGS:
387+
if _normalize_section_heading(canonical) == normalized:
388+
return canonical
389+
return None
390+
391+
392+
def _resolve_section_canonical(raw_name: str) -> str | None:
393+
key = _normalize_section_heading(raw_name)
394+
canonical = _SECTION_SYNONYMS.get(key)
395+
if canonical is not None:
396+
return canonical
397+
return _exact_canonical_heading(raw_name)
398+
399+
400+
def _canonicalize_sections(sections: dict[str, str]) -> tuple[dict[str, str], list[str], list[tuple[str, str]]]:
368401
resolved: dict[str, str] = {}
369402
owners: dict[str, str] = {}
370403
errors: list[str] = []
404+
noncanonical: list[tuple[str, str]] = []
371405
for raw_name, body in sections.items():
372406
key = _normalize_section_heading(raw_name)
373407
canonical = _SECTION_SYNONYMS.get(key)
374408
if canonical is None:
375-
resolved[raw_name] = body
409+
exact = _exact_canonical_heading(raw_name)
410+
if exact is not None:
411+
prior = owners.get(exact)
412+
if prior is not None and prior != raw_name:
413+
errors.append(f"ambiguous section headings for {exact}: {prior!r}, {raw_name!r}")
414+
continue
415+
owners[exact] = raw_name
416+
if exact not in resolved or (body and not resolved[exact]):
417+
resolved[exact] = body
418+
if raw_name != exact:
419+
noncanonical.append((raw_name, exact))
420+
else:
421+
resolved[raw_name] = body
376422
continue
377423
prior = owners.get(canonical)
378424
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
381427
owners[canonical] = raw_name
382428
if canonical not in resolved or (body and not resolved[canonical]):
383429
resolved[canonical] = body
384-
return resolved, errors
430+
if raw_name != canonical:
431+
noncanonical.append((raw_name, canonical))
432+
return resolved, errors, noncanonical
433+
434+
435+
def _canonical_sections_in_file_order(sections: dict[str, str]) -> list[str]:
436+
seen: set[str] = set()
437+
ordered: list[str] = []
438+
for raw_name in sections:
439+
canonical = _resolve_section_canonical(raw_name)
440+
if canonical is None or canonical not in CANONICAL_HANDOFF_SECTION_HEADINGS:
441+
continue
442+
if canonical in seen:
443+
continue
444+
seen.add(canonical)
445+
ordered.append(canonical)
446+
return ordered
447+
448+
449+
def _lint_noncanonical_heading_messages(
450+
noncanonical: list[tuple[str, str]],
451+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
452+
if not noncanonical:
453+
return (), ()
454+
warnings = tuple(f"noncanonical section heading {raw!r}; use ## {canonical}" for raw, canonical in noncanonical)
455+
hints = tuple(f"use ## {canonical} instead of ## {raw}" for raw, canonical in noncanonical)
456+
return warnings, hints
457+
458+
459+
def _lint_section_order_messages(
460+
sections: dict[str, str],
461+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
462+
ordered = _canonical_sections_in_file_order(sections)
463+
if len(ordered) < 2:
464+
return (), ()
465+
indices = [CANONICAL_HANDOFF_SECTION_HEADINGS.index(name) for name in ordered]
466+
if indices == sorted(indices):
467+
return (), ()
468+
expected = ", ".join(f"## {name}" for name in CANONICAL_HANDOFF_SECTION_HEADINGS)
469+
return ("sections are out of canonical order",), (f"Expected section order: {expected}",)
385470

386471

387472
def _section_value(sections: dict[str, str], name: str) -> str:

src/brigade/handoff_cmd/linting.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,14 @@ def lint_file(path: Path) -> HandoffLintResult:
192192
)
193193

194194
raw_sections = _parse_markdown_sections(text)
195-
sections, collision_errors = _canonicalize_sections(raw_sections)
195+
sections, collision_errors, noncanonical_headings = _canonicalize_sections(raw_sections)
196196
errors.extend(collision_errors)
197+
heading_warnings, heading_hints = _lint_noncanonical_heading_messages(noncanonical_headings)
198+
warnings.extend(heading_warnings)
199+
hints.extend(heading_hints)
200+
order_warnings, order_hints = _lint_section_order_messages(raw_sections)
201+
warnings.extend(order_warnings)
202+
hints.extend(order_hints)
197203
for required in ("Type", "Title", "Summary", "Recommended memory action"):
198204
if required not in sections or not _section_value(sections, required):
199205
errors.append(f"missing required section: {required}")
@@ -236,7 +242,7 @@ def _loose_field(text: str, name: str) -> str | None:
236242
def _migrate_extract(text: str) -> tuple[dict[str, str], list[str]]:
237243
"""Merge proper `## Section` values with loose bullet metadata; report gaps."""
238244
raw_sections = _parse_markdown_sections(text)
239-
sections, _collision_errors = _canonicalize_sections(raw_sections)
245+
sections, _collision_errors, _ = _canonicalize_sections(raw_sections)
240246

241247
def field(section_name: str) -> str:
242248
return _section_value(sections, section_name) or _loose_field(text, section_name) or ""

tests/test_handoff_cmd.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@
8787
"""
8888

8989

90+
HOMEGROWN_NO_CARD_HANDOFF = """# Memory Handoff
91+
92+
## Category
93+
workflow
94+
95+
## Subject
96+
Keep release checks attached to Brigade receipts
97+
98+
## What changed
99+
Verification commands now run through Brigade so the outcome ledger receives the result.
100+
101+
## Recommended memory action
102+
no-card
103+
104+
## Target document
105+
.learnings/LEARNINGS.md
106+
107+
## Suggested document content
108+
### Receipt-backed verification
109+
110+
Run completion checks through `brigade work verify run` and capture the outcome.
111+
"""
112+
113+
90114
PROMOTED_IMPORT_HANDOFF = """# Memory Handoff
91115
92116
## Type
@@ -2090,11 +2114,90 @@ def test_handoff_lint_surfaces_injection_signals(tmp_path, capsys):
20902114
assert "handoff migrate" not in out
20912115

20922116

2117+
def test_handoff_lint_accepts_homegrown_section_headings_with_warnings(tmp_path):
2118+
from brigade import ingest as ingest_mod
2119+
2120+
note = tmp_path / "homegrown.md"
2121+
note.write_text(HOMEGROWN_NO_CARD_HANDOFF)
2122+
result = handoff_cmd.lint_file(note)
2123+
assert result.valid, result.errors
2124+
assert result.warnings
2125+
assert any("noncanonical" in warning for warning in result.warnings)
2126+
assert any("Category" in warning for warning in result.warnings)
2127+
assert any("Subject" in warning for warning in result.warnings)
2128+
assert any("What changed" in warning for warning in result.warnings)
2129+
assert result.hints
2130+
assert any("use ## Type instead of ## Category" in hint for hint in result.hints)
2131+
assert any("use ## Title instead of ## Subject" in hint for hint in result.hints)
2132+
assert any("use ## Summary instead of ## What changed" in hint for hint in result.hints)
2133+
2134+
sections = ingest_mod.parse(note)
2135+
assert sections.get("recommended memory action", "").strip().casefold() == "no-card"
2136+
2137+
2138+
def test_handoff_lint_accepts_case_and_punctuation_heading_variants_with_warnings(tmp_path):
2139+
note = tmp_path / "variants.md"
2140+
note.write_text(
2141+
NO_CARD_HANDOFF.replace("## Type", "## TYPE")
2142+
.replace("## Title", "## title")
2143+
.replace("## Summary", "## Summary:")
2144+
)
2145+
result = handoff_cmd.lint_file(note)
2146+
assert result.valid, result.errors
2147+
assert result.warnings
2148+
assert any("TYPE" in warning for warning in result.warnings)
2149+
assert any("title" in warning for warning in result.warnings)
2150+
assert any("Summary:" in warning for warning in result.warnings)
2151+
assert result.hints
2152+
assert any("use ## Type instead of ## TYPE" in hint for hint in result.hints)
2153+
assert any("use ## Title instead of ## title" in hint for hint in result.hints)
2154+
assert any("use ## Summary instead of ## Summary:" in hint for hint in result.hints)
2155+
2156+
2157+
def test_handoff_lint_accepts_reordered_canonical_sections_with_warnings(tmp_path):
2158+
note = tmp_path / "reordered.md"
2159+
note.write_text(
2160+
"""# Memory Handoff
2161+
2162+
## Summary
2163+
Document handoffs should be routable.
2164+
2165+
## Type
2166+
learning
2167+
2168+
## Title
2169+
Handoff lint documents
2170+
2171+
## Recommended memory action
2172+
no-card
2173+
2174+
## Target document
2175+
.learnings/LEARNINGS.md
2176+
2177+
## Suggested document content
2178+
### Handoff lint documents
2179+
2180+
Document handoffs only include document fields.
2181+
"""
2182+
)
2183+
result = handoff_cmd.lint_file(note)
2184+
assert result.valid, result.errors
2185+
assert result.warnings
2186+
assert any("out of canonical order" in warning for warning in result.warnings)
2187+
assert result.hints
2188+
assert any("Expected section order:" in hint for hint in result.hints)
2189+
assert any("## Type" in hint for hint in result.hints)
2190+
assert any("## Summary" in hint for hint in result.hints)
2191+
2192+
20932193
def test_handoff_lint_accepts_synonym_section_headings(tmp_path):
20942194
note = tmp_path / "synonym.md"
20952195
note.write_text(SYNONYM_NO_CARD_HANDOFF)
20962196
result = handoff_cmd.lint_file(note)
20972197
assert result.valid, result.errors
2198+
assert result.warnings
2199+
assert any("noncanonical" in warning for warning in result.warnings)
2200+
assert result.hints
20982201

20992202

21002203
def test_handoff_lint_rejects_ambiguous_synonym_collision(tmp_path):
@@ -2137,6 +2240,8 @@ def test_handoff_lint_still_accepts_canonical_headings(tmp_path):
21372240
note.write_text(NO_CARD_HANDOFF)
21382241
result = handoff_cmd.lint_file(note)
21392242
assert result.valid, result.errors
2243+
assert not result.warnings
2244+
assert not result.hints
21402245

21412246

21422247
def test_handoff_draft_summary_resolves_synonym_target_card(tmp_path):

0 commit comments

Comments
 (0)