Repository navigation
Add --write to migrate article-ids - #191
ArmelRandy wants to merge 1 commit into
Conversation
work list refuses formalizable leaves without an article_id, and the Formalize skill tells the agent to copy each planned id into frontmatter by hand. `migrate article-ids --write` adds each planned id as the first frontmatter line of the article that lacks one, or as a frontmatter block of its own, and changes nothing else. An article is written only while its bytes still have the hash the plan was made from. Every article is checked before the first write and again just before its own replacement, so a concurrent edit is never overwritten, and running the command again completes the plan. Writes go through a temporary file in the same directory and a rename, keep the file mode and line endings, and refuse symlinks. --write cannot be combined with --check; with --json, stdout carries only the plan. The Formalize and Roadmap skills, the work list error and the README now point to --write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0c2447c to
7481b64
Compare
Review at 7481b64Thanks for this. Should fix 1. An article with CR-only line endings loses its frontmatter (
Repro: Fix: find the opener the way the loader does. text = content.decode("utf-8")
# Split as graph._parse_node does, so both agree on where the frontmatter is.
first = text.splitlines(keepends=True)[0] if text else ""
opener = first.splitlines()[0] if first else ""
ending = first[len(opener):]
newline = ending if ending in ("\r\n", "\r") else "\n"
line = f"article_id: {article_id}{newline}"
if opener.strip() == "---" and ending:
return (first + line + text[len(first):]).encode("utf-8")
return (f"---{newline}{line}---{newline}{newline}" + text).encode("utf-8")I checked this against def test_write_reads_frontmatter_lines_as_the_loader_does(tmp_path: Path) -> None:
blueprint = _blueprint(tmp_path)
result = blueprint / "roadmap/chapter/result.md"
result.write_bytes(b"---\rnot_ready: true\r---\r\r# Result\r")
written = {entry.article_path: entry.article_id for entry in write_article_ids(blueprint)}
article_id = written["roadmap/chapter/result.md"]
assert result.read_bytes() == f"---\rarticle_id: {article_id}\rnot_ready: true\r---\r\r# Result\r".encode()
assert load_graph(blueprint).nodes["chapter/result"].not_ready2. The reload after the writes can hide what was written ( That reload can fail in two ways that bypass
Fix: delete lines 151-160 as part of the same change as item 1. Once the write agrees with the loader, the reload can only catch concurrent edits, and def test_cli_lists_written_articles_when_the_blueprint_breaks_meanwhile(tmp_path: Path, monkeypatch, capsys) -> None:
blueprint = _blueprint(tmp_path)
other = blueprint / "roadmap/chapter/other.md"
_article(other, "Other", "af_0123456789abcdef01234567")
real_replace = article_identity._replace_if_unchanged
calls: list[Path] = []
def replace_then_break_a_link(path, expected, content, mode):
real_replace(path, expected, content, mode)
calls.append(path)
if len(calls) == 3:
broken = other.read_text(encoding="utf-8") + "\n## Depends on\n\n- [x](missing.md)\n"
other.write_text(broken, encoding="utf-8")
monkeypatch.setattr(article_identity, "_replace_if_unchanged", replace_then_break_a_link)
assert main(["migrate", "article-ids", str(blueprint), "--write"]) == 2
captured = capsys.readouterr()
assert (captured.out + captured.err).count("added article_id af_") == 3
assert "missing.md" in captured.errTest gaps I mutated the new code and ran
These four tests catch all five mutants. Each passes at this head and fails on its mutant (add def test_cli_write_json_keeps_stdout_for_the_plan(tmp_path: Path, capsys) -> None:
blueprint = _blueprint(tmp_path)
assert main(["migrate", "article-ids", str(blueprint), "--write", "--json"]) == 0
captured = capsys.readouterr()
assert json.loads(captured.out)["complete"] is True
assert captured.err.count("added article_id af_") == 3
def test_write_keeps_the_file_mode(tmp_path: Path) -> None:
blueprint = _blueprint(tmp_path)
result = blueprint / "roadmap/chapter/result.md"
result.chmod(0o640)
write_article_ids(blueprint)
assert stat.S_IMODE(result.stat().st_mode) == 0o640
def test_cli_lists_what_it_wrote_before_a_refusal(tmp_path: Path, monkeypatch, capsys) -> None:
blueprint = _blueprint(tmp_path)
real_replace = article_identity._replace_if_unchanged
calls: list[Path] = []
def edit_the_second(path, expected, content, mode):
calls.append(path)
if len(calls) == 2:
path.write_text(path.read_text(encoding="utf-8") + "Edited meanwhile.\n", encoding="utf-8")
return real_replace(path, expected, content, mode)
monkeypatch.setattr(article_identity, "_replace_if_unchanged", edit_the_second)
assert main(["migrate", "article-ids", str(blueprint), "--write"]) == 2
err = capsys.readouterr().err
assert err.count("added article_id af_") == 1
assert "error: added 1 article_id(s) before stopping" in err
assert not list(blueprint.rglob("*.tmp"))
def test_write_refuses_a_byte_order_mark(tmp_path: Path) -> None:
blueprint = _blueprint(tmp_path)
(blueprint / "roadmap/chapter/result.md").write_bytes(b"\xef\xbb\xbf---\nnot_ready: true\n---\n\n# Result\n")
before = _bytes(blueprint)
with pytest.raises(article_identity.ArticleIdWriteError, match="byte-order mark"):
write_article_ids(blueprint)
assert _bytes(blueprint) == beforeWith items 1 and 2 applied and all six tests added, Nits
Checked, no change needed
Posted by PR swarm: PR Swarm Lead |
work listrefuses formalizable leaves without anarticle_id. Today the wayout is to run
migrate article-ids --jsonand copy each id into frontmatter byhand, and the Formalize skill tells the agent to do exactly that. On a project
with 53 articles, that is 53 edits.
autoform migrate article-ids <blueprint> --writeadds each planned id as thefirst frontmatter line of the article that lacks one, or as a frontmatter block
of its own when the article has none. Nothing else in the file changes.
made from. Every article is checked before the first write, and again just
before its own replacement, so an article edited after planning is refused
rather than overwritten. After a refusal, running it again completes the plan.
file mode and line endings are kept. Symlinks are refused.
--writecannot be combined with--check. With--json, stdout stillcarries only the plan, and the added ids go to stderr.
work listerror and the README nowpoint to
--write.Checked on copies of two consumer projects (53 and 12 articles): the ids
written are the ones
--jsonplanned,checkgives the same result before andafter, and a second run changes nothing.
🤖 Generated with Claude Code