Skip to content

refactor: one frontmatter splitter, every reader uses it - #744

Merged
markramm merged 3 commits into
devfrom
refactor/one-frontmatter-splitter
Oct 6, 2026
Merged

markramm merged 3 commits into
devfrom
refactor/one-frontmatter-splitter

Conversation

@markramm

@markramm markramm commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

One function, split_frontmatter in pyrite/utils/frontmatter.py, now decides where a file's frontmatter starts and ends (design.md, principle 8). Round 1 (cold read, Andon #745) rebased the rule on Hugo and YAML instead of the old loader; see "Oracle" below. Closes the backlog item one-frontmatter-splitter-... (retro 2026-10-03 quality theme).

Shape for B6 P1 (pyrite/storage/operations.py)

split_frontmatter(text) returns Frontmatter | NoFrontmatter | Unterminated. Frontmatter carries text (the YAML), body (stripped), and offsets into the text as given (BOM and CRLF included):

  • text[open_start:yaml_start] the opening --- line with its line end (open_start is 0, or 1 with a BOM)
  • text[yaml_start:close_start] the YAML (== .text; empty frontmatter has yaml_start == close_start)
  • text[close_start:close_end] the closing line: ---, trailing blanks, line end
  • text[close_end:] the body, unstripped

load_frontmatter(text) -> (meta, body) | None is the old _frontmatter_of. next_delimiter(text, pos) finds a delimiter line for stream readers.

Oracle (round 1)

ORACLE in tests/test_frontmatter_splitter.py has 32 rows: Hugo 0.151.2's recorded behaviour (re-run live when hugo is installed), YAML's, and ours, classed agrees, fixed or departs. Departures refuse visibly with a typed result:

