Repository navigation
populate: strip hard line breaks by default; text_unwrap joins _utils (#6) - #8
Conversation
…in _utils (#6) _utils/text_unwrap.py is a pure, total, idempotent str -> str reflow: wrapped paragraphs and list-item continuations join onto single lines (two spaces after a sentence end, one otherwise) while everything non-prose survives byte-for-byte — headings, rules and setext underlines, tables, blockquotes, fenced and indented code, HTML-comment and generated-region marker lines, and the plan grammar's line-oriented **Labels:**/**Milestone:**/**Repository**: metadata. The acceptance property, pinned by tests: a plan file parses IDENTICALLY before and after unwrapping (milestones, labels with colors, issue ids and label sets) and still lints clean; only bodies and descriptions reflow. populate unwraps the input by default before parsing, so pushed issue bodies and milestone descriptions reach GitHub without authored ~72-col hard breaks and the issue page soft-wraps them; --keep-line-breaks pushes verbatim. Either way the run output states the mode. The file on disk is never rewritten by the transform — the (#N) write-back still edits the authored text (heading lines are identical in both forms), and the post-populate update run is what lands normalized content. Normalize-after-populate is now documented and hinted (from another session's field report): a fresh plan's first 'update --check' is ALWAYS stale because update's canonical rendering drops **Milestone:** lines, reorders labels to GitHub's order, and trims trailing '---' — populate now ends real creation runs with exactly that three-step hint (commit; one plain update committed as normalization; then --check as the drift gate), and the README plus both committed skills say the same. The create-only gap this exposes (reflowed bodies cannot reach already-existing issues) is filed as #7 (--sync-bodies) rather than grown into this change. Suite 134 -> 143; engine VERSION 0.4.0. AI-assisted: Claude Fable 5 (Anthropic)
There was a problem hiding this comment.
Pull request overview
Adds default Markdown prose unwrapping before populate pushes content to GitHub, with opt-out support and normalization guidance.
Changes:
- Adds structure-aware prose unwrapping and tests.
- Integrates
--keep-line-breaksinto populate. - Documents the post-populate normalization workflow.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
scripts/VERSION |
Bumps engine version to 0.4.0. |
scripts/_utils/text_unwrap.py |
Implements Markdown prose unwrapping. |
scripts/gh_project_populate.py |
Applies unwrapping and prints workflow guidance. |
scripts/tests/test_text_unwrap.py |
Tests reflow and parsing invariants. |
scripts/tests/test_integration.py |
Tests populate line-break modes and hints. |
README.md |
Documents unwrapping and normalization. |
.claude/skills/roadmap-upkeep/SKILL.md |
Adds normalization guidance. |
.claude/skills/project-setup/SKILL.md |
Updates project setup instructions. |
Suppressed comments (3)
scripts/_utils/text_unwrap.py:47
- Bold metadata is only treated as a boundary when another paragraph is already open. If valid input omits the optional blank line after
**Repository**:or**Description:**, the following prose is joined onto that metadata line; the repository regex then stops matching, and milestone descriptions can disappear. Treat these line-oriented metadata records as structural in every position so unwrapping cannot change parsing.
BOLD_META_RE = re.compile(r"^\s{0,3}\*\*[^*\n]+(?:\*\*\s*:|:\*\*)")
scripts/_utils/text_unwrap.py:58
- Only table rows beginning with
|are protected. GitHub-flavored Markdown also permits tables without leading pipes (Name | Valuefollowed by--- | ---); this implementation joins all such rows into one prose line and destroys the table. Table detection needs to preserve the header, delimiter, and data rows for both valid forms.
or stripped.startswith(("|", ">", "<!--", "-->"))
scripts/_utils/text_unwrap.py:118
- Markdown permits a tab to supply the indentation for an indented code block. Tab-indented code currently falls through to paragraph reflow, which strips its indentation and joins its lines, despite the transform's code-preservation contract.
if line.startswith(" "):
out.append(line)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| FENCE_RE = re.compile(r"^(\s*)(`{3,}|~{3,})") | ||
| LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])\s+\S") | ||
| HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") | ||
| HR_OR_SETEXT_RE = re.compile(r"^\s{0,3}(?:-{3,}|_{3,}|\*{3,}|={3,})\s*$") |
There was a problem hiding this comment.
Confirmed, all four locations — and the underlying pattern was under-recognition of structure generally, so I also swept the review's three suppressed findings, which were legitimate instances of the same family. Fixed in 6270d59: (1) this comment — setext underlines now match any =/- run (one character suffices in CommonMark) and thematic breaks match 3+ of the same marker with spaces/tabs between (- - -, * * *); (2) bold plan metadata (**Description:**, **Repository**:, …) is structural in every position, not only when a paragraph is already open — the blankless-**Description:** shape would otherwise have its value joined on and destroyed; (3) pipe-less GFM tables are protected by treating any pipe-bearing line as structural (a false positive costs only an unremoved break, never a changed rendering); (4) tab-indented code blocks are preserved. Six new golden cases plus a parse-identity test over the blankless metadata shapes pin all of it; suite 143 → 144, each case idempotence-checked.
Copilot's review of PR #8 (explicit comment plus three suppressed findings, all legitimate, all the same family — under-recognized structure gets merged into prose): - Setext underlines need only ONE = or - in CommonMark, and thematic breaks may space their markers ('- - -', '* * *'); the old 3+ contiguous pattern joined both into adjacent prose. Split into SETEXT_RE (any =+/-+ run alone on a line) and THEMATIC_RE (3+ of the SAME marker, spaces/tabs permitted). - Bold plan metadata is now structural in EVERY position, not only when a paragraph is already open: '**Description:**' immediately followed by its prose (valid — the milestone regex takes the value from the next line) had that prose joined on, destroying the description; '**Repository**: ...' followed by blankless prose had the prose joined on, breaking the line-anchored header regex. Nothing joins onto metadata and it joins onto nothing; a hand-wrapped metadata VALUE therefore stays as authored — unwrap's contract is 'never change how the plan parses', not 'repair wrapped grammar', which lint owns. - GFM tables may omit leading pipes ('Name | Value' / '--- | ---'); any pipe-bearing line is now protected. A false positive costs only an unremoved break — conservative in the safe direction. - Tab-indented code blocks are code; they fell through to reflow. Six new golden cases (each also idempotence-checked) plus a parse-identity test over the blankless **Description:**/**Repository**: plan shapes: repository and milestone descriptions extract identically before and after unwrapping. Suite 143 -> 144. AI-assisted: Claude Fable 5 (Anthropic)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (7)
scripts/_utils/text_unwrap.py:42
- Bare ATX headings are valid Markdown (
#through######may end immediately), but this pattern requires trailing whitespace.unwrap("#\nbody\n")therefore produces"# body\n", turning an empty heading plus paragraph into a different heading. Accept end-of-line as well, and add this shape to the golden cases.
HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s")
scripts/gh_project_populate.py:536
- The printed backtick commands are not executable names in this project: the supported interface is
make update/make update-check(Makefile:81-85), while the installed binary isgh-project-update. Because this is the post-run action users will copy, print the canonical Make targets used in README.md:99-105 instead ofupdateandupdate --check.
print(" 2. run a plain `update` once and commit the result — it")
print(" normalizes the generated regions to the engine's")
print(" canonical rendering (the first `update --check` on a")
print(" fresh plan is ALWAYS stale otherwise)")
print(" 3. from then on, `update --check` is the drift gate")
scripts/_utils/text_unwrap.py:41
- Empty list items are not recognized because this requires a non-whitespace character after the marker. For example,
*\nfollowing paragraph\nis joined into* following paragraph, moving the paragraph into the list item and changing the Markdown structure. Empty*,+, and ordered markers need to be treated as standalone structure rather than as paragraphs with joinable continuations.
This issue also appears on line 42 of the same file.
LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])\s+\S")
scripts/_utils/text_unwrap.py:137
- This assumption fails for a list item's second paragraph. In
- item\n\n continued\n prose\n, the four-space lines are prose inside the list (list-relative code would require additional indentation), but after the blank lineparagraphis empty and both lines are preserved as “code.” Thus the promised list-item reflow leaves these hard breaks intact. Track list context/indentation across blank lines before classifying every fresh four-space line as code.
# Fresh block position: four-space- or tab-indented lines are
# code (list continuations never reach here — their paragraph
# is still open when they arrive).
if line.startswith((" ", "\t")):
out.append(line)
scripts/gh_project_populate.py:523
- This also fires after a partial failed run because
issues_createdis incremented before later issue failures and before a write-back failure. The hint then tells the user to normalize from incomplete GitHub state;updatecan overwrite generated regions and drop issue definitions that were not created, and “it now records the issue numbers” is false when persistence failed. Emit these next steps only after a fully successful run; failed runs should retain the existing rerun guidance.
This issue also appears on line 532 of the same file.
if issues_created:
.claude/skills/roadmap-upkeep/SKILL.md:31
update --checkis not a command exposed by this repository. The same skill identifies the actual target asmake update-checkat line 19, and Makefile:84 maps that target to the engine's--checkoption. Usemake update-checkhere so the normalization guidance is actionable.
A freshly populated plan's FIRST `update --check` is always stale —
.claude/skills/project-setup/SKILL.md:52
update --checkis not an available command; this workflow otherwise consistently uses the Make targets, includingmake updatefour lines above andmake update-checkin the roadmap-upkeep skill. Namemake update-checkhere to avoid sending users to a nonexistent executable.
`update --check` on a freshly populated plan is ALWAYS stale until
…esign
Acted on (all legitimate):
- The next-steps hint fired after PARTIAL runs (issues_created counts
successes before later failures), where its advice is wrong twice
over: the file does not yet record every number, and normalizing via
update from incomplete GitHub state would drop the not-yet-created
issue definitions from the plan. The hint now requires a fully
successful run (created > 0, failed == 0); failed runs keep the
per-issue rerun guidance. The most consequential of the seven.
- The hint printed bare 'update' / 'update --check', which are not
runnable names in any channel; it now names the canonical make
targets, as do the two committed skills that had the same slip.
- Bare ATX headings: '#' may end a line in CommonMark (an empty
heading); requiring trailing whitespace joined the next paragraph
into it.
- Bare list markers ('*', '+', '1.') are valid EMPTY list items;
joining the next line moved that paragraph into the item.
Declined, with the rationale now documented in the module docstring:
a list item's second paragraph (four-space prose after a blank line
inside the item) is indistinguishable from an indented code block
without tracking list context across blank lines — a substantial slice
of a block parser for a case whose current behavior errs in the safe
direction (an unremoved break, never a changed rendering). A golden
case pins the conservative behavior as deliberate.
Suite 144 -> 145 (three new golden cases, hint tests split into
fully-successful vs partial-failure).
AI-assisted: Claude Fable 5 (Anthropic)
Response to the final review's seven suppressed findings (d38fbf4)Acted on six — all legitimate:
Declined one, by design — the list item's second paragraph (four-space prose after a blank line inside an item). Distinguishing it from an indented code block requires tracking list context and indentation across blank lines — a substantial slice of a CommonMark block parser — for a case whose current behavior errs in the safe direction: an unremoved line break, never a changed rendering. The limitation is now documented in the module docstring and pinned as deliberate by a golden case. Suite 144 → 145; every new case idempotence-checked. |
Closes #6.
What changes
_utils/text_unwrap.py— a pure, total, idempotent reflow of markdown prose onto single lines (two spaces after sentence ends, one otherwise), preserving byte-for-byte everything that is not reflowable prose: headings, rules/setext underlines, tables, blockquotes, fenced code (mermaid/agda), indented code,<!--/-->/generated-region marker lines, and the plan grammar's line-oriented**Labels:**/**Milestone:**/**Repository**:metadata. No I/O, no exceptions — callers usefile_ops.--keep-line-breakspushes verbatim. The run output states the mode either way. The plan file on disk is never rewritten by the transform — the(#N)write-back still edits the authored text, and the post-populateupdaterun is what lands normalized content in the file.updateand commit it as normalization; thenupdate --checkis the drift gate — because the first--checkon a fresh plan is always stale (update drops**Milestone:**lines, reorders labels to GitHub's order, trims trailing---). README and both committed skills say the same.Acceptance property (the review's suggested test, adopted)
Pinned in
test_text_unwrap.py: a plan file parses identically before and after unwrapping — same milestones, labels (names and colors), issue ids, titles, and label sets — andlint_planreports zero errors on the unwrapped text; only bodies and descriptions reflow. Checked against the shared fixture and the shipped worked example (mermaid fence intact).Deliberately out of scope
The create-only gap the field report flagged — reflowed bodies can't reach issues that already exist on GitHub — is filed as #7 (
--sync-bodies) with its design questions, rather than grown into this PR.Tests
Suite 134 → 143: reflow semantics (9 golden cases + idempotence + degenerate inputs), the parse-identity acceptance property, and three fake-gh integration tests (default strips bodies and milestone descriptions with the mode line printed;
--keep-line-breakspushes verbatim; the next-steps hint appears only after real creations).nix flake checkgreen; ruff/shellcheck clean. EngineVERSION→ 0.4.0.AI-assisted development: Claude Fable 5 (Anthropic)