feat(parser): implement AST-based markdown chunking with full document TOC

- Replace legacy line-based chunking with AST tree approach that builds
  a complete document skeleton with content inlined under relevant
  sections
- Add new chunking parameters: chunk_chars (default 2000) and embed_toc
  (default True) to control content size and TOC inclusion
- Implement recursive chunking algorithm that respects structural
  boundaries (code lines, table rows, list items) and prevents splits
  inside blocks
- Introduce part markers [Part X/N] for oversized leaf blocks that
  require splitting
- Add CLI tool for inspecting parsed chunks and edges with options for
  preview and configuration
- Refactor edge extraction to use FileEdge.from_text instead of
  parse_wikilinks for consistency

BREAKING CHANGE: Chunk format changes significantly with full TOC
skeleton wrapping content, affecting embedding models expecting
breadcrumb prefixes.
This commit is contained in:
huangsen 2026-05-13 14:32:00 +08:00
parent 90da850200
commit 51ec09d98f
8 changed files with 1979 additions and 486 deletions

View file

@ -1,204 +1,795 @@
"""Markdown file parser — frontmatter + wikilink graph + AST-aware chunking.
"""Markdown file parser — frontmatter + wikilink graph + AST tree chunks.
The chunker splits markdown into semantic blocks using:
- ATX headings (#, ##, ..., ######) as section anchors
- Blank lines (paragraph boundaries) as soft splits within a section
- Code fences (``` or ~~~) preserved as a single block
Chunking algorithm (full-doc skeleton + inlined content)
========================================================
Each block carries a `heading_path` breadcrumb prepended to its text gives
the embedding model section context AND lets retrieval results show callers
where the hit lives. The hash is computed over the final text (with
breadcrumb), so renaming a heading correctly invalidates child block
embeddings.
Each chunk renders the **complete heading skeleton of the entire
document**, with this chunk's content inlined under the section that
owns it. Sections that don't own this chunk's content appear as bare
headings every chunk gives the reader a complete map of the document
and shows exactly where its slice belongs.
Hash-diff cache compatibility: blocks with identical (heading_path + body)
across edits produce the same hash, so the file_store can reuse old
embeddings and only call the embedding API for dirty blocks.
Two phases:
Edge extraction inlines `parse_wikilinks` directly: edges live in body
text only (bare wikilinks + Dataview line-level + Dataview inline-bracketed)
and the predicate vocabulary is closed at the `FileEdge` schema layer.
The slow path (maintainer's `enrich_links` / `discover_links` ops) handles
upgrading bare links and discovering new ones.
1. **Build phase** fold mistletoe's flat `Document.children` into a
layered `MdNode` tree. A `section` node owns its heading + body
blocks + child subsections, established by a heading-level stack.
2. **Chunk phase** recursive `chunk(node, parent_section)`:
if len(content) <= chunk_chars:
emit one chunk (TOC wrapped on top, additive to size) and return
if node is section/root:
walk children body siblings pack as a run inside the
current section's TOC slot, subsections recurse
else (leaf body):
split by internal structure (List items / Table rows /
code lines / paragraph lines)
Example doc with sections A, B (containing B1), C chunking content
of B1 produces a chunk like:
# Doc
## A
## B
### B1
<chunk content>
## C
The current section (here `### B1`) is the **owner**: its slot holds
the chunk's body. All other sections appear as bare headings.
**Budget rule**: ``chunk_chars`` constrains the **content** only
the TOC skeleton is added as a free prefix on top of every chunk and
does NOT count toward the budget. This keeps content sizing predictable
even when a doc has a large heading outline.
**Toggle**: ``embed_toc=False`` disables the TOC wrap entirely; chunks
become plain content with no document-level navigation prefix. The
chunking decisions themselves are unchanged only the final emitted
text differs.
Section integrity: a node is split only when it cannot fit as a whole.
Block-internal structure (code lines, table rows, list items) is
respected splits land on those boundaries, never inside.
**Part markers**: when a single leaf block (table / code fence / list /
paragraph) is too large to fit and gets split into N > 1 pieces, each
piece is annotated with a ``[Part X/N]\\n\\n`` prefix so readers know
they're seeing a fragment. Single-piece outputs are unmarked.
Chunk identity: `hash_text(path::start::end::text)` content-deterministic
so the file_store's hash-diff cache hits across re-parses of unchanged
sections.
"""
import re
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import frontmatter
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
from .base_file_parser import BaseFileParser
from ..component_registry import R
from ...enumeration import FileSuffixEnum
from ...schema import FileChunk, FileEdge, FileNode, parse_wikilinks
from ...schema import FileChunk, FileEdge, FileNode
from ...utils import hash_text
# -- Helpers --------------------------------------------------------------
def _kind(node) -> str:
return type(node).__name__
def _is_heading(node) -> bool:
return _kind(node) in ("Heading", "SetextHeading")
def _line_count(text: str) -> int:
return len(text.split("\n")) if text else 0
def _heading_text(node, renderer: MarkdownRenderer) -> str:
"""Heading text without `#` markers (for outline)."""
rendered = renderer.render(node).rstrip("\n")
if rendered.startswith("#"):
return rendered.lstrip("#").strip()
return rendered.split("\n", 1)[0].strip()
# Reserved overhead for the worst-case "[Part NNN/NNN]\n\n" prefix added
# to leaf-block split pieces. Reserved upfront in budgets so the prefix
# fits even for the chunk that just barely passed the size check.
_PART_MARKER_RESERVE = 18
# -- AST tree -------------------------------------------------------------
@dataclass
class MdNode:
"""Composed markdown AST node.
Three synthesised kinds wrap mistletoe blocks into a layered tree:
* ``root`` sole top-level node; ``children`` are bodies and/or
sections; carries no heading.
* ``section`` synthesised from a heading + everything beneath it
until the next equal-or-shallower heading; children
are bodies and child sections.
* ``body`` wraps one mistletoe block (paragraph / list / table /
code / quote / html / etc.); ``block`` is the
original mistletoe node, ``text`` is its rendered
markdown.
Ranges (`start_line`, `end_line`) span the full subtree so callers can
record provenance on emitted chunks.
"""
kind: str # "root" | "section" | "body"
heading: str | None = None
level: int = 0
children: list["MdNode"] = field(default_factory=list)
block: Any = None
text: str = ""
start_line: int = 0
end_line: int = 0
@R.register("md")
class LinkedFileParser(BaseFileParser):
"""Parser for Markdown files with YAML frontmatter and wikilink support."""
"""Markdown parser: frontmatter + wikilink edges + full-skeleton chunks."""
suffixes = [FileSuffixEnum.MD, FileSuffixEnum.MARKDOWN]
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_FENCE_RE = re.compile(r"^(```|~~~)")
def __init__(self, encoding: str = "utf-8", **kwargs):
def __init__(
self,
encoding: str = "utf-8",
chunk_chars: int = 2000,
embed_toc: bool = True,
**kwargs,
):
super().__init__(**kwargs)
self.encoding = encoding
self.chunk_chars = max(100, chunk_chars)
self.embed_toc = embed_toc
async def parse(self, path: str) -> tuple[FileNode, list[FileChunk]]:
async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
file_path = Path(path)
raw = file_path.read_text(encoding=self.encoding)
post = frontmatter.loads(raw)
stat = file_path.stat()
metadata = dict(post.metadata)
content = post.content
absolute_path = str(file_path.absolute())
edges = self._extract_edges(content)
chunks = self.chunk_markdown(content, absolute_path)
edges = self._dedup_edges(FileEdge.from_text(post.content))
chunks = self._chunk(post.content, absolute_path)
node = FileNode(
path=absolute_path,
st_mtime=stat.st_mtime,
edges=edges,
**metadata,
**dict(post.metadata),
)
return node, chunks
# -- Edge extraction --------------------------------------------------
@staticmethod
def _extract_edges(text: str) -> list[FileEdge]:
"""Body-only wikilink extraction with structural dedup."""
def _dedup_edges(edges: list[FileEdge]) -> list[FileEdge]:
seen: set[tuple] = set()
out: list[FileEdge] = []
for edge in parse_wikilinks(text or ""):
key = (edge.target, edge.predicate, edge.anchor, edge.alias, edge.embed)
for e in edges:
key = (e.link, e.predicate)
if key in seen:
continue
seen.add(key)
out.append(edge)
out.append(e)
return out
# -- Chunker ----------------------------------------------------------
# -- Chunker entry ---------------------------------------------------
@staticmethod
def _breadcrumb(heading_path: list[str]) -> str:
return " > ".join(heading_path) if heading_path else ""
@classmethod
def _make_block_text(cls, heading_path: list[str], body: str) -> str:
"""Compose final block text: breadcrumb line (if any) + blank + body."""
body = body.rstrip("\n")
crumb = cls._breadcrumb(heading_path)
if crumb:
return f"{crumb}\n\n{body}" if body else crumb
return body
@classmethod
def chunk_markdown(cls, text: str, path: str) -> list[FileChunk]:
"""Split markdown into AST-aware blocks (headings / paragraphs / fences)."""
def _chunk(self, text: str, path: str) -> list[FileChunk]:
if not text or not text.strip():
return []
out: list[FileChunk] = []
with MarkdownRenderer() as renderer:
doc = Document(text)
tree = self._build_tree(doc, renderer)
self._chunk_tree(tree, tree, tree, renderer, path, out)
return out
lines = text.split("\n")
chunks: list[FileChunk] = []
# -- Build phase: mistletoe doc → MdNode tree -------------------------
heading_stack: list[tuple[int, str]] = [] # [(level, title)]
body_lines: list[str] = []
body_start = 1
in_fence = False
fence_marker = ""
def _build_tree(self, doc, renderer: MarkdownRenderer) -> MdNode:
"""Fold mistletoe's flat children into a section tree.
def current_path() -> list[str]:
return [t for _, t in heading_stack]
Algorithm: walk children with a stack of open sections. Each
heading pops sections of equal-or-deeper level and pushes a new
section. Non-heading blocks attach as ``body`` children to the
current section (or to root before the first heading).
"""
root = MdNode(kind="root", level=0, start_line=1, end_line=1)
stack: list[MdNode] = [root]
def emit(block_text: str, start_line: int, end_line: int) -> None:
h = hash_text(block_text)
chunks.append(
FileChunk(
id=hash_text(f"{path}::{start_line}::{end_line}::{h}::{len(chunks)}"),
path=path,
start_line=start_line,
end_line=end_line,
text=block_text,
hash=h,
),
for child in doc.children or []:
kind = _kind(child)
if kind == "BlankLine":
continue
if _is_heading(child):
level = max(1, getattr(child, "level", 1))
# Close any sections of equal-or-greater level.
while len(stack) > 1 and stack[-1].level >= level:
stack.pop()
sec = MdNode(
kind="section",
heading=_heading_text(child, renderer),
level=level,
start_line=child.line_number or stack[-1].start_line,
)
stack[-1].children.append(sec)
stack.append(sec)
else:
rendered = renderer.render(child).rstrip("\n")
if not rendered:
continue
start = child.line_number or stack[-1].start_line
body = MdNode(
kind="body",
block=child,
text=rendered,
start_line=start,
end_line=start + _line_count(rendered) - 1,
)
stack[-1].children.append(body)
# Propagate end_line bottom-up.
def _close(n: MdNode) -> None:
if not n.children:
if n.end_line < n.start_line:
n.end_line = n.start_line
return
for c in n.children:
_close(c)
n.end_line = max(c.end_line for c in n.children)
n.start_line = min(n.start_line or n.children[0].start_line, n.children[0].start_line)
_close(root)
return root
# -- Chunk phase: recursive ------------------------------------------
def _chunk_tree(
self,
tree: MdNode,
node: MdNode,
parent_section: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Try the whole subtree first; on overflow descend into children.
``tree`` is the whole-document root used to render the full TOC
skeleton (when ``embed_toc`` is on). ``parent_section`` is the
nearest enclosing section/root for body nodes it's the slot
owner; for sections it becomes the slot owner when the section
itself fits whole. The ``chunk_chars`` budget only constrains
``content`` TOC overhead is excluded.
"""
if node.kind == "body":
owner = parent_section
content = node.text
owns_subtree = False
else: # root or section
owner = node
content = self._render_node_content(node)
owns_subtree = True
if not content.strip():
return
if len(content) <= self.chunk_chars:
full = self._finalize(tree, owner, content, owns_subtree)
self._emit(full, node.start_line, node.end_line, file_path, out)
return
if node.kind in ("root", "section"):
self._chunk_children(tree, node, renderer, file_path, out)
else:
self._split_leaf(tree, node, parent_section, renderer, file_path, out)
def _chunk_children(
self,
tree: MdNode,
parent: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Walk a section's children: body run packs together, each
subsection recurses. Body runs use ``parent`` as TOC owner."""
run: list[MdNode] = []
def flush_run() -> None:
nonlocal run
if run:
self._chunk_body_run(tree, run, parent, renderer, file_path, out)
run = []
for c in parent.children:
if c.kind == "section":
flush_run()
self._chunk_tree(tree, c, parent, renderer, file_path, out)
else:
run.append(c)
flush_run()
def _chunk_body_run(
self,
tree: MdNode,
run: list[MdNode],
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""A run of consecutive body siblings sharing ``owner``'s slot.
Try whole run first; on overflow greedy-pack into ``chunk_chars``
(content-only budget); an oversized single body recurses through
``_split_leaf``.
"""
composite = "\n\n".join(b.text for b in run)
if len(composite) <= self.chunk_chars:
full = self._finalize(tree, owner, composite, owns_subtree=False)
self._emit(full, run[0].start_line, run[-1].end_line, file_path, out)
return
budget = self.chunk_chars
bucket: list[MdNode] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
text = "\n\n".join(b.text for b in bucket)
piece = self._finalize(tree, owner, text, owns_subtree=False)
self._emit(piece, bucket[0].start_line, bucket[-1].end_line, file_path, out)
bucket = []
bucket_chars = 0
for body in run:
if len(body.text) > budget:
flush()
self._split_leaf(tree, body, owner, renderer, file_path, out)
continue
sep = 2 if bucket else 0 # "\n\n"
if bucket and bucket_chars + sep + len(body.text) > budget:
flush()
sep = 0
bucket.append(body)
bucket_chars += sep + len(body.text)
flush()
# -- Leaf-internal splitters -----------------------------------------
def _split_leaf(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
kind = _kind(body.block) if body.block is not None else "Paragraph"
if kind == "Table":
self._split_table(tree, body, owner, file_path, out)
elif kind == "CodeFence":
self._split_code(tree, body, owner, file_path, out)
elif kind == "List":
self._split_list(tree, body, owner, renderer, file_path, out)
else:
self._split_lines(tree, body, owner, file_path, out)
def _split_table(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a table by data rows; repeat header + separator per piece."""
table = body.block
rendered = body.text
all_lines = rendered.split("\n")
header = "\n".join(all_lines[:2])
data_lines = all_lines[2:]
rows = [r for r in (table.children or []) if _kind(r) == "TableRow"]
start = body.start_line
if len(rows) == len(data_lines):
units = [
(data_lines[i],
rows[i].line_number or (start + 2 + i),
rows[i].line_number or (start + 2 + i))
for i in range(len(data_lines))
]
else:
units = [
(data_lines[i], start + 2 + i, start + 2 + i)
for i in range(len(data_lines))
]
self._emit_packed(
tree, owner, units, joiner="\n",
wrap=f"{header}\n{{inner}}", file_path=file_path, out=out,
)
def _split_code(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a code fence by body lines; repeat opener / closer per piece."""
code = body.block
indent = " " * (code.indentation or 0)
info = code.info_string or ""
opener = f"{indent}{code.delimiter}{info}"
closer = f"{indent}{code.delimiter}"
wrap = f"{opener}\n{{inner}}\n{closer}"
raw = (code.children[0].content if code.children else "").rstrip("\n")
if not raw:
return
start = body.start_line + 1
units = [
(indent + ln, start + i, start + i)
for i, ln in enumerate(raw.split("\n"))
]
self._emit_packed(
tree, owner, units, joiner="\n", wrap=wrap,
file_path=file_path, out=out, allow_empty=True,
)
def _split_list(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
renderer: MarkdownRenderer,
file_path: str,
out: list[FileChunk],
) -> None:
"""Split a list by items; greedy-pack items.
Multi-part splits annotate each piece with ``[Part X/N]``
(see ``_emit_parts``). An item that exceeds the budget on its
own is emitted as one part (continuity wins over hard cap).
"""
items = [c for c in (body.block.children or []) if _kind(c) == "ListItem"]
if not items:
self._split_lines(tree, body, owner, file_path, out)
return
budget = max(64, self.chunk_chars - _PART_MARKER_RESERVE)
rendered_items = [
(renderer.render(it).rstrip("\n"), it.line_number or body.start_line)
for it in items
]
parts: list[tuple[str, int, int]] = []
bucket: list[tuple[str, int, int]] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
text = "\n".join(t for t, _, _ in bucket)
parts.append((text, bucket[0][1], bucket[-1][2]))
bucket = []
bucket_chars = 0
for text, line in rendered_items:
if not text:
continue
end = line + _line_count(text) - 1
if len(text) > budget:
# Oversized item: emit alone (overflow accepted).
flush()
parts.append((text, line, end))
continue
sep = 1 if bucket else 0 # "\n"
if bucket and bucket_chars + sep + len(text) > budget:
flush()
sep = 0
bucket.append((text, line, end))
bucket_chars += sep + len(text)
flush()
self._emit_parts(tree, owner, parts, wrap="{inner}", file_path=file_path, out=out)
def _split_lines(
self,
tree: MdNode,
body: MdNode,
owner: MdNode,
file_path: str,
out: list[FileChunk],
) -> None:
"""Last-resort split: line-greedy. Used for paragraphs / quotes /
html / oversized list items."""
start = body.start_line
units = [
(line, start + i, start + i)
for i, line in enumerate(body.text.split("\n"))
]
self._emit_packed(
tree, owner, units, joiner="\n", wrap="{inner}",
file_path=file_path, out=out,
)
def _emit_packed(
self,
tree: MdNode,
owner: MdNode,
units: list[tuple[str, int, int]],
joiner: str,
wrap: str,
file_path: str,
out: list[FileChunk],
allow_empty: bool = False,
) -> None:
"""Greedy-pack units into the wrap envelope; emit each bucket
as a chunk via ``_emit_parts`` (which adds ``[Part X/N]`` when
more than one piece results).
The envelope (e.g. table header, code fence) IS counted against
``chunk_chars`` because it's part of the chunk's content. The
TOC skeleton, when ``embed_toc`` is on, is added as a free
prefix downstream.
A unit larger than the inner budget is emitted alone (continuity
wins over hard cap readers can still see the overflowing line).
"""
envelope = len(wrap.replace("{inner}", ""))
inner_budget = max(64, self.chunk_chars - envelope - _PART_MARKER_RESERVE)
sep_len = len(joiner)
parts: list[tuple[str, int, int]] = []
bucket: list[tuple[str, int, int]] = []
bucket_chars = 0
def flush() -> None:
nonlocal bucket, bucket_chars
if not bucket:
return
inner = joiner.join(t for t, _, _ in bucket)
parts.append((inner, bucket[0][1], bucket[-1][2]))
bucket = []
bucket_chars = 0
for text, s, e in units:
if not text and not allow_empty:
continue
sep = sep_len if bucket else 0
if bucket and bucket_chars + sep + len(text) > inner_budget:
flush()
sep = 0
bucket.append((text, s, e))
bucket_chars += sep + len(text)
flush()
self._emit_parts(tree, owner, parts, wrap=wrap, file_path=file_path, out=out)
def _emit_parts(
self,
tree: MdNode,
owner: MdNode,
parts: list[tuple[str, int, int]],
wrap: str,
file_path: str,
out: list[FileChunk],
) -> None:
"""Emit a list of leaf-block split parts.
Each part is a ``(inner_text, start_line, end_line)`` triple.
``wrap`` is a format string with ``{inner}`` substituted per
piece (e.g. ``"| header |\\n{inner}"`` for tables; ``"{inner}"``
for plain line splits).
When ``len(parts) > 1`` each piece is prefixed with
``[Part X/N]\\n\\n`` so readers know it's a fragment of a
larger leaf block. A single part emits with no marker.
"""
total = len(parts)
for idx, (inner, s, e) in enumerate(parts, 1):
piece = wrap.replace("{inner}", inner)
if total > 1:
piece = f"[Part {idx}/{total}]\n\n{piece}"
full = self._finalize(tree, owner, piece, owns_subtree=False)
self._emit(full, s, e, file_path, out)
# -- Render helpers ---------------------------------------------------
def _finalize(
self,
tree: MdNode,
owner: MdNode,
content: str,
owns_subtree: bool,
) -> str:
"""Produce the final chunk text from raw ``content``.
When ``embed_toc`` is on (default), wrap content with the full
document heading skeleton (see ``_render_with_full_toc``) so the
chunk shows where its slice belongs in the doc. When off, return
``content`` unchanged the chunk is just its own text.
``chunk_chars`` is checked against ``content`` upstream of this
call; the TOC skeleton is *additive* to the chunk text and does
not consume the budget. Callers wanting to know the final chunk
length must call ``len(self._finalize(...))`` themselves.
"""
if not self.embed_toc:
return content
return self._render_with_full_toc(tree, owner, content, owns_subtree)
@classmethod
def _render_with_full_toc(
cls,
tree: MdNode,
owner: MdNode,
content: str,
owns_subtree: bool,
) -> str:
"""Render the full document heading skeleton with ``content``
inlined under ``owner``'s heading.
Walks the entire tree. Every section emits its heading; only the
``owner`` slot also emits ``content``. If ``owns_subtree`` is
True, recursion stops at ``owner`` (its subsection headings are
assumed already present in ``content``); otherwise traversal
continues so descendant headings still appear in the TOC.
Bodies are never emitted by this walk they're brought in only
via ``content``. ``owner`` may be the root node, in which case
the content sits before the first heading.
"""
lines: list[str] = []
def walk(node: MdNode) -> None:
if node.kind == "section" and node.heading is not None:
lines.append(f"{'#' * max(1, node.level)} {node.heading}")
if node is owner:
if content:
lines.append(content)
if owns_subtree:
return
for c in node.children:
if c.kind == "section":
walk(c)
walk(tree)
return "\n\n".join(p for p in lines if p)
@classmethod
def _render_node_content(cls, node: MdNode) -> str:
"""Render a node's content BENEATH its own heading.
The node's own heading is NOT included — when `_render_with_full_toc`
emits the section's TOC entry, the slot it appends ``content`` to
already sits below that heading. Subsection headings ARE included
because they're deeper than the focused node and would otherwise
be swallowed when ``owns_subtree=True``.
"""
if node.kind == "body":
return node.text
parts: list[str] = []
for c in node.children:
if c.kind == "section":
sub_heading = f"{'#' * max(1, c.level)} {c.heading or ''}"
inner = cls._render_node_content(c)
parts.append(sub_heading + ("\n\n" + inner if inner else ""))
else:
if c.text:
parts.append(c.text)
return "\n\n".join(parts)
# -- Emit -------------------------------------------------------------
@staticmethod
def _emit(
text: str, start_line: int, end_line: int, path: str, out: list[FileChunk],
) -> None:
chunk_id = hash_text(f"{path}::{start_line}::{end_line}::{text}")
out.append(FileChunk(
id=chunk_id,
path=path,
start_line=start_line,
end_line=end_line,
text=text,
))
# -- CLI: parse a markdown file and print chunks + edges ------------------
def _main() -> None:
"""Parse a markdown file and print its edges + chunks for inspection.
Usage:
python -m reme2.component.file_parser.linked_file_parser <path> [--chunk-chars N]
"""
import argparse
import asyncio
ap = argparse.ArgumentParser(
description="Parse a markdown file with LinkedFileParser and dump chunks + edges.",
)
ap.add_argument("path", help="Path to a markdown file.")
ap.add_argument(
"--chunk-chars", type=int, default=2000,
help="Max characters per chunk content (default: 2000). "
"Excludes TOC skeleton when embed_toc is on.",
)
ap.add_argument(
"--no-toc", action="store_true",
help="Disable the full-doc TOC skeleton wrap; chunks become plain content.",
)
ap.add_argument(
"--show-edges", action="store_true",
help="Print extracted FileEdges before chunks.",
)
ap.add_argument(
"--preview", type=int, default=0,
help="Truncate each chunk to N chars in output (0 = full text).",
)
args = ap.parse_args()
parser = LinkedFileParser(
chunk_chars=args.chunk_chars,
embed_toc=not args.no_toc,
)
node, chunks = asyncio.run(parser.parse(args.path))
print(f"file: {node.path}")
print(f"chunk_chars: {args.chunk_chars}")
print(f"embed_toc: {parser.embed_toc}")
print(f"chunks: {len(chunks)}")
print(f"chars total: {sum(len(c.text) for c in chunks)}")
if chunks:
sizes = [len(c.text) for c in chunks]
print(f"chars min/avg/max: {min(sizes)} / {sum(sizes)//len(sizes)} / {max(sizes)}")
if args.show_edges:
print(f"\nedges ({len(node.edges)}):")
for e in node.edges:
print(
f"{e.link}"
+ (f" predicate={e.predicate}" if e.predicate else "")
+ (f" anchor={e.anchor}" if e.anchor else "")
)
def flush_body(end_line: int) -> None:
nonlocal body_lines, body_start
if not body_lines:
return
# Strip leading/trailing blank lines from the block (paragraph
# boundaries eat their own newline, but whitespace can sneak in
# via the fence path).
while body_lines and not body_lines[0].strip():
body_lines.pop(0)
body_start += 1
while body_lines and not body_lines[-1].strip():
body_lines.pop()
end_line -= 1
if not body_lines:
body_lines = []
return
raw_body = "\n".join(body_lines)
block_text = cls._make_block_text(current_path(), raw_body)
emit(block_text, body_start, end_line)
body_lines = []
for i, c in enumerate(chunks):
print(f"\n{'=' * 72}")
print(f"chunk {i} lines {c.start_line}-{c.end_line} {len(c.text)} chars")
print("-" * 72)
text = c.text if args.preview <= 0 else c.text[: args.preview]
print(text)
if args.preview > 0 and len(c.text) > args.preview:
print(f"... ({len(c.text) - args.preview} more chars truncated)")
for i, line in enumerate(lines, 1):
stripped = line.strip()
# Code fences: keep contents intact, no inner splits.
if not in_fence and cls._FENCE_RE.match(stripped):
if body_lines:
flush_body(end_line=i - 1)
in_fence = True
fence_marker = stripped[:3]
body_lines = [line]
body_start = i
continue
if in_fence:
body_lines.append(line)
if stripped.startswith(fence_marker):
in_fence = False
fence_marker = ""
flush_body(end_line=i)
continue
if __name__ == "__main__":
_main()
# ATX heading: closes prior block, opens a new section.
m = cls._HEADING_RE.match(line)
if m:
if body_lines:
flush_body(end_line=i - 1)
level = len(m.group(1))
title = m.group(2).strip()
heading_stack = [(lv, t) for lv, t in heading_stack if lv < level]
heading_stack.append((level, title))
# Heading line itself becomes a block (so the heading text is
# searchable as its own unit).
block_text = cls._make_block_text(current_path(), "")
emit(block_text, i, i)
body_start = i + 1
continue
# Blank line: paragraph boundary.
if not stripped:
if body_lines:
flush_body(end_line=i - 1)
body_start = i + 1
continue
# Regular content line.
if not body_lines:
body_start = i
body_lines.append(line)
if body_lines:
flush_body(end_line=len(lines))
return chunks

View file

@ -418,7 +418,7 @@ class Maintainer(BaseStep):
2. Read body, ask the LLM (one call per file) to assign one
of `ALLOWED_PREDICATES` to each bare target or 'skip'.
3. For each accepted (target, predicate), locate each bare
occurrence span via `parse_wikilinks` and build a unique
occurrence span via `FileEdge.from_text` and build a unique
context window snippet.
4. Emit EnrichOp(path, target, predicate, old_string, new_string,
confidence, reason). The apply step calls memory_update.

View file

@ -39,7 +39,7 @@ from pathlib import Path
import frontmatter
from ..schema import ChunkFilter, FileChunk, FileNode, extract_wikilinks
from ..schema.file_edge import WIKILINK_RE
from ..schema.file_edge import _WIKILINK_RE
from ..utils.wikilink_resolver import (
resolve_wikilink as _resolve_wikilink,
wikilink_candidates,
@ -119,8 +119,6 @@ def _edge_to_dict(node, edge) -> dict:
"metadata": node.metadata,
"predicate": edge.predicate,
"anchor": edge.anchor,
"alias": edge.alias,
"embed": edge.get_embeddings,
}
@ -232,7 +230,7 @@ def _replace_wikilink_targets(text: str, mapping: dict[str, str]) -> str:
return m.group(0).replace(target_raw, mapping[target], 1)
return m.group(0)
return WIKILINK_RE.sub(sub, text)
return _WIKILINK_RE.sub(sub, text)
def create_file(

View file

@ -5,7 +5,7 @@ from .as_msg_stat import AsBlockStat, AsMsgStat
from .emb_node import EmbNode
from .chunk_filter import ChunkFilter
from .file_chunk import FileChunk
from .file_edge import FileEdge, extract_wikilinks, parse_wikilinks
from .file_edge import FileEdge, extract_wikilinks
from .file_node import FileNode
from .request import Request
from .response import Response
@ -26,5 +26,4 @@ __all__ = [
"Response",
"StreamChunk",
"extract_wikilinks",
"parse_wikilinks",
]

View file

@ -1,16 +1,30 @@
"""FileEdge — typed wikilink edge between vault files.
This module is the single source of truth for both the edge **schema**
and the **inline parser** that recovers edges from body text. Edges
live exclusively in body text (frontmatter is not walked); the
predicate vocabulary is **open** any identifier-shaped token
(`[A-Za-z][A-Za-z0-9_]*`) that the parser sees is preserved verbatim
on `FileEdge.predicate`. Vocabulary curation, if any, is the
maintainer's job, not the schema's.
Single source of truth for the edge **schema** and the **inline parser**
(`FileEdge.from_text`) that recovers edges from body text. Edges live
exclusively in body text (frontmatter is not walked); the predicate
vocabulary is **open** any identifier-shaped token
(`[A-Za-z][A-Za-z0-9_]*`) the parser sees is preserved verbatim on
`FileEdge.predicate`. Vocabulary curation, if any, is the maintainer's
job, not the schema's.
## Inline forms recognised by `parse_wikilinks`
## Inline forms recognised by `FileEdge.from_text`
[[X]] bare wikilink predicate=None
extends:: [[X]] line-level Dataview predicate="extends"
[extends:: [[X]]] inline-bracketed predicate="extends"
Multi-target every wikilink under one typed context inherits its
predicate (any separator works, not just commas):
extends:: [[A]], [[B]] line-level multi 2 edges, both "extends"
extends:: [[A]] and [[B]] prose-style multi 2 edges, both "extends"
[concerns:: [[A]], [[B]]] inline multi 2 edges, both "concerns"
extends:: [[A#s1]], [[B#s2]] multi w/ anchors → anchors preserved per link
Context precedence is **inline-bracketed > line-level > bare**
a wikilink inside a `[predicate:: ]` envelope is typed by that
envelope even if the line happens to start `predicate:: `.
"""
from __future__ import annotations
@ -20,35 +34,19 @@ import re
from pydantic import BaseModel, ConfigDict, Field
# -- Schema ---------------------------------------------------------------
# -- Regexes (module-private) ---------------------------------------------
class FileEdge(BaseModel):
"""5-field minimal edge model. No provenance, no confidence."""
model_config = ConfigDict(extra="forbid")
target: str = Field(..., description="Raw wikilink target as written in source.")
predicate: str | None = Field(
default=None,
description="Typed-edge predicate (Dataview-style). None for bare [[X]].",
)
anchor: str | None = Field(default=None, description="Heading or block anchor (after #).")
alias: str | None = Field(default=None, description="Display alias (after |).")
embed: bool = Field(default=False, description="True for `![[X]]` embed prefix.")
# -- Regexes --------------------------------------------------------------
# Bare wikilink. `(?:!)?` is non-capturing so `m.group(1)` stays the
# target — read the embed prefix off `m.group(0).startswith("!")`.
WIKILINK_RE = re.compile(
# Wikilink. The optional `!` embed marker and `|alias` are matched but
# not captured — both are presentational and dropped from the edge.
# `target` is the file part; `anchor` is the optional `#…` suffix; we
# rejoin them into a single `link` string at edge-construction time.
_WIKILINK_RE = re.compile(
r"""
(?:!)?
\[\[
(?P<target>[^\]\|\#\n]+?)
(?:\#(?P<anchor>[^\]\|\n]+))?
(?:\|(?P<alias>[^\]\n]+))?
(?:\|[^\]\n]+)?
\]\]
""",
re.VERBOSE,
@ -56,50 +54,28 @@ WIKILINK_RE = re.compile(
# Line-level Dataview field. Anchored MULTILINE; allows leading bullet
# (`-`/`*`/`+`) so `- extends:: [[X]]` works inside Markdown lists.
# Predicate identifier follows Dataview convention: letter, then
# letters / digits / underscore.
DATAVIEW_LINE_RE = re.compile(
_DATAVIEW_LINE_RE = re.compile(
r"^[ \t]*(?:[-*+][ \t]+)?(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*(?P<value>.+?)\s*$",
re.MULTILINE,
)
# Inline-bracketed field: opens with `[predicate::`. The closing `]` is
# located by `_iter_inline_fields` via depth-counted bracket scan because
# the value may contain `[[wikilink]]` whose inner `[[…]]` brackets are
# part of the value, not field delimiters.
# Inline-bracketed field opener: `[predicate::`. The matching `]` is
# located by depth-counted scan because the value may contain
# `[[wikilink]]` whose inner brackets are part of the value.
_INLINE_FIELD_OPEN_RE = re.compile(r"\[(?P<predicate>[A-Za-z][A-Za-z0-9_]*)\s*::\s*")
# -- Internal helpers -----------------------------------------------------
def _iter_inline_fields(text: str) -> list[tuple[int, int, str]]:
"""Find inline-bracketed `[predicate:: …]` field spans by depth scan.
def _edge_from_wm(wm: re.Match, *, predicate: str | None) -> FileEdge:
anchor = wm.group("anchor")
alias = wm.group("alias")
return FileEdge(
target=wm.group("target").strip(),
anchor=anchor.strip() if anchor else None,
alias=alias.strip() if alias else None,
embed=wm.group(0).startswith("!"),
predicate=predicate,
)
def _iter_inline_fields(text: str) -> list[tuple[int, int, str, int]]:
"""Find inline-bracketed `[predicate:: …]` fields by depth scan.
Returns ``(start, end, predicate, value_start)`` tuples `value_start`
is the absolute offset where the value begins inside `text`, used to
project wikilink spans back to absolute positions for dedup.
Newlines terminate the scan: an inline field that spans a line break
is treated as malformed and skipped (matches Dataview's parser).
Returns a list of ``(start, end, predicate)`` triples. Newlines
terminate the scan: an inline field that spans a line break is
treated as malformed (matches Dataview semantics) and skipped.
"""
out: list[tuple[int, int, str, int]] = []
out: list[tuple[int, int, str]] = []
for m in _INLINE_FIELD_OPEN_RE.finditer(text):
value_start = m.end()
depth = 1 # the outer '[' was the regex's first character
i = value_start
i = m.end()
n = len(text)
while i < n:
c = text[i]
@ -110,65 +86,139 @@ def _iter_inline_fields(text: str) -> list[tuple[int, int, str, int]]:
elif c == "]":
depth -= 1
if depth == 0:
out.append((m.start(), i + 1, m.group("predicate"), value_start))
out.append((m.start(), i + 1, m.group("predicate")))
break
i += 1
return out
# -- Public parsing API ---------------------------------------------------
# -- Schema ---------------------------------------------------------------
class FileEdge(BaseModel):
"""2-field minimal edge model: ``link`` + ``predicate``.
``link`` preserves the wikilink as written, including any ``#anchor``
suffix (e.g. ``"X"`` or ``"X#sec"``). The presentational ``|alias``
and ``!`` embed prefix are discarded by the parser. ``path`` and
``anchor`` are derived ``@property``s that split ``link`` on the
first ``#`` for callers that want either part without re-splitting
the string themselves.
"""
model_config = ConfigDict(extra="forbid")
link: str = Field(
...,
description=(
"Wikilink as written, preserving any '#anchor' suffix "
"(e.g. 'X' or 'X#sec'). Display alias and embed prefix discarded."
),
)
predicate: str | None = Field(
default=None,
description="Typed-edge predicate (Dataview-style). None for bare [[X]].",
)
@property
def path(self) -> str:
"""File-part of ``link`` (before any ``#anchor``).
For ``"X"`` returns ``"X"``; for ``"X#sec"`` returns ``"X"``;
for ``"topics/Foo#bar"`` returns ``"topics/Foo"``. Always
returns a non-empty string because ``link`` is required and the
regex never matches an empty target.
"""
return self.link.split("#", 1)[0].strip()
@property
def anchor(self) -> str | None:
"""Heading or block anchor parsed from ``link`` (text after first ``#``).
Returns ``None`` if the link has no anchor or the anchor is empty.
"""
if "#" not in self.link:
return None
tail = self.link.split("#", 1)[1].strip()
return tail or None
@classmethod
def _from_match(cls, wm: re.Match, *, predicate: str | None) -> FileEdge:
target = wm.group("target").strip()
anchor = wm.group("anchor")
link = f"{target}#{anchor.strip()}" if anchor else target
return cls(link=link, predicate=predicate)
@classmethod
def from_text(cls, text: str) -> list[FileEdge]:
"""Extract all edges from body text in source order.
Single pass: every wikilink in the text becomes one edge, and
its ``predicate`` is decided by the surrounding context with
precedence **inline-bracketed > line-level > bare**:
* ``[predicate:: [[X]]]`` ``predicate="predicate"``
* ``predicate:: [[X]]`` ``predicate="predicate"`` (line-level Dataview)
* ``[[X]]`` ``predicate=None`` (bare)
No consumed-span bookkeeping, no second sort: ``finditer``
already yields wikilinks in source order, and per-position
classification is unambiguous.
"""
if not text:
return []
# Inline-bracketed `[predicate:: ...]` envelopes need a
# depth-counted scan (regex can't match balanced `[[…]]` inside).
inline_spans = _iter_inline_fields(text)
return [
cls._from_match(wm, predicate=_predicate_for(text, wm.start(), inline_spans))
for wm in _WIKILINK_RE.finditer(text)
]
def _predicate_for(
text: str,
pos: int,
inline_spans: list[tuple[int, int, str]],
) -> str | None:
"""Resolve the predicate governing a wikilink at offset ``pos``.
Checks the two typed-edge contexts in precedence order; falls
through to ``None`` (bare) when neither applies.
"""
# 1. Inline-bracketed envelope `[predicate:: …]` containing pos.
for field_start, field_end, predicate in inline_spans:
if field_start <= pos < field_end:
return predicate
# 2. Line-level `predicate:: value` whose value range covers pos.
line_start = text.rfind("\n", 0, pos) + 1
line_end = text.find("\n", pos)
if line_end == -1:
line_end = len(text)
m = _DATAVIEW_LINE_RE.match(text[line_start:line_end])
if m and line_start + m.start("value") <= pos:
return m.group("predicate")
# 3. Bare wikilink — no predicate.
return None
# -- Public utility (target-only fast path) -------------------------------
def extract_wikilinks(text: str) -> list[str]:
"""Targets-only list of wikilinks in body text (no dedup).
"""Flat list of wikilink **file targets** in body text (no dedup).
Used by callers that need to follow links structurally without caring
about predicates (e.g. the ingestor's auto-discovery hint).
Returns just the file part of each wikilink (before any ``#anchor``)
because callers feed the result to `resolve_wikilink`, which matches
against vault file stems / paths and would not recognise an anchor
suffix. Single regex pass cheaper than `FileEdge.from_text` when
callers don't need predicates (e.g. ingestor's auto-discovery hint,
memory_io anchor resolution).
"""
if not text:
return []
return [m.group("target").strip() for m in WIKILINK_RE.finditer(text)]
def parse_wikilinks(text: str) -> list[FileEdge]:
"""Structured parse of all three edge forms.
Order is by source position. Wikilinks attributed to an inline-
bracketed or line-level field are not also reported as bare; the
same wikilink span is consumed exactly once.
"""
if not text:
return []
consumed: list[tuple[int, int]] = []
items: list[tuple[int, FileEdge]] = []
# 1. Inline-bracketed: most specific, handled first.
for field_start, field_end, predicate, value_start in _iter_inline_fields(text):
value = text[value_start : field_end - 1]
for wm in WIKILINK_RE.finditer(value):
items.append((field_start, _edge_from_wm(wm, predicate=predicate)))
consumed.append((field_start, field_end))
# 2. Line-level: skip if entirely inside an inline-bracketed span.
for m in DATAVIEW_LINE_RE.finditer(text):
if any(cs <= m.start() and m.end() <= ce for cs, ce in consumed):
continue
predicate = m.group("predicate")
value = m.group("value")
value_start = m.start("value")
for wm in WIKILINK_RE.finditer(value):
wl_start = value_start + wm.start()
wl_end = value_start + wm.end()
items.append((wl_start, _edge_from_wm(wm, predicate=predicate)))
consumed.append((wl_start, wl_end))
# 3. Bare: anything left over.
for wm in WIKILINK_RE.finditer(text):
s, e = wm.span()
if any(cs <= s and e <= ce for cs, ce in consumed):
continue
items.append((s, _edge_from_wm(wm, predicate=None)))
items.sort(key=lambda pair: pair[0])
return [edge for _, edge in items]
return [m.group("target").strip() for m in _WIKILINK_RE.finditer(text)]

348
tests/test_file_edge.py Normal file
View file

@ -0,0 +1,348 @@
"""FileEdge unit tests — body-only edge extraction.
Covers the three legal inline forms (bare / line-level Dataview /
inline-bracketed Dataview), multi-target expansion, dedup against
typed wrappers, open-vocabulary predicate pass-through, the explicit
decision that frontmatter is no longer walked for links, and the
2-field schema (`link` + `predicate`) with `anchor` as a derived
property and `alias` / `embed` discarded at parse time.
"""
from reme2.schema.file_edge import FileEdge, extract_wikilinks
# --------------------------------------------------------------------------
# Bare wikilinks
# --------------------------------------------------------------------------
def test_bare_wikilink():
edges = FileEdge.from_text("see [[X]]")
assert len(edges) == 1
assert edges[0].link == "X"
assert edges[0].predicate is None
assert edges[0].anchor is None
def test_anchor_alias_embed_collapse_into_link():
"""`![[X#sec|Alias]]` → link='X#sec' (alias and embed dropped)."""
edges = FileEdge.from_text("![[X#sec|Alias]]")
assert len(edges) == 1
assert edges[0].link == "X#sec"
# anchor is a derived property parsed from link.
assert edges[0].anchor == "sec"
def test_alias_only_drops_to_link():
edges = FileEdge.from_text("[[X|Alias]]")
assert len(edges) == 1
assert edges[0].link == "X"
assert edges[0].anchor is None
def test_embed_only_drops_to_link():
edges = FileEdge.from_text("![[X]]")
assert len(edges) == 1
assert edges[0].link == "X"
# --------------------------------------------------------------------------
# Line-level Dataview
# --------------------------------------------------------------------------
def test_line_level_field():
edges = FileEdge.from_text("extends:: [[Source Topic]]")
assert len(edges) == 1
assert edges[0].link == "Source Topic"
assert edges[0].predicate == "extends"
def test_line_level_multi_target():
edges = FileEdge.from_text("concerns:: [[A]], [[B]], [[C]]")
assert [(e.link, e.predicate) for e in edges] == [
("A", "concerns"),
("B", "concerns"),
("C", "concerns"),
]
def test_line_level_with_bullet():
edges = FileEdge.from_text("- extends:: [[X]]\n * concerns:: [[Y]]")
assert [(e.link, e.predicate) for e in edges] == [
("X", "extends"),
("Y", "concerns"),
]
# --------------------------------------------------------------------------
# Multi-edge cases — many wikilinks under one or several typed contexts
# --------------------------------------------------------------------------
def test_multi_target_with_anchors_preserves_each():
"""Each comma-separated target keeps its own anchor in `link`."""
edges = FileEdge.from_text("extends:: [[A#sec1]], [[B#sec2]], [[C]]")
assert [(e.link, e.anchor, e.predicate) for e in edges] == [
("A#sec1", "sec1", "extends"),
("B#sec2", "sec2", "extends"),
("C", None, "extends"),
]
def test_multi_target_non_comma_separator_still_typed():
"""Wikilinks anywhere in the value range (not just comma-separated)
inherit the line's predicate. Useful for prose-style fields."""
edges = FileEdge.from_text("extends:: [[A]] and also [[B]]")
assert [(e.link, e.predicate) for e in edges] == [
("A", "extends"),
("B", "extends"),
]
def test_multi_dataview_lines_each_multi_target():
"""Multiple Dataview lines each with multi-target → all edges typed
by their respective line's predicate."""
edges = FileEdge.from_text(
"extends:: [[A]], [[B]]\nrelates:: [[C]], [[D]]"
)
assert [(e.link, e.predicate) for e in edges] == [
("A", "extends"),
("B", "extends"),
("C", "relates"),
("D", "relates"),
]
def test_inline_bracketed_then_bare_on_same_line():
"""Inline-bracketed governs only the wikilinks inside its brackets;
a trailing bare wikilink on the same line stays bare."""
edges = FileEdge.from_text("[ext:: [[A]]] then [[B]]")
assert [(e.link, e.predicate) for e in edges] == [
("A", "ext"),
("B", None),
]
def test_mid_line_dataview_like_not_typed():
"""``predicate::`` only counts at line start (modulo bullet) —
a `predicate::` mid-line is just prose, so its wikilinks are bare."""
edges = FileEdge.from_text("[ext:: [[A]]] and concerns:: [[B]]")
assert [(e.link, e.predicate) for e in edges] == [
("A", "ext"),
("B", None), # `concerns::` mid-line is not Dataview
]
# --------------------------------------------------------------------------
# Inline-bracketed Dataview
# --------------------------------------------------------------------------
def test_inline_bracketed():
edges = FileEdge.from_text("This [extends:: [[Y]]] something else.")
assert len(edges) == 1
assert edges[0].link == "Y"
assert edges[0].predicate == "extends"
def test_inline_bracketed_multi_target():
edges = FileEdge.from_text("[concerns:: [[A]], [[B]]]")
assert [(e.link, e.predicate) for e in edges] == [
("A", "concerns"),
("B", "concerns"),
]
def test_inline_bracketed_skips_cross_line():
# A `[predicate:: ...]` that spans a newline is malformed → the inner
# wikilink falls back to bare; the unmatched `[` does not eat tail text.
edges = FileEdge.from_text("[extends:: [[X]]\nbad]")
assert len(edges) == 1
assert edges[0].link == "X"
assert edges[0].predicate is None
# --------------------------------------------------------------------------
# Dedup: a wikilink inside a typed wrapper should not double-emit
# --------------------------------------------------------------------------
def test_inline_bracketed_does_not_double_emit():
edges = FileEdge.from_text("see [extends:: [[X]]] again.")
assert len(edges) == 1
assert edges[0].predicate == "extends"
def test_line_level_value_does_not_double_emit():
edges = FileEdge.from_text("extends:: [[X]]")
assert len(edges) == 1
def test_typed_and_bare_coexist_for_same_target():
edges = FileEdge.from_text("extends:: [[X]]\nFree text mentioning [[X]] again.")
links_preds = sorted(((e.link, e.predicate or "") for e in edges))
assert links_preds == [("X", ""), ("X", "extends")]
# --------------------------------------------------------------------------
# Open-vocabulary predicates — any identifier-shaped token passes through
# --------------------------------------------------------------------------
def test_arbitrary_predicate_preserved():
edges = FileEdge.from_text("anything_goes:: [[X]]")
assert len(edges) == 1
assert edges[0].link == "X"
assert edges[0].predicate == "anything_goes"
def test_inline_arbitrary_predicate_preserved():
edges = FileEdge.from_text("[wat:: [[X]]]")
assert len(edges) == 1
assert edges[0].predicate == "wat"
def test_predicate_must_be_identifier_shaped():
# A leading digit fails the regex `[A-Za-z][A-Za-z0-9_]*` so the line is
# not recognised as a Dataview field — the wikilink falls back to bare.
edges = FileEdge.from_text("123bad:: [[X]]")
assert len(edges) == 1
assert edges[0].link == "X"
assert edges[0].predicate is None
def test_file_edge_accepts_any_predicate_string():
e = FileEdge(link="X", predicate="totally_made_up")
assert e.predicate == "totally_made_up"
# --------------------------------------------------------------------------
# Frontmatter is NOT walked for links — explicit regression
# --------------------------------------------------------------------------
def test_frontmatter_links_block_is_ignored():
# Even if a YAML-shaped string sits at the top of body, FileEdge.from_text
# only operates on body. We pass body text directly here, so this test
# asserts the API surface no longer accepts a metadata dict.
import inspect
sig = inspect.signature(FileEdge.from_text)
assert list(sig.parameters.keys()) == ["text"], (
"FileEdge.from_text should accept body text only — frontmatter walk removed"
)
def test_no_frontmatter_walker_exported():
from reme2.schema import file_edge as fe
for removed in (
"parse_wikilinks_from_metadata",
"extract_wikilinks_from_metadata",
"extract_inline_fields",
"extract_typed_edges",
"InlineField",
):
assert not hasattr(fe, removed), (
f"{removed} should have been removed when YAML edges were dropped"
)
# --------------------------------------------------------------------------
# FileEdge schema
# --------------------------------------------------------------------------
def test_file_edge_extra_forbid():
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
FileEdge(link="X", target="X") # type: ignore[call-arg]
def test_file_edge_minimal_field_set():
"""Stored fields are just `link` and `predicate`. `path` and `anchor`
are `@property`s (not stored fields) so they shouldn't appear in
`model_dump()`. `target` / `alias` / `embed` were dropped entirely."""
e = FileEdge(link="X")
dumped = e.model_dump()
assert set(dumped.keys()) == {"link", "predicate"}
for removed in ("target", "alias", "embed", "anchor", "path"):
assert removed not in dumped
def test_anchor_property_parses_from_link():
"""`anchor` is derived from `link`, not stored separately."""
assert FileEdge(link="X").anchor is None
assert FileEdge(link="X#sec").anchor == "sec"
assert FileEdge(link="X#sec#more").anchor == "sec#more"
# Empty anchor is treated as no anchor.
assert FileEdge(link="X#").anchor is None
assert FileEdge(link="X# ").anchor is None
def test_path_property_parses_from_link():
"""`path` is derived from `link` — file part before any `#anchor`."""
assert FileEdge(link="X").path == "X"
assert FileEdge(link="X#sec").path == "X"
assert FileEdge(link="topics/Foo").path == "topics/Foo"
assert FileEdge(link="topics/Foo#bar").path == "topics/Foo"
# Multiple '#' — only first splits; the rest live in anchor.
assert FileEdge(link="X#a#b").path == "X"
assert FileEdge(link="X#a#b").anchor == "a#b"
# Empty anchor → path is still the full prefix.
assert FileEdge(link="X#").path == "X"
def test_path_anchor_roundtrip_via_link():
"""Reconstructing `link` from `path` + `anchor` yields the original."""
for link in ("X", "X#sec", "topics/Foo", "topics/Foo#bar", "X#a#b"):
e = FileEdge(link=link)
rebuilt = e.path if not e.anchor else f"{e.path}#{e.anchor}"
assert rebuilt == link, f"{link!r}{rebuilt!r}"
def test_anchor_is_not_constructor_arg():
"""Since `anchor` and `path` are properties, passing them to the
constructor should fail (extra='forbid')."""
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
FileEdge(link="X", anchor="sec") # type: ignore[call-arg]
with pytest.raises(ValidationError):
FileEdge(link="X", path="X") # type: ignore[call-arg]
# --------------------------------------------------------------------------
# Back-compat: extract_wikilinks returns flat target list (used by ingestor)
# --------------------------------------------------------------------------
def test_extract_wikilinks_flat_targets():
targets = extract_wikilinks("see [[X]] and extends:: [[Y]] and [extends:: [[Z]]]")
assert targets == ["X", "Y", "Z"]
def test_extract_wikilinks_strips_anchor():
"""`extract_wikilinks` returns just the file part — anchor stripped
so callers can feed it to `resolve_wikilink`."""
targets = extract_wikilinks("see [[X#sec]] and ![[Y#a|alias]]")
assert targets == ["X", "Y"]
# --------------------------------------------------------------------------
# Source ordering stability
# --------------------------------------------------------------------------
def test_edges_sorted_by_source_position():
body = (
"intro [[First]] then\n"
"extends:: [[Second]]\n"
"tail [[Third]]\n"
)
edges = FileEdge.from_text(body)
assert [e.link for e in edges] == ["First", "Second", "Third"]

726
tests/test_md_chunker.py Normal file
View file

@ -0,0 +1,726 @@
"""Markdown AST chunker tests — full-skeleton TOC + inlined content.
Each chunk renders the **complete heading skeleton of the document**
(every heading, top-to-bottom) with the chunk's content inlined under
the section that owns it. Sections that don't own this chunk's content
appear as bare headings every chunk gives the reader a full document
map.
Covers:
* Tree build: heading-stack folding, section ranges, body wrap-up.
* Whole-fit: small docs / sections emit as a single chunk.
* Skeleton completeness: every chunk lists every doc heading.
* Owner positioning: content sits under the right section's heading.
* Body run packing: bodies under one section share the same owner slot.
* Subsection recursion: each subsection chunks under the same skeleton
with its own owner slot.
* Leaf split: lists / tables / code fences / paragraphs split internally
with their structural header (table separator, code fence, list bullet)
preserved per piece and the full doc skeleton wraps each piece.
"""
from reme2.component.file_parser.linked_file_parser import (
LinkedFileParser,
MdNode,
)
def _parser(chunk_chars: int, embed_toc: bool = True) -> LinkedFileParser:
"""Construct a parser without invoking BaseComponent.__init__ (no app context)."""
p = LinkedFileParser.__new__(LinkedFileParser)
p.encoding = "utf-8"
p.chunk_chars = chunk_chars
p.embed_toc = embed_toc
return p
def _all_headings(text: str) -> list[str]:
"""All markdown heading lines in `text`, in order."""
return [ln.strip() for ln in text.split("\n") if ln.lstrip().startswith("#")]
# --------------------------------------------------------------------------
# Tree build
# --------------------------------------------------------------------------
def test_tree_groups_under_headings():
txt = (
"# Top\n"
"para1\n"
"\n"
"## Sub A\n"
"para2\n"
"\n"
"### Deeper\n"
"para3\n"
"\n"
"## Sub B\n"
"para4\n"
)
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(2000)
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
assert tree.kind == "root"
assert len(tree.children) == 1
h1 = tree.children[0]
assert h1.kind == "section" and h1.heading == "Top" and h1.level == 1
kinds = [c.kind for c in h1.children]
assert kinds == ["body", "section", "section"]
sub_a, sub_b = h1.children[1], h1.children[2]
assert sub_a.heading == "Sub A" and sub_a.level == 2
assert sub_b.heading == "Sub B" and sub_b.level == 2
assert [c.kind for c in sub_a.children] == ["body", "section"]
deeper = sub_a.children[1]
assert deeper.heading == "Deeper" and deeper.level == 3
def test_tree_handles_body_before_first_heading():
txt = "intro paragraph\n\n# H1\nbody\n"
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(2000)
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
assert [c.kind for c in tree.children] == ["body", "section"]
def test_tree_section_pop_on_equal_level():
txt = "# Top\n## A\nx\n## B\ny\n"
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(2000)
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
h1 = tree.children[0]
assert [c.heading for c in h1.children if c.kind == "section"] == ["A", "B"]
# --------------------------------------------------------------------------
# Whole-fit emit
# --------------------------------------------------------------------------
def test_small_doc_emits_one_chunk():
txt = "# Top\nhello world\n"
chunks = _parser(500)._chunk(txt, "/x.md")
assert len(chunks) == 1
text = chunks[0].text
assert "# Top" in text and "hello world" in text
# --------------------------------------------------------------------------
# Full-skeleton TOC: every chunk shows every heading
# --------------------------------------------------------------------------
def test_every_chunk_lists_every_doc_heading():
"""No matter which slice is being chunked, the chunk text must
contain every heading in the document that's what 'complete TOC
structure' means."""
txt = (
"# Doc\n"
"intro paragraph here\n"
"\n"
"## Section A\n"
"para A content here long\n"
"\n"
"### Subsection\n"
"deeper content here long\n"
"\n"
"## Section B\n"
"para B content long\n"
)
chunks = _parser(80)._chunk(txt, "/x.md")
assert len(chunks) >= 3
expected_headings = {"# Doc", "## Section A", "### Subsection", "## Section B"}
for c in chunks:
present = set(_all_headings(c.text))
assert expected_headings <= present, (
f"chunk missing headings {expected_headings - present}: {c.text!r}"
)
def test_owner_section_holds_chunk_content():
"""The chunk's content sits directly under its owner heading — not
under any other section's heading."""
txt = (
"# Doc\n"
"\n"
"## A\n"
"alpha alpha alpha alpha here\n"
"\n"
"## B\n"
"bravo bravo bravo bravo here\n"
)
chunks = _parser(60)._chunk(txt, "/x.md")
a_chunk = next(c for c in chunks if "alpha" in c.text)
b_chunk = next(c for c in chunks if "bravo" in c.text)
# In A's chunk, "alpha" must appear AFTER "## A" and BEFORE "## B"
a_idx = a_chunk.text.find("alpha")
a_a_idx = a_chunk.text.find("## A")
a_b_idx = a_chunk.text.find("## B")
assert a_a_idx < a_idx < a_b_idx
# In B's chunk, "bravo" must appear AFTER "## B"
b_idx = b_chunk.text.find("bravo")
b_b_idx = b_chunk.text.find("## B")
assert b_b_idx < b_idx
# And the OTHER section's body must NOT appear in this chunk.
assert "alpha" not in b_chunk.text
assert "bravo" not in a_chunk.text
def test_skeleton_unchanged_across_chunks():
"""Strip out body content — every chunk should yield the same
sequence of heading lines (the doc's skeleton)."""
txt = (
"# Top\n"
"\n"
"## A\n"
"aaa aaa aaa long\n"
"\n"
"## B\n"
"bbb bbb bbb long\n"
"\n"
"## C\n"
"ccc ccc ccc long\n"
)
chunks = _parser(60)._chunk(txt, "/x.md")
skeletons = [_all_headings(c.text) for c in chunks]
expected = ["# Top", "## A", "## B", "## C"]
for sk in skeletons:
assert sk == expected
def test_subsection_skeleton_preserved():
"""Subsection headings still appear in EVERY chunk's skeleton, not
just the chunk that owns the subsection's content."""
txt = (
"# Doc\n"
"\n"
"## A\n"
"para A long content here\n"
"\n"
"## B\n"
"\n"
"### B1\n"
"deep content here long\n"
"\n"
"## C\n"
"para C long content here\n"
)
chunks = _parser(60)._chunk(txt, "/x.md")
a_chunk = next(c for c in chunks if "para A" in c.text)
# B1 heading must appear in A's chunk too — full skeleton preserved.
assert "### B1" in a_chunk.text
assert "## C" in a_chunk.text
# And in B1's chunk, A's heading must appear before B's.
b1_chunk = next(c for c in chunks if "deep content" in c.text)
assert "## A" in b1_chunk.text
assert "## B" in b1_chunk.text
assert "### B1" in b1_chunk.text
assert "## C" in b1_chunk.text
# --------------------------------------------------------------------------
# Body run packing
# --------------------------------------------------------------------------
def test_body_run_greedy_packs_under_one_owner():
"""Multiple bodies under one section pack into one chunk; each
chunk still carries the full skeleton."""
txt = (
"# Top\n"
"\n"
"## Single\n"
"para1 content here\n"
"\n"
"para2 different content\n"
"\n"
"para3 last paragraph\n"
)
chunks = _parser(80)._chunk(txt, "/x.md")
for c in chunks:
# Skeleton has both top and single.
assert "# Top" in c.text
assert "## Single" in c.text
joined = "\n".join(c.text for c in chunks)
for tag in ("para1", "para2", "para3"):
assert tag in joined
# --------------------------------------------------------------------------
# Leaf splits: full skeleton wraps each piece
# --------------------------------------------------------------------------
def test_table_split_keeps_skeleton():
txt = (
"# Doc\n"
"\n"
"## Tables\n"
"\n"
"| name | value |\n"
"|------|-------|\n"
"| r1 | a |\n"
"| r2 | b |\n"
"| r3 | c |\n"
"| r4 | d |\n"
"\n"
"## Other\n"
"other content\n"
)
chunks = _parser(80)._chunk(txt, "/x.md")
table_chunks = [c for c in chunks if "| name | value |" in c.text]
assert len(table_chunks) >= 2
for c in table_chunks:
# Header repeats per piece.
assert "| name | value |" in c.text
assert "----" in c.text
# Skeleton complete: # Doc, ## Tables, ## Other all present.
assert "# Doc" in c.text
assert "## Tables" in c.text
assert "## Other" in c.text
def test_code_fence_split_keeps_skeleton():
txt = (
"# Doc\n"
"\n"
"## Code\n"
"\n"
"```python\n"
"def line1():\n"
" pass\n"
"\n"
"def line2():\n"
" pass\n"
"\n"
"def line3():\n"
" pass\n"
"\n"
"def line4():\n"
" pass\n"
"```\n"
"\n"
"## After\n"
"after content\n"
)
chunks = _parser(100)._chunk(txt, "/x.md")
code_chunks = [c for c in chunks if "```python" in c.text]
assert len(code_chunks) >= 2
for c in code_chunks:
# Fence opener + closer repeat per piece.
assert "```python" in c.text
# Skeleton: # Doc, ## Code, ## After.
assert "# Doc" in c.text
assert "## Code" in c.text
assert "## After" in c.text
def test_list_split_keeps_skeleton():
txt = (
"# Doc\n"
"\n"
"## Items\n"
"\n"
"- item one with some text\n"
"- item two with text\n"
"- item three text\n"
"- item four text\n"
"- item five text\n"
"- item six text\n"
"\n"
"## After\n"
"after content\n"
)
chunks = _parser(110)._chunk(txt, "/x.md")
list_chunks = [c for c in chunks if "- item" in c.text]
assert len(list_chunks) >= 2
for c in list_chunks:
assert "# Doc" in c.text
assert "## Items" in c.text
assert "## After" in c.text
joined = "\n".join(c.text for c in list_chunks)
for tag in ("one", "two", "three", "four", "five", "six"):
assert tag in joined
def test_paragraph_line_split_keeps_skeleton():
txt = (
"# P\n"
"\n"
"## Section\n"
"alpha line one with extra padding text here\n"
"beta line two with extra padding text here\n"
"gamma line three with extra padding text here\n"
"delta line four with extra padding text here\n"
"\n"
"## After\n"
"after content\n"
)
chunks = _parser(110)._chunk(txt, "/x.md")
para_chunks = [c for c in chunks if any(t in c.text for t in ("alpha", "beta", "gamma", "delta"))]
assert len(para_chunks) >= 2
for c in para_chunks:
assert "# P" in c.text
assert "## Section" in c.text
assert "## After" in c.text
# --------------------------------------------------------------------------
# embed_toc toggle + content-only budget
# --------------------------------------------------------------------------
def test_embed_toc_off_strips_skeleton():
"""With embed_toc=False, chunks contain only their own content —
no full-doc heading skeleton wrapping them."""
txt = (
"# Doc\n"
"\n"
"## A\n"
"para A long content here\n"
"\n"
"## B\n"
"para B long content here\n"
)
chunks = _parser(60, embed_toc=False)._chunk(txt, "/x.md")
a = next(c for c in chunks if "para A" in c.text)
b = next(c for c in chunks if "para B" in c.text)
# Neither chunk should carry the OTHER section's heading.
assert "## B" not in a.text
assert "## A" not in b.text
# And neither should carry the doc title (no full-doc TOC).
assert "# Doc" not in a.text
assert "# Doc" not in b.text
def test_embed_toc_off_content_only():
"""A whole-doc chunk under embed_toc=False is just the rendered
content its own section headings remain (they're part of the
content), but no extra TOC wrap is added."""
txt = "# Top\nhello world content here\n"
chunks = _parser(500, embed_toc=False)._chunk(txt, "/x.md")
assert len(chunks) == 1
text = chunks[0].text.strip()
# The doc's own heading IS the content of the root chunk.
assert text.startswith("# Top")
assert "hello world content here" in text
# But chunk size matches just the rendered doc — no extra prefix
# would have been added that isn't in the source.
assert text == "# Top\n\nhello world content here"
def test_embed_toc_default_is_on():
"""Default behavior keeps TOC embedding on."""
p = _parser(500)
assert p.embed_toc is True
chunks = p._chunk("# Top\nbody content\n", "/x.md")
assert "# Top" in chunks[0].text
def test_chunk_chars_constrains_content_only():
"""chunk_chars limits CONTENT size; the TOC skeleton is additive
and may push final chunk text well beyond chunk_chars."""
# Doc with a deep heading skeleton (~70 chars) and short body.
txt = (
"# H1 long heading title here\n"
"## H2 long subheading title\n"
"### H3 deep heading title\n"
"x\n" # body of H3
)
chunks = _parser(50)._chunk(txt, "/x.md")
assert len(chunks) == 1
final = chunks[0].text
# Final chunk text > chunk_chars because TOC was added on top.
assert len(final) > 50
# All headings present (full skeleton).
assert "# H1" in final and "## H2" in final and "### H3" in final
# Body present.
assert "x" in final
def test_body_run_budget_excludes_toc():
"""A body run greedy-pack uses chunk_chars purely for body content;
the TOC skeleton overhead doesn't squeeze the budget."""
# Two bodies whose joined size = 50 chars; with TOC skeleton,
# old behavior (TOC counted) might split, but new should fit.
txt = (
"# Top\n"
"## Sub long heading title here\n"
"abcdefghij abcdefghij abcdefghij\n" # 32 chars body
"\n"
"klmnopqrst klmnopqrst klmnopqrst\n" # 32 chars body
)
# 80 chars budget covers the joined body (32+2+32=66) but is
# smaller than body+TOC under old (counting) semantics (~120).
chunks = _parser(80)._chunk(txt, "/x.md")
# Both bodies in one chunk because content-only budget allows it.
assert len(chunks) == 1
assert "abcdefghij" in chunks[0].text
assert "klmnopqrst" in chunks[0].text
def test_finalize_off_returns_content_unchanged():
"""White-box: _finalize with embed_toc=False is the identity for content."""
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(500, embed_toc=False)
txt = "# A\n## B\nbody\n"
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
h1 = tree.children[0]
b = h1.children[0] # ## B
out = p._finalize(tree, b, "RAW", owns_subtree=False)
assert out == "RAW"
def test_finalize_on_wraps_with_toc():
"""White-box: _finalize with embed_toc=True wraps content with skeleton."""
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(500, embed_toc=True)
txt = "# A\n## B\nbody\n"
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
h1 = tree.children[0]
b = h1.children[0]
out = p._finalize(tree, b, "RAW", owns_subtree=False)
assert "# A" in out and "## B" in out and "RAW" in out
# --------------------------------------------------------------------------
# Empty / whitespace-only
# --------------------------------------------------------------------------
def test_empty_text_yields_no_chunks():
assert _parser(500)._chunk("", "/x.md") == []
assert _parser(500)._chunk(" \n\n ", "/x.md") == []
# --------------------------------------------------------------------------
# Leaf-block split parts: [Part X/N] markers
# --------------------------------------------------------------------------
def test_table_split_marks_parts():
"""Table split into N pieces gets [Part X/N] prefix on each."""
txt = (
"# Doc\n"
"\n"
"## T\n"
"\n"
"| a | b |\n"
"|---|---|\n"
"| 1 | x |\n"
"| 2 | y |\n"
"| 3 | z |\n"
"| 4 | u |\n"
"| 5 | v |\n"
"| 6 | w |\n"
)
chunks = _parser(80)._chunk(txt, "/x.md")
table_chunks = [c for c in chunks if "| a | b" in c.text]
assert len(table_chunks) >= 2
n = len(table_chunks)
for i, c in enumerate(table_chunks, 1):
assert f"[Part {i}/{n}]" in c.text
# The marker sits BEFORE the table header.
for c in table_chunks:
marker_pos = c.text.find("[Part")
header_pos = c.text.find("| a")
assert 0 <= marker_pos < header_pos
def test_code_split_marks_parts():
txt = (
"# Doc\n"
"\n"
"## C\n"
"\n"
"```python\n"
"def f1(): pass\n"
"def f2(): pass\n"
"def f3(): pass\n"
"def f4(): pass\n"
"def f5(): pass\n"
"def f6(): pass\n"
"```\n"
)
chunks = _parser(70)._chunk(txt, "/x.md")
code_chunks = [c for c in chunks if "```python" in c.text]
assert len(code_chunks) >= 2
n = len(code_chunks)
for i, c in enumerate(code_chunks, 1):
assert f"[Part {i}/{n}]" in c.text
# Marker before the fence opener.
assert c.text.find("[Part") < c.text.find("```python")
def test_list_split_marks_parts():
txt = (
"# Doc\n"
"\n"
"## L\n"
"\n"
"- item one with extra padding text content here\n"
"- item two with extra padding text content here\n"
"- item three with extra padding text content here\n"
"- item four with extra padding text content here\n"
"- item five with extra padding text content here\n"
"- item six with extra padding text content here\n"
)
chunks = _parser(120)._chunk(txt, "/x.md")
list_chunks = [c for c in chunks if "- item" in c.text]
assert len(list_chunks) >= 2
n = len(list_chunks)
for i, c in enumerate(list_chunks, 1):
assert f"[Part {i}/{n}]" in c.text
def test_paragraph_split_marks_parts():
txt = (
"# Doc\n"
"\n"
"## P\n"
"alpha line one with extra padding text here\n"
"beta line two with extra padding text here\n"
"gamma line three with extra padding text here\n"
"delta line four with extra padding text here\n"
)
chunks = _parser(100)._chunk(txt, "/x.md")
para_chunks = [
c for c in chunks
if any(t in c.text for t in ("alpha", "beta", "gamma", "delta"))
]
assert len(para_chunks) >= 2
n = len(para_chunks)
for i, c in enumerate(para_chunks, 1):
assert f"[Part {i}/{n}]" in c.text
def test_single_piece_leaf_has_no_part_marker():
"""When a leaf block fits in one piece, no [Part] prefix is added."""
txt = (
"# Doc\n"
"\n"
"## T\n"
"\n"
"| a | b |\n"
"|---|---|\n"
"| 1 | 2 |\n"
)
chunks = _parser(500)._chunk(txt, "/x.md")
assert len(chunks) == 1
assert "[Part" not in chunks[0].text
def test_part_marker_absent_for_body_run_packing():
"""Body run packing (separate blocks under one section) doesn't
use [Part] markers that's reserved for splitting ONE leaf block."""
txt = (
"# Doc\n"
"\n"
"## S\n"
"para1 first paragraph content\n"
"\n"
"para2 second paragraph content\n"
"\n"
"para3 third paragraph content\n"
)
# Force greedy-pack across separate paragraphs.
chunks = _parser(60)._chunk(txt, "/x.md")
for c in chunks:
assert "[Part" not in c.text
# --------------------------------------------------------------------------
# Provenance
# --------------------------------------------------------------------------
def test_chunk_line_ranges_track_source():
txt = (
"# Top\n" # line 1
"intro\n" # line 2
"\n"
"## Sub\n" # line 4
"body\n" # line 5
)
chunks = _parser(500)._chunk(txt, "/x.md")
assert len(chunks) == 1
assert chunks[0].start_line == 1
assert chunks[0].end_line == 5
def test_chunk_id_changes_with_content():
a = _parser(500)._chunk("# X\nhello\n", "/x.md")[0]
b = _parser(500)._chunk("# X\nhello\n", "/x.md")[0]
c = _parser(500)._chunk("# X\nhellp\n", "/x.md")[0]
assert a.id == b.id
assert a.id != c.id
# --------------------------------------------------------------------------
# Render helper directly
# --------------------------------------------------------------------------
def test_render_with_full_toc_inlines_at_owner():
"""White-box: build a tree, call _render_with_full_toc directly and
verify the slot-fill behavior."""
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(2000)
txt = "# A\n\n## B\nbody B\n\n## C\nbody C\n"
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
h1 = tree.children[0]
b = h1.children[0] # ## B
rendered = p._render_with_full_toc(tree, b, "INSERTED", owns_subtree=False)
# Skeleton lists # A, ## B, ## C; INSERTED sits under ## B but BEFORE ## C
assert "# A" in rendered
assert "## B" in rendered
assert "## C" in rendered
b_idx = rendered.find("## B")
inserted_idx = rendered.find("INSERTED")
c_idx = rendered.find("## C")
assert b_idx < inserted_idx < c_idx
def test_render_with_full_toc_root_owner():
"""Root owner means content sits BEFORE the first heading."""
from mistletoe.block_token import Document
from mistletoe.markdown_renderer import MarkdownRenderer
p = _parser(2000)
txt = "# A\n\nbody A\n"
with MarkdownRenderer() as r:
tree = p._build_tree(Document(txt), r)
rendered = p._render_with_full_toc(tree, tree, "PRELUDE", owns_subtree=False)
assert rendered.startswith("PRELUDE")
assert "# A" in rendered
# --------------------------------------------------------------------------
# MdNode dataclass
# --------------------------------------------------------------------------
def test_mdnode_defaults():
n = MdNode(kind="body")
assert n.heading is None and n.level == 0
assert n.children == [] and n.block is None
assert n.text == "" and n.start_line == 0

View file

@ -1,219 +0,0 @@
"""Wikilink protocol unit tests — body-only edge extraction.
Covers the three legal inline forms (bare / line-level Dataview /
inline-bracketed Dataview), multi-target expansion, dedup against
typed wrappers, open-vocabulary predicate pass-through, and the
explicit decision that frontmatter is no longer walked for links.
"""
from reme2.schema.file_edge import FileEdge, extract_wikilinks, parse_wikilinks
# --------------------------------------------------------------------------
# Bare wikilinks
# --------------------------------------------------------------------------
def test_bare_wikilink():
edges = parse_wikilinks("see [[X]]")
assert len(edges) == 1
assert edges[0].target == "X"
assert edges[0].predicate is None
assert edges[0].embed is False
def test_bare_with_anchor_alias_embed():
edges = parse_wikilinks("![[X#sec|Alias]]")
assert len(edges) == 1
assert edges[0].target == "X"
assert edges[0].anchor == "sec"
assert edges[0].alias == "Alias"
assert edges[0].embed is True
# --------------------------------------------------------------------------
# Line-level Dataview
# --------------------------------------------------------------------------
def test_line_level_field():
edges = parse_wikilinks("extends:: [[Source Topic]]")
assert len(edges) == 1
assert edges[0].target == "Source Topic"
assert edges[0].predicate == "extends"
def test_line_level_multi_target():
edges = parse_wikilinks("concerns:: [[A]], [[B]], [[C]]")
assert [(e.target, e.predicate) for e in edges] == [
("A", "concerns"),
("B", "concerns"),
("C", "concerns"),
]
def test_line_level_with_bullet():
edges = parse_wikilinks("- extends:: [[X]]\n * concerns:: [[Y]]")
assert [(e.target, e.predicate) for e in edges] == [
("X", "extends"),
("Y", "concerns"),
]
# --------------------------------------------------------------------------
# Inline-bracketed Dataview
# --------------------------------------------------------------------------
def test_inline_bracketed():
edges = parse_wikilinks("This [extends:: [[Y]]] something else.")
assert len(edges) == 1
assert edges[0].target == "Y"
assert edges[0].predicate == "extends"
def test_inline_bracketed_multi_target():
edges = parse_wikilinks("[concerns:: [[A]], [[B]]]")
assert [(e.target, e.predicate) for e in edges] == [
("A", "concerns"),
("B", "concerns"),
]
def test_inline_bracketed_skips_cross_line():
# A `[predicate:: ...]` that spans a newline is malformed → the inner
# wikilink falls back to bare; the unmatched `[` does not eat tail text.
edges = parse_wikilinks("[extends:: [[X]]\nbad]")
assert len(edges) == 1
assert edges[0].target == "X"
assert edges[0].predicate is None
# --------------------------------------------------------------------------
# Dedup: a wikilink inside a typed wrapper should not double-emit
# --------------------------------------------------------------------------
def test_inline_bracketed_does_not_double_emit():
edges = parse_wikilinks("see [extends:: [[X]]] again.")
assert len(edges) == 1
assert edges[0].predicate == "extends"
def test_line_level_value_does_not_double_emit():
edges = parse_wikilinks("extends:: [[X]]")
assert len(edges) == 1
def test_typed_and_bare_coexist_for_same_target():
edges = parse_wikilinks("extends:: [[X]]\nFree text mentioning [[X]] again.")
targets_preds = sorted(((e.target, e.predicate or "") for e in edges))
assert targets_preds == [("X", ""), ("X", "extends")]
# --------------------------------------------------------------------------
# Open-vocabulary predicates — any identifier-shaped token passes through
# --------------------------------------------------------------------------
def test_arbitrary_predicate_preserved():
edges = parse_wikilinks("anything_goes:: [[X]]")
assert len(edges) == 1
assert edges[0].target == "X"
assert edges[0].predicate == "anything_goes"
def test_inline_arbitrary_predicate_preserved():
edges = parse_wikilinks("[wat:: [[X]]]")
assert len(edges) == 1
assert edges[0].predicate == "wat"
def test_predicate_must_be_identifier_shaped():
# A leading digit fails the regex `[A-Za-z][A-Za-z0-9_]*` so the line is
# not recognised as a Dataview field — the wikilink falls back to bare.
edges = parse_wikilinks("123bad:: [[X]]")
assert len(edges) == 1
assert edges[0].target == "X"
assert edges[0].predicate is None
def test_file_edge_accepts_any_predicate_string():
e = FileEdge(target="X", predicate="totally_made_up")
assert e.predicate == "totally_made_up"
# --------------------------------------------------------------------------
# Frontmatter is NOT walked for links — explicit regression
# --------------------------------------------------------------------------
def test_frontmatter_links_block_is_ignored():
# Even if a YAML-shaped string sits at the top of body, parse_wikilinks
# only operates on body. We pass body text directly here, so this test
# asserts the API surface no longer accepts a metadata dict.
import inspect
sig = inspect.signature(parse_wikilinks)
assert list(sig.parameters.keys()) == ["text"], (
"parse_wikilinks should accept body text only — frontmatter walk removed"
)
def test_no_frontmatter_walker_exported():
from reme2.schema import file_edge as wl
for removed in (
"parse_wikilinks_from_metadata",
"extract_wikilinks_from_metadata",
"extract_inline_fields",
"extract_typed_edges",
"InlineField",
):
assert not hasattr(wl, removed), (
f"{removed} should have been removed when YAML edges were dropped"
)
# --------------------------------------------------------------------------
# FileEdge schema
# --------------------------------------------------------------------------
def test_file_edge_extra_forbid():
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
FileEdge(target="X", source="regex") # type: ignore[call-arg]
def test_file_edge_minimal_field_set():
e = FileEdge(target="X")
dumped = e.model_dump()
assert set(dumped.keys()) == {"target", "predicate", "anchor", "alias", "embed"}
# --------------------------------------------------------------------------
# Back-compat: extract_wikilinks returns flat target list (used by ingestor)
# --------------------------------------------------------------------------
def test_extract_wikilinks_flat_targets():
targets = extract_wikilinks("see [[X]] and extends:: [[Y]] and [extends:: [[Z]]]")
assert targets == ["X", "Y", "Z"]
# --------------------------------------------------------------------------
# Source ordering stability
# --------------------------------------------------------------------------
def test_edges_sorted_by_source_position():
body = (
"intro [[First]] then\n"
"extends:: [[Second]]\n"
"tail [[Third]]\n"
)
edges = parse_wikilinks(body)
assert [e.target for e in edges] == ["First", "Second", "Third"]