Input Hugo Pyrite
----, ---x, --- + NBSP as the closer closes on the prefix, tail becomes body Malformed (line number), file refused
TOML +++, JSON { read Unsupported, file refused with the format named
--- # c as the closer comment starts the body comment belongs to the delimiter
--- + tab on the opening line error accepted (YAML allows it)
leading blank lines, --- , --- # c opening accepted accepted (old Pyrite refused: fixed)

Every reader runs on every row (test_every_reader_follows_the_rule_or_refuses); list_templates skips a bad template with a warning; the importer no longer invents an entry from a body rule; create --body-file exits non-zero on frontmatter it cannot close.

Round 2

  • A first line starting with --- that is not a valid opener (----, ---x, --- + tab or NBSP) is Malformed, never plain text; create joined the reader matrix. The opener takes spaces only (PyYAML and ruamel raise on --- + tab).
  • The ORACLE's YAML column is now asserted against PyYAML and ruamel on every run.
  • Importer rule: a --- followed directly by title:, type: or id: starts the next entry, which must close and parse or the file is refused; any other --- is a body rule. An entry's body no longer repeats the entries after it. stream=False opts out. import_cmd's docstring now says a markdown file is refused whole.
  • Stated limit: leading blank lines reopen r1030's risk for that shape (docstring, changelog, entry-model).
  • For the maintainer: ADR-0041:178 says TOML/JSON frontmatter is "reported as malformed"; the code calls it Unsupported (refused, message names the format). I did not edit the ADR.
  • Out of scope: web/src/routes/changes/+page.svelte:72-73, scripts/*appointee*.py.

Round 3 (maintainer: one entry per file)

import_markdown never splits a body by default (the stream rule had truncated 9 of 931 valid kb/ entries on fenced frontmatter examples). Streams are opt-in: pyrite import --stream, REST stream=true, import_markdown(stream=True), with the limits in each help text. A test imports every kb/ file with valid frontmatter and expects one entry with the file's body. Rebased onto B6 P1: file_operations.py and its test now call load_frontmatter / split_frontmatter instead of the removed _frontmatter_of.

Riskiest assumption

The groom (the item) assumed the two splitters differ only in edge cases. Ran both over the cases first: they differ on a BOM and on blanks after the closing ---. The repository loader rejected both and fell back to EventEntry.load, so a typed entry loaded as an event (test test_repository_keeps_the_type_the_loader_keeps). Kept the loader's answer (from_markdown, which read_entry_id and ids pin already follow). The item also undercounted readers: eleven, not two.

Every splitter (grep of pyrite/ and extensions/)

Reader Status
Entry.from_markdown (models/base.py) guarded: test_split_table, test_repository_and_from_markdown_agree
_frontmatter_of (core_types) removed; load_frontmatter
KBRepository._load_entry guarded: test_repository_reads_every_row_as_the_splitter_does, ..._keeps_the_type_the_loader_keeps
id_pin_service (own _OPENING/_CLOSING, private import) guarded: test_id_pin_inserts_before_a_closing_line_with_trailing_blanks and the existing pin tests
markdown_importer guarded: test_markdown_importer_reads_a_file_with_no_body_and_no_final_newline, ..._still_reads_a_stream_of_entries
export_commands (2 copies, now one helper) guarded: test_export_does_not_build_an_entry_from_prose_between_two_rules
schema_commands._parse_frontmatter guarded: test_schema_check_reads_quoted_dashes
kb_service.add_entry_from_file, _extract_frontmatter guarded: test_add_entry_from_file_reads_quoted_dashes, test_git_change_summary_reads_quoted_dashes
template_service._parse_template_file guarded: test_template_file_reads_quoted_dashes
entry_commands create --body-file/--stdin guarded: test_create_body_file_frontmatter_with_dashes_in_a_value (tests/test_cli_commands.py)
cascade hooks._load_aliases_for_actor, migration (3) guarded: test_cascade_alias_hook_..., test_cascade_inject_ids_..., test_cascade_migration_keeps_a_bom_and_crlf
journalism known_entities._extract_aliases guarded: test_journalism_known_entities_reads_quoted_dashes
any new splitter under pyrite/ or extensions/*/src guarded: test_no_other_module_splits_frontmatter_with_its_own_pattern
writers (to_markdown, quartz, export_service, ...) already safe: they emit ---, never search for it
scripts/{cross_reference,conflict_analysis,scrape}_appointee*.py out of scope: one-off appointee scripts outside pyrite/; not shipped

Parity

pyrite index build on kb/ (927 entries), tests/fixtures/roundtrip (10) and the tutorial fixture (4), with origin/dev's code and with this branch: identical rows (941 of 941). A fourth KB of four hand-written files (plain, CRLF, BOM, trailing blanks after the closing ---) differs in exactly the two files expected: BOM and trailing-blanks go from event to note.

Changelog fragment added: files with a BOM or blanks after the closing --- now read differently.

Not touched: pyrite/storage/operations.py (B6 P1).

🤖 Generated with Claude Code


Round 3 (scoped, maintainer-approved; pushed c4a0de5): one entry per markdown file

  • Default: import_markdown reads one file as one entry with its whole body. Streams are opt-in through pyrite import --stream, REST stream=true or import_markdown(stream=True), are refused for non-markdown formats, and their limits are stated.
  • Corpus test: every kb/ file with valid frontmatter imports as exactly one entry whose body equals the split body (more than 500 files). The cold read's boundary inputs are tests.
  • B6 P1 integration: file_operations.py now uses split_frontmatter/load_frontmatter, since P1 had imported the private _frontmatter_of this branch removes.
  • Evidence:
    • pre-push passed on the final tree; 491 passed in the two module test files;
    • the full suite (7792) ran before the rebase onto P1;
    • verify-red was not re-run after the repoint;
    • parity is identical.

Conductor review, round 3 (2026-10-06)

The delta cold read on c4a0de5 found nothing that breaks. All 931 kb/ files import as one whole entry each (ADR-0042 keeps its full 68k body). --stream is off by default and refused for non-markdown formats on both the CLI and REST. apply() output on the P1 probe set (p10-p30, an 3765-case sweep) matches dev byte for byte. With the default flipped, 12 targeted tests fail, so the tests guard it. CI on this head: test (3.12), verify-red, kb and gate all pass.

Known limits, fail-safe, tracked in #756: blank lines are allowed before the --stream entry key, which the docs do not say; the test oracle shares the splitter; SHAPES does not cover the newly accepted delimiter shapes; one error message is too generic; .md is not auto-detected.

markramm and others added 3 commits October 5, 2026 15:17
split_frontmatter in pyrite/utils/frontmatter.py is the one rule for where a
file's frontmatter starts and ends, and returns the span so a writer can edit
in place (the B6 write path). Its reference is Hugo and YAML, not the old
loader: the expected column of tests/test_frontmatter_splitter.py is Hugo's
recorded output (re-run live when hugo is installed) and PyYAML/ruamel's safe
loader's, and every place Pyrite departs is a stated row that refuses visibly.

A file the convention refuses is refused by every reader: a first line that
starts with --- and is not a valid opener, or a column-0 ---x / ---- before
the closer, is Malformed; TOML and JSON frontmatter are Unsupported. None is
plain text, so no reader stores its YAML as body. Opening and closing lines
are symmetric apart from a tab (refused on the opener, as YAML parsers do).
Leading blank lines are ignored as Hugo ignores them (a stated limit against
r1030).

A markdown file is one entry: the importer never splits a body into entries
by default. Multi-entry streams are opt-in (pyrite import --stream, the REST
stream parameter, import_markdown(stream=True)) with their limits stated; the
stream rule had truncated 9 of 931 valid entries in kb/ (fenced frontmatter
examples), which a test over kb/ now guards.

Two old splitters disagreed (a BOM and blanks after the closing --- made the
repository load a typed entry as an event) and eleven readers used
find("---", 3), which closes on a --- inside a quoted value. An AST scan fails
on a new fence of most shapes outside the module (its misses are documented).
pyrite index build on kb/ and the fixture KBs gives the same rows as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@markramm
markramm force-pushed the refactor/one-frontmatter-splitter branch from 5c85219 to c4a0de5 Compare October 5, 2026 20:49
@markramm
markramm marked this pull request as ready for review October 6, 2026 08:02
@markramm
markramm added this pull request to the merge queue Oct 6, 2026
Merged via the queue into dev with commit 7d75837 Oct 6, 2026
21 of 22 checks passed
@markramm
markramm deleted the refactor/one-frontmatter-splitter branch October 6, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant