v2 chunk 2b: cli format-notes subcommand and reformat_notes()

Wire the v2 note formatter into the CLI so it can be invoked on
real markdown drafts. The format-notes subcommand mirrors v1's
format subcommand: parser → formatter → reassemble, with concurrent
API calls.

src/cmos/cli.py:
- Import find_notes and format_note_entry alongside the existing
  v1 imports.
- Add reformat_notes(text, formatter, concurrency) that finds
  pandoc-style markdown footnote definitions via find_notes,
  formats each definition's text via the v2 note formatter (or
  an injected fake), and substitutes the formatted text back into
  the original line position. Non-definition lines preserved
  byte-for-byte. Returns text unchanged when no definitions found.
- Register the format-notes argparse subparser with the same
  --concurrency flag as v1's format.
- Dispatch args.command == "format-notes" to reformat_notes.
- Module docstring updated to document both subcommands.

tests/test_cli.py:
- 7 new tests for reformat_notes covering: in-place substitution,
  order preservation under concurrency, prose preservation,
  reference markers staying verbatim, no-op on empty input,
  trailing newline preservation.
- Extended test_python_dash_m_invocation_actually_runs_main to
  also assert "format-notes" appears in --help, catching accidental
  subcommand removal.

Path B integrity: formatter.py, note_formatter.py, parser.py,
linter.py, harness/score.py all unchanged. No LINTER_VERSION
bump. 96/96 v1 tests still passing. Total suite: 123/123.

Real-API end-to-end smoke test on a temp draft with 2 footnote
definitions: both reformatted byte-for-byte, prose and headings
preserved, ## Conclusion section after the notes preserved.
This commit is contained in:
Mark Eaton
2026-04-11 18:33:45 -04:00
parent d061f78b7f
commit 87cb70b4fb
2 changed files with 209 additions and 6 deletions
+126 -1
View File
@@ -3,6 +3,10 @@
The CLI orchestrates parser → formatter → reassemble. Tests inject a fake
formatter so no API calls happen; the goal is to pin the glue logic, not the
LLM behavior.
v1: ``reformat_draft`` rewrites the ``## Bibliography`` section.
v2: ``reformat_notes`` rewrites pandoc-style markdown footnote definitions
in place by line number, preserving everything else byte-for-byte.
"""
import subprocess
@@ -11,7 +15,7 @@ from pathlib import Path
import pytest
from cmos.cli import reformat_draft
from cmos.cli import reformat_draft, reformat_notes
from cmos.parser import NoBibliographyError
@@ -80,6 +84,10 @@ def test_python_dash_m_invocation_actually_runs_main():
the worst kind of bug, since callers assume the pipeline ran. This
test forces the guard to exist by invoking the CLI as a subprocess
with --help and asserting argparse actually fired.
Also acts as a smoke test for both subcommands appearing in the
top-level help: catches the case where a future refactor removes
a subcommand registration without anyone noticing.
"""
result = subprocess.run(
[sys.executable, "-m", "cmos.cli", "--help"],
@@ -98,9 +106,126 @@ def test_python_dash_m_invocation_actually_runs_main():
'`if __name__ == "__main__": sys.exit(main())` guard at the bottom.'
)
assert "format" in result.stdout
assert "format-notes" in result.stdout
assert "usage" in result.stdout.lower()
# --- reformat_notes (v2 CLI plumbing) ----------------------------------------
def _fake_note_formatter(messy: str) -> str:
# Deterministic fake mirroring the v1 fake_formatter pattern.
return f"NOTE_FORMATTED({messy.strip()})"
def test_reformat_notes_substitutes_definition_in_place():
draft = """\
Some prose with a citation.[^1]
[^1]: yu, charles. interior chinatown. 2020. p 45.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "Some prose with a citation.[^1]" in output
assert "[^1]: NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output
# The original messy definition line must be gone.
assert "[^1]: yu, charles" not in output
def test_reformat_notes_rewrites_multiple_definitions_in_order():
draft = """\
Prose.[^1] More.[^2] Again.[^3]
[^1]: first messy.
[^2]: second messy.
[^3]: third messy.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "[^1]: NOTE_FORMATTED(first messy.)" in output
assert "[^2]: NOTE_FORMATTED(second messy.)" in output
assert "[^3]: NOTE_FORMATTED(third messy.)" in output
def test_reformat_notes_preserves_non_definition_lines():
draft = """\
# Title
Some prose with a citation.[^1]
More prose, no citation here.
[^1]: messy definition.
Conclusion paragraph.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "# Title" in output
assert "Some prose with a citation.[^1]" in output
assert "More prose, no citation here." in output
assert "Conclusion paragraph." in output
assert "[^1]: NOTE_FORMATTED(messy definition.)" in output
def test_reformat_notes_preserves_reference_markers_in_prose():
"""The [^1] reference inside the prose must NOT be touched — only
the [^1]: definition line should be reformatted."""
draft = """\
Some prose with a citation.[^1] And another.[^2]
[^1]: first.
[^2]: second.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
# References in prose stay verbatim
assert "Some prose with a citation.[^1] And another.[^2]" in output
# Definitions are reformatted
assert "[^1]: NOTE_FORMATTED(first.)" in output
assert "[^2]: NOTE_FORMATTED(second.)" in output
def test_reformat_notes_returns_unchanged_when_no_definitions():
draft = "Just prose with no footnote definitions.\n"
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert output == draft
def test_reformat_notes_preserves_trailing_newline():
with_trailing = "[^1]: messy.\n"
without_trailing = "[^1]: messy."
assert reformat_notes(with_trailing, formatter=_fake_note_formatter).endswith("\n")
assert not reformat_notes(without_trailing, formatter=_fake_note_formatter).endswith("\n")
def test_reformat_notes_preserves_order_under_concurrency():
"""With concurrent execution the formatter is called on all definitions
in parallel; the output must reassemble them in input order regardless
of which call finishes first. Mirrors the v1 order-preservation test."""
import time
def slow_fake(messy: str) -> str:
# Earlier definitions sleep longer so they finish last under naive
# completion-order tracking.
n = int(messy.split()[-1])
time.sleep(0.05 * (5 - n))
return f"NOTE_FORMATTED({messy})"
draft = """\
Prose.
[^1]: definition 0
[^2]: definition 1
[^3]: definition 2
[^4]: definition 3
[^5]: definition 4
"""
output = reformat_notes(draft, formatter=slow_fake, concurrency=4)
# Check order: each marker should be paired with its correct definition.
assert "[^1]: NOTE_FORMATTED(definition 0)" in output
assert "[^2]: NOTE_FORMATTED(definition 1)" in output
assert "[^3]: NOTE_FORMATTED(definition 2)" in output
assert "[^4]: NOTE_FORMATTED(definition 3)" in output
assert "[^5]: NOTE_FORMATTED(definition 4)" in output
def test_reformat_draft_preserves_order_under_concurrency():
# With concurrent execution the formatter is called on all entries in
# parallel; the CLI must reassemble them in input order regardless